Перейти до змісту

Партіонний облік (Batch / Lot Tracking)

Модуль: essentials Стандарт: IFRS IAS 2 — Inventories Методи оцінки: FIFO (First-In, First-Out) та WAC (Weighted Average Cost, Perpetual Moving Average) Метод LIFO заборонений — не відповідає IFRS.


Зміст

  1. Архітектура
  2. Моделі
  3. Сервісний шар
  4. Інтеграція з документами
  5. REST API
  6. Frontend
  7. Файлова структура
  8. Ключові рішення

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 (нові поля):

cost_price = Decimal(15,6)  # заповнюється автоматично при post_document (FIFO COGS)

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() — відміна приходу

@transaction.atomic
def reverse_batch_receipt(*, document_type: str, document_id: int) -> None:

Захист: якщо будь-яка партія вже частково відвантажена (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() — відміна відвантаження/списання

@transaction.atomic
def reverse_batch_deductions(*, document_type: str, document_id: int) -> None:

Кроки: 1. Знаходимо всі StockTransaction для документа 2. Для кожної: batch.current_qty += txn.qty; якщо DEPLETEDACTIVE 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

def _refresh_stock_snapshot(*, item_id, warehouse_id, tenant_id=None) -> ItemWarehouseStock:

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/:

?warehouse_id=<int>   — фільтр по складу
?item_id=<int>        — фільтр по товару

/inventory/item-batches/:

?item_id=<int>        — обов'язковий
?warehouse_id=<int>   — обов'язковий

/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} Якщо не вказано вручну