19 KiB
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)
- Serials are never received.
CreateGrnLineInputhas no serial field (frontendtypes/grn.tsdocuments this as "KNOWN GAP"),GrnServicenever createsSerialrows, andConfirmAsynccreates one layer per line withserialId: null. ASerial-mode item can be received with no serials at all. - FIFO cannot consume a specific serial.
ConsumeAsynctakesbatchIdonly; the slip posting flips the serial toSoldwithout proving that serial's stock was the stock consumed. - Sales invoices drop batch/serial.
SalesInvoiceLinehas noBatchId/SerialId;PostInvoiceAsyncpassesnull, null(SalesPostingService.cs:166). The invoice UI carries them as display-only metadata. Bundle sales also passnull, null. - 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.
- No FEFO. Consumption orders by
ReceiptDate, althoughBatchdocs say "expiry drives FEFO". - Warranty never starts.
GrnLineWarrantyNumberrecords a period, but nothing records the sale date, customer, or expiry date, and there is no lookup. - Returns lose identity.
SalesReturnServicerestocks viaStockDelta(itemId, null, null, qty)— no batch, no serial status change, warranty not voided.PurchaseReturnServicelikewise ignores serials. - Transfers/adjustments carry
BatchIdonly; serial location is never tracked. Serial.Statusis 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 withHasConversion<string>()inBatchSerialConfiguration— 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 asResolveWarrantyNumbers: required iffTrackingMode == Serial; count must equalQty, andQtymust be a whole number; trimmed, distinct within the request, and not already an existingSerialfor the item; rejected when the mode is notSerial. Per D1, a serial+warranty item does not needWarrantyNumbers.ConfirmAsync: for serial lines create oneSerialper number (InStock, warehouse/bin,GrnLineId) and one layer + one ledger row per serial (qty 1) via the existingCreateInboundLayerAsync(..., 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/Rejectedline remainInStockbut non-issuable through the existing layer filter.
Phase 3 — Serial-aware and FEFO consumption (FifoCostingService)
ConsumeAsync(itemId, warehouseId, batchId, qty, ct, allowShortfall, serialIds = null): whenserialIdsis given, issue exactly those layers (each must be open, qty 1, issuable) or throw a newSERIAL_NOT_AVAILABLE(409).- Ordering: for
Batchitems with no batch given, order candidates byb.ExpiryDate(nulls last), thenReceiptDate, which gives FEFO.Noneitems 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 acceptbatchId?andserialIds?; existingSalesSlipLine.SerialId/BatchIdare still read for old documents. - Validation in
SalesDomainService(shared by slip/invoice/bundle):Serial→ serial count equalsQty + FreeQty, eachInStockin the line warehouse (D6 exception);Batch→ given batch has stock and is not expired, or none given → FEFO allocation on post. SalesPostingService.PostAsync:PostingLinecarries allocations; consume per allocation; one ledger row per serial; serial →Sold, location cleared. Replace the currentnull, nullfor 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
PostAsyncafter consumption for items withWarranty = Warranty:PeriodMonths= the receipt snapshot (GrnLineWarrantyNumber.WarrantyPeriodMonths, or the item's currentWarrantyPeriodMonthsfor serial items) andEndDate = 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
SalesReturnLinegetsBatchId?and serial allocations.StockDelta(IStockMutator) gets an optionalSerialId;StockMutatorpasses it to layer and ledger.- The return reason (existing
ReasonCode) maps the serial toInStock(restock) orDamaged; the batch restocks into the same batch; the matchingWarrantyRegistration→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 updatesSerial.WarehouseId/BinId; a negative adjustment of a serial →Scrapped/Damaged.CountServicevariance 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 (
ProductionRunServiceblocks tracked finished goods) — out of scope, unchanged.
Phase 8 — Frontend (Frontend/erp-system)
receiving/grn/new: per-line serial entry forSerialitems (scan field — Enter adds; paste list; auto-generate prefix + start; "n of N" counter; save disabled until complete). Remove the "KNOWN GAP" note intypes/grn.tsand add rules inlib/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
InStockserials 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 PostgreSQLxminforRowVersion. - 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.