Refactor production and stock tests to remove UOM dependency

- Updated production.spec.ts, stock-adjustments.spec.ts, and stock-transfers.spec.ts to eliminate UOM references in API seeder and test cases.
- Adjusted ApiSeeder methods to remove UOM parameters from stock receiving and production template creation.
- Revised documentation to reflect changes in UOM handling, emphasizing that stock is counted in base UOM only.
- Introduced new enums for MeasureUnit and StageQtyUnit to clarify content size and stage input quantities.
- Implemented ItemContent service to validate and normalize content sizes.
- Updated smoke tests to validate production stage inputs expressed in content units, ensuring correct consumption calculations.
- Modified frontend UOM label handling to reflect the removal of per-line UOMs in document lines.
This commit is contained in:
2026-08-11 11:32:40 +05:30
parent d37824cecc
commit 15ddac178c
108 changed files with 1221 additions and 987 deletions
+4 -3
View File
@@ -119,8 +119,8 @@ One base currency; invoicing/3-way match in Accounting (GRN carries data); users
| ID | Requirement | Pri |
|---|---|---|
| FR-MD-01 | Maintain **Item master**: SKU (unique), name, description, category (+ optional subcategory), optional brand, **stock nature** (Stocked/NonStocked/Service), tracking mode (None/Batch/Serial), status, tax class, default vendor, **optional fixed sale price** (nullable; Sales-only — never enters costing/GRN/FIFO; `null` ⇒ item is sold at its stock/FIFO value). | M |
| FR-MD-02 | Maintain **UOM master** with base UOM per item and **conversion factors** (purchase→stock→base). | M |
| FR-MD-03 | Convert quantities between UOMs on every transaction; store base-UOM quantity in the ledger. | M |
| FR-MD-02 | Maintain **UOM master**, with one base UOM per item — the pack it is stocked and counted in. An item may also carry an optional **content size** (how much one pack holds: 500 ml, 50 kg), entered in ml/L/g/kg and stored normalised to ml or g. *(Revised 2026-08-11: per-item conversion factors are gone — see the note below.)* | M |
| FR-MD-03 | Record every transaction quantity as a count of the item's base UOM. There is **no conversion**: a differently sized pack is a different item. The one exception is a production stage input, which may be written in the item's content unit and is divided by the content size to get the pack count (FR-MFG-04). | M |
| FR-MD-04 | Maintain **item categories with one optional subcategory level**. An item references a category (required) and a subcategory (optional) that must belong to it. Deeper nesting is not supported. | S |
| FR-MD-05 | Hold **reorder point** and **reorder quantity** per item, optionally per warehouse. | M |
| FR-MD-06 | Maintain **Vendor master**: code, name, contact, terms, tax reg, status, currency. | M |
@@ -266,9 +266,10 @@ SUBCATEGORY(subcategory_id PK, category_id FK→CATEGORY, name, status)
BRAND(brand_id PK, name, status)
ITEM_TYPE(item_type_id PK, name, status) -- Color, Size, Material — standalone
UOM(uom_id PK, name)
UOM_CONVERSION(conversion_id PK, item_id FK→ITEM, from_uom FK→UOM, to_uom FK→UOM, factor)
ITEM(item_id PK, sku, name, category_id FK→CATEGORY, subcategory_id FK→SUBCATEGORY [nullable],
brand_id FK→BRAND [nullable], base_uom_id FK→UOM,
content_qty [nullable], content_unit [nullable], -- as entered (Ml|L|G|Kg)
content_base_qty [nullable], content_base_unit [nullable], -- normalised, Ml|G only
default_vendor_id FK→VENDOR, stock_nature, tracking_mode, tax_class,
sale_price [nullable], status) -- sale_price: Sales-only selling price; NULL ⇒ sell at stock (FIFO) value
ITEM_REORDER(reorder_id PK, item_id FK→ITEM, warehouse_id FK→WAREHOUSE, reorder_point, reorder_qty)
+17 -11
View File
@@ -185,11 +185,23 @@ Query: `q`, `status` (`Active|Inactive`), `categoryId`, `subCategoryId`, `brandI
"brandId": 2, "baseUomId": 1,
"defaultVendorId": 5, "stockNature": "Stocked", "trackingMode": "Batch", "taxClass": "STD",
"salePrice": 12.5000, "status": "Active",
"contentQty": 500, "contentUnit": "Ml",
"contentBaseQty": 500, "contentBaseUnit": "Ml",
"reorder": [ { "warehouseId": 1, "reorderPoint": 500, "reorderQty": 2000 } ],
"conversions": [ { "conversionId": 33, "fromUom": 7, "toUom": 1, "factor": 12 } ],
"createdAt": "2026-06-01T08:00:00Z", "updatedAt": "2026-07-01T10:15:00Z" }
```
`conversions` is inlined (added 2026-07-17) because they are otherwise unreadable: `PUT /items/{id}/uom-conversions` returns them but nothing reads them back, so a detail screen could never show current state before editing.
> **`conversions` was removed 2026-08-11**, together with the `PUT /items/{id}/uom-conversions`
> endpoint and the `uom_conversions` table. Stock is counted in `baseUomId` and nothing converts.
>
> **Content size.** All four fields are null together when the item has nothing measurable to
> hold (a screw, a label). `contentQty`/`contentUnit` are what the user entered and are the only
> two accepted on write — send both or neither, or the server returns 422.
> `contentBaseQty`/`contentBaseUnit` are derived (L→Ml, Kg→G, both ×1000) and read-only, so
> `contentBaseUnit` is only ever `Ml` or `G`.
>
> **`baseUomId` is frozen once the item has stock history** — a layer or a ledger row — because
> it is the sole meaning of every quantity already recorded. Changing it then returns
> `409 MASTER_IN_USE`.
#### `POST /items`
The `sku` is **generated by the client** (it encodes the chosen item-type values, e.g. `BL-100-0003`); the server only enforces uniqueness. `subCategoryId`/`brandId` are optional.
@@ -232,15 +244,9 @@ Full update; requires `If-Match`. → **200 OK** updated resource; `412` on ETag
```
**201 Created**`{ "uomId": 7, "name": "Box-12" }`
#### `PUT /items/{itemId}/uom-conversions`
```json
{ "conversions": [ { "fromUom": 7, "toUom": 1, "factor": 12 } ] }
```
**200 OK**
```json
{ "itemId": 1001, "baseUomId": 1,
"conversions": [ { "conversionId": 33, "fromUom": 7, "toUom": 1, "factor": 12 } ] }
```
> **`PUT /items/{itemId}/uom-conversions` was removed 2026-08-11.** Per-item conversion factors
> no longer exist: an item is stocked in exactly one unit, and a differently sized pack is a
> different item. Document lines no longer carry a `uomId` at all — see §2.1.
### 2.3 Categories & Subcategories
> **Two-level hierarchy (2026-07-16).** Categories no longer self-nest: `parentId` and `GET /categories?tree=true` are **gone**, replaced by a dedicated Subcategory resource one level below. Categories also gained `status` + an `ETag` (they previously had neither, so there was no update path at all).
+2 -1
View File
@@ -245,7 +245,8 @@ All are `POST /production-runs/{id}/stages/{sid}/…`, transactional, and return
> **AS BUILT — clarifications and deviations, all verified by `Backend/smoke/`:**
>
> - **`Idempotency-Key` is accepted and ignored**, matching the Phase-1 posture exactly (`GrnsController` takes the header, `GrnService.ConfirmAsync` ignores it). Replay safety comes from the status guards this section already specifies: a double-fire finds the stage already moved on and gets a `409`, which `21-FRONTEND-PHASE2 §6` already tells the client to treat as a silent refetch. No key store was built. `RunStage` additionally carries an `xmin` `RowVersion` so two genuinely concurrent terminal approves cannot both read `Done` and post two receipts.
> - **UOM conversion on Stock inputs.** Not mentioned anywhere in this doc, but `STAGE_INPUT.uom_id` is a free FK while the FIFO engine works exclusively in the item's **base** UOM. All consumption therefore converts through the shared `IUomConverter` (extracted from `GrnService.ToBaseAsync`). Consequence for the contract: **`plannedQty` is in the input's declared UOM, while `consumedQty`/`consumedValue`/`returnedQty`/`returnedValue` are in the item's base UOM.** An input whose UOM has no conversion defined for the item is refused with `422`, never assumed 1:1.
> - **Content units on Stock inputs (revised 2026-08-11).** `STAGE_INPUT.uom_id` is gone, along with the per-item UOM conversion table it depended on. An input now carries `qtyUnit` — `Pack` or `Content` — and the FIFO engine still works exclusively in the item's base UOM (packs). A `Content` quantity is an amount of the item's content in its base content unit (ml or g) and is divided by `Item.ContentBaseQty` to get packs: 2000 ml of a 500 ml bottle consumes 4, and 300 ml consumes **0.6 — fractional packs are legal**, which is why the quantity columns are `(18,4)`. Consequence for the contract, unchanged in spirit: **`plannedQty` is in `qtyUnit`, while `consumedQty`/`consumedValue`/`returnedQty`/`returnedValue` are always a pack count.** `Content` on an item with no content size is refused with `422` at template save, never assumed 1:1. Upstream inputs must be `Pack` — WIP has no content size.
> - **Output units.** Outputs are **always** pack counts, so scrap is recorded in whole broken bottles rather than in millilitres. An output that references an item takes its unit from that item and must send `uomId: null`; an intermediate WIP output has no item and therefore **must** name its own `uomId` as a display label (`422 WIP_UNIT_REQUIRED` otherwise). That label is never converted — WIP touches neither stock nor the ledger.
> - **Transfers route by input, not by edge.** FR-MFG-12 says "per outbound edge", but the model connects an output to a specific *input* (`RUN_STAGE_INPUT.from_run_output_id`): an edge can exist with no input drawing from it, and one output can feed inputs on several children. Delivery therefore routes by `fromRunOutputId`; `RUN_EDGE` is display and validation only. When one output feeds several inputs and no explicit target is given, they fill in `runInputId` order up to each one's outstanding need with any overflow to the last; the request accepts an optional `runInputId` to remove the ambiguity.
> - **A re-complete overwrites, it does not accumulate.** Completing a stage that already has `producedQty > 0` (a rework re-complete) *replaces* the figures. Adding would double the produced quantity on every rework pass. A re-complete that would drop the good quantity below what has already been transferred is refused with `422 TRANSFER_EXCEEDS_AVAILABLE`.
> - **Start consumes only the delta.** Every start consumes `max(0, plannedBase consumedQty)`. A rework restart with an unchanged planned quantity therefore makes **no FIFO call at all**, and one after a raise consumes only the increase. This is what makes FR-MFG-16's "edit planned Stock-input qty upward" work.