Dev #55
@@ -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.
|
||||
Reference in New Issue
Block a user