serial numbers

This commit is contained in:
2026-10-07 14:00:32 +05:30
committed by ImanThiyanga
parent 3abcfa6e6f
commit a788349138
89 changed files with 15320 additions and 216 deletions
+242
View File
@@ -0,0 +1,242 @@
# 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.