Files
ERP-core/docs/ITEM_TRACKING_PLAN.md
2026-10-12 14:57:15 +05:30

19 KiB
Raw Permalink Blame History

Item Tracking (Serial / Batch / Expiry / Warranty) — ERP-core Plan

Status: Implemented 2026-10-07 on branch ui-bug-fixers (uncommitted). Migration 20261007054513_AddItemTrackingSerialsWarranty generated and not yet applied — Program.cs runs MigrateAsync on startup, so it applies on the next backend start. See §6 for what was built differently from the plan and §7 for what is still open. Origin: adapted from the distribution system's item_tracking_plan.md. That plan does not map 1:1 — ERP-core already has most of its foundation (see §1). This plan covers only the gaps.

Phase Title Status
0 Decisions (see §3) ✅ Recommendations D1–D7 adopted as written
1 Serial entity hardening (status enum, location, provenance) ✅
2 GRN serial capture + per-serial FIFO layers ✅
3 Serial-aware / FEFO stock consumption ✅
4 Sales documents — batch/serial on invoice lines, mandatory on tracked items ✅ (bundles: see §7)
5 Warranty start at sale (WarrantyRegistration) ✅
6 Returns — sales return restock to batch/serial, purchase return of serials ✅
7 Transfers, adjustments, counts — serial carry-through ✅
8 Frontend — GRN serial entry, sale pickers, lookups ✅ (gaps in §7)
9 Reports — serial/warranty lookup, batch expiry, stock by batch ✅ (expiry notifications: §7)
10 Migration + tests ✅ migration generated · ✅ 15 unit tests · ❌ e2e

1. What already exists (do not rebuild)

Area Where Notes
Global toggles Domain/Entities/ProductConfig.cs (SerialNumbersEnabled, BatchListsEnabled, WarrantyPeriodsEnabled, CustomWarrantyPeriodsEnabled) Singleton row, enforced on item writes. Replaces the other plan's SysConfig keys.
Per-item mode Item.TrackingMode (None/Batch/Serial), Item.Warranty, Item.WarrantyPeriodMonths Mutually exclusive mode — an item is batch or serial, never both. Expiry lives on Batch, so there is no separate expiry flag.
Warranty periods WarrantyPeriod master + WarrantyPeriods.AllowedMonths (3/6/12/18) Months, not days. Validated in ItemService.ValidateWarrantyPeriod.
Batches Batch (BatchNo, ExpiryDate, Barcode), unique (ItemId, BatchNo) Created/reused at GRN by GrnService.ResolveBatchAsync.
Serials Serial (SerialNo, Status string, Barcode), unique (ItemId, SerialNo) Entity exists but nothing creates rows (see §2).
Ledger / layers StockLedger.BatchId/SerialId, StockLayer.BatchId/SerialId Columns exist; SerialId is always written null today.
Expiry block FifoCostingService.ConsumeAsync excludes expired batches, throws EXPIRED_BATCH_BLOCKED for an explicit expired batch
Warranty numbers at GRN GrnLineWarrantyNumber — one per received unit, period snapshotted from item Unique only per GRN line, not globally.
Sales slip SalesSlipLine.BatchId/SerialId; SalesPostingService passes batch to FIFO and flips Serial.Status = "Sold"
Scan lookup ItemService scan resolves item / batch / serial by barcode or number
Counts CountService snapshots per batch and per serial Serial branch relies on per-serial layers that never exist.

2. Gaps found (the actual work)

  1. Serials are never received. CreateGrnLineInput has no serial field (frontend types/grn.ts documents this as "KNOWN GAP"), GrnService never creates Serial rows, and ConfirmAsync creates one layer per line with serialId: null. A Serial-mode item can be received with no serials at all.
  2. FIFO cannot consume a specific serial. ConsumeAsync takes batchId only; the slip posting flips the serial to Sold without proving that serial's stock was the stock consumed.
  3. Sales invoices drop batch/serial. SalesInvoiceLine has no BatchId/SerialId; PostInvoiceAsync passes null, null (SalesPostingService.cs:166). The invoice UI carries them as display-only metadata. Bundle sales also pass null, null.
  4. Tracking is never mandatory on issue. A batch- or serial-tracked item can be sold, transferred, or adjusted with no batch/serial, so batch balances and serial status drift.
  5. No FEFO. Consumption orders by ReceiptDate, although Batch docs say "expiry drives FEFO".
  6. Warranty never starts. GrnLineWarrantyNumber records a period, but nothing records the sale date, customer, or expiry date, and there is no lookup.
  7. Returns lose identity. SalesReturnService restocks via StockDelta(itemId, null, null, qty) — no batch, no serial status change, warranty not voided. PurchaseReturnService likewise ignores serials.
  8. Transfers/adjustments carry BatchId only; serial location is never tracked.
  9. Serial.Status is a free string ("InStock", "Sold") — no enum, no validation.

