Партіонний облік (Batch / Lot Tracking)¶
Модуль: essentials
Стандарт: IFRS IAS 2 — Inventories
Методи оцінки: FIFO (First-In, First-Out) та WAC (Weighted Average Cost, Perpetual Moving Average)
Метод LIFO заборонений — не відповідає IFRS.
Зміст¶
- Архітектура
- Моделі
- Сервісний шар
- Інтеграція з документами
- REST API
- Frontend
- Файлова структура
- Ключові рішення
1. Архітектура¶
┌──────────────────────────────────────────────────────────────────┐
│ GoodsReceipt.post_document() │
│ └─► receive_batch() ──► Batch(ACTIVE) + StockTx(RECEIPT) │
│ └─► ItemWarehouseStock (WAC refresh) │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ GoodsShipment.post_document() │
│ └─► deduct_fifo() ──► Batch.current_qty ↓ │
│ └─► StockTx(ISSUE, FIFO) per batch │
│ └─► GoodsShipmentLine.cost_price ← WACF │
│ └─► ItemWarehouseStock (WAC refresh) │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ GoodsWriteoff.post_document() │
│ └─► deduct_writeoff() ─► Batch.current_qty ↓ │
│ └─► StockTx(WRITEOFF, FIFO) per batch │
│ └─► GoodsWriteoffLine.cost_price ← WAC │
│ └─► ItemWarehouseStock (WAC refresh) │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Unpost (будь-який з трьох) │
│ GoodsReceipt → reverse_batch_receipt() — видаляє Batch │
│ GoodsShipment → reverse_batch_deductions()— відновлює qty │
│ GoodsWriteoff → reverse_batch_deductions()— відновлює qty │
└──────────────────────────────────────────────────────────────────┘
WAC — Perpetual Moving Average¶
Перераховується після кожного надходження (не в кінці звітного періоду як Periodic WAC):
WAC_new = (V_old + qty_in × price_in) / (Q_old + qty_in)
де:
V_old = Σ(current_qty_i × purchase_price_i) усіх активних партій
Q_old = Σ(current_qty_i) усіх активних партій
Чому Perpetual, а не Periodic: - DOP працює в реальному часі → собівартість актуальна після кожної операції. - Periodic дає точну картину лише на кінець місяця. - Обидва методи дозволені IAS 2; Perpetual є стандартом для онлайн-систем.
2. Моделі¶
2.1 Batch — партія товару¶
Файл: backend/essentials/models/batch.py
class Batch(TenantAwareModel):
item = FK(Item) # товар
warehouse = FK(Warehouse) # склад
batch_number = CharField(64) # номер партії (унікальний в межах item+warehouse)
serial_number = CharField(64) # серійний номер (опц., для поштучного обліку)
purchase_price = Decimal(15,6) # закупівельна ціна — НЕЗМІННА після проведення
currency = FK(Currency)
initial_qty = Decimal(15,3) # початкова кількість — НЕЗМІННА
current_qty = Decimal(15,3) # залишок (зменшується при списанні)
received_at = DateTimeField # дата надходження — КЛЮЧ СОРТУВАННЯ FIFO
expiry_date = DateField # дата придатності (опц.)
supplier = FK(Client)
source_document_type = CharField # 'GoodsReceipt'
source_document_id = PositiveInt # id прихідної накладної
status = Choice(active | depleted | expired | cancelled)
Стани партії:
| Статус | Опис |
|---|---|
active |
Є залишки, доступна для списання |
depleted |
current_qty = 0, повністю відвантажена |
expired |
Протермінована (майбутня автоматика) |
cancelled |
Скасована вручну |
Індекси:
INDEX batch_fifo_idx ON (item, warehouse, status, received_at)
INDEX batch_tenant_item_idx ON (tenant, item, warehouse)
Властивість:
@property
def total_value(self) -> Decimal:
return (self.current_qty * self.purchase_price).quantize(Decimal('0.01'))
2.2 ItemWarehouseStock — WAC-snapshot¶
Файл: backend/essentials/models/batch.py
Денормалізована таблиця для швидкого доступу до поточних залишків
і WAC без агрегації по всіх Batch.
class ItemWarehouseStock(TenantAwareModel):
item = FK(Item)
warehouse = FK(Warehouse)
total_qty = Decimal(15,3) # = Σ(batch.current_qty)
total_value = Decimal(15,2) # = Σ(batch.current_qty × batch.purchase_price)
wac_price = Decimal(15,6) # = total_value / total_qty
last_updated = DateTimeField(auto_now=True)
class Meta:
unique_together = [('tenant', 'item', 'warehouse')]
Оновлюється автоматично через _refresh_stock_snapshot() після кожної операції.
2.3 StockTransaction — аудит-лог¶
Файл: backend/essentials/models/stock_transaction.py
Append-only журнал кожного руху залишків на рівні партій.
class StockTransaction(TenantAwareModel):
batch = FK(Batch)
item = FK(Item)
warehouse = FK(Warehouse)
transaction_type = Choice(receipt | issue | writeoff | adjustment | return)
valuation_method = Choice(FIFO | WAC)
qty = Decimal(15,3)
unit_cost = Decimal(15,6)
total_cost = Decimal(15,2)
qty_before = Decimal(15,3) # snapshot ДО операції
qty_after = Decimal(15,3) # snapshot ПІСЛЯ операції
reference_type = CharField # 'GoodsReceipt' | 'GoodsShipment' | 'GoodsWriteoff'
reference_id = PositiveInt # id документа-джерела
note = TextField
Індекси:
INDEX stx_item_wh_type_idx ON (item, warehouse, transaction_type)
INDEX stx_ref_idx ON (reference_type, reference_id)
INDEX stx_batch_type_idx ON (batch, transaction_type)
2.4 Нові поля на рядках накладних¶
GoodsReceiptLine (нові поля):
batch_number = CharField(64, blank=True) # порожній → автогенерація '{number}-{line.pk}'
serial_number = CharField(64, blank=True) # серійний номер
expiry_date = DateField(null=True) # дата придатності партії
GoodsShipmentLine (нові поля):
GoodsWriteoffLine — поле cost_price вже існувало; тепер заповнюється з FIFO.
3. Сервісний шар¶
Файл: backend/essentials/services/batch_service.py
3.1 receive_batch() — реєстрація надходження¶
@transaction.atomic
def receive_batch(
*, tenant_id, item_id, warehouse_id,
batch_number, purchase_price, qty,
received_at=None, expiry_date=None,
supplier_id=None, serial_number='',
source_document_type='GoodsReceipt',
source_document_id=None,
) -> tuple[Batch, Decimal]: # (batch, new_WAC)
Кроки:
1. Валідація qty > 0, purchase_price >= 0
2. Batch.objects.create(status=ACTIVE)
3. StockTransaction(RECEIPT, WAC, qty_before=0, qty_after=qty)
4. _refresh_stock_snapshot() → перерахунок WAC
3.2 deduct_fifo() — FIFO списання (відвантаження)¶
@transaction.atomic
def deduct_fifo(
*, item_id, warehouse_id, qty_needed,
reference_type='', reference_id=None, note='',
) -> list[BatchDeduction]:
Алгоритм:
1. SELECT FOR UPDATE → блокуємо рядки Batch
(захист від race condition при паралельних списаннях)
2. Σ(current_qty) < qty_needed → raise InsufficientStockError
3. Для кожної партії від найстарішої до найновішої:
a. deduct = min(batch.current_qty, remaining)
b. batch.current_qty -= deduct
c. if current_qty == 0 → status = DEPLETED
d. StockTransaction(ISSUE, FIFO, qty_before, qty_after)
e. remaining -= deduct
4. _refresh_stock_snapshot()
5. return [BatchDeduction(...), ...]
Повернення:
class BatchDeduction(NamedTuple):
batch_id: int
batch_number: str
qty_deducted: Decimal
unit_cost: Decimal
total_cost: Decimal
3.3 deduct_writeoff() — FIFO списання (списання браку)¶
Ідентична логіка до deduct_fifo(), але тип транзакції = WRITEOFF.
3.4 reverse_batch_receipt() — відміна приходу¶
Захист: якщо будь-яка партія вже частково відвантажена
(current_qty < initial_qty) → raise ValidationError з переліком партій.
Кроки:
1. Перевірка: batches.filter(current_qty__lt=F('initial_qty')).exists()
2. StockTransaction.objects.filter(reference_*).delete()
3. Batch.objects.filter(source_document_*).delete()
4. _refresh_stock_snapshot() для всіх зачеплених позицій
3.5 reverse_batch_deductions() — відміна відвантаження/списання¶
Кроки:
1. Знаходимо всі StockTransaction для документа
2. Для кожної: batch.current_qty += txn.qty; якщо DEPLETED → ACTIVE
3. txns.delete()
4. _refresh_stock_snapshot() для всіх зачеплених позицій
3.6 calculate_wac() — розрахунок WAC на льоту¶
def calculate_wac(item_id: int, warehouse_id: int) -> Decimal:
# WAC = Σ(qty_i × price_i) / Σ(qty_i)
Використовується для ad-hoc розрахунків. В нормальному режимі дані
беруться з ItemWarehouseStock.wac_price (кешований snapshot).
3.7 _refresh_stock_snapshot() — оновлення snapshot¶
update_or_create на ItemWarehouseStock з актуальними total_qty,
total_value, wac_price.
3.8 InsufficientStockError¶
class InsufficientStockError(Exception):
item_id: int
warehouse_id: int
needed: Decimal
available: Decimal
4. Інтеграція з документами¶
4.1 GoodsReceipt (Прихідна накладна)¶
post_document:
for line in lines:
batch_number = line.batch_number or f'{obj.number}-{line.pk}'
receive_batch(
tenant_id, item_id, warehouse_id,
batch_number, purchase_price=line.price,
qty=line.quantity, received_at=obj.date,
expiry_date=line.expiry_date,
serial_number=line.serial_number,
source_document_type='GoodsReceipt',
source_document_id=obj.id,
)
→ InventoryJournal(receipt)
→ InventoryLedger(+qty, +amount)
unpost_document:
reverse_batch_receipt(document_type='GoodsReceipt', document_id=obj.id)
→ InventoryJournal.delete()
→ InventoryLedger.delete()
4.2 GoodsShipment (Видаткова накладна)¶
post_document:
for line in lines:
deductions = deduct_fifo(
item_id, warehouse_id, qty_needed=line.quantity,
reference_type='GoodsShipment', reference_id=obj.id,
)
avg_cost = Σ(d.total_cost) / Σ(d.qty_deducted) # FIFO-WAC для цього рядка
line.cost_price = avg_cost # зберігається на рядку
→ InventoryJournal(shipment)
→ InventoryLedger(-qty, -cogs_amount)
→ IncomeExpenseJournal(income, total_amount)
unpost_document:
reverse_batch_deductions(document_type='GoodsShipment', document_id=obj.id)
→ InventoryJournal.delete()
→ InventoryLedger.delete()
→ IncomeExpenseJournal.delete()
4.3 GoodsWriteoff (Списання товарів)¶
post_document:
for line in lines:
deductions = deduct_writeoff(
item_id, warehouse_id, qty_needed=line.quantity,
reference_type='GoodsWriteoff', reference_id=obj.id,
)
avg_cost = Σ(d.total_cost) / Σ(d.qty_deducted)
line.cost_price = avg_cost
→ InventoryJournal(write_off)
→ InventoryLedger(-qty, -cogs_amount)
→ IncomeExpenseJournal(expense, total_amount)
unpost_document:
reverse_batch_deductions(document_type='GoodsWriteoff', document_id=obj.id)
→ InventoryJournal.delete()
→ InventoryLedger.delete()
→ IncomeExpenseJournal.delete()
4.4 Ідемпотентність проведення¶
При повторному post_document (без попереднього unpost) система:
1. Очищує старі журнальні записи (InventoryJournal, InventoryLedger)
2. Видаляє stale Batch записи тільки якщо жодна партія не була видана
3. Якщо видана — відмовляє з описовою помилкою
5. REST API¶
5.1 Нові endpoints¶
| Метод | URL | Опис |
|---|---|---|
GET |
/api/v1/essentials/batches/ |
Список партій (фільтр: item, warehouse, status) |
GET |
/api/v1/essentials/batches/{id}/ |
Деталь партії |
GET |
/api/v1/essentials/stock-transactions/ |
Аудит-лог рухів залишків |
GET |
/api/v1/essentials/inventory/valuation/ |
Звіт FIFO + WAC по всіх позиціях |
GET |
/api/v1/essentials/inventory/item-batches/ |
Активні партії для item/warehouse в FIFO-порядку |
5.2 Query params¶
/inventory/valuation/:
/inventory/item-batches/:
/batches/:
?item=<int>
?warehouse=<int>
?status=active|depleted|expired|cancelled
?search=<batch_number|serial_number>
5.3 Приклад відповіді /inventory/valuation/¶
{
"lines": [
{
"item_id": 12,
"item_code": "ITEM-001",
"item_name": "Мука пшенична в/г",
"warehouse_id": 3,
"warehouse_name": "Склад №1",
"total_qty": "150.000",
"fifo_value": "4500.00",
"wac_value": "4480.00",
"wac_price": "29.866667"
}
],
"total_fifo_value": "4500.00",
"total_wac_value": "4480.00"
}
5.4 Приклад відповіді /inventory/item-batches/¶
[
{
"id": 1,
"batch_number": "GR-2026-001-1",
"serial_number": "",
"purchase_price": "30.000000",
"current_qty": "50.000",
"total_value": "1500.00",
"received_at": "2026-01-15T10:00:00Z",
"expiry_date": "2026-12-31",
"status": "active"
},
{
"id": 2,
"batch_number": "GR-2026-002-1",
"purchase_price": "29.500000",
"current_qty": "100.000",
"total_value": "2950.00",
"received_at": "2026-02-01T08:30:00Z",
"expiry_date": null,
"status": "active"
}
]
5.5 Існуючі endpoints (доопрацьовані)¶
POST /api/v1/essentials/goods-receipts/{id}/post_document/
POST /api/v1/essentials/goods-receipts/{id}/unpost_document/
POST /api/v1/essentials/goods-shipments/{id}/post_document/
POST /api/v1/essentials/goods-shipments/{id}/unpost_document/
POST /api/v1/essentials/goods-writeoffs/{id}/post_document/
POST /api/v1/essentials/goods-writeoffs/{id}/unpost_document/
Всі post_document тепер повертають помилку 400 з полем errors: [...]
якщо виникла InsufficientStockError хоч на одному рядку.
6. Frontend¶
6.1 BatchPickerModal¶
Файл: frontend/erp/src/components/Inventory/BatchPickerModal.tsx
Модальне вікно для комірника при відвантаженні. Дозволяє бачити і коригувати з яких партій буде здійснено списання.
Props:
interface BatchPickerModalProps {
opened: boolean;
onClose: () => void;
itemId: number;
itemName: string;
warehouseId: number;
requiredQty: number; // кількість з рядка накладної
onConfirm: (allocations: BatchAllocation[]) => void;
}
Логіка:
- Завантажує активні партії через GET /inventory/item-batches/
- Автоматично формує FIFO-пропозицію (клієнтська копія серверного алгоритму)
- Оператор може вручну змінити кількість по кожній партії
- Для серійних товарів: step=1, max=1 (вибір конкретного серійного номера)
- Індикатори протермінованих/тих що закінчуються партій
- Валідація: Σ(qty_to_deduct) == requiredQty перед підтвердженням
Тип результату:
interface BatchAllocation {
batch_id: number;
batch_number: string;
serial_number: string;
qty_to_deduct: number;
unit_cost: number;
line_total: number;
}
6.2 InventoryValuationPage¶
Файл: frontend/erp/src/components/Inventory/InventoryValuationPage.tsx
Сторінка бухгалтерського звіту по залишках.
Відображає: - Summary cards: FIFO Value / WAC Value / Δ FIFO-WAC - Таблиця по кожній позиції: товар, склад, кількість, WAC-ціна, FIFO-вартість, WAC-вартість, відхилення - Підсумковий рядок
6.3 api/inventory.ts¶
Файл: frontend/erp/src/api/inventory.ts
fetchItemBatches(itemId, warehouseId): Promise<Batch[]>
fetchBatches(params?): Promise<{ results: Batch[]; count: number }>
fetchStockTransactions(params?): Promise<{ results: StockTransaction[]; count: number }>
fetchInventoryValuation(params?): Promise<InventoryValuation>
postGoodsReceipt(id): Promise<unknown>
unpostGoodsReceipt(id): Promise<unknown>
postGoodsShipment(id): Promise<unknown>
unpostGoodsShipment(id): Promise<unknown>
postGoodsWriteoff(id): Promise<unknown>
unpostGoodsWriteoff(id): Promise<unknown>
7. Файлова структура¶
backend/essentials/
├── models/
│ ├── batch.py ← Batch, ItemWarehouseStock, BatchStatus, ValuationMethod
│ ├── stock_transaction.py ← StockTransaction, TransactionType
│ ├── goods_receipt.py ← +batch_number, +serial_number, +expiry_date на GoodsReceiptLine
│ ├── goods_shipment.py ← +cost_price на GoodsShipmentLine
│ └── __init__.py ← +Batch, +ItemWarehouseStock, +StockTransaction, +TransactionType
├── services/
│ ├── __init__.py
│ └── batch_service.py ← receive_batch, deduct_fifo, deduct_writeoff,
│ reverse_batch_receipt, reverse_batch_deductions,
│ calculate_wac, _refresh_stock_snapshot
├── views/
│ ├── inventory.py ← BatchViewSet, StockTransactionViewSet,
│ │ InventoryValuationView, ItemBatchesView
│ └── transactions.py ← доопрацьовані GoodsReceipt/Shipment/WriteoffViewSet
├── serializers/
│ └── inventory.py ← BatchSerializer, ItemWarehouseStockSerializer,
│ StockTransactionSerializer, InventoryValuationSerializer
├── migrations/
│ └── 0003_batch_tracking.py ← міграція: 3 нові моделі + 4 нові поля
└── urls.py ← +batches, +stock-transactions, +inventory/*
frontend/erp/src/
├── api/
│ └── inventory.ts ← TypeScript API-функції та типи
└── components/Inventory/
├── BatchPickerModal.tsx ← модальне вікно вибору партій
└── InventoryValuationPage.tsx ← звіт FIFO + WAC
8. Ключові рішення¶
| Питання | Рішення | Причина |
|---|---|---|
| WAC тип | Perpetual Moving Average | Реальний час, IAS 2, стандарт для онлайн-DOP |
| LIFO | Заборонено | Не відповідає IFRS |
| Race condition | SELECT FOR UPDATE на Batch |
Блокує рядки до кінця транзакції Django |
| Аудит | StockTransaction (qty_before/after) |
Повна трасуємість без JOIN |
| Звітність | ItemWarehouseStock (snapshot) |
Швидкий доступ до WAC без агрегації |
| Серійні номери | serial_number на Batch + UI step=1 |
Поштучний облік без окремої моделі |
| Числа | Decimal (не float) |
Точна фінансова арифметика |
| Unpost приходу | Валідація current_qty < initial_qty |
Не можна скасувати якщо вже відвантажено |
| Unpost відвантаження | Відновлення qty по StockTransaction | Точний реверс без перерахунків |
| Ідемпотентність | Очищення stale-даних перед re-post | Безпечне повторне проведення |
| Batch number | Auto-генерація {number}-{line.pk} |
Якщо не вказано вручну |