Merge pull request 'sku and item fix' (#39) from fix/uom-overall into Dev

Reviewed-on: #39
This commit was merged in pull request #39.
This commit is contained in:
2026-08-12 05:32:49 +00:00
15 changed files with 557 additions and 96 deletions
+23 -2
View File
@@ -7,12 +7,15 @@ namespace ERPCore.Domain.Entities;
/// Material.
/// <para>
/// <b>Deliberately unlinked.</b> Nothing references this entity and it references
/// nothing: there is no value table and no join to <see cref="Item"/>. Its only job is
/// to feed the frontend's item-builder dropdown via <c>GET /item-types</c>. The chosen
/// nothing: there is no value table and no join to <see cref="Item"/>. The chosen
/// values (Red, S, M) are encoded by the client into the generated SKU
/// (e.g. <c>BL-100-0003</c>) 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.
/// </para>
/// <para>
/// It does, however, carry one piece of meaning the client acts on:
/// <see cref="IsMeasurable"/>. So this is no longer purely a dropdown source.
/// </para>
/// Not to be confused with <see cref="Enums.StockNature"/> (Stocked/NonStocked/Service),
/// which is what the old <c>ItemType</c> 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;
/// <summary>
/// When true, this dimension's values are content <b>measurements</b> (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 <see cref="Item.ContentQty"/> /
/// <see cref="Item.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.
/// <para>
/// This is what lets an apparel <c>Size</c> (S/M/L) stay plain text while a
/// <c>Pack Size</c>/<c>Volume</c> dimension carries ml/g/L/kg.
/// </para>
/// <para>
/// 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 <c>ItemContent</c>.
/// </para>
/// </summary>
public bool IsMeasurable { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
+17 -3
View File
@@ -5,12 +5,16 @@ namespace ERPCore.Dtos.ItemTypes;
/// <summary>
/// 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: <c>GET /item-types</c> 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).
/// <para>
/// <c>IsMeasurable</c> 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.
/// </para>
/// </summary>
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;
/// <summary>Omitted ⇒ false, i.e. plain-text values. See <see cref="ItemTypeDto"/>.</summary>
public bool IsMeasurable { get; set; }
}
public sealed class UpdateItemTypeRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
/// <summary>
/// Nullable on purpose: a plain <c>bool</c> binds an absent property as <c>false</c>, 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 <b>preserves</b> the stored value.
/// </summary>
public bool? IsMeasurable { get; set; }
}
public sealed class UpdateItemTypeStatusRequest
@@ -19,6 +19,11 @@ public sealed class ItemTypeConfiguration : IEntityTypeConfiguration<ItemType>
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<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
+6 -2
View File
@@ -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<ItemTypeDto>.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);
}
+37 -1
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
{"runId": 64, "templateId": 2, "warehouseId": 4, "assembleStageId": 118, "finishedItemId": 13, "rawItemId": 18, "packItemId": 14}
{"runId": 20, "templateId": 6, "warehouseId": 4, "assembleStageId": 30, "finishedItemId": 1, "rawItemId": 4, "packItemId": 3}
+52 -8
View File
@@ -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)
@@ -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<ItemType | null>(null)
const [name, setName] = useState("")
const [isMeasurable, setIsMeasurable] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(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() {
<h1 className="text-2xl font-bold text-foreground">Item Types</h1>
<p className="text-base text-muted-foreground">
Dimensions the item builder offers (e.g. Color, Size, Material). Values are captured per item and encoded in its SKU.
A <span className="font-medium">measurement</span> dimension captures a number plus a unit instead, which becomes each item&apos;s content size.
</p>
</div>
</div>
@@ -135,6 +143,23 @@ export default function ItemTypesPage() {
/>
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<Field>
<div className="flex items-start gap-4">
<Switch
checked={isMeasurable}
onCheckedChange={setIsMeasurable}
aria-label="Values are measurements"
className="mt-1 shrink-0"
/>
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Values are measurements</span>
<span className="text-sm text-muted-foreground">
Values are entered as a number plus a unit (500 ml, 1 L) and become each item&apos;s
content size. Leave off for plain labels like Red or Small.
</span>
</div>
</div>
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
@@ -173,6 +198,7 @@ export default function ItemTypesPage() {
<TableRow>
<TableHead className="h-12 px-3 text-sm">ID</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Values</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
@@ -183,6 +209,9 @@ export default function ItemTypesPage() {
<TableRow key={t.itemTypeId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{t.itemTypeId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{t.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline">{t.isMeasurable ? "Measurement" : "Text"}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant={t.status === "Active" ? "default" : "secondary"}>{t.status}</Badge>
</TableCell>
@@ -13,7 +13,12 @@ import { productConfig } from "@/lib/api/product-config"
import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { contentPairErrors, validateVariantItemForm, validateVariantPrices } from "@/lib/validations/master-data"
import {
contentPairErrors,
validateVariantContent,
validateVariantItemForm,
validateVariantPrices,
} from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Brand, Category, ItemType, MeasureUnit, ProductConfig, StockNature, SubCategory } from "@/types/master-data"
@@ -36,15 +41,43 @@ function skuSegment(text: string, maxLen: number): string {
return cleaned.slice(0, maxLen) || "GEN"
}
function buildVariantSku(categoryLabel: string, values: string[]): string {
return [skuSegment(categoryLabel, 3), ...values.map((v) => skuSegment(v, 3))].join("-")
/**
* A value on a builder dimension. `qty`/`unit` are set only on a measurable dimension;
* `label` is the single source of truth for the chip, the item name and the SKU segment.
*/
type DimensionValue = { label: string; qty: number | null; unit: MeasureUnit | null }
const isMeasured = (v: DimensionValue): v is DimensionValue & { qty: number; unit: MeasureUnit } =>
v.qty !== null && v.unit !== null
/** Canonical casing, so 1.5 L renders as "1.5L" and not "1.5l". */
const UNIT_LABEL: Record<MeasureUnit, string> = { Ml: "ml", L: "L", G: "g", Kg: "kg" }
const measuredLabel = (qty: number, unit: MeasureUnit) => `${qty}${UNIT_LABEL[unit]}`
/**
* Normalised identity of a measurement, mirroring the server's `ItemContent.Normalize`
* (L/Kg ×1000 → ml/g). Used for BOTH the SKU segment and value dedupe, so the client's
* notion of "same size" is the server's.
*
* The decimal point becomes `P` rather than being stripped: `skuSegment` deletes it, which
* would make 2.5ml and 25ml — and 1.5L and 15L — produce identical SKUs and fail the
* create loop partway with SKU_DUPLICATE.
*/
function measureKey(qty: number, unit: MeasureUnit): string {
const factor = unit === "L" || unit === "Kg" ? 1000 : 1
const base = unit === "L" || unit === "Ml" ? "ML" : "G"
const n = Math.round(qty * factor * 10000) / 10000
const digits = n.toFixed(4).replace(/0+$/, "").replace(/\.$/, "")
return `${digits.replace(".", "P")}${base}`
}
/** The item builder only ever offers these two dimensions, regardless of what else exists
* in the Item Types master list. */
const BUILDER_ITEM_TYPES = ["color", "size"]
function isBuilderItemType(name: string): boolean {
return BUILDER_ITEM_TYPES.includes(name.trim().toLowerCase())
function buildVariantSku(categoryLabel: string, values: DimensionValue[]): string {
return [
skuSegment(categoryLabel, 3),
// Measurement segments are deliberately NOT truncated — truncation is what collides.
...values.map((v) => (isMeasured(v) ? measureKey(v.qty, v.unit) : skuSegment(v.label, 3))),
].join("-")
}
export default function NewItemPage() {
@@ -63,6 +96,7 @@ export default function NewItemPage() {
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
const [loadError, setLoadError] = useState<string | null>(null)
const [productName, setProductName] = useState("")
const [categoryId, setCategoryId] = useState<number | null>(null)
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
@@ -73,8 +107,12 @@ export default function NewItemPage() {
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [checkedItemTypeIds, setCheckedItemTypeIds] = useState<number[]>([])
const [valuesByCategory, setValuesByCategory] = useState<Record<number, string[]>>({})
const [valuesByCategory, setValuesByCategory] = useState<Record<number, DimensionValue[]>>({})
const [inputByCategory, setInputByCategory] = useState<Record<number, string>>({})
// Measurable dimensions enter a number + unit instead of free text.
const [qtyByCategory, setQtyByCategory] = useState<Record<number, string>>({})
const [unitByCategory, setUnitByCategory] = useState<Record<number, MeasureUnit | null>>({})
const [valueErrors, setValueErrors] = useState<Record<number, string>>({})
// Lets a specific generated combination be dropped from the preview table before
// submit, without having to remove and re-add the whole value that produced it.
const [removedVariantKeys, setRemovedVariantKeys] = useState<Set<string>>(new Set())
@@ -128,11 +166,15 @@ export default function NewItemPage() {
.catch(() => setSubCategories([]))
}, [categoryId, config?.subcategoriesEnabled])
const categoryLabel = (categories ?? []).find((c) => c.categoryId === categoryId)?.name ?? ""
const subCategoryLabel = subCategories.find((s) => s.subCategoryId === subCategoryId)?.name ?? ""
/** SKU/name read best off the most specific level, but BOTH ids are sent to the server. */
const effectiveLabel = subCategoryLabel || categoryLabel
const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? ""
/**
* The product being built ("Coca Cola"), typed rather than derived. It names every generated
* item and supplies the SKU's leading segment.
*
* Category and subcategory still classify the item and are both sent to the server; they just
* no longer name it, which is what produced labels like "Beverages - 500ml".
*/
const productLabel = productName.trim()
function handleCategoryChange(value: number | null) {
setCategoryId(value)
@@ -145,22 +187,60 @@ export default function NewItemPage() {
)
}
/**
* Add is the real validation gate for a measurement — rejecting here means a half or
* duplicate pair never reaches state, so nothing downstream has to defend against one.
*/
function addValue(itemTypeId: number) {
const value = (inputByCategory[itemTypeId] ?? "").trim()
if (value) {
const measurable = (itemTypes ?? []).find((t) => t.itemTypeId === itemTypeId)?.isMeasurable ?? false
if (!measurable) {
const label = (inputByCategory[itemTypeId] ?? "").trim()
if (label) {
setValuesByCategory((prev) => {
const existing = prev[itemTypeId] ?? []
if (existing.some((v) => v.toLowerCase() === value.toLowerCase())) return prev
return { ...prev, [itemTypeId]: [...existing, value] }
if (existing.some((v) => v.label.toLowerCase() === label.toLowerCase())) return prev
return { ...prev, [itemTypeId]: [...existing, { label, qty: null, unit: null }] }
})
}
setInputByCategory((prev) => ({ ...prev, [itemTypeId]: "" }))
return
}
const raw = (qtyByCategory[itemTypeId] ?? "").trim()
const unit = unitByCategory[itemTypeId] ?? null
const qty = Number(raw)
if (!raw || Number.isNaN(qty) || qty <= 0) {
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "Enter a size greater than 0" }))
return
}
if (!unit) {
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "Select a unit" }))
return
}
// Dedupe on the NORMALISED size, not the label: 500 ml and 0.5 L read differently but
// store identically, so a label-only check would create two items of the same size.
const key = measureKey(qty, unit)
const existing = valuesByCategory[itemTypeId] ?? []
if (existing.some((v) => isMeasured(v) && measureKey(v.qty, v.unit) === key)) {
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "That size is already in the list" }))
return
}
function removeValue(itemTypeId: number, value: string) {
setValuesByCategory((prev) => ({
...prev,
[itemTypeId]: (prev[itemTypeId] ?? []).filter((v) => v !== value),
[itemTypeId]: [...(prev[itemTypeId] ?? []), { label: measuredLabel(qty, unit), qty, unit }],
}))
setQtyByCategory((prev) => ({ ...prev, [itemTypeId]: "" }))
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "" }))
}
function removeValue(itemTypeId: number, label: string) {
setValuesByCategory((prev) => ({
...prev,
[itemTypeId]: (prev[itemTypeId] ?? []).filter((v) => v.label !== label),
}))
}
@@ -175,13 +255,13 @@ export default function NewItemPage() {
const allVariants = useMemo(() => {
if (activeCategories.length === 0) return []
let combinations: { key: string; parts: { name: string; value: string }[] }[] = [{ key: "", parts: [] }]
let combinations: { key: string; parts: { name: string; value: DimensionValue }[] }[] = [{ key: "", parts: [] }]
for (const cat of activeCategories) {
const next: typeof combinations = []
for (const combo of combinations) {
for (const value of cat.values) {
next.push({
key: combo.key ? `${combo.key}::${value}` : value,
key: combo.key ? `${combo.key}::${value.label}` : value.label,
parts: [...combo.parts, { name: cat.name, value }],
})
}
@@ -190,9 +270,9 @@ export default function NewItemPage() {
}
return combinations.map((c) => ({
...c,
sku: buildVariantSku(effectiveLabel, c.parts.map((p) => p.value)),
sku: buildVariantSku(productLabel, c.parts.map((p) => p.value)),
}))
}, [activeCategories, effectiveLabel])
}, [activeCategories, productLabel])
const variants = useMemo(
() => allVariants.filter((v) => !removedVariantKeys.has(v.key)),
@@ -203,12 +283,76 @@ export default function NewItemPage() {
setRemovedVariantKeys((prev) => new Set(prev).add(key))
}
/**
* True once a checked dimension supplies each variant's content size. The form-level
* content field is then hidden and cleared, so the two can never disagree.
*/
const measurableCheckedCount = (itemTypes ?? []).filter(
(t) => checkedItemTypeIds.includes(t.itemTypeId) && t.isMeasurable
).length
// Keyed off *checked*, not "checked and already has values": the moment a measurement
// dimension is ticked the form-level pair is irrelevant, so it should not linger while the
// first value is being typed.
const hasMeasurableDimension = measurableCheckedCount > 0
/**
* The shared content pair is offered only when it is the ONLY way to set a content size:
* dimensions are chosen, and none of them measures anything (a Colour-only product, whose
* variants really do all hold the same amount).
*
* Deliberately hidden on the untouched page. Showing it there invites someone to fill in one
* size before discovering that ticking Size would have captured a size per value — which
* reads as though sizes still apply to every variant at once.
*/
const showSharedContent = checkedItemTypeIds.length > 0 && !hasMeasurableDimension
useEffect(() => {
if (!hasMeasurableDimension) return
setContentQty("")
setContentUnit(null)
}, [hasMeasurableDimension])
/** A variant's own measured part, else the form-level fallback pair. */
function contentForVariant(variant: { parts: { value: DimensionValue }[] }) {
const measured = variant.parts.find((p) => isMeasured(p.value))
if (measured && isMeasured(measured.value)) {
return { qty: measured.value.qty, unit: measured.value.unit }
}
// Only fall back to the shared pair when that pair is the one on offer, so a value left
// behind by an earlier selection can never be applied invisibly.
if (!showSharedContent || !contentQty.trim() || !contentUnit) return null
return { qty: Number(contentQty), unit: contentUnit }
}
function contentLabelFor(variant: { parts: { value: DimensionValue }[] }) {
const content = contentForVariant(variant)
return content ? measuredLabel(content.qty, content.unit) : "—"
}
async function handleSubmit() {
setSubmitError(null)
const nextErrors = {
...validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 }),
...contentPairErrors(contentQty, contentUnit),
const nextErrors: Record<string, string> = {
...validateVariantItemForm({ productName, categoryId, hasVariants: variants.length > 0 }),
// Validate the shared pair exactly when it is on screen. Validating it while hidden
// would block submit with a message the user cannot see.
...(showSharedContent ? contentPairErrors(contentQty, contentUnit) : {}),
}
if (measurableCheckedCount > 1) {
nextErrors.measurable = "Only one measurement dimension can be used at a time."
}
// Should never fire — addValue is the real gate — so it catches stale state only.
const contentSweep = validateVariantContent(
variants.map((v) => v.key),
(key) => {
const variant = variants.find((v) => v.key === key)
const content = variant ? contentForVariant(variant) : null
return content
? { qty: String(content.qty), unit: content.unit }
: { qty: "", unit: null }
},
)
const sweepMessage = Object.values(contentSweep)[0]
if (sweepMessage) nextErrors.variantContent = sweepMessage
setErrors(nextErrors)
// In fixed mode, block the whole submit until every variant has a price > 0.
const nextPriceErrors =
@@ -224,9 +368,10 @@ export default function NewItemPage() {
let created = 0
try {
for (const variant of variants) {
const content = contentForVariant(variant)
await itemsApi.create({
sku: variant.sku,
name: `${brandLabel ? brandLabel + " " : ""}${effectiveLabel} - ${variant.parts.map((p) => p.value).join("/")}`,
name: `${brandLabel ? brandLabel + " " : ""}${productLabel} - ${variant.parts.map((p) => p.value.label).join("/")}`,
// Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the
// category, which lost the parent entirely. The server rejects a mismatched
// pair with 422.
@@ -237,8 +382,11 @@ export default function NewItemPage() {
stockNature,
trackingMode: "None",
salePrice: priceMode === "fixed" ? Number(priceFor(variant.key)) : null,
contentQty: contentQty.trim() ? Number(contentQty) : null,
contentUnit: contentQty.trim() ? contentUnit : null,
// Each variant carries its OWN size when a measurement dimension supplied one;
// otherwise the shared form-level pair, which is correct when the varying dimension
// isn't size (colour variants of one 500 ml bottle all hold 500 ml).
contentQty: content?.qty ?? null,
contentUnit: content?.unit ?? null,
})
created += 1
}
@@ -290,6 +438,22 @@ export default function NewItemPage() {
{!loading && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">Product name</Label>
<Input
value={productName}
onChange={(e) => setProductName(e.target.value)}
placeholder="e.g. Coca Cola"
aria-invalid={!!errors.productName}
className="h-12! text-base"
/>
<FieldError errors={[errors.productName ? { message: errors.productName } : undefined]} />
<p className="text-sm text-muted-foreground">
Names every variant and starts its SKU
{productLabel ? ` — "${brandLabel ? brandLabel + " " : ""}${productLabel} - 500ml", SKU ${skuSegment(productLabel, 3)}-…` : "."}
</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Category</Label>
<Select<number | null>
@@ -394,7 +558,10 @@ export default function NewItemPage() {
</Select>
</div>
{/* Content size (FR-MD-02): how much one stocked pack holds. Optional — a screw
or a label has none. Litres/kilograms are normalised to ml/g by the server. */}
or a label has none. Litres/kilograms are normalised to ml/g by the server.
Hidden once a measurement dimension is in play, because that dimension then
gives each variant its own size and this pair would only contradict it. */}
{showSharedContent && (
<div className="flex flex-col gap-2">
<Label className="text-base">Content size</Label>
<div className="flex gap-2">
@@ -432,9 +599,11 @@ export default function NewItemPage() {
]}
/>
<p className="text-sm text-muted-foreground">
Leave blank for items with no measurable content. Stock is still counted in the base UOM.
Applies to every variant. Leave blank for items with no measurable content stock is
counted in the base UOM either way.
</p>
</div>
)}
<div className="flex flex-col gap-2">
<Label className="text-base">Stock nature</Label>
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)}>
@@ -512,23 +681,54 @@ export default function NewItemPage() {
<p className="text-sm text-muted-foreground">
Check the item types that apply, then add their values to generate a SKU per combination.
</p>
{/* Without this, the per-size content feature is invisible: nothing is flagged as a
measurement out of the box, so the builder silently falls back to one shared
content size and the user has no reason to suspect another page is involved. */}
{(itemTypes ?? []).length > 0 && !(itemTypes ?? []).some((t) => t.isMeasurable) && (
<p className="mt-2 text-sm text-muted-foreground">
Selling the same product in several sizes (500 ml, 1 L)?{" "}
<Link href="/dashboard/products/item-types" className="font-medium underline">
Mark that item type as a measurement
</Link>{" "}
and each size will capture its own number and unit, giving every variant its own content size.
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-4">
{(itemTypes ?? [])
.filter((t) => isBuilderItemType(t.name))
.map((t) => (
<label key={t.itemTypeId} className="flex items-center gap-2.5 rounded-lg border px-3 py-2 hover:bg-muted/50">
{(itemTypes ?? []).map((t) => {
const checked = checkedItemTypeIds.includes(t.itemTypeId)
// Only one dimension may supply the content size, or a variant would have two.
const blocked = t.isMeasurable && !checked && measurableCheckedCount > 0
return (
<label
key={t.itemTypeId}
title={blocked ? "Only one measurement dimension can be used at a time." : undefined}
className={cn(
"flex items-center gap-2.5 rounded-lg border px-3 py-2",
blocked ? "opacity-50" : "hover:bg-muted/50"
)}
>
<Checkbox
checked={checkedItemTypeIds.includes(t.itemTypeId)}
checked={checked}
disabled={blocked}
onCheckedChange={() => toggleItemType(t.itemTypeId)}
/>
<span className="text-base font-medium">{t.name}</span>
{t.isMeasurable && (
<Badge variant="outline" className="text-xs">measurement</Badge>
)}
</label>
))}
)
})}
</div>
<FieldError errors={[errors.variants ? { message: errors.variants } : undefined]} />
<FieldError
errors={[
errors.variants ? { message: errors.variants } : undefined,
errors.measurable ? { message: errors.measurable } : undefined,
]}
/>
{checkedItemTypeIds.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -540,6 +740,48 @@ export default function NewItemPage() {
return (
<div key={t.itemTypeId} className="flex flex-col gap-2">
<Label className="text-base">{t.name} values</Label>
{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.
<div className="flex gap-2">
<Input
type="number"
min="0"
step="any"
value={qtyByCategory[t.itemTypeId] ?? ""}
onChange={(e) => 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"
/>
<Select<MeasureUnit | null>
value={unitByCategory[t.itemTypeId] ?? null}
onValueChange={(v) => setUnitByCategory((prev) => ({ ...prev, [t.itemTypeId]: v }))}
items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}
>
<SelectTrigger className="h-11! w-24 shrink-0 text-base">
<SelectValue placeholder="Unit" />
</SelectTrigger>
<SelectContent>
{CONTENT_UNITS.map((u) => (
<SelectItem key={u} value={u} className="text-base">
{UNIT_LABEL[u]}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}>
<Plus className="size-4" />
Add
</Button>
</div>
) : (
<div className="flex gap-2">
<Input
value={currentInput}
@@ -558,15 +800,19 @@ export default function NewItemPage() {
Add {t.name}
</Button>
</div>
)}
<FieldError
errors={[valueErrors[t.itemTypeId] ? { message: valueErrors[t.itemTypeId] } : undefined]}
/>
<div className="flex flex-wrap gap-2">
{(valuesByCategory[t.itemTypeId] ?? []).map((v) => (
<Badge key={v} variant="outline" className="h-7 gap-1 pr-1 text-sm">
{v}
<Badge key={v.label} variant="outline" className="h-7 gap-1 pr-1 text-sm">
{v.label}
<button
type="button"
onClick={() => removeValue(t.itemTypeId, v)}
onClick={() => removeValue(t.itemTypeId, v.label)}
className="rounded-full p-0.5 hover:bg-muted"
aria-label={`Remove ${v}`}
aria-label={`Remove ${v.label}`}
>
<X className="size-3" />
</button>
@@ -579,6 +825,10 @@ export default function NewItemPage() {
</div>
)}
{/* 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. */}
<FieldError errors={[errors.variantContent ? { message: errors.variantContent } : undefined]} />
{variants.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-base">
@@ -588,6 +838,7 @@ export default function NewItemPage() {
<TableHead key={cat.itemTypeId} className="h-11 px-3 text-sm">{cat.name}</TableHead>
))}
<TableHead className="h-11 px-3 text-sm">SKU</TableHead>
<TableHead className="h-11 px-3 text-sm">Content</TableHead>
{priceMode === "fixed" && (
<TableHead className="h-11 px-3 text-sm">Sale price</TableHead>
)}
@@ -603,10 +854,15 @@ export default function NewItemPage() {
<TableRow key={variant.key}>
{variant.parts.map((part, i) => (
<TableCell key={i} className="px-3 py-2.5">
{part.value}
{part.value.label}
</TableCell>
))}
<TableCell className="py-2.5 pr-1 pl-3 font-medium">{variant.sku}</TableCell>
{/* Read-only: content is derived, so an editable cell here could only
disagree with the value that produced it. */}
<TableCell className="px-3 py-2.5 text-muted-foreground">
{contentLabelFor(variant)}
</TableCell>
{priceMode === "fixed" && (
<TableCell className="px-3 py-2.5">
<Input
@@ -81,10 +81,14 @@ export function validateItemTypeName(name: string): Record<string, string> {
}
export function validateVariantItemForm(input: {
productName: string
categoryId: number | null
hasVariants: boolean
}): Record<string, string> {
const errors: Record<string, string> = {}
// 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<string, string> {
const errors: Record<string, string> = {}
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,
+14 -3
View File
@@ -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
}
/**
+3 -2
View File
@@ -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.
+18 -5
View File
@@ -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).