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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user