3. Decisions needed before Phase 1 (Phase 0)

# Question Recommendation
D1 Warranty number vs serial number — for a Serial + Warranty item, are they the same identifier? Yes, use the serial as the warranty identity and skip WarrantyNumbers for serial items. Keep GrnLineWarrantyNumber only for warranty items with TrackingMode = None.
D2 Should Warranty = Warranty require TrackingMode = Serial? Not forced now (would break existing items); revisit after D1 ships.
D3 Which document starts warranty? Posting of the stock-consuming sale: slip, invoice and bundle all go through SalesPostingService.PostAsync, so one hook covers all three. This settles the other plan's open Phase 13 question.
D4 One serial per sales line, or many? Many: add a child …LineSerial table rather than forcing Qty = 1 per line (SalesSlipLine.SerialId becomes legacy/compat).
D5 Batch item sold with no batch chosen Auto-allocate FEFO server-side, splitting across batches. Record each allocation on the line.
D6 Legacy stock of serial items received before Phase 2 (layers with no serial) Allow issuing serial-less stock only while untracked layers remain (QtyRemaining > 0, SerialId IS NULL); after that, a serial is required. Optionally a one-off "assign serials to existing stock" adjustment.
D7 Warranty start date: post date or a delivery date? Post date (no delivery document exists).

4. Phases

Phase 1 — Serial hardening

  • Domain/Enums/SerialStatus.cs: InStock, Reserved, Sold, ReturnedByCustomer, ReturnedToVendor, Damaged, Scrapped. Map with HasConversion<string>() in BatchSerialConfiguration — existing "InStock"/"Sold" values stay valid, so no data migration is needed.
  • Add to Serial: WarehouseId?, BinId? (current location), GrnLineId? (provenance), ReceivedAt, CreatedAt, UpdatedAt, RowVersion.
  • Index (ItemId, Status), (WarehouseId, ItemId, Status).

Phase 2 — GRN serial capture

  • CreateGrnLineInput.SerialNumbers: List<string>?; GrnLineDto.Serials.
  • New GrnLineSerial (GrnLineId, SerialNo), unique (GrnLineId, SerialNo) — the draft holds numbers only.
  • GrnService.CreateAsync → ResolveSerialNumbers(item, input, qty), the same pattern as ResolveWarrantyNumbers: required iff TrackingMode == Serial; count must equal Qty, and Qty must be a whole number; trimmed, distinct within the request, and not already an existing Serial for the item; rejected when the mode is not Serial. Per D1, a serial+warranty item does not need WarrantyNumbers.
  • ConfirmAsync: for serial lines create one Serial per number (InStock, warehouse/bin, GrnLineId) and one layer + one ledger row per serial (qty 1) via the existing CreateInboundLayerAsync(..., serialId, ...) / PostLedgerAsync(..., serialId, ...). Re-check uniqueness inside the transaction (a concurrent GRN could take the number). GL posting is unchanged.
  • Hold/reject: serials on an OnHold/Rejected line remain InStock but non-issuable through the existing layer filter.

Phase 3 — Serial-aware and FEFO consumption (FifoCostingService)

  • ConsumeAsync(itemId, warehouseId, batchId, qty, ct, allowShortfall, serialIds = null): when serialIds is given, issue exactly those layers (each must be open, qty 1, issuable) or throw a new SERIAL_NOT_AVAILABLE (409).
  • Ordering: for Batch items with no batch given, order candidates by b.ExpiryDate (nulls last), then ReceiptDate, which gives FEFO. None items keep pure FIFO.
  • New AllocateBatchesAsync(itemId, warehouseId, qty) → [(batchId, qty)] for D5 and for UI suggestions.
  • Keep the deliberate "settle shortfall layers before the batch filter" behaviour; add tests around it.

Phase 4 — Sales documents

  • New SalesInvoiceLineAllocation / SalesSlipLineAllocation (LineId, BatchId?, SerialId?, Qty), following D4/D5. Request DTOs accept batchId? and serialIds?; existing SalesSlipLine.SerialId/BatchId are still read for old documents.
  • Validation in SalesDomainService (shared by slip/invoice/bundle): Serial → serial count equals Qty + FreeQty, each InStock in the line warehouse (D6 exception); Batch → given batch has stock and is not expired, or none given → FEFO allocation on post.
  • SalesPostingService.PostAsync: PostingLine carries allocations; consume per allocation; one ledger row per serial; serial → Sold, location cleared. Replace the current null, null for invoices (line 166) and bundles (line 236).
  • Over-stock sale (allowShortfall) is not allowed for serial items (a serial cannot go negative).

