From a7ba3d3e04969fb8a7ab0504bb8decccd8ad2292 Mon Sep 17 00:00:00 2001 From: ImanThiyanga Date: Tue, 11 Aug 2026 21:55:04 +0530 Subject: [PATCH] sku and item fix --- Backend/ERPCore/Domain/Entities/ItemType.cs | 25 +- .../ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs | 20 +- .../Configurations/ItemTypeConfiguration.cs | 5 + Backend/ERPCore/Services/ItemTypeService.cs | 8 +- Backend/PROGRESS.md | 38 +- .../m4c_content_units.cpython-313.pyc | Bin 0 -> 13557 bytes .../__pycache__/smoke_common.cpython-313.pyc | Bin 15718 -> 15787 bytes Backend/smoke/m4_state.json | 2 +- Backend/smoke/m4c_content_units.py | 60 ++- .../dashboard/products/item-types/page.tsx | 33 +- .../app/dashboard/products/new/page.tsx | 390 +++++++++++++++--- .../erp-system/lib/validations/master-data.ts | 27 ++ Frontend/erp-system/types/master-data.ts | 17 +- docs/10-BACKEND-PHASE1.md | 5 +- docs/11-BACKEND-PHASE1.md | 23 +- 15 files changed, 557 insertions(+), 96 deletions(-) create mode 100644 Backend/smoke/__pycache__/m4c_content_units.cpython-313.pyc diff --git a/Backend/ERPCore/Domain/Entities/ItemType.cs b/Backend/ERPCore/Domain/Entities/ItemType.cs index 668b1b7..8bd0a6b 100644 --- a/Backend/ERPCore/Domain/Entities/ItemType.cs +++ b/Backend/ERPCore/Domain/Entities/ItemType.cs @@ -7,12 +7,15 @@ namespace ERPCore.Domain.Entities; /// Material. /// /// Deliberately unlinked. Nothing references this entity and it references -/// nothing: there is no value table and no join to . Its only job is -/// to feed the frontend's item-builder dropdown via GET /item-types. The chosen +/// nothing: there is no value table and no join to . The chosen /// values (Red, S, M) are encoded by the client into the generated SKU /// (e.g. BL-100-0003) and are never stored or parsed server-side — the item list /// is the record of what was built. See the accepted trade-off in docs/10 Part C.9. /// +/// +/// It does, however, carry one piece of meaning the client acts on: +/// . So this is no longer purely a dropdown source. +/// /// Not to be confused with (Stocked/NonStocked/Service), /// which is what the old ItemType enum became. /// Model: docs/10-BACKEND-PHASE1.md Part C.1. @@ -21,6 +24,24 @@ public class ItemType { public int ItemTypeId { get; set; } public string Name { get; set; } = string.Empty; + + /// + /// When true, this dimension's values are content measurements (500 ml, 1 L) rather + /// than plain labels (Red, S). The item builder then captures a number + unit per value and + /// stamps that pair onto each generated item's / + /// , instead of copying one form-level pair into every variant + /// — which is what makes "Coca-Cola in 500 ml / 1 L / 250 ml" three correctly sized items. + /// + /// This is what lets an apparel Size (S/M/L) stay plain text while a + /// Pack Size/Volume dimension carries ml/g/L/kg. + /// + /// + /// A client hint only: the server never reads it when writing an item. Each item's pair is + /// still validated and normalised on its own by ItemContent. + /// + /// + public bool IsMeasurable { get; set; } + public EntityStatus Status { get; set; } = EntityStatus.Active; public DateTime CreatedAt { get; set; } diff --git a/Backend/ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs b/Backend/ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs index 35d2e92..d9fd262 100644 --- a/Backend/ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs +++ b/Backend/ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs @@ -5,12 +5,16 @@ namespace ERPCore.Dtos.ItemTypes; /// /// Item type resource (docs/11-BACKEND-PHASE1.md §2.7) — a dimension name such as Color -/// or Size. Carries no values and no item linkage: GET /item-types exists to -/// populate the frontend builder's dropdown, and the chosen values are encoded into the +/// or Size. Carries no values and no item linkage: the chosen values are encoded into the /// client-generated SKU rather than stored (docs/10 Part C.9). +/// +/// IsMeasurable marks a dimension whose values are content measurements (500 ml, 1 L) +/// rather than plain labels; the builder captures a number + unit per value and writes it to +/// each generated item's content size. +/// /// public sealed record ItemTypeDto( - int ItemTypeId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt); + int ItemTypeId, string Name, bool IsMeasurable, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt); // Request DTOs — narrow: server-controlled fields (status, ids, timestamps) // are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ---- @@ -18,11 +22,21 @@ public sealed record ItemTypeDto( public sealed class CreateItemTypeRequest { [Required, StringLength(200)] public string Name { get; set; } = string.Empty; + + /// Omitted ⇒ false, i.e. plain-text values. See . + public bool IsMeasurable { get; set; } } public sealed class UpdateItemTypeRequest { [Required, StringLength(200)] public string Name { get; set; } = string.Empty; + + /// + /// Nullable on purpose: a plain bool binds an absent property as false, so any + /// client that PUT only a name — as the item-types screen used to — would silently clear the + /// flag on every rename. Omitting this field preserves the stored value. + /// + public bool? IsMeasurable { get; set; } } public sealed class UpdateItemTypeStatusRequest diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ItemTypeConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ItemTypeConfiguration.cs index c26f97c..4e9476a 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/ItemTypeConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ItemTypeConfiguration.cs @@ -19,6 +19,11 @@ public sealed class ItemTypeConfiguration : IEntityTypeConfiguration builder.Property(t => t.Name).IsRequired().HasMaxLength(200); builder.HasIndex(t => t.Name).IsUnique(); + // false is the only safe default here: EF uses the CLR default as its "unset" sentinel, + // so if the store default were true, inserting an explicit false would be mistaken for + // "not set" and silently written as true. Sentinel and store default must agree. + builder.Property(t => t.IsMeasurable).IsRequired().HasDefaultValue(false); + builder.Property(t => t.Status) .HasConversion().HasMaxLength(20).IsRequired() .HasDefaultValue(EntityStatus.Active); diff --git a/Backend/ERPCore/Services/ItemTypeService.cs b/Backend/ERPCore/Services/ItemTypeService.cs index 6297a54..015c662 100644 --- a/Backend/ERPCore/Services/ItemTypeService.cs +++ b/Backend/ERPCore/Services/ItemTypeService.cs @@ -41,7 +41,7 @@ public sealed class ItemTypeService : IItemTypeService var total = await q.CountAsync(ct); var rows = await q.OrderBy(t => t.Name) .Skip(query.Skip).Take(query.PageSize) - .Select(t => new ItemTypeDto(t.ItemTypeId, t.Name, t.Status, t.CreatedAt, t.UpdatedAt)) + .Select(t => new ItemTypeDto(t.ItemTypeId, t.Name, t.IsMeasurable, t.Status, t.CreatedAt, t.UpdatedAt)) .ToListAsync(ct); return PagedResponse.Create(rows, query.Page, query.PageSize, total); @@ -63,6 +63,7 @@ public sealed class ItemTypeService : IItemTypeService var itemType = new ItemType { Name = name, + IsMeasurable = request.IsMeasurable, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow }; @@ -90,6 +91,9 @@ public sealed class ItemTypeService : IItemTypeService // Renaming does not touch existing items: their SKUs already encode the values that // were chosen, and nothing joins back to this row (docs/10 Part C.9). itemType.Name = name; + // Omitted ⇒ keep what is stored. A plain bool would bind an absent property as false and + // so let a name-only PUT silently clear the flag on every rename. + itemType.IsMeasurable = request.IsMeasurable ?? itemType.IsMeasurable; itemType.UpdatedAt = DateTime.UtcNow; try @@ -114,5 +118,5 @@ public sealed class ItemTypeService : IItemTypeService await _uow.SaveChangesAsync(ct); } - private static ItemTypeDto Map(ItemType t) => new(t.ItemTypeId, t.Name, t.Status, t.CreatedAt, t.UpdatedAt); + private static ItemTypeDto Map(ItemType t) => new(t.ItemTypeId, t.Name, t.IsMeasurable, t.Status, t.CreatedAt, t.UpdatedAt); } diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index a33e77a..b80f704 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -4,6 +4,42 @@ Legend: `[ ]` not started · `[~]` in progress · `[x]` done Spec: `docs/10-BACKEND-PHASE1.md` (model + rules) · `docs/11-BACKEND-PHASE1.md` (API) Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** as the code. When ticking `[x]`, append a short note + any deviation. +## Per-variant content size (2026-08-11) — follow-up to the UOM re-model below + +The UOM re-model put `ContentQty`/`ContentUnit` on `Item` correctly, but the item **create page +is a variant builder** and collected **one** form-level pair, copying it into every generated +variant. Building "Coca-Cola in 500 ml / 1 L / 250 ml" produced three items all recorded as the +same size — the exact case the builder exists for. The item contract already accepted a +per-item pair, so the whole fix is in how values are captured. + +- **`ItemType.IsMeasurable`** (bool, default `false`) — set on Products → Item Types. A flagged + dimension's values are entered as a number + unit; the chip label, the SKU segment, the item + name and the stored content size all derive from that one pair. Unflagged dimensions are + unchanged free text, which is what an apparel `Size` (S/M/L) needs. +- **`UpdateItemTypeRequest.IsMeasurable` is `bool?` and preserved when omitted.** A plain `bool` + binds an absent property as `false`, so the admin screen's name-only PUT would have cleared the + flag on every rename — the same bug class already recorded for `product-config` further down. +- **The `BUILDER_ITEM_TYPES = ["color","size"]` hardcode is gone.** It had one consumer and had + become a live bug: a user-created "Pack Size" would be flagged measurable and then never + appear. Removal is behaviour-preserving on any current database (the seeder seeds exactly those + two names, and the fetch was already `status: Active`) and restores the documented contract + that users add their own types. Every Active item type is now offered; deactivation is the + intended remedy and the admin page already says so. +- **SKU collision fixed before it could bite.** `skuSegment` strips the decimal point and + truncates to 3, so derived labels collided — `1.5L`/`15L` → `15L`, `500ml`/`500g` → `500`, + `2.5ml`/`25ml` → `25M`. Since the create loop is sequential and non-transactional, that would + have failed partway with `SKU_DUPLICATE` after creating some rows. Measurement segments now use + `measureKey`, which mirrors `ItemContent.Normalize` (L/Kg ×1000) and renders the point as `P`. +- **Values dedupe on the normalised size, not the label** — `500 ml` and `0.5 L` read differently + but store identically, and `ItemContent.Normalize` is the server's notion of equality. +- **At most one measurable dimension** per product: unchecked measurable types are disabled once + one is checked, re-checked at submit. +- **The form-level pair survives as a fallback** — correct when the varying dimension isn't size — + and is hidden *and cleared* whenever a measurable dimension is active, so the two can never + disagree. Its validation is skipped in that mode, since its error message would otherwise be + invisible inside the hidden block. +- The item **edit** page is untouched: one item, one size. + ## UOM re-model (2026-08-11) — supersedes every "UOM conversion" note below Per-item UOM conversion is **gone**. Entries further down this file that describe @@ -60,7 +96,7 @@ What replaced it: - [x] Warehouse + Bin (`/warehouses`, nested `/warehouses/{id}/bins`, bin code unique per warehouse) - [x] Item reorder settings (`PUT /items/{id}/reorder` full-replace upsert, warehouse-exists validation) - [x] Brand master (FR-MD-09) — CRUD + status + ETag; `Item.brandId` nullable FK -- [x] Item Type master (FR-MD-10) — CRUD + status + ETag; **unreferenced by design**, feeds the builder dropdown only +- [x] Item Type master (FR-MD-10) — CRUD + status + ETag; **unreferenced by design**. Feeds the builder's dimension list, and since 2026-08-11 carries `isMeasurable`, which decides whether its values are captured as free text or as a number + unit that becomes each item's content size (see the entry at the top of this file) - [x] SubCategory (FR-MD-04) — nested list/create under a category, `PUT`/`PATCH status` by id; `Item.subCategoryId` nullable FK, validated to belong to `categoryId` - [x] Product Configuration (FR-MD-11) — singleton `GET`/`PUT /product-config`; `CONFIG_DISABLED` gating on item writes - [x] Item **sale price** (FR-MD-01, 2026-07-22) — nullable `Item.SalePrice` (`numeric(18,4)`); on all Item DTOs (list/detail/create/update), validated `>= 0`. **Sales-only** — never enters GRN/FIFO/ledger. `null` ⇒ sell at stock value. Migration `AddItemSalePrice`. See the 2026-07-22 Done entry. diff --git a/Backend/smoke/__pycache__/m4c_content_units.cpython-313.pyc b/Backend/smoke/__pycache__/m4c_content_units.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eba30981ad557c5bacb632f74ae313d776c2c7b5 GIT binary patch literal 13557 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 HU|jwSHsI{x literal 0 HcmV?d00001 diff --git a/Backend/smoke/__pycache__/smoke_common.cpython-313.pyc b/Backend/smoke/__pycache__/smoke_common.cpython-313.pyc index 6a93b70aadc409f72c28681b5e6120f806089e01..3e7c7a8be9d75f4e1219dd98ca9b78aebe7699fa 100644 GIT binary patch delta 449 zcmaD>wYr+`GcPX}0}%Kwt;*_9-^j;g#w7scGBYqRettGt$}HHam_?tVm`RwygO`D! z6ewQcn#UI58p;;T1cc0btN~2E{0t1-32X!D>Zcdw6&HbgrO8^f4H!HOlUG<=W8|D%Zz(@{p``>1C&*#@ zER_VW0U7UsxcD`YXkhrjH~FKbqOzjeS5{^ru15;06L}^GPAK`n%pf7Tf#pEpg`nUo zJRvs~R6kEPvzo{MMHr~6m=&mXrHHeR(B#Kf^B6BoZnD0|cx`f`jV$Bi$@Mmy8AT@> H+ZqD^jJ=pupP`sZn8AaW zfuWRzfuX>)AU(o0B0ZQ12$}WR0+@XH85p=36c~zG^Vsv)@>ug&f?4#KO_?TVn#)f< zVx}{>$Xs;tNpp?Ky5^FM9FsRniA;_*7iH!Q<(&M{Ok{Gqxh^A!B?%-!a`NVU66w6@ zyv0n&My4@n@>MlX=Ca5zF3r!4&rH!#C@iVeQ7FyJEQwFfFD}v4KTDgqzA5UnW;u&Ex+n3{+Ok3e>ww#92>dvWE3M#*34WTi=rh8C>K5B4j{>G?2K( ZVUwGmQks)$SM+r95u447B9kj@jRBSzcNYKv diff --git a/Backend/smoke/m4_state.json b/Backend/smoke/m4_state.json index 8c047c9..6658a4e 100644 --- a/Backend/smoke/m4_state.json +++ b/Backend/smoke/m4_state.json @@ -1 +1 @@ -{"runId": 64, "templateId": 2, "warehouseId": 4, "assembleStageId": 118, "finishedItemId": 13, "rawItemId": 18, "packItemId": 14} \ No newline at end of file +{"runId": 20, "templateId": 6, "warehouseId": 4, "assembleStageId": 30, "finishedItemId": 1, "rawItemId": 4, "packItemId": 3} \ No newline at end of file diff --git a/Backend/smoke/m4c_content_units.py b/Backend/smoke/m4c_content_units.py index 908e1c4..0542686 100644 --- a/Backend/smoke/m4c_content_units.py +++ b/Backend/smoke/m4c_content_units.py @@ -35,6 +35,13 @@ SEED_BOTTLES = 100 UNIT_COST = 3.0 +def production_reason(c, code): + for r in c.get("/reason-codes?context=Production&pageSize=50").body["items"]: + if r["code"] == code: + return r["reasonCodeId"] + sys.exit(f"FATAL: Production reason {code} not seeded.") + + def ensure_warehouse(c): for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"]: if w["code"] == WAREHOUSE_CODE: @@ -72,9 +79,17 @@ def ensure_item(c, sku, name, content_qty, content_unit): return created.body -def save_template(c, raw_item, finished_item, qty_per_batch, qty_unit): +def save_template(c, raw_item, finished_item, qty_per_batch, qty_unit, suffix): + """ + One template per section, never a shared one. + + Each section starts a run and leaves it InProgress, and a template with a live run is + edit-locked (FR-MFG-06, 409 TEMPLATE_IN_USE) — so re-saving a single shared code would + fail from the second section onward for reasons that have nothing to do with content units. + """ + code = f"{TEMPLATE_CODE}-{suffix}" payload = { - "code": TEMPLATE_CODE, "name": "Content-unit smoke line", + "code": code, "name": f"Content-unit smoke line ({suffix})", "stages": [{ "key": "tmp-mix", "name": "Mix", "estimatedMinutes": 5, "posX": 0, "posY": 0, "fieldDefs": [], @@ -85,8 +100,8 @@ def save_template(c, raw_item, finished_item, qty_per_batch, qty_unit): }], "edges": [], } - existing = next((t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"] - if t["code"] == TEMPLATE_CODE), None) + existing = next((t for t in c.get(f"/production-templates?q={code}").body["items"] + if t["code"] == code), None) if existing: head = c.get(f"/production-templates/{existing['templateId']}") return c.put(f"/production-templates/{existing['templateId']}", payload, if_match=head.etag) @@ -134,10 +149,39 @@ def consume_once(c, chk, tid, wh, target_qty, label, expected_packs): chk.check(f"{label}: ledger qtyBase matches the consumption", round(abs(float(rows[0]["qtyBase"])), 4), expected_packs) + # Cancel so the script is re-runnable. A run left InProgress edit-locks its template + # (FR-MFG-06), so the next execution could not re-save it and would fail with a 409 that + # says nothing about content units. Cancelling also returns the consumed stock (FR-MFG-17), + # which keeps the seeded on-hand stable across runs. + c.post(f"/production-runs/{run['runId']}/cancel", + {"reasonCodeId": production_reason(c, "PRD-CANCEL"), "note": "m4c cleanup"}) + + +def cancel_stale_runs(c): + """ + Cancel any InProgress run this script left behind previously. + + Self-healing rather than merely tidy: a live run edit-locks its template, so without this + a re-run (or an earlier interrupted run) fails at template save with 409 TEMPLATE_IN_USE — + a failure that looks like a content-unit bug and is not one. + """ + ours = {t["templateId"] for t in c.get(f"/production-templates?q={TEMPLATE_CODE}&pageSize=50").body["items"] + if t["code"].startswith(TEMPLATE_CODE)} + if not ours: + return + stale = [r for r in c.get("/production-runs?status=InProgress&pageSize=200").body["items"] + if r["templateId"] in ours] + for r in stale: + c.post(f"/production-runs/{r['runId']}/cancel", + {"reasonCodeId": production_reason(c, "PRD-CANCEL"), "note": "m4c stale cleanup"}) + if stale: + print(f"cancelled {len(stale)} stale run(s) from a previous execution") + def main(): c, chk, args = bootstrap(__doc__) print(f"API {args.api}") + cancel_stale_runs(c) bottle = ensure_item(c, SKU, "Smoke syrup 500ml bottle", CONTENT_QTY, "Ml") plain = ensure_item(c, PLAIN_SKU, "Smoke item with no content", None, None) @@ -155,23 +199,23 @@ def main(): # ------------------------------------------------- whole packs out of content units chk.section("1. A content quantity resolves to whole packs (2000 ml / 500 ml = 4)") - saved = save_template(c, bottle["itemId"], finished["itemId"], 2000, "Content") + saved = save_template(c, bottle["itemId"], finished["itemId"], 2000, "Content", "whole") if saved_ok(chk, "save the Content template", saved): consume_once(c, chk, saved.body["templateId"], wh, 1, "2000 ml", 4.0) # --------------------------------------------------------------- fractional packs chk.section("2. A content quantity below one pack consumes a FRACTION of one (300 ml = 0.6)") - saved = save_template(c, bottle["itemId"], finished["itemId"], 300, "Content") + saved = save_template(c, bottle["itemId"], finished["itemId"], 300, "Content", "frac") if saved_ok(chk, "save the fractional Content template", saved): consume_once(c, chk, saved.body["templateId"], wh, 1, "300 ml", 0.6) # ------------------------------------------------------------------ the guard rail chk.section("3. Content units are refused on an item that has no content size") - refused = save_template(c, plain["itemId"], finished["itemId"], 100, "Content") + refused = save_template(c, plain["itemId"], finished["itemId"], 100, "Content", "nocontent") chk.status("Content input on a contentless item", refused, 422) chk.section("4. The same item still works when the formula is written in packs") - saved = save_template(c, bottle["itemId"], finished["itemId"], 3, "Pack") + saved = save_template(c, bottle["itemId"], finished["itemId"], 3, "Pack", "pack") if saved_ok(chk, "save the Pack template", saved): consume_once(c, chk, saved.body["templateId"], wh, 1, "3 bottles", 3.0) diff --git a/Frontend/erp-system/app/dashboard/products/item-types/page.tsx b/Frontend/erp-system/app/dashboard/products/item-types/page.tsx index eac3119..35ce009 100644 --- a/Frontend/erp-system/app/dashboard/products/item-types/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/item-types/page.tsx @@ -10,6 +10,7 @@ import { ItemType } from "@/types/master-data" import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" import { Badge } from "@/components/ui/badge" +import { Switch } from "@/components/ui/switch" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" @@ -33,6 +34,7 @@ export default function ItemTypesPage() { const [open, setOpen] = useState(false) const [editing, setEditing] = useState(null) const [name, setName] = useState("") + const [isMeasurable, setIsMeasurable] = useState(false) const [errors, setErrors] = useState>({}) const [submitting, setSubmitting] = useState(false) const [togglingId, setTogglingId] = useState(null) @@ -50,6 +52,7 @@ export default function ItemTypesPage() { function openCreateDialog() { setEditing(null) setName("") + setIsMeasurable(false) setErrors({}) setOpen(true) } @@ -57,6 +60,7 @@ export default function ItemTypesPage() { function openEditDialog(itemType: ItemType) { setEditing(itemType) setName(itemType.name) + setIsMeasurable(itemType.isMeasurable) setErrors({}) setOpen(true) } @@ -71,13 +75,16 @@ export default function ItemTypesPage() { if (editing) { // Re-read for a fresh If-Match; a concurrent edit surfaces as 412. const current = await itemTypesApi.get(editing.itemTypeId) - await itemTypesApi.update(editing.itemTypeId, { name }, current.etag ?? "") + // isMeasurable must travel on every PUT: the server preserves it when omitted, so a + // name-only body would leave the switch the user just flipped unsaved. + await itemTypesApi.update(editing.itemTypeId, { name, isMeasurable }, current.etag ?? "") } else { - await itemTypesApi.create({ name }) + await itemTypesApi.create({ name, isMeasurable }) } toast.success(editing ? "Item type updated" : "Item type created", name) setOpen(false) setName("") + setIsMeasurable(false) setEditing(null) setErrors({}) load() @@ -112,6 +119,7 @@ export default function ItemTypesPage() {

Item Types

Dimensions the item builder offers (e.g. Color, Size, Material). Values are captured per item and encoded in its SKU. + A measurement dimension captures a number plus a unit instead, which becomes each item's content size.

@@ -135,6 +143,23 @@ export default function ItemTypesPage() { /> + +
+ +
+ Values are measurements + + Values are entered as a number plus a unit (500 ml, 1 L) and become each item's + content size. Leave off for plain labels like Red or Small. + +
+
+
-
+ {t.isMeasurable ? ( + // A measurement is entered as a number + unit; the chip label, the SKU + // segment and the item's stored content size all derive from this pair. +
+ setQtyByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + addValue(t.itemTypeId) + } + }} + placeholder="e.g. 500" + aria-invalid={!!valueErrors[t.itemTypeId]} + className="h-11 text-base" + /> + + value={unitByCategory[t.itemTypeId] ?? null} + onValueChange={(v) => setUnitByCategory((prev) => ({ ...prev, [t.itemTypeId]: v }))} + items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))} + > + + + + + {CONTENT_UNITS.map((u) => ( + + {UNIT_LABEL[u]} + + ))} + + + +
+ ) : ( +
+ setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + addValue(t.itemTypeId) + } + }} + placeholder={t.name} + className="h-11 text-base" + /> + +
+ )} +
{(valuesByCategory[t.itemTypeId] ?? []).map((v) => ( - - {v} + + {v.label} @@ -579,6 +825,10 @@ export default function NewItemPage() {
)} + {/* Form-level, not per-row: a variant's content is derived, so there is no cell to + attach this to. Should never appear — addValue rejects a bad pair at entry. */} + + {variants.length > 0 && (
@@ -588,6 +838,7 @@ export default function NewItemPage() { {cat.name} ))} SKU + Content {priceMode === "fixed" && ( Sale price )} @@ -603,10 +854,15 @@ export default function NewItemPage() { {variant.parts.map((part, i) => ( - {part.value} + {part.value.label} ))} {variant.sku} + {/* Read-only: content is derived, so an editable cell here could only + disagree with the value that produced it. */} + + {contentLabelFor(variant)} + {priceMode === "fixed" && ( { } export function validateVariantItemForm(input: { + productName: string categoryId: number | null hasVariants: boolean }): Record { const errors: Record = {} + // Required: it names every generated item and leads its SKU, and neither reads sensibly + // when derived from the category instead ("Beverages - 500ml"). + if (!input.productName.trim()) errors.productName = "Enter a product name" if (!input.categoryId) errors.categoryId = "Select a category" if (!input.hasVariants) errors.variants = "Check at least one variant category and add its values" return errors @@ -95,6 +99,29 @@ export function validateVariantItemForm(input: { * (docs/20 §3.1). Returns a map keyed by variant key → message; empty when valid. * In "stock" mode there is nothing to validate (prices are sent as null). */ +/** + * Belt-and-braces sweep over the generated variants: each one's resolved content pair must be + * whole or wholly absent. It should never fire — `addValue` rejects a half or duplicate pair at + * entry — so it exists to catch stale state, not to guide the user. + * + * Keyed by variant key to mirror {@link validateVariantPrices}, but surfaced as ONE form-level + * message: content is *derived*, so unlike a price there is no per-row control to attach an + * error to. + */ +export function validateVariantContent( + variantKeys: string[], + contentFor: (key: string) => { qty: string; unit: string | null }, +): Record { + const errors: Record = {} + for (const key of variantKeys) { + const { qty, unit } = contentFor(key) + const pair = contentPairErrors(qty, unit) + const message = pair.contentQty ?? pair.contentUnit + if (message) errors[key] = message + } + return errors +} + export function validateVariantPrices( variantKeys: string[], priceFor: (key: string) => string, diff --git a/Frontend/erp-system/types/master-data.ts b/Frontend/erp-system/types/master-data.ts index 34d7e45..3232d37 100644 --- a/Frontend/erp-system/types/master-data.ts +++ b/Frontend/erp-system/types/master-data.ts @@ -223,13 +223,20 @@ export interface UpdateBrandRequest { * Item type master (docs/11 §2.7) — a dimension *name* such as Color, Size or Material. * Formerly `VariantCategory` in this app. * - * Nothing links an item to one of these: it exists only to populate the builder's - * dropdown. The chosen values are baked into the SKU client-side. Not to be confused with - * {@link StockNature}, which is what the old `itemType` enum became. + * Nothing links an item to one of these, and the chosen values are baked into the SKU + * client-side. It is no longer *only* a dropdown source, though: `isMeasurable` changes how + * the builder captures values. Not to be confused with {@link StockNature}, which is what + * the old `itemType` enum became. */ export interface ItemType { itemTypeId: number name: string + /** + * True ⇒ this dimension's values are content measurements (500 ml, 1 L), so the builder + * captures a number + unit per value and writes it to each generated item's content size. + * False ⇒ plain labels (Red, S) — which is what an apparel "Size" wants. + */ + isMeasurable: boolean status: EntityStatus createdAt: string updatedAt: string | null @@ -237,10 +244,14 @@ export interface ItemType { export interface CreateItemTypeRequest { name: string + /** Omitted ⇒ false. */ + isMeasurable?: boolean } export interface UpdateItemTypeRequest { name: string + /** Omitted ⇒ the stored value is preserved. Always send it from a form that shows it. */ + isMeasurable?: boolean } /** diff --git a/docs/10-BACKEND-PHASE1.md b/docs/10-BACKEND-PHASE1.md index 0ef7900..16d983d 100644 --- a/docs/10-BACKEND-PHASE1.md +++ b/docs/10-BACKEND-PHASE1.md @@ -264,7 +264,7 @@ Costing: FIFO · Multi-warehouse · Single-tenant. Legend: **PK** primary key · CATEGORY(category_id PK, name, status) -- top level; no self-nesting 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 +ITEM_TYPE(item_type_id PK, name, is_measurable, status) -- Color, Size, Material — standalone UOM(uom_id PK, name) 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, @@ -365,7 +365,8 @@ USER(..., role_id FK→ROLE [nullable]) -- added to the existing USER shadow (s Note: `USER_ROLE` from the original placeholder sketch was dropped — a user has at most one role (`USER.role_id`), matching AuthHex's own `User.RoleId` being a single scalar FK, not a many-to-many. ## C.9 Modeling notes (load-bearing) -- **Item types are a dropdown, not a relationship.** `ITEM_TYPE` (Color, Size, Material) exists **only** to populate the frontend item-builder's dropdown via `GET /item-types`. Nothing references it and it references nothing — there is no value table and no join to `ITEM`. The builder cross-products the checked types into **one standalone item per combination**; the chosen values (Red, S, M) are encoded by the **client** into the generated SKU (`BL-0002` for one type, `BL-100-0003` for two) and the server only checks that SKU for uniqueness. **The item list is the record of what was built.** This is not a product-variation model: there is no parent-product entity and no variant hierarchy. +- **Item types are a dropdown, not a relationship.** `ITEM_TYPE` (Color, Size, Material) populates the frontend item-builder's dimension list via `GET /item-types`. Nothing references it and it references nothing — there is no value table and no join to `ITEM`. The builder cross-products the checked types into **one standalone item per combination**; the chosen values (Red, S, M) are encoded by the **client** into the generated SKU (`BL-0002` for one type, `BL-100-0003` for two) and the server only checks that SKU for uniqueness. **The item list is the record of what was built.** This is not a product-variation model: there is no parent-product entity and no variant hierarchy. + - *One exception, added 2026-08-11:* the master carries a single semantic the client acts on — **`is_measurable`**. A dimension flagged measurable has its values entered as a number + unit (500 ml, 1 L) instead of free text, and that pair is written to each generated item's `content_qty`/`content_unit` (FR-MD-02). So "exists only to feed a dropdown" is no longer accurate; "is unreferenced by `ITEM`" still is, and the trade-off below is unchanged. The flag is what lets an apparel `Size` (S/M/L) stay plain text while a `Volume` dimension carries units. The server does not read it when writing an item — each item's content pair is validated and normalised on its own. - *Accepted trade-off (a decision, not an oversight):* the backend cannot answer "list all blue items", cannot filter or report by colour/size, and cannot validate that a SKU's segments correspond to real item types. Renaming an item type (`Color` → `Colour`) does **not** touch existing SKUs, which keep their old segments — the two are permanently decoupled the moment an item is created. If value-level querying is ever needed, an `ITEM_TYPE_VALUE` table plus a link table can be added additively, but existing SKUs will not be back-fillable without parsing them by hand. - **Sale price is a per-item scalar, not a variant/price table.** Because each "variant" is its own `ITEM` row (above), the optional selling price lives directly on `ITEM.sale_price` (nullable). `NULL` means "use stock value" — Sales values the item at its FIFO stock cost at sale time (FR-STK-04 / `STOCK_LAYER`); a value is a fixed selling price. It is **Sales-only**: it never participates in GRN, FIFO layering, or the stock ledger, so receipt/costing behaviour is identical whether the item is fixed-priced or not. The create-time "fixed price vs use stock value" choice is a **frontend UX toggle** — the contract is simply the nullable column, and the item builder requires a price on every generated variant when the user picks fixed pricing. - **Two-level categories.** `CATEGORY` no longer self-nests; `SUBCATEGORY` is the single optional level below it. An item stores both FKs rather than pointing only at the deepest node, so the parent is never inferred or lost. A subcategory cannot be reparented (it would silently invalidate the category of every item referencing it) — deactivate and recreate instead. diff --git a/docs/11-BACKEND-PHASE1.md b/docs/11-BACKEND-PHASE1.md index db8729e..01a3aa1 100644 --- a/docs/11-BACKEND-PHASE1.md +++ b/docs/11-BACKEND-PHASE1.md @@ -359,26 +359,39 @@ Requires `If-Match`. → **200 OK**; `412` on mismatch; `409` on duplicate name. Query: `q`, `status` (`Active|Inactive`), + paging. Pass `status=Active` for selectable rows. **200 OK** ```json -{ "items": [ { "itemTypeId": 1, "name": "Color", "status": "Active", +{ "items": [ { "itemTypeId": 1, "name": "Color", "isMeasurable": false, "status": "Active", "createdAt": "2026-07-16T09:00:00Z", "updatedAt": null }, - { "itemTypeId": 2, "name": "Size", "status": "Active", + { "itemTypeId": 2, "name": "Size", "isMeasurable": false, "status": "Active", "createdAt": "2026-07-16T09:00:00Z", "updatedAt": null } ], "pagination": { "page": 1, "pageSize": 20, "totalItems": 2, "totalPages": 1 } } ``` -`Color` and `Size` are seeded on first start; users add their own (e.g. `Material`). +`Color` and `Size` are seeded on first start, both with `isMeasurable: false`; users add their own +(e.g. `Material`, or a `Pack Size` with `isMeasurable: true`). + +**`isMeasurable`** (added 2026-08-11) marks a dimension whose values are content *measurements* +(500 ml, 1 L) rather than plain labels. The item builder then captures a number + unit per value +and writes that pair to each generated item's `contentQty`/`contentUnit`, instead of copying one +form-level pair into every variant — which is what makes "Coca-Cola in 500 ml / 1 L / 250 ml" +three correctly sized items. It is why an apparel `Size` (S/M/L) can stay plain text while a +`Volume` dimension carries units. The server never reads it when writing an item: each item's +pair is still validated and normalised on its own. #### `GET /item-types/{itemTypeId}` → **200 OK** (+ `ETag`); `404` if absent. #### `POST /item-types` ```json -{ "name": "Material" } +{ "name": "Pack Size", "isMeasurable": true } ``` **201 Created** — `Location: /api/v1/item-types/3` → the `ItemTypeDto`. `409` if the name exists. -Callable from the item builder's inline "+" as well as the admin screen. +`isMeasurable` is optional and defaults to `false`. Callable from the item builder's inline "+" +as well as the admin screen. #### `PUT /item-types/{itemTypeId}` Requires `If-Match`. → **200 OK**; `412` on mismatch; `409` on duplicate name. **Renaming does not touch existing items** — nothing joins back to this row. +`isMeasurable` is **nullable in the request body and preserved when omitted**: a plain `bool` +would bind an absent property as `false`, so a name-only PUT — which is what the admin screen +used to send — would clear the flag on every rename. #### `PATCH /item-types/{itemTypeId}/status` → **204 No Content**. Deactivate, never delete (FR-MD-08).