From edb5fddc3a9143989433598e4fd8d87b64b6f9cc Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Tue, 6 Oct 2026 13:32:37 +0530 Subject: [PATCH] Add item tracking and the configuration docs --- .../refactor_docs/item_tracking_config.md | 286 ++++++++++++ .../docs/refactor_docs/item_tracking_plan.md | 419 ++++++++++++++++++ 2 files changed, 705 insertions(+) create mode 100644 Distribution/docs/refactor_docs/item_tracking_config.md create mode 100644 Distribution/docs/refactor_docs/item_tracking_plan.md diff --git a/Distribution/docs/refactor_docs/item_tracking_config.md b/Distribution/docs/refactor_docs/item_tracking_config.md new file mode 100644 index 0000000..63b322f --- /dev/null +++ b/Distribution/docs/refactor_docs/item_tracking_config.md @@ -0,0 +1,286 @@ +# [Feature] Item Tracking Configuration — Serial Numbers, Batches, Warranty, Expiry + +**Status**: Implemented (see [item_tracking_plan.md](./item_tracking_plan.md) for per-phase status). Open items: apply migration `AddDirectGoodsReceipt`; build the sale/delivery step that starts warranty. +**Touches**: `Item` catalogue, GRN intake, stock ledger (`StockBalance`/`StockMovement`), new +Configuration page (`distribution_fe/app/(dashboard)/configuration`) + +--- + +## Goal + +Let specific catalogue items opt into serial-number tracking, batch/lot tracking, and/or expiry-date +tracking, with an optional warranty period (system default or per-item custom override), while: + +1. Each feature (serial tracking, batch tracking, expiry-date tracking, warranty) can be turned + **on/off system-wide** from the new Configuration page, independent of any single item. +2. Each **item** can independently track serial numbers, batches, expiry dates, any combination of + the three, or none of them — never forced into a tracking mode it doesn't need. Expiry-date + tracking does not require batch tracking — a perishable item can carry a single expiry date per + goods receipt without ever being assigned a batch number. +3. Items that track none of these continue to work exactly as today — this must be additive, not a + breaking change to the existing stock ledger or GRN flow. +4. Batches and/or expiry-only lots carry an **expiry date**; serials carry a **warranty expiry**, + derived from a period in days that is either the system default or a per-item custom value (only + if the item is flagged to allow a custom override). + +--- + +## Current State (as found in the codebase, before implementation) + +- **`Item`** (`Models/Item.cs`) has no tracking-related fields today — just SKU, category, UOM, + pricing, `ReorderLevel` (added in the most recent migration, `20260928101132_AddItemReorderLevel`). +- **Stock ledger** (`Models/StockBalance.cs`, `Models/StockMovement.cs`) is keyed purely on + `FK_ItemId` + `LocationId`/`LocationType`. There is no concept of a batch or a serial unit + anywhere in the ledger — one aggregate quantity per item per location. +- **GRN** (`Features/Grn/GrnService.cs`, `Models/GoodsReceiptItem.cs`) records only + `AcceptedQuantity`/`RejectedQuantity` per PO line. No batch number, expiry, or serial capture + point exists anywhere in intake. +- **No existing entity** anywhere in the backend represents a batch, lot, serial number, or + warranty period — this is a net-new domain concept, not a rename/extension of something existing. +- **`SysConfig`** (`Models/SysConfig.cs`, `Features/SysConfig/*`) is a generic key/value store + already used for one system-wide toggle (`InvoiceCreationMode`, see + `SysConfigService.KeyInvoiceCreationMode`). This is the existing mechanism for global on/off + switches and is the natural home for the new feature toggles — no new entity needed for that part. + +--- + +## Two Layers of Configuration + +### 1. Global feature toggles (configuration page) — reuse `SysConfig` + +New well-known keys, following the exact pattern `KeyInvoiceCreationMode` already uses +(`SysConfigService.cs:8`, validated in `ValidateValue`): + +| Key | Values | Meaning | +|---|---|---| +| `Feature.SerialNumberTracking` | `"true"` / `"false"` | Whether any item is allowed to track serial numbers. | +| `Feature.BatchTracking` | `"true"` / `"false"` | Whether any item is allowed to track batches/lots. | +| `Feature.ExpiryDateTracking` | `"true"` / `"false"` | Whether any item is allowed to require an expiry date at intake — independent of batch tracking. | +| `Feature.WarrantyTracking` | `"true"` / `"false"` | Whether warranty periods apply at all. | +| `Feature.CustomWarrantyOverride` | `"true"` / `"false"` | Whether individual items may override the system default warranty period. | +| `Feature.DefaultWarrantyPeriodDays` | integer string | System-wide default warranty length in days, used when an item has warranty enabled but no custom override. | + +`SysConfigService` gains typed getters (`GetSerialTrackingEnabledAsync()`, etc.) mirroring +`GetInvoiceCreationModeAsync()`, plus validation in `ValidateValue` for the boolean/int keys. +`SysConfigController` already exposes generic `GET/PUT /api/sys-config/{key}`, so the +configuration page frontend needs no new backend endpoints for this layer — just calls to the +existing key/value routes. + +Turning a global toggle **off** does not delete per-item settings or existing batch/serial data — +it only hides the corresponding controls in the Item Sheet and disables new intake of that kind +(enforced in `ItemService`/`GrnService`, not just the UI). + +### 2. Per-item settings — new columns on `Item` + +| Column | Type | Notes | +|---|---|---| +| `TracksSerialNumbers` | `bool` | Default `false`. Can only be set `true` if `Feature.SerialNumberTracking` is on. | +| `TracksBatches` | `bool` | Default `false`. Can only be set `true` if `Feature.BatchTracking` is on. | +| `TracksExpiryDate` | `bool` | Default `false`. Can only be set `true` if `Feature.ExpiryDateTracking` is on. Independent of `TracksBatches` — see below. | +| `HasWarranty` | `bool` | Default `false`. Can only be set `true` if `Feature.WarrantyTracking` is on. | +| `WarrantyPeriodDays` | `int?` | Only meaningful if `HasWarranty`. Null = use `Feature.DefaultWarrantyPeriodDays`. | +| `AllowCustomWarrantyPeriod` | `bool` | Default `false`. Gates whether `WarrantyPeriodDays` may be set on this item at all — requires `Feature.CustomWarrantyOverride` on. | + +`TracksSerialNumbers`, `TracksBatches`, and `TracksExpiryDate` are independent booleans, so every +combination (none / any one / any two / all three) falls out naturally — no extra enum needed. An +item can have `TracksExpiryDate = true` with `TracksBatches = false`: it gets a per-receipt expiry +date without ever needing a batch number (see `ItemBatch` below, whose `BatchNumber` becomes +optional to support this case). + +Validation lives in `ItemService.CreateAsync`/`UpdateAsync`: reject the request if a per-item flag +is `true` while its global feature is off, with a message like *"Serial number tracking is +disabled system-wide."* + +--- + +## New Entities — Batches and Serial Numbers + +### `ItemBatch` + +| Column | Type | Notes | +|---|---|---| +| Id | Guid | PK | +| FK_ItemId | Guid | FK → Item. Item must have `TracksBatches = true` and/or `TracksExpiryDate = true`. | +| BatchNumber | string? | Supplier/internal lot code. **Required if `TracksBatches`, otherwise null** — an expiry-only item (`TracksExpiryDate` with `TracksBatches = false`) gets a row here purely to carry the expiry date, with no batch number. | +| ManufactureDate | DateTime? | Optional. | +| ExpiryDate | DateTime? | Required if `TracksExpiryDate`. Drives expiry alerts and (later phase) dispatch blocking. | +| IsActive | bool | Standard soft-delete/versioning flag. | +| CreatedAt | DateTime | From `Template`. | + +`ItemBatch` is the single mechanism behind both "batch tracking" and "expiry-date tracking" — a +batch is a subdivision of one item's stock, not a new stock-holding location, and quantity for a +batch/lot still lives in the ledger (see below). The row only exists at all when the item tracks +batches or expiry dates (or both); an item that tracks neither never gets `ItemBatch` rows. + +### `ItemSerialNumber` + +| Column | Type | Notes | +|---|---|---| +| Id | Guid | PK | +| FK_ItemId | Guid | FK → Item. Item must have `TracksSerialNumbers = true`. | +| SerialNumber | string | Unique per item. | +| Status | enum `SerialStatus { InStock, Sold, Returned, Damaged }` | Current lifecycle state. | +| CurrentLocationId / CurrentLocationType | Guid? / `LocationType?` | Mirrors the ledger's location model — where this specific unit currently sits. | +| ReceivedAt | DateTime | When it entered stock (GRN). | +| WarrantyPeriodDaysOverride | int? | Snapshotted at receipt from `Item.WarrantyPeriodDays` when `Item.HasWarranty` and `Item.AllowCustomWarrantyPeriod`; otherwise null (system default applies when the warranty starts). | +| WarrantyExpiresAt | DateTime? | **Set at sale/delivery, not receipt**: sale date + (override ?? `Feature.DefaultWarrantyPeriodDays`). Null while in stock or if `Item.HasWarranty` is false. *(Original design computed this at receipt; changed so warehouse time doesn't consume the customer's warranty.)* | +| IsActive | bool | Standard flag. | + +A serial number represents exactly one physical unit — quantity is always 1, so serials are +tracked as rows, not as a quantity column. + +--- + +## Stock Ledger Changes (additive, nullable) + +`StockBalance` and `StockMovement` (`Models/StockBalance.cs`, `Models/StockMovement.cs`) each gain: + +| Column | Type | Notes | +|---|---|---| +| `FK_ItemBatchId` | Guid? | Null for items that don't track batches — **no behavior change for existing items.** | +| `FK_ItemSerialNumberId` | Guid? | Null for items that don't track serials. | + +- Untracked items: both columns stay null, ledger behaves exactly as it does today (one aggregate + row per item+location). +- Batch-tracked items: one `StockBalance` row per (Item, Location, Batch) instead of per (Item, + Location) — the existing `StockRepository.GetBalanceAsync`/`AddBalanceAsync` calls in + `GrnService.CreateAsync` (`Features/Grn/GrnService.cs:106-122`) need a batch-aware overload. +- Serial-tracked items: quantity is always 1 per movement; a `StockMovement` moves one serial from + one location to another and updates `ItemSerialNumber.CurrentLocationId/Type` + `Status` in the + same transaction. A running `StockBalance` row can still exist (count of serials at a location) + for consistency with existing balance-reporting UI, kept in sync alongside the serial's own + status. + +--- + +## GRN Changes (intake is where batches/serials are born) + +`GoodsReceiptItem` (`Models/GoodsReceiptItem.cs`) needs a capture point matching the item's +tracking mode: + +- **Batch-tracked item**: the GRN line must supply a batch number (+ expiry date if the item also + has `TracksExpiryDate`) instead of a bare accepted quantity. New table `GoodsReceiptItemBatch` + (FK_GoodsReceiptItemId, BatchNumber?, ExpiryDate?, Quantity) — a single PO line can be split across + multiple batches in one receipt. +- **Expiry-only item** (`TracksExpiryDate` without `TracksBatches`): the GRN line must supply an + expiry date but no batch number — same `GoodsReceiptItemBatch` table, `BatchNumber` left null. +- **Serial-tracked item**: the GRN line must supply exactly `AcceptedQuantity` serial numbers. New + table `GoodsReceiptItemSerial` (FK_GoodsReceiptItemId, SerialNumber). +- **None of the above**: unchanged — today's `AcceptedQuantity`/`RejectedQuantity` flow stays as-is. + +`GrnService.CreateAsync` (`Features/Grn/GrnService.cs:44`) needs a branch per tracking mode when +writing `StockBalance`/`StockMovement`: create/lookup the `ItemBatch` (or create each +`ItemSerialNumber`) before writing the ledger rows, and validate the request shape matches the +item's `TracksSerialNumbers`/`TracksBatches`/`TracksExpiryDate` flags (e.g. reject a batch number on +an item that doesn't track batches, or a missing expiry date on an item that requires one). + +--- + +## Frontend Changes + +- **configuration page** (`distribution_fe/app/(dashboard)/configuration/page.tsx`, currently a + placeholder): add an "Item Tracking" section with toggles for the five `Feature.*` keys and a + number input for the default warranty period, following the same form pattern as + `drivers/compliance/page.tsx` (a single-purpose settings form backed by a GET on load / PUT on + save) but hitting the generic `/api/sys-config/{key}` routes instead of a dedicated controller. +- **Item Sheet** (`catalogue/items/_components/item-sheet.tsx`): add a "Tracking" `FieldGroup` with + `Track serial numbers` / `Track batches` / `Track expiry date` checkboxes, a `Has warranty` checkbox revealing a + warranty-period number input, and (only when `Feature.CustomWarrantyOverride` is on) an + `Allow custom warranty period` checkbox. Checkboxes for a disabled global feature are hidden + entirely, not just disabled, to avoid confusing item editors. +- **GRN entry** — built as two full pages (against a PO: `/procurement/grn/po/new`; direct: + `/procurement/grn/direct/new`). Tracked lines show a status chip that opens a panel for serial + entry (scan / auto-generate / type-paste-edit) or batch rows (batch no. with Auto, expiry, qty). + Save is disabled until every tracked line is complete. Details in the plan, Phases 10–12. +- **configuration page**: a single Products card on `/configuration` holds the switches and warranty + defaults (no separate sub-page). **Item view** shows tracking badges. + +--- + +## Direct Goods Receipt (no purchase order) + +A goods receipt may exist without a PO. `GoodsReceipt.FK_PurchaseOrderId` and +`GoodsReceiptItem.FK_PurchaseOrderItemId` are nullable; direct lines carry `FK_ItemId` instead; +an optional `FK_SupplierId` identifies the supplier. `POST /api/goods-receipts/direct` creates one. +Serial/batch/expiry capture, stock balances and movements are identical to a PO receipt (shared +`PostReceiptAsync`); the only differences are that no PO quantity or status is updated and no PO +validation applies. See the plan, Phase 11. + +--- + +## Process — How Item Tracking Works End to End + +### 1. Setup (once) +1. **Configuration → Products card**: switch on the capabilities the business needs (serial, batch, + expiry, warranty; optionally per-item custom warranty) and set the default warranty days. Changes + save immediately and are stored in `SysConfig` (`Feature.*` keys). +2. **Catalogue → Items → edit item**: tick `Track serial numbers` / `Track batches` / + `Track expiry date` and, if applicable, `Has warranty` (+ `Use a custom warranty period` and days). + A flag can only be set if its global feature is on. Untracked items are unaffected. + +### 2. Receiving goods (where serials / batches are entered) +Entered **only at goods receipt**, never on the item or PO forms. + +| Item tracks | GRN line needs | Stored as | +|---|---|---| +| nothing | accepted / rejected qty | one `StockBalance` row per item+warehouse | +| serial numbers | exactly *accepted qty* unique serials | one `ItemSerialNumber` (InStock) + one `StockBalance` (qty 1) + one `StockMovement` per unit | +| batches | batch rows (batch no., [expiry], qty) summing to accepted qty | `ItemBatch` (reused by item + batch no.) + `StockBalance` per (item, warehouse, batch) + `StockMovement` per batch | +| expiry only | lot rows (expiry, qty), **no** batch number | fresh `ItemBatch` per row carrying only the expiry date | +| serial + batch/expiry | serials only (serial wins; batch/expiry not linked — follow-up) | as serial | + +Rejected quantity never touches stock. Everything posts in one unit of work and rolls back on any +failure. A serial that already exists for the item, or a mismatch with the item's flags, is rejected. + +**Two ways to receive**: *against a PO* (lines come from the PO, PO received-quantities and status +update) or *direct* (no PO; item chosen per line, optional supplier). The UI exposes them as two +pages and both share the same serial/batch entry panel. + +### 3. Entering serials / batches in the UI +- Each tracked line shows a chip (amber until complete, green ✓ when complete) → opens the panel. +- **Serials**: scan (Enter adds one), auto-generate (prefix + start number, fills only missing units), + or type/paste one per line; every entry stays editable; duplicates are flagged. +- **Batches**: add rows; batch number can be typed, scanned or auto-generated; expiry date warns if + expired or within 30 days; quantities must add up to the accepted quantity. +- Save is disabled until all tracked lines are complete; the server re-validates. +- Prefer real supplier serials/batch numbers where available — auto-generated values suit internal tags. + +### 4. Warranty lifecycle +1. *Receipt*: serial created `InStock`; only the item's custom period (if any) is snapshotted. +2. *Sale/delivery* (**not built yet**): serial → `Sold`; `WarrantyExpiresAt = sale date + (override ?? + default days)`. +3. *Claim / return* (future): `WarrantyExpiresAt` decides eligibility; status moves to + `Returned` / `Damaged`. + +### 5. Viewing +GRN view shows batches (with expiry) and an expandable serial list per line; the item view shows +tracking badges; the receipts list marks direct receipts. + +--- + +## Explicitly Out of Scope for This Phase + +- **Picking/dispatch by batch or serial** (loading sheets, delivery tasks, sales returns) — this + doc only covers catalogue configuration and stock intake (GRN) + ledger schema. Selecting *which* + batch or serial goes out on a given ride/loading sheet is a separate follow-up doc once this + foundation exists. +- **FEFO (first-expiry-first-out) enforcement** at dispatch time. +- **Expired-batch blocking** — whether dispatch should hard-block an expired batch or just warn is + an open question below, deferred to the dispatch-side follow-up doc. + +--- + +## Open Questions + +1. Should an expired `ItemBatch` hard-block further dispatch, or just surface a warning? (Decision + deferred to the dispatch follow-up doc, but the `ExpiryDate` column needs to exist now either way.) +2. Is `WarrantyPeriodDays` always in days, or should the Item Sheet offer months/years and convert? +~~Resolved:~~ warranty timing — decided to start at sale, not receipt (see Process §4). + +3. Can serial numbers ever be added outside a GRN (e.g. a manual stock adjustment for an + already-tracked item), or is GRN the only entry point in this phase? +4. When batch tracking splits one item's stock into multiple `StockBalance` rows per location, does + the existing Stock Balances page (`stock/_components/stock-balances-client.tsx`) need a + drill-down, or is an aggregated total (sum across batches) sufficient for now? +5. Naming for the migration: proposed `AddItemTrackingConfig`, continuing from the most recent + migration `20260928101132_AddItemReorderLevel`. diff --git a/Distribution/docs/refactor_docs/item_tracking_plan.md b/Distribution/docs/refactor_docs/item_tracking_plan.md new file mode 100644 index 0000000..d442185 --- /dev/null +++ b/Distribution/docs/refactor_docs/item_tracking_plan.md @@ -0,0 +1,419 @@ +# Item Tracking Configuration — Implementation Plan + +**Status**: Implemented — serial/batch/expiry capture on both receipt flows (against a PO and direct), config + item UI done. Pending: DB migration `AddDirectGoodsReceipt`, and the sale/delivery step that starts warranty (not built) +**Design doc**: [item_tracking_config.md](./item_tracking_config.md) + +| Phase | Title | Status | +|-------|-------|--------| +| 1 | Enums | ✅ Done | +| 2 | Global feature toggles (`SysConfig`) | ✅ Done | +| 3 | `Item` per-item tracking fields | ✅ Done | +| 4 | New entities — `ItemBatch`, `ItemSerialNumber` | ✅ Done | +| 5 | Stock ledger — batch/serial columns | ✅ Done | +| 6 | GRN intake — batch/serial capture | ✅ Done (serial path takes precedence when an item tracks both serials and batches — see `GrnService.ValidateLineShape`). **Warranty is no longer computed here** — see "Warranty timing" below | +| 7 | Migration | ✅ Done — `20261001080630_AddItemTrackingConfig`, applied | +| 8 | configuration page UI | ✅ Done — single Products card on `/configuration` (no separate `/configuration/products` route) | +| 9 | Item Sheet UI | ✅ Done (also fixed pre-existing `reorderLevel` drift in `types/api.ts`/`ItemBody`) | +| 10 | GRN form UI — serial/batch entry | ✅ Done — see Phase 10 | +| 11 | Direct goods receipt (no PO) — backend | ✅ Code done · ⏳ migration `AddDirectGoodsReceipt` generated, **not yet applied** | +| 12 | GRN pages — two routes (against PO / direct) | ✅ Done | +| 13 | Sale/delivery step — start warranty, mark serial `Sold` | ❌ Not built (needs a decision on which document triggers it) | + +--- + +## Scope + +Add opt-in serial-number and batch/lot tracking to catalogue items, each independently on/off +system-wide via the new configuration page, with warranty periods (system default or per-item +custom override) and batch expiry dates. Fully additive to the existing schema — items that track +neither must behave exactly as they do today. See the design doc for full rationale, entity shapes, +and open questions; this file only sequences the work. + +--- + +## Execution Order + +``` +Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5 → Phase 6 → Phase 7 (migration) +→ Phase 8, 9, 10 (frontend, independent of each other, after Phase 7) +``` + +Backend phases 2–6 touch shared files (`Item.cs`, `AppDBContext.cs`) in sequence and should not be +parallelized. Frontend phases 8–10 have no dependencies on each other. + +--- + +## Phase 1 — Enums + +**File**: `Models/Enums.cs` + +Add: + +```csharp +public enum SerialStatus { InStock, Sold, Returned, Damaged } +``` + +--- + +## Phase 2 — Global feature toggles (`SysConfig`) + +**Files**: `Features/SysConfig/SysConfigService.cs`, `Features/SysConfig/SysConfigController.cs` + +### 2.1 New well-known keys + +In `SysConfigService`, alongside `KeyInvoiceCreationMode`: + +```csharp +public const string KeySerialNumberTrackingEnabled = "Feature.SerialNumberTracking"; +public const string KeyBatchTrackingEnabled = "Feature.BatchTracking"; +public const string KeyExpiryDateTrackingEnabled = "Feature.ExpiryDateTracking"; +public const string KeyWarrantyTrackingEnabled = "Feature.WarrantyTracking"; +public const string KeyCustomWarrantyOverrideEnabled = "Feature.CustomWarrantyOverride"; +public const string KeyDefaultWarrantyPeriodDays = "Feature.DefaultWarrantyPeriodDays"; +``` + +### 2.2 Typed getters + +Mirror `GetInvoiceCreationModeAsync()`: + +```csharp +public async Task GetSerialNumberTrackingEnabledAsync() => await GetBoolAsync(KeySerialNumberTrackingEnabled); +public async Task GetBatchTrackingEnabledAsync() => await GetBoolAsync(KeyBatchTrackingEnabled); +public async Task GetExpiryDateTrackingEnabledAsync() => await GetBoolAsync(KeyExpiryDateTrackingEnabled); +public async Task GetWarrantyTrackingEnabledAsync() => await GetBoolAsync(KeyWarrantyTrackingEnabled); +public async Task GetCustomWarrantyOverrideEnabledAsync() => await GetBoolAsync(KeyCustomWarrantyOverrideEnabled); +public async Task GetDefaultWarrantyPeriodDaysAsync() +{ + var config = await repo.GetByKeyAsync(KeyDefaultWarrantyPeriodDays); + return config is not null && int.TryParse(config.Value, out var days) ? days : 0; +} + +private async Task GetBoolAsync(string key) +{ + var config = await repo.GetByKeyAsync(key); + return config is not null && bool.TryParse(config.Value, out var enabled) && enabled; +} +``` + +### 2.3 Validation + +Extend `ValidateValue` to accept `"true"`/`"false"` for the five boolean keys and a non-negative +integer string for `KeyDefaultWarrantyPeriodDays`. + +### 2.4 Controller + +No new routes — the existing generic `GET/PUT /api/sys-config/{key}` already covers reading and +setting these keys (same pattern the frontend will already use for `InvoiceCreationMode`). + +--- + +## Phase 3 — `Item` per-item tracking fields + +**File**: `Models/Item.cs` + +Add after `ReorderLevel`: + +```csharp +public bool TracksSerialNumbers { get; set; } = false; +public bool TracksBatches { get; set; } = false; +public bool TracksExpiryDate { get; set; } = false; +public bool HasWarranty { get; set; } = false; +public int? WarrantyPeriodDays { get; set; } +public bool AllowCustomWarrantyPeriod { get; set; } = false; +``` + +**File**: `Features/Item/ItemDTOs.cs` + +Add the same six fields to `CreateItemRequest`, `UpdateItemRequest`, `ItemSummaryResponse`, and +`ItemDetailResponse` (all optional/defaulted, matching how `ReorderLevel` was added). + +**File**: `Features/Item/ItemService.cs` + +In `CreateAsync`/`UpdateAsync`, before saving, validate each per-item flag against the matching +`SysConfigService` getter from Phase 2 and throw `InvalidOperationException` with a message like +*"Serial number tracking is disabled system-wide."* if a flag is `true` while its feature is off +(same check for `TracksExpiryDate` against `KeyExpiryDateTrackingEnabled`). Same check for +`AllowCustomWarrantyPeriod` against `KeyCustomWarrantyOverrideEnabled`, and for +`WarrantyPeriodDays` requiring `HasWarranty = true`. + +--- + +## Phase 4 — New entities: `ItemBatch`, `ItemSerialNumber` + +**New file**: `Models/ItemBatch.cs` + +```csharp +public class ItemBatch : Template +{ + public Guid FK_ItemId { get; set; } + public Item Item { get; set; } + + // Null when the item tracks expiry dates but not batch numbers (TracksExpiryDate without TracksBatches). + public string? BatchNumber { get; set; } + public DateTime? ManufactureDate { get; set; } + public DateTime? ExpiryDate { get; set; } +} +``` + +This single entity backs both `TracksBatches` and `TracksExpiryDate` — an item can have one, the +other, or both; `ItemBatch` rows only get created at all when at least one of the two is true. + +**New file**: `Models/ItemSerialNumber.cs` + +```csharp +public class ItemSerialNumber : Template +{ + public Guid FK_ItemId { get; set; } + public Item Item { get; set; } + + public string SerialNumber { get; set; } + public SerialStatus Status { get; set; } = SerialStatus.InStock; + + public Guid? CurrentLocationId { get; set; } + public LocationType? CurrentLocationType { get; set; } + + public DateTime ReceivedAt { get; set; } + public int? WarrantyPeriodDaysOverride { get; set; } + public DateTime? WarrantyExpiresAt { get; set; } +} +``` + +**File**: `Data/AppDBContext.cs` — add `DbSet ItemBatches`, +`DbSet ItemSerialNumbers`, unique index on `(FK_ItemId, SerialNumber)` for +`ItemSerialNumber`, and index on `(FK_ItemId, BatchNumber)` for `ItemBatch`. + +--- + +## Phase 5 — Stock ledger: batch/serial columns + +**Files**: `Models/StockBalance.cs`, `Models/StockMovement.cs` + +Add to both: + +```csharp +public Guid? FK_ItemBatchId { get; set; } +[ForeignKey("FK_ItemBatchId")] +public ItemBatch? ItemBatch { get; set; } + +public Guid? FK_ItemSerialNumberId { get; set; } +[ForeignKey("FK_ItemSerialNumberId")] +public ItemSerialNumber? ItemSerialNumber { get; set; } +``` + +**Files**: `Features/Stock/IStockRepository.cs`, `Features/Stock/StockRepository.cs` + +Add batch-aware overloads: `GetBalanceAsync(itemId, locationId, batchId)` alongside the existing +`GetBalanceAsync(itemId, locationId)`, used only when `Item.TracksBatches` is true so untracked +items keep calling the existing method unchanged. + +--- + +## Phase 6 — GRN intake: batch/serial capture + +**New file**: `Models/GoodsReceiptItemBatch.cs` + +```csharp +public class GoodsReceiptItemBatch : Template +{ + public Guid FK_GoodsReceiptItemId { get; set; } + public GoodsReceiptItem GoodsReceiptItem { get; set; } + + public string BatchNumber { get; set; } + public DateTime? ExpiryDate { get; set; } + public int Quantity { get; set; } +} +``` + +**New file**: `Models/GoodsReceiptItemSerial.cs` + +```csharp +public class GoodsReceiptItemSerial : Template +{ + public Guid FK_GoodsReceiptItemId { get; set; } + public GoodsReceiptItem GoodsReceiptItem { get; set; } + + public string SerialNumber { get; set; } +} +``` + +**File**: `Features/Grn/GrnDTOs.cs` — extend the create-GRN line request with optional +`List? Batches` (each carrying `BatchNumber?`, `ExpiryDate?`, `Quantity`) and +`List? SerialNumbers`, validated so exactly one of {bare quantity, batches, serials} is +populated depending on the item's tracking flags. + +**File**: `Features/Grn/GrnService.cs` + +In `CreateAsync` (`Features/Grn/GrnService.cs:44`), branch per line on +`poItem.Item.TracksSerialNumbers` / `TracksBatches` / `TracksExpiryDate`: + +- None of the three: unchanged — today's `AcceptedQuantity`/`RejectedQuantity` path. +- Batches and/or expiry-only: reject if `line.Batches` is null/empty or its quantities don't sum to + `AcceptedQuantity`; reject a populated `BatchNumber` if `TracksBatches` is false, and reject a + missing `ExpiryDate` if `TracksExpiryDate` is true. For each batch entry, create or look up an + `ItemBatch` (by `FK_ItemId` + `BatchNumber` when present, otherwise a fresh expiry-only row), + write one `GoodsReceiptItemBatch`, and write/increment `StockBalance` keyed by + `(ItemId, WarehouseId, BatchId)` plus a `StockMovement` carrying `FK_ItemBatchId`. +- Serials: reject if `line.SerialNumbers.Count != AcceptedQuantity` or any serial number already + exists for the item; create one `ItemSerialNumber` per entry (`Status = InStock`; **no `WarrantyExpiresAt` yet** — see "Warranty timing"), one + `GoodsReceiptItemSerial`, and one `StockMovement` per serial carrying `FK_ItemSerialNumberId` + (quantity always 1). + +--- + +### Warranty timing (changed from the original plan) + +The original plan computed `WarrantyExpiresAt = ReceivedAt + period` at GRN. That made stock sitting in +a warehouse consume the customer's warranty, so it was changed: **the warranty clock starts when the +serial is sold/delivered**, not when it is received. + +- At GRN, `GrnService` only snapshots the item's custom period onto the serial + (`WarrantyPeriodDaysOverride = Item.WarrantyPeriodDays`, and only when `HasWarranty` and + `AllowCustomWarrantyPeriod`). `WarrantyExpiresAt` stays `null`. +- `GrnService` no longer depends on `SysConfigService`. +- When the sale/delivery step exists (Phase 13), it sets + `WarrantyExpiresAt = saleDate + (WarrantyPeriodDaysOverride ?? Feature.DefaultWarrantyPeriodDays)` + and moves the serial to `SerialStatus.Sold`. +- Caveat: with the default `Feature.DefaultWarrantyPeriodDays = 0` and no per-item period, the + warranty would expire the day it starts — set a default or per-item periods before enabling warranty. + +### Shared posting code (refactor done while adding Phase 11) + +`GrnService.PostReceiptAsync(goodsReceipt, lines, warehouseId, userId, po?, ct)` now holds the +unit-of-work block: add the receipt, per line write serials / batches / `StockBalance` / +`StockMovement` (type `PurchaseReceipt`), update the PO when present, commit. Both +`CreateAsync` (PO receipt) and `CreateDirectAsync` (no PO) validate their own input and then call +it, so tracking behaviour is identical in both flows. + +--- + +## Phase 7 — Migration + +(Applied.) Run `dotnet ef migrations add AddItemTrackingConfig` after Phases 3–6 are code-complete, covering: +`Item` new columns, `ItemBatch`, `ItemSerialNumber`, `GoodsReceiptItemBatch`, +`GoodsReceiptItemSerial` tables, and the new nullable FK columns on `StockBalance`/`StockMovement`. +Review the generated migration for the FK-drop pitfall noted in `refactor_plan.md` Phase 5 before +running `dotnet ef database update`. + +--- + +## Phase 8 — configuration page UI + +**Files**: `distribution_fe/app/(dashboard)/configuration/page.tsx`, +`configuration/_components/product-capabilities-card.tsx` + +**As built**: `/configuration` shows one **Products** card whose body holds the switches and warranty +defaults directly (each switch saves immediately via `sysConfig.set`, keys in +`ITEM_TRACKING_KEYS`). There is no separate `/configuration/products` page. The original +description follows. Replace the placeholder with a form (pattern: `drivers/compliance/page.tsx`) that on load calls +`GET /api/sys-config` (or the five individual `GET /api/sys-config/{key}` calls) and on save calls +`PUT /api/sys-config/{key}` once per changed key: + +- Checkbox — "Enable serial number tracking" (`Feature.SerialNumberTracking`) +- Checkbox — "Enable batch/lot tracking" (`Feature.BatchTracking`) +- Checkbox — "Enable expiry date tracking" (`Feature.ExpiryDateTracking`) +- Checkbox — "Enable warranty periods" (`Feature.WarrantyTracking`) +- Checkbox — "Allow items to set a custom warranty period" (`Feature.CustomWarrantyOverride`), only + shown/enabled when warranty tracking is on +- Number input — "Default warranty period (days)" (`Feature.DefaultWarrantyPeriodDays`), only shown + when warranty tracking is on + +Add `sysConfig` helpers to `lib/api/index.ts` (`get(key)`, `set(key, value)`) if not already present. + +--- + +## Phase 9 — Item Sheet UI + +**File**: `distribution_fe/app/(dashboard)/catalogue/items/_components/item-sheet.tsx` + +Add a "Tracking" `FieldGroup` after the existing fields: + +- Checkbox `TracksSerialNumbers` — rendered only if the global serial-tracking toggle is on +- Checkbox `TracksBatches` — rendered only if the global batch-tracking toggle is on +- Checkbox `TracksExpiryDate` — rendered only if the global expiry-date toggle is on; independent + of `TracksBatches`, so it can be checked on its own for perishables with no batch numbering +- Checkbox `HasWarranty` — rendered only if the global warranty toggle is on +- Number input `WarrantyPeriodDays` — shown only when `HasWarranty` is checked +- Checkbox `AllowCustomWarrantyPeriod` — shown only when `HasWarranty` is checked **and** the + global custom-override toggle is on + +Fetch the four global toggles once (e.g. via a small `useSysConfig` hook or inline `Promise.all` +next to the existing `categoriesApi`/`brandsApi`/`uomsApi` calls) when the sheet opens, so hidden +fields never get submitted as `true`. + +Also update `ItemSummaryResponse`/`ItemDetailResponse` in `distribution_fe/types/api.ts` with the +five new fields (this file was already found to be missing `reorderLevel` from a prior backend +change — fix that drift in the same pass). + +--- + +## Phase 10 — GRN form UI (serial / batch entry) + +**Files** (`distribution_fe/app/(dashboard)/procurement/grn/_components/`): `grn-tracking.tsx` +(shared), `grn-view-sheet.tsx`; types in `types/api.ts`, `lib/api/procurement.ts`. + +- The form loads each line's item (`items.get`) to read its tracking flags — the PO line response + does not carry them. +- Tracked lines show a **status chip** ("Enter serials · 0/50", amber until complete, green "✓" when + complete). Clicking it opens a panel (`LineTracking`): + - **Serial items**: scan field (Enter adds one serial, duplicates rejected), **auto-generate** + (prefix + start number, fills only the missing units, skips taken values), and an editable + list box (type / paste / edit / clear). Live "n of N entered" count, duplicates listed. + - **Batch / expiry items**: rows of batch number (+ **Auto** button), expiry date, quantity; + "Add batch" pre-fills the remaining quantity; warns "Already expired" / "Expires within 30 + days"; live "allocated of accepted" count. Batch-number column hidden if the item does not track + batches; expiry column hidden if it does not track expiry. + - Warranty is a read-only note ("starts at sale") — never an input at GRN. +- `trackingProgress()` drives chip state; **Save is disabled until every tracked line is complete**. + `trackingPayload()` mirrors `GrnService.ValidateLineShape` and builds the request (or an error + toast). Serial tracking takes precedence over batch/expiry, as on the server. +- **GRN view** lists batches per line (batch no., qty, expiry) and serials in an expandable + "n serial numbers" row. **Item view sheet** shows tracking badges (Serial / Batch / Expiry / + Warranty period). + +--- + +## Phase 11 — Direct goods receipt (no purchase order) + +Goods can now be received without a PO. Both flows work independently and share the posting code. + +**Models / migration `AddDirectGoodsReceipt`** (generated; **apply with `dotnet ef database update`**): +- `GoodsReceipt.FK_PurchaseOrderId` → nullable; new optional `FK_SupplierId` (+ `Supplier` nav). +- `GoodsReceiptItem.FK_PurchaseOrderItemId` → nullable; new `FK_ItemId` (+ `Item` nav) set on + direct lines. Existing PO lines resolve their item through `PurchaseOrderItem` (`MapItem`: + `i.Item ?? i.PurchaseOrderItem!.Item`). + +**DTOs** (`GrnDTOs.cs`): `GrnItemRequest.PurchaseOrderItemId` is now `Guid?`, plus +`Guid? ItemId = null` for direct lines; new `CreateDirectGrnRequest(WarehouseId, Items, SupplierId?, +ReceivedByUserId?)`; `GrnResponse` gains `SupplierId`/`SupplierName` and a nullable +`PurchaseOrderId`; `GrnItemResponse.PurchaseOrderItemId` is nullable. + +**Endpoint**: `POST /api/goods-receipts/direct` (`GrnController.CreateDirect`). +`GrnService.CreateDirectAsync` validates: at least one line; supplier (if given) exists and is +active; each line has an `ItemId` of an active item; quantities non-negative and not both zero; +`ValidateLineShape`; serials not already in stock and not repeated across lines of the same +request. It then calls `PostReceiptAsync` with no PO — no PO quantities or status change. + +**Not included (by choice)**: unit cost on direct lines; a received-date input (the server stamps +`DateTime.Now` — also true of the PO flow). + +--- + +## Phase 12 — GRN pages (two routes) + +| Route | Purpose | +|---|---| +| `/procurement/grn` | Receipts list. "Receipt Against PO" and "Direct Receipt" buttons navigate to the pages below. PO column shows "Direct receipt" when there is no PO. | +| `/procurement/grn/po/new` | Pick an issued / partially received PO, warehouse, date, user; PO lines pre-filled with remaining quantity; serial/batch chips on tracked lines. | +| `/procurement/grn/direct/new` | Warehouse, optional supplier, user, then "Add item" rows (item + accepted / rejected + serial/batch chip). | + +Both are full pages (not dialogs), using the shared `grn-tracking.tsx`. The old popup components +`grn-sheet.tsx` / `direct-grn-sheet.tsx` were removed. The GRN view remains a dialog. + +--- + +## Phase 13 — Sale/delivery step (not built) + +Needed to complete warranty: on sale/delivery of a serial, set `Status = Sold`, +`WarrantyExpiresAt`, and (for batch items) consume batch balances (FEFO suggestion). Also still +missing: stock screen drill-down by batch/serial and any warranty-status lookup UI. Requires a +decision on which document (invoice vs delivery) triggers it.