Add item tracking and the configuration docs

This commit is contained in:
2026-10-06 13:32:37 +05:30
parent 05d44ab9f0
commit edb5fddc3a
2 changed files with 705 additions and 0 deletions
@@ -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`.
@@ -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<bool> GetSerialNumberTrackingEnabledAsync() => await GetBoolAsync(KeySerialNumberTrackingEnabled);
public async Task<bool> GetBatchTrackingEnabledAsync() => await GetBoolAsync(KeyBatchTrackingEnabled);
public async Task<bool> GetExpiryDateTrackingEnabledAsync() => await GetBoolAsync(KeyExpiryDateTrackingEnabled);
public async Task<bool> GetWarrantyTrackingEnabledAsync() => await GetBoolAsync(KeyWarrantyTrackingEnabled);
public async Task<bool> GetCustomWarrantyOverrideEnabledAsync() => await GetBoolAsync(KeyCustomWarrantyOverrideEnabled);
public async Task<int> GetDefaultWarrantyPeriodDaysAsync()
{
var config = await repo.GetByKeyAsync(KeyDefaultWarrantyPeriodDays);
return config is not null && int.TryParse(config.Value, out var days) ? days : 0;
}
private async Task<bool> 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<ItemBatch> ItemBatches`,
`DbSet<ItemSerialNumber> 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<GrnBatchLineRequest>? Batches` (each carrying `BatchNumber?`, `ExpiryDate?`, `Quantity`) and
`List<string>? 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.