sku and item fix
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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}
|
||||
@@ -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'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'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) {
|
||||
setValuesByCategory((prev) => {
|
||||
const existing = prev[itemTypeId] ?? []
|
||||
if (existing.some((v) => v.toLowerCase() === value.toLowerCase())) return prev
|
||||
return { ...prev, [itemTypeId]: [...existing, value] }
|
||||
})
|
||||
}
|
||||
setInputByCategory((prev) => ({ ...prev, [itemTypeId]: "" }))
|
||||
}
|
||||
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.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,33 +740,79 @@ export default function NewItemPage() {
|
||||
return (
|
||||
<div key={t.itemTypeId} className="flex flex-col gap-2">
|
||||
<Label className="text-base">{t.name} values</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={currentInput}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}>
|
||||
<Plus className="size-4" />
|
||||
Add {t.name}
|
||||
</Button>
|
||||
</div>
|
||||
{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}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}>
|
||||
<Plus className="size-4" />
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user