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.
+ 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.
+
+