# 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()` 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?`; `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.