Phase 5 — Warranty registration

  • New WarrantyRegistration: ItemId, SerialId?, GrnLineWarrantyNumberId?, CustomerId?, SourceDocType/SourceDocId/LineId, StartDate, PeriodMonths, EndDate, Status (Active, Voided, Claimed).
  • Created in PostAsync after consumption for items with Warranty = Warranty: PeriodMonths = the receipt snapshot (GrnLineWarrantyNumber.WarrantyPeriodMonths, or the item's current WarrantyPeriodMonths for serial items) and EndDate = StartDate.AddMonths(PeriodMonths).
  • For non-serial warranty items: the sale must name which warranty numbers leave (picker, the same shape as serials), or else D1/D2 is revisited.
  • GET /api/warranty?serialNo=|warrantyNo=|customerId= → status and days remaining.

Phase 6 — Returns

  • SalesReturnLine gets BatchId? and serial allocations. StockDelta (IStockMutator) gets an optional SerialId; StockMutator passes it to layer and ledger.
  • The return reason (existing ReasonCode) maps the serial to InStock (restock) or Damaged; the batch restocks into the same batch; the matching WarrantyRegistration → Voided.
  • A serial can be returned only if it was sold on the referenced invoice line.
  • PurchaseReturnService: serial layers being returned → ReturnedToVendor.

Phase 7 — Transfers, adjustments, counts

  • StockTransferLine + StockAdjustmentLine: serial allocations; transfer moves the per-serial layer and updates Serial.WarehouseId/BinId; a negative adjustment of a serial → Scrapped/Damaged.
  • CountService variance posting for serials: found → create layer + InStock; missing → consume + Damaged (confirm wording with ops). Its existing serial snapshot starts working once Phase 2 writes per-serial layers.
  • Production (ProductionRunService blocks tracked finished goods) — out of scope, unchanged.

Phase 8 — Frontend (Frontend/erp-system)

  • receiving/grn/new: per-line serial entry for Serial items (scan field — Enter adds; paste list; auto-generate prefix + start; "n of N" counter; save disabled until complete). Remove the "KNOWN GAP" note in types/grn.ts and add rules in lib/validations/grn.ts. Hide warranty-number entry for serial items (D1).
  • receiving/grn/[id]: show serials per line (collapsible).
  • Sales slip / invoice / bundle: serial picker (scan or search InStock serials in the line warehouse) and batch picker with expiry and FEFO suggestion; turn the existing scan metadata into real request fields.
  • Sales return: pick serials and batches from the original invoice line.
  • New stock/serials (search by serial → status, location, GRN, sale, warranty) and a warranty lookup.

Phase 9 — Reports

  • Stock on hand by batch, with expiry; "expiring in N days" (feeds the notification module — NotificationModuleCatalog).
  • Serial register / history (from StockLedger.SerialId).
  • Warranty register: active, expiring, expired.

Phase 10 — Migration + tests

  • One EF migration per phase group (1–2, 3–4, 5–7). This is MySQL/MariaDB: re-check any CHECK constraints for the quoting issue fixed in 20261005190000_FixCheckConstraintQuotingForMySql, and note that entity comments still mention PostgreSQL xmin for RowVersion.
  • Backfill: none required (status values are compatible; legacy layers are handled by D6).
  • Tests (Testing/): GRN serial validation and duplicate race; FEFO order with expired/null-expiry batches; serial issue on slip, invoice, and bundle; shortfall blocked for serials; return restores status and voids warranty; transfer moves serial location; count variance for serials.

5. Execution order

Phase 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7     (backend, sequential: shared FifoCostingService / SalesPostingService)
Phase 8 starts per screen once its backend phase lands (GRN after 2, sales after 4, returns after 6)
Phase 9 after 5 · Phase 10 alongside each group

Smallest shippable slice: Phases 1 + 2 + GRN UI (serials can be received correctly). Next: 3 + 4 + 5 (sell with serial/batch, warranty starts).

6. As built — differences from the plan

Plan said Built Why
One stock-ledger row per serial Ledger stays one row per document line (per batch for FEFO splits); new append-only SerialMovement table records every serial status/location change FifoCostingService.PostLedgerAsync posts a GL journal per ledger row — per-serial rows would mean one GL call per unit
Warranty period read from the item at sale (serial items) Snapshotted onto Serial.WarrantyPeriodMonths at GRN confirm Same rule as GrnLineWarrantyNumber — later item edits don't change stock already received
SalesInvoiceLineAllocation / SalesSlipLineAllocation Draft choices in SalesInvoiceLineSerial / SalesSlipLineSerial; what posting actually issued in one polymorphic SalesLineAllocation (doc type/id/line, batch, serial, qty, cost, qtyReturned) Invoices, slips and bundles share one posting path; returns read one table
Transfer: serial allocations on the line StockTransferLineSerial (+ per-serial cost/batch from dispatch) and new status InTransit A serial must be in no warehouse between dispatch and receive
Batch item transfer with no batch Server splits the line FEFO into one line per batch at create Otherwise the batch is lost when the destination layer is created
Count: missing serial → Damaged Missing → Scrapped, found → InStock A unit that can't be found isn't "damaged"
Return reason → serial status DEF/QREJ ⇒ Damaged, else InStock; request markDamaged overrides Reason codes are user data — the flag keeps it explicit
— Pickers live on api/v1/tracking (base ERP access); registers on api/v1/serials (stock), api/v1/warranties (sales), api/v1/stock/batches Sales, GRN and stock screens all need the pickers

Bugs fixed on the way: count posting dropped BatchId on batch lines; the count's serial snapshot wasn't warehouse-scoped; the GRN form never sent a batch expiry date (FEFO had nothing to sort on).

New error codes: SERIAL_DUPLICATE, SERIAL_NOT_AVAILABLE, TRACKING_REQUIRED.

Frontend: GRN "Serial / warranty numbers" tab (serial items: scan/paste, no auto-generated numbers) + batch expiry input; GRN detail shows batch/serials; LineTrackingPicker on sales invoice (new + edit), transfer and adjustment forms; serial ticking on sales and purchase return forms; new pages Stock → Serials, Stock → Batch Expiry, Sales → Warranties (sidebar reuses existing nav codes).

Auto-generated serial numbers (GRN): the serial tab has Prefix (default SKU-) + Auto-generate. GET /tracking/serials/next-numbers?itemId&prefix&count continues the highest number already used under that prefix for the item (received serials and draft-GRN numbers), zero-padded to ≥ 4 digits; the form also skips numbers already typed on it and fills only empty units, so scanned manufacturer serials are never overwritten. Nothing is reserved — the GRN's own uniqueness check on save and confirm remains the guard.

8. Serial numbers on/off switch

Products → Configuration → Serial numbers (ProductConfig.SerialNumbersEnabled) now governs serial tracking at runtime, not just item creation. Read through ISerialTrackingService.IsEnabledAsync (backend) and GET /tracking/settings + useTrackingSettings() (frontend; any logged-in user).

On Off
GRN one serial per unit required no serials; sending some → CONFIG_DISABLED; serial+warranty items take warranty numbers again
Sales / returns / transfers / adjustments / counts serials required (D6 exception) serial items behave as untracked stock; serials refused with CONFIG_DISABLED
Stock issue (FIFO) serial layers only leave by naming the serial serial layers issued oldest-first like any stock; their serials become IssuedUntracked (purchase return of a rejected line → ReturnedToVendor)
Item edit — items already on Serial/Batch can still be edited (only switching to a disabled mode is refused)
UI pickers, GRN serial tab, Serials page hidden; Serials page (if opened) shows a notice; history kept

No schema change (IssuedUntracked is a new string value). Switching back on resumes tracking for stock received from then on; units issued while off are not re-identified.

7. Still open

  • Apply the migration (next backend start, or dotnet ef database update) — the dev connection string points at the shared remote MariaDB.
  • Bundle sales and free issues have no serial picker: a serial item there can only post from pre-tracking stock, otherwise it fails with TRACKING_REQUIRED.
  • Transfer receive UI sends qty only: receiving all outstanding serials works; a partial receive of a serial line needs serialIds (API supports it, the screen doesn't).
  • Non-serial warranty items: warranty registered per sales line by quantity; choosing which GRN warranty numbers leave (Phase 5 picker) is not built.
  • Expiring-batch notifications (NotificationModuleCatalog) not wired; the Batch Expiry page covers it manually.
  • Tests: FIFO/FEFO and posting paths use MariaDB-only SQL, so they are not unit-tested — needs an e2e/integration pass (Testing/e2e) on a real database: GRN serials → sell → return → transfer → count.
  • Production of tracked finished goods — unchanged (still blocked), out of scope.