کاردکس کالا و روش FIFO در نرمافزار انبارداری: از مثال عددی تا کوئری
بهای تمامشده کالا به روش FIFO با مثال عددی، مقایسه با میانگین موزون، و پیادهسازی واقعی در نرمافزار انبارداری: لایههای موجودی، قفل ردیف، برگشت از فروش، انتقال بین انبارها و گزارش کاردکس.
وقتی یک کالا را در زمانهای مختلف با قیمتهای متفاوت خریدهاید، «سود این فروش چقدر بود؟» سؤال سادهای نیست. جوابش به این بستگی دارد که بهای تمامشده کالای فروشرفته را چطور حساب کنید. در روش FIFO (اولین ورودی، اولین خروجی) فرض میشود قدیمیترین موجودی زودتر فروخته میشود. این مقاله اول با یک مثال عددی منطق FIFO را نشان میدهد، بعد سراغ پیادهسازی واقعی آن در نرمافزار انبارداری میرود؛ همان چیزی که در سامانه ۲حساب ساختهام، با جزئیاتی که معمولاً در آموزشها جا میافتد: همزمانی، برگشت از فروش، انتقال بین انبارها و موجودی منفی.
FIFO با یک مثال عددی
فرض کنید از یک کالا این خریدها را داشتهاید:
۱. خرید اول: ۱۰ عدد، هر عدد ۱۰۰ هزار تومان.
۲. خرید دوم: ۱۰ عدد، هر عدد ۱۲۰ هزار تومان.
حالا ۱۵ عدد میفروشید. در FIFO، اول کل خرید قدیمی مصرف میشود و بعد بخشی از خرید جدید: ۱۰ عدد × ۱۰۰ بهعلاوه ۵ عدد × ۱۲۰، یعنی بهای تمامشده این فروش ۱٬۶۰۰ هزار تومان است. ۵ عدد باقیمانده با بهای ۱۲۰ در انبار میمانند.
در روش میانگین موزون، بعد از دو خرید بهای میانگین ۱۱۰ است و بهای همان ۱۵ عدد ۱٬۶۵۰ میشود. هر دو روش در حسابداری رایجاند و هیچکدام «غلط» نیست؛ اما وقتی قیمت خرید مدام بالا میرود (که در اقتصاد تورمی تقریباً همیشه همین است)، FIFO بهای تمامشده کمتر و سود بیشتری نشان میدهد و موجودی پایان دوره را به قیمتهای تازهتر نزدیکتر نگه میدارد. انتخاب روش تصمیم حسابداری مجموعه است و نرمافزار باید آن را دقیق و یکنواخت اجرا کند.
نکات کلیدی
- FIFO: بهای فروش از قدیمیترین لایهها.
- میانگین موزون: یک بهای میانگین که با هر خرید بهروز میشود.
- روش انتخابشده باید در طول دوره مالی ثابت بماند.
مدل داده: لایههای موجودی
پیادهسازی درست FIFO با یک ستون «موجودی» در جدول کالا ممکن نیست. باید بدانید موجودی فعلی از کدام خریدها و با چه بهایی تشکیل شده. برای این کار هر رسید خرید یک «لایه» میسازد: مقدار اولیه، مقدار باقیمانده و بهای واحد. موجودی کالا در هر انبار، جمع مقدار باقیمانده لایههای آن است.
کنار لایهها، یک جدول حرکت (همان کاردکس) هم لازم است که هر ورود و خروج را با تاریخ، سند مرجع و بهای آن ثبت میکند. لایهها «وضعیت فعلی» را نگه میدارند و جدول حرکت «تاریخچه» را. گزارش کاردکس از جدول حرکت ساخته میشود و حسابرسی آن ساده است.
یک ایندکس جزئی (partial index) روی لایههایی که هنوز موجودی دارند، باعث میشود پیدا کردن قدیمیترین لایه حتی بعد از سالها و میلیونها ردیف سریع بماند.
CREATE TABLE stock_layers (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
received_at TIMESTAMPTZ NOT NULL,
source_doc BIGINT NOT NULL, -- رسید خرید یا انتقال
qty_in NUMERIC(14, 3) NOT NULL CHECK (qty_in > 0),
qty_left NUMERIC(14, 3) NOT NULL CHECK (qty_left >= 0 AND qty_left <= qty_in),
unit_cost NUMERIC(20, 4) NOT NULL
);
-- فقط لایههای دارای موجودی، به ترتیب FIFO
CREATE INDEX stock_layers_fifo
ON stock_layers (product_id, warehouse_id, received_at, id)
WHERE qty_left > 0;مصرف لایهها هنگام فروش، با قفل ردیف
هنگام فروش، از قدیمیترین لایه شروع میکنیم و مقدار را کم میکنیم تا مقدار فروش پوشش داده شود. جمع «مقدار مصرفشده × بهای واحد» هر لایه، بهای کالای فروشرفته است.
نکتهای که در بیشتر نمونهکدها نیست، همزمانی است. اگر دو صندوق همزمان آخرین واحدهای یک کالا را بفروشند و هر دو لایه را بدون قفل بخوانند، هر دو همان لایه را مصرف میکنند و موجودی منفی یا بهای اشتباه ثبت میشود. راهحل این است که کل کار داخل یک تراکنش انجام شود و لایهها با FOR UPDATE قفل شوند. فروش دوم صبر میکند تا اولی تمام شود و بعد وضعیت درست را میبیند.
سند حسابداری بهای کالای فروشرفته (بدهکار: بهای تمامشده کالای فروشرفته، بستانکار: موجودی کالا) هم باید در همان تراکنش ثبت شود. اگر سند ثبت نشود، مصرف لایهها هم برمیگردد و انبار و دفاتر هیچوقت از هم جدا نمیشوند.
import Decimal from 'decimal.js';
export async function consumeFifo(tx: Tx, productId: number, warehouseId: number, qty: string) {
let need = new Decimal(qty);
let cost = new Decimal(0);
const layers = await tx.query(
`SELECT id, qty_left, unit_cost FROM stock_layers
WHERE product_id = $1 AND warehouse_id = $2 AND qty_left > 0
ORDER BY received_at, id
FOR UPDATE`,
[productId, warehouseId]
);
const used: { layerId: number; qty: string; unitCost: string }[] = [];
for (const l of layers.rows) {
if (need.lte(0)) break;
const take = Decimal.min(need, l.qty_left);
await tx.query('UPDATE stock_layers SET qty_left = qty_left - $1 WHERE id = $2', [take.toFixed(), l.id]);
used.push({ layerId: l.id, qty: take.toFixed(), unitCost: l.unit_cost });
cost = cost.plus(take.mul(l.unit_cost));
need = need.minus(take);
}
if (need.gt(0)) throw new Error(`موجودی کافی نیست؛ کسری: ${need.toFixed()}`);
return { cogs: cost.toDecimalPlaces(0).toFixed(0), used }; // used برای کاردکس و برگشت از فروش
}برگشت از فروش، انتقال و موجودی منفی
سه موقعیت روزمره هستند که اگر از اول برایشان تصمیم نگیرید، کاردکس بعد از چند ماه قابل اعتماد نیست.
برگشت از فروش: کالای برگشتی باید با همان بهایی برگردد که موقع فروش از انبار خارج شده، نه با بهای امروز. به همین دلیل در کد بالا فهرست لایههای مصرفشده (used) نگه داشته میشود. برگشت، یک لایه جدید با همان بهای واحد میسازد و سود و زیان فروش اصلی دستنخورده میماند.
انتقال بین انبارها: حواله انتقال نباید بهای کالا را عوض کند. انتقال در انبار مبدأ مثل فروش لایهها را مصرف میکند و در انبار مقصد لایههایی با همان بهای واحد و همان تاریخ ورود اصلی میسازد، تا ترتیب FIFO در انبار مقصد هم درست بماند.
موجودی منفی: در دنیای واقعی گاهی فروش زودتر از رسید خرید در سیستم ثبت میشود. دو سیاست ممکن است: اجازه ندهید (سادهتر و دقیقتر)، یا لایه موقت منفی بسازید و بعد از ثبت خرید، بهای آن را اصلاح کنید و سند تعدیل بزنید. انتخاب به نحوه کار مجموعه بستگی دارد، اما باید صریح باشد. بدترین حالت این است که سیستم بیصدا موجودی منفی بپذیرد و بهای صفر ثبت کند.
نکات کلیدی
- برگشت از فروش با بهای همان خروج، نه بهای روز.
- انتقال بین انبار، بها و تاریخ لایه را حفظ میکند.
- برای موجودی منفی یک سیاست صریح داشته باشید.
گزارش کاردکس با مانده جاری
گزارش کاردکس یعنی فهرست ورود و خروجهای یک کالا به ترتیب زمان، با مانده بعد از هر حرکت. اگر جدول حرکت درست ثبت شده باشد، این گزارش با یک تابع پنجرهای (window function) در PostgreSQL ساخته میشود و لازم نیست مانده را جایی ذخیره کنید که بعداً از واقعیت جدا شود.
SELECT moved_at,
doc_type,
doc_no,
qty_in,
qty_out,
SUM(qty_in - qty_out) OVER w AS balance_qty,
SUM(value_in - value_out) OVER w AS balance_value
FROM stock_moves
WHERE product_id = $1 AND warehouse_id = $2
WINDOW w AS (ORDER BY moved_at, id ROWS UNBOUNDED PRECEDING)
ORDER BY moved_at, id;آزمونهایی که باید قبل از استفاده واقعی پاس شوند
موتور FIFO از آن بخشهایی است که خطایش دیر دیده میشود. این سناریوها را قبل از اینکه داده واقعی وارد سیستم شود، با تست خودکار روی دیتابیس واقعی (نه mock) امتحان میکنم:
۱. فروشی که دقیقاً یک لایه را تمام میکند و فروشی که از وسط لایه دوم تمام میشود.
۲. دو فروش همزمان روی آخرین واحدها؛ فقط یکی باید موفق شود.
۳. برگشت از فروش و بعد فروش دوباره همان کالا.
۴. انتقال به انبار دیگر و فروش از انبار مقصد با بهای درست.
۵. مقایسه جمع ارزش لایههای باقیمانده با مانده حساب موجودی کالا در دفتر کل؛ این دو باید همیشه برابر باشند.
مورد پنجم مهمترین کنترل است. اگر ارزش انبار و حساب موجودی در دفتر کل از هم جدا شدند، جایی سندی بدون حرکت انبار یا حرکتی بدون سند ثبت شده است.
جمعبندی
FIFO روی کاغذ ساده است، اما پیادهسازی قابل اعتمادش به چند تصمیم بستگی دارد: لایههای صریح موجودی بهجای یک عدد موجودی، مصرف لایهها داخل تراکنش با قفل ردیف، ثبت سند بهای تمامشده در همان تراکنش، و سیاست روشن برای برگشت، انتقال و موجودی منفی. محاسبات هم باید با Decimal باشد؛ دلیلش را در چرا در نرمافزار حسابداری نباید با Float حساب کرد نوشتهام. این موتور در سامانه ۲حساب در حال کار است و اگر برای کسبوکارتان نرمافزار انبارداری با کاردکس دقیق میخواهید، صفحه طراحی نرمافزار انبارداری اختصاصی را ببینید.