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 0000000..eba3098 Binary files /dev/null and b/Backend/smoke/__pycache__/m4c_content_units.cpython-313.pyc differ diff --git a/Backend/smoke/__pycache__/smoke_common.cpython-313.pyc b/Backend/smoke/__pycache__/smoke_common.cpython-313.pyc index 6a93b70..3e7c7a8 100644 Binary files a/Backend/smoke/__pycache__/smoke_common.cpython-313.pyc and b/Backend/smoke/__pycache__/smoke_common.cpython-313.pyc differ 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).