diff --git a/Backend/ERPCore/Controllers/ItemsController.cs b/Backend/ERPCore/Controllers/ItemsController.cs index fe4ed11..f792529 100644 --- a/Backend/ERPCore/Controllers/ItemsController.cs +++ b/Backend/ERPCore/Controllers/ItemsController.cs @@ -81,11 +81,4 @@ public sealed class ItemsController : ApiControllerBase [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> UpdateReorder(int itemId, [FromBody] UpdateReorderRequest request, CancellationToken ct) => Ok(await _items.UpdateReorderAsync(itemId, request, ct)); - - /// Replace the item's UOM conversions (FR-MD-02). - [HttpPut("{itemId:int}/uom-conversions")] - [ProducesResponseType(typeof(ItemUomConversionsDto), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> UpdateUomConversions(int itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct) - => Ok(await _items.UpdateUomConversionsAsync(itemId, request, ct)); } diff --git a/Backend/ERPCore/Domain/Entities/BundleSaleLine.cs b/Backend/ERPCore/Domain/Entities/BundleSaleLine.cs index fecf7ef..dff3e32 100644 --- a/Backend/ERPCore/Domain/Entities/BundleSaleLine.cs +++ b/Backend/ERPCore/Domain/Entities/BundleSaleLine.cs @@ -12,8 +12,6 @@ public class BundleSaleLine public Item? Item { get; set; } public string Description { get; set; } = string.Empty; public decimal Qty { get; set; } - public int UomId { get; set; } - public Uom? Uom { get; set; } public int WarehouseId { get; set; } public Warehouse? Warehouse { get; set; } public decimal UnitPrice { get; set; } diff --git a/Backend/ERPCore/Domain/Entities/BundleSaleTemplateLine.cs b/Backend/ERPCore/Domain/Entities/BundleSaleTemplateLine.cs index e41414b..72d1371 100644 --- a/Backend/ERPCore/Domain/Entities/BundleSaleTemplateLine.cs +++ b/Backend/ERPCore/Domain/Entities/BundleSaleTemplateLine.cs @@ -8,8 +8,6 @@ public class BundleSaleTemplateLine public int ItemId { get; set; } public Item? Item { get; set; } - public int UomId { get; set; } - public Uom? Uom { get; set; } public int WarehouseId { get; set; } public Warehouse? Warehouse { get; set; } diff --git a/Backend/ERPCore/Domain/Entities/GrnLine.cs b/Backend/ERPCore/Domain/Entities/GrnLine.cs index f6981d4..c6094dd 100644 --- a/Backend/ERPCore/Domain/Entities/GrnLine.cs +++ b/Backend/ERPCore/Domain/Entities/GrnLine.cs @@ -25,9 +25,6 @@ public class GrnLine public int ItemId { get; set; } public Item? Item { get; set; } - public int UomId { get; set; } - public Uom? Uom { get; set; } - public int? BinId { get; set; } public Bin? Bin { get; set; } diff --git a/Backend/ERPCore/Domain/Entities/Item.cs b/Backend/ERPCore/Domain/Entities/Item.cs index 1065286..9b39ae5 100644 --- a/Backend/ERPCore/Domain/Entities/Item.cs +++ b/Backend/ERPCore/Domain/Entities/Item.cs @@ -24,6 +24,12 @@ public class Item public int? BrandId { get; set; } public Brand? Brand { get; set; } + /// + /// The stocking unit — the pack the item is counted in (BOTTLE, PACKET, BOX, PCS). + /// Every quantity in the system is a count of these: stock layers, the ledger, + /// and every document line. Nothing converts, so this is the sole meaning of a + /// quantity and cannot be changed once the item has stock history. + /// public int BaseUomId { get; set; } public Uom? BaseUom { get; set; } @@ -41,6 +47,34 @@ public class Item /// public decimal? SalePrice { get; set; } + /// + /// How much one pack holds, as the user entered it — 500 with + /// Ml for a 500 ml bottle, 1.5 with L + /// for a 1.5 L one. Null (together with the other three) when the item has no + /// measurable content: a screw, a label, a service. + /// + /// Content never affects stock — that is always a pack count. It exists so production + /// can express a formula in millilitres or grams and resolve it to packs + /// (see IItemMeasure). + /// + /// + /// A loose bulk item bought by weight is modelled the same way: + /// BaseUom = KG, ContentQty = 1, ContentUnit = Kg ⇒ 1000 g per stocked unit. + /// + /// + public decimal? ContentQty { get; set; } + public MeasureUnit? ContentUnit { get; set; } + + /// + /// / normalised to a base unit + /// (L→Ml, Kg→G, both ×1000) at write time by ItemContent.Normalize. Server-derived + /// and never accepted from a client. is therefore only ever + /// or . + /// Stored rather than recomputed so every consumer reads one settled number. + /// + public decimal? ContentBaseQty { get; set; } + public MeasureUnit? ContentBaseUnit { get; set; } + public EntityStatus Status { get; set; } = EntityStatus.Active; public DateTime CreatedAt { get; set; } @@ -50,5 +84,4 @@ public class Item public uint RowVersion { get; set; } public ICollection ReorderSettings { get; set; } = new List(); - public ICollection UomConversions { get; set; } = new List(); } diff --git a/Backend/ERPCore/Domain/Entities/ItemType.cs b/Backend/ERPCore/Domain/Entities/ItemType.cs index 668b1b7..8bd0a6b 100644 --- a/Backend/ERPCore/Domain/Entities/ItemType.cs +++ b/Backend/ERPCore/Domain/Entities/ItemType.cs @@ -7,12 +7,15 @@ namespace ERPCore.Domain.Entities; /// Material. /// /// Deliberately unlinked. Nothing references this entity and it references -/// nothing: there is no value table and no join to . Its only job is -/// to feed the frontend's item-builder dropdown via GET /item-types. The chosen +/// nothing: there is no value table and no join to . The chosen /// values (Red, S, M) are encoded by the client into the generated SKU /// (e.g. BL-100-0003) and are never stored or parsed server-side — the item list /// is the record of what was built. See the accepted trade-off in docs/10 Part C.9. /// +/// +/// It does, however, carry one piece of meaning the client acts on: +/// . So this is no longer purely a dropdown source. +/// /// Not to be confused with (Stocked/NonStocked/Service), /// which is what the old ItemType enum became. /// Model: docs/10-BACKEND-PHASE1.md Part C.1. @@ -21,6 +24,24 @@ public class ItemType { public int ItemTypeId { get; set; } public string Name { get; set; } = string.Empty; + + /// + /// When true, this dimension's values are content measurements (500 ml, 1 L) rather + /// than plain labels (Red, S). The item builder then captures a number + unit per value and + /// stamps that pair onto each generated item's / + /// , instead of copying one form-level pair into every variant + /// — which is what makes "Coca-Cola in 500 ml / 1 L / 250 ml" three correctly sized items. + /// + /// This is what lets an apparel Size (S/M/L) stay plain text while a + /// Pack Size/Volume dimension carries ml/g/L/kg. + /// + /// + /// A client hint only: the server never reads it when writing an item. Each item's pair is + /// still validated and normalised on its own by ItemContent. + /// + /// + public bool IsMeasurable { get; set; } + public EntityStatus Status { get; set; } = EntityStatus.Active; public DateTime CreatedAt { get; set; } diff --git a/Backend/ERPCore/Domain/Entities/PoLine.cs b/Backend/ERPCore/Domain/Entities/PoLine.cs index 0a5f087..e588dd7 100644 --- a/Backend/ERPCore/Domain/Entities/PoLine.cs +++ b/Backend/ERPCore/Domain/Entities/PoLine.cs @@ -15,9 +15,6 @@ public class PoLine public int ItemId { get; set; } public Item? Item { get; set; } - public int UomId { get; set; } - public Uom? Uom { get; set; } - public int WarehouseId { get; set; } public Warehouse? Warehouse { get; set; } diff --git a/Backend/ERPCore/Domain/Entities/RunStageInput.cs b/Backend/ERPCore/Domain/Entities/RunStageInput.cs index a1b9c09..eadcfbb 100644 --- a/Backend/ERPCore/Domain/Entities/RunStageInput.cs +++ b/Backend/ERPCore/Domain/Entities/RunStageInput.cs @@ -31,10 +31,14 @@ public class RunStageInput public int? FromRunOutputId { get; set; } public RunStageOutput? FromRunOutput { get; set; } - public int UomId { get; set; } - public Uom? Uom { get; set; } + /// Copied from the template input: what is expressed in. + public StageQtyUnit QtyUnit { get; set; } = StageQtyUnit.Pack; - /// Scaled at creation; per-run editable until the stage starts (FR-MFG-08, 409 STAGE_NOT_EDITABLE). + /// + /// Scaled at creation; per-run editable until the stage starts (FR-MFG-08, + /// 409 STAGE_NOT_EDITABLE). Expressed in — so unlike the + /// consumption figures below it is not necessarily a pack count. + /// public decimal PlannedQty { get; set; } /// diff --git a/Backend/ERPCore/Domain/Entities/RunStageOutput.cs b/Backend/ERPCore/Domain/Entities/RunStageOutput.cs index 6b7d801..0a0027a 100644 --- a/Backend/ERPCore/Domain/Entities/RunStageOutput.cs +++ b/Backend/ERPCore/Domain/Entities/RunStageOutput.cs @@ -23,10 +23,17 @@ public class RunStageOutput public string Name { get; set; } = string.Empty; - public int UomId { get; set; } + /// + /// Display label for intermediate WIP; null on the terminal output, whose unit is the + /// finished item's base UOM. Never converted — see . + /// + public int? UomId { get; set; } public Uom? Uom { get; set; } - /// Scaled at creation; per-run editable until the stage starts. + /// + /// Scaled at creation; per-run editable until the stage starts. Every quantity on an + /// output is a pack count, so scrap is recorded in whole broken bottles rather than ml. + /// public decimal PlannedQty { get; set; } /// Recorded at complete. A re-complete after a rework overwrites this, never adds to it. diff --git a/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs b/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs index e7e4beb..d8ff592 100644 --- a/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs +++ b/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs @@ -16,8 +16,6 @@ public class SalesInvoiceLine public decimal Qty { get; set; } public decimal FreeQty { get; set; } - public int UomId { get; set; } - public Uom? Uom { get; set; } public int WarehouseId { get; set; } public Warehouse? Warehouse { get; set; } diff --git a/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs b/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs index 0eb3de2..11302d2 100644 --- a/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs +++ b/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs @@ -16,8 +16,6 @@ public class SalesSlipLine public decimal Qty { get; set; } public decimal FreeQty { get; set; } - public int UomId { get; set; } - public Uom? Uom { get; set; } public int WarehouseId { get; set; } public Warehouse? Warehouse { get; set; } diff --git a/Backend/ERPCore/Domain/Entities/StageInput.cs b/Backend/ERPCore/Domain/Entities/StageInput.cs index dd63212..0ef08ce 100644 --- a/Backend/ERPCore/Domain/Entities/StageInput.cs +++ b/Backend/ERPCore/Domain/Entities/StageInput.cs @@ -32,8 +32,13 @@ public class StageInput public int? FromOutputId { get; set; } public StageOutput? FromOutput { get; set; } - public int UomId { get; set; } - public Uom? Uom { get; set; } + /// + /// What is expressed in. Stock inputs may use + /// (ml/g) when the item has a content size; Upstream + /// inputs are always — WIP is counted in the unit its + /// source output declares. + /// + public StageQtyUnit QtyUnit { get; set; } = StageQtyUnit.Pack; public decimal QtyPerBatch { get; set; } } diff --git a/Backend/ERPCore/Domain/Entities/StageOutput.cs b/Backend/ERPCore/Domain/Entities/StageOutput.cs index 2bd69e6..928b0ad 100644 --- a/Backend/ERPCore/Domain/Entities/StageOutput.cs +++ b/Backend/ERPCore/Domain/Entities/StageOutput.cs @@ -20,8 +20,14 @@ public class StageOutput public string Name { get; set; } = string.Empty; - public int UomId { get; set; } + /// + /// Display label for intermediate work-in-progress. Required when + /// is null and must be null when it is set — a real item's unit is its own base UOM. + /// WIP never touches stock or the ledger, so this is never converted, only shown. + /// + public int? UomId { get; set; } public Uom? Uom { get; set; } + /// Always a pack count: of the WIP unit above, or of the item's base UOM. public decimal QtyPerBatch { get; set; } } diff --git a/Backend/ERPCore/Domain/Entities/Uom.cs b/Backend/ERPCore/Domain/Entities/Uom.cs index 485b16a..71b5769 100644 --- a/Backend/ERPCore/Domain/Entities/Uom.cs +++ b/Backend/ERPCore/Domain/Entities/Uom.cs @@ -1,8 +1,10 @@ namespace ERPCore.Domain.Entities; /// -/// Unit of Measure (FR-MD-02). Referenced as an item's base UOM and as the -/// endpoints of a . Model: docs/10-BACKEND-PHASE1.md Part C.1. +/// Unit of Measure (FR-MD-02). A flat lookup, used as an item's base UOM — the pack every +/// quantity in the system counts — and as the display label on an intermediate production +/// output. There are no conversions between UOMs: an item is stocked in exactly one, and a +/// differently sized pack is a different item. Model: docs/10-BACKEND-PHASE1.md Part C.1. /// public class Uom { diff --git a/Backend/ERPCore/Domain/Entities/UomConversion.cs b/Backend/ERPCore/Domain/Entities/UomConversion.cs deleted file mode 100644 index f44a99d..0000000 --- a/Backend/ERPCore/Domain/Entities/UomConversion.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace ERPCore.Domain.Entities; - -/// -/// Per-item conversion factor between two UOMs (FR-MD-02/03): quantity in -/// × = quantity in . -/// Model: docs/10-BACKEND-PHASE1.md Part C.1. -/// -public class UomConversion -{ - public int ConversionId { get; set; } - - public int ItemId { get; set; } - public Item? Item { get; set; } - - public int FromUomId { get; set; } - public Uom? FromUom { get; set; } - - public int ToUomId { get; set; } - public Uom? ToUom { get; set; } - - public decimal Factor { get; set; } -} diff --git a/Backend/ERPCore/Domain/Enums/MeasureUnit.cs b/Backend/ERPCore/Domain/Enums/MeasureUnit.cs new file mode 100644 index 0000000..c3f9aec --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/MeasureUnit.cs @@ -0,0 +1,23 @@ +namespace ERPCore.Domain.Enums; + +/// +/// Unit of an item's content size — how much a single stocked pack holds +/// (a 500 ml bottle, a 50 kg sack). Stored as a string in the database. +/// +/// This is not a stocking unit. Stock is always counted in packs +/// (Item.BaseUomId); content is a separate, optional attribute used by +/// production to turn "2000 ml of syrup" into a pack count. +/// +/// +/// Only and are ever stored as a base content +/// unit. and are entry conveniences normalised ×1000 +/// on write by ItemContent.Normalize, so nothing downstream has to convert. +/// +/// +public enum MeasureUnit +{ + Ml, + L, + G, + Kg +} diff --git a/Backend/ERPCore/Domain/Enums/StageQtyUnit.cs b/Backend/ERPCore/Domain/Enums/StageQtyUnit.cs new file mode 100644 index 0000000..01e73a7 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/StageQtyUnit.cs @@ -0,0 +1,22 @@ +namespace ERPCore.Domain.Enums; + +/// +/// What a stage input's quantity is expressed in (FR-MFG-04). Stored as a string. +/// +/// Deliberately explicit rather than inferred from whether the item happens to have a +/// content size: templates outlive item edits, so an inferred unit would let adding a +/// content size to an existing item silently reinterpret every saved formula — "300" +/// meaning 300 packs would become 300 ml. +/// +/// +public enum StageQtyUnit +{ + /// A count of the item's base UOM — bottles, packets, pieces. + Pack, + + /// + /// An amount of the item's content in its base content unit (ml or g), resolved to + /// packs by IItemMeasure at stage start. Requires the item to have a content size. + /// + Content +} diff --git a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs index aec88c2..81889d8 100644 --- a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs +++ b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs @@ -6,7 +6,7 @@ namespace ERPCore.Dtos.Grn; // Responses (docs/11 §4) -------------------------------------------------------- public sealed record GrnLineDto( - int GrnLineId, int? PoLineId, int ItemId, int UomId, int? BinId, + int GrnLineId, int? PoLineId, int ItemId, int? BinId, decimal Qty, decimal UnitCost, decimal? PoUnitPrice, decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount, decimal ReceivedValue, decimal LineTotal, decimal PriceVariance, @@ -44,7 +44,6 @@ public sealed class CreateGrnLineInput /// Set for a PO-based receipt; cost is then derived from the PO line (02-SECURITY C.3). public int? PoLineId { get; set; } [Required] public int ItemId { get; set; } - [Required] public int UomId { get; set; } public int? BinId { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } /// diff --git a/Backend/ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs b/Backend/ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs index 35d2e92..d9fd262 100644 --- a/Backend/ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs +++ b/Backend/ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs @@ -5,12 +5,16 @@ namespace ERPCore.Dtos.ItemTypes; /// /// Item type resource (docs/11-BACKEND-PHASE1.md §2.7) — a dimension name such as Color -/// or Size. Carries no values and no item linkage: GET /item-types exists to -/// populate the frontend builder's dropdown, and the chosen values are encoded into the +/// or Size. Carries no values and no item linkage: the chosen values are encoded into the /// client-generated SKU rather than stored (docs/10 Part C.9). +/// +/// IsMeasurable marks a dimension whose values are content measurements (500 ml, 1 L) +/// rather than plain labels; the builder captures a number + unit per value and writes it to +/// each generated item's content size. +/// /// public sealed record ItemTypeDto( - int ItemTypeId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt); + int ItemTypeId, string Name, bool IsMeasurable, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt); // Request DTOs — narrow: server-controlled fields (status, ids, timestamps) // are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ---- @@ -18,11 +22,21 @@ public sealed record ItemTypeDto( public sealed class CreateItemTypeRequest { [Required, StringLength(200)] public string Name { get; set; } = string.Empty; + + /// Omitted ⇒ false, i.e. plain-text values. See . + public bool IsMeasurable { get; set; } } public sealed class UpdateItemTypeRequest { [Required, StringLength(200)] public string Name { get; set; } = string.Empty; + + /// + /// Nullable on purpose: a plain bool binds an absent property as false, so any + /// client that PUT only a name — as the item-types screen used to — would silently clear the + /// flag on every rename. Omitting this field preserves the stored value. + /// + public bool? IsMeasurable { get; set; } } public sealed class UpdateItemTypeStatusRequest diff --git a/Backend/ERPCore/Dtos/Items/ItemDtos.cs b/Backend/ERPCore/Dtos/Items/ItemDtos.cs index 9433c76..b61aec9 100644 --- a/Backend/ERPCore/Dtos/Items/ItemDtos.cs +++ b/Backend/ERPCore/Dtos/Items/ItemDtos.cs @@ -9,7 +9,10 @@ namespace ERPCore.Dtos.Items; public sealed record ItemListItemDto( int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode, - string? TaxClass, decimal? SalePrice, EntityStatus Status); + string? TaxClass, decimal? SalePrice, + decimal? ContentQty, MeasureUnit? ContentUnit, + decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit, + EntityStatus Status); /// A single per-warehouse reorder policy row. public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty); @@ -17,26 +20,21 @@ public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decim /// /// Full item resource for GET /items/{id} and create/update responses. /// -/// is embedded because they are otherwise unreadable: they can -/// only be written via PUT /items/{id}/uom-conversions, which returns them, but no -/// endpoint reads them back — so a detail screen could never show current state before -/// editing. Mirrors how is already inlined. +/// ContentBaseQty/ContentBaseUnit are echoed back so a detail screen can show +/// what the entered size normalised to (1.5 L ⇒ 1500 ml) — they are server-derived and are +/// not accepted on write. /// /// public sealed record ItemDetailDto( int ItemId, string Sku, string Name, string? Description, int CategoryId, int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode, - string? TaxClass, decimal? SalePrice, EntityStatus Status, IReadOnlyList Reorder, - IReadOnlyList Conversions, + string? TaxClass, decimal? SalePrice, + decimal? ContentQty, MeasureUnit? ContentUnit, + decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit, + EntityStatus Status, IReadOnlyList Reorder, DateTime CreatedAt, DateTime? UpdatedAt); -/// UOM conversion row (docs/11 §2.2). -public sealed record UomConversionDto(int ConversionId, int FromUom, int ToUom, decimal Factor); - -/// Response body for PUT /items/{id}/uom-conversions. -public sealed record ItemUomConversionsDto(int ItemId, int BaseUomId, IReadOnlyList Conversions); - /// Response body for PUT /items/{id}/reorder. public sealed record ItemReorderSettingsDto(IReadOnlyList Settings); @@ -64,6 +62,14 @@ public sealed class CreateItemRequest [StringLength(20)] public string? TaxClass { get; set; } /// Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value. [Range(0, double.MaxValue)] public decimal? SalePrice { get; set; } + + /// + /// How much one pack holds. Supply with or leave both null + /// for items with no measurable content. The normalised base pair is derived by the + /// server and is deliberately not accepted here. + /// + [Range(0.0001, double.MaxValue)] public decimal? ContentQty { get; set; } + [EnumDataType(typeof(MeasureUnit))] public MeasureUnit? ContentUnit { get; set; } } public sealed class UpdateItemRequest @@ -83,6 +89,14 @@ public sealed class UpdateItemRequest [StringLength(20)] public string? TaxClass { get; set; } /// Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value. [Range(0, double.MaxValue)] public decimal? SalePrice { get; set; } + + /// + /// How much one pack holds. Supply with or leave both null + /// for items with no measurable content. The normalised base pair is derived by the + /// server and is deliberately not accepted here. + /// + [Range(0.0001, double.MaxValue)] public decimal? ContentQty { get; set; } + [EnumDataType(typeof(MeasureUnit))] public MeasureUnit? ContentUnit { get; set; } } public sealed class UpdateItemStatusRequest @@ -101,15 +115,3 @@ public sealed class UpdateReorderRequest { [Required, MinLength(1)] public List Settings { get; set; } = new(); } - -public sealed class UomConversionInput -{ - [Required] public int FromUom { get; set; } - [Required] public int ToUom { get; set; } - [Range(0.000001, double.MaxValue)] public decimal Factor { get; set; } -} - -public sealed class UpdateUomConversionsRequest -{ - [Required, MinLength(1)] public List Conversions { get; set; } = new(); -} diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs index d062179..01177ff 100644 --- a/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs +++ b/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs @@ -6,7 +6,7 @@ namespace ERPCore.Dtos.Procurement; // Responses (docs/11 §3.3) ------------------------------------------------------ public sealed record PoLineDto( - int PoLineId, int ItemId, int UomId, int WarehouseId, + int PoLineId, int ItemId, int WarehouseId, decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived); public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency); @@ -25,7 +25,6 @@ public sealed record PurchaseOrderSummaryDto( public sealed class CreatePoLineInput { [Required] public int ItemId { get; set; } - [Required] public int UomId { get; set; } [Required] public int WarehouseId { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } [Range(0, double.MaxValue)] public decimal UnitPrice { get; set; } diff --git a/Backend/ERPCore/Dtos/Production/RunDtos.cs b/Backend/ERPCore/Dtos/Production/RunDtos.cs index 50de0a8..9c45f1a 100644 --- a/Backend/ERPCore/Dtos/Production/RunDtos.cs +++ b/Backend/ERPCore/Dtos/Production/RunDtos.cs @@ -29,17 +29,23 @@ public sealed record RunSummaryDto( /// public sealed record CostPoolDto(decimal Consumed, decimal Returned, decimal Net); +/// +/// One input of a run stage. PlannedQty is expressed in QtyUnit — content +/// (ml/g) or packs — while every consumption figure is always a pack count, so the two are +/// not directly comparable for a Content input. +/// public sealed record RunStageInputDto( - int RunInputId, StageInputSource Source, int? ItemId, int? FromRunOutputId, int UomId, + int RunInputId, StageInputSource Source, int? ItemId, int? FromRunOutputId, StageQtyUnit QtyUnit, decimal PlannedQty, decimal ConsumedQty, decimal ConsumedValue, decimal DeliveredQty, decimal ReturnedQty, decimal ReturnedValue); /// /// One output of a run stage. AvailableToTransfer is derived — produced − scrapped − -/// transferred (FR-MFG-12) — and never stored. +/// transferred (FR-MFG-12) — and never stored. UomId is the WIP label and is null on +/// the terminal output, whose unit is the finished item's base UOM. /// public sealed record RunStageOutputDto( - int RunOutputId, int? ItemId, string Name, int UomId, + int RunOutputId, int? ItemId, string Name, int? UomId, decimal PlannedQty, decimal ProducedQty, decimal ScrappedQty, int? ScrapReasonCodeId, decimal TransferredQty, decimal AvailableToTransfer); diff --git a/Backend/ERPCore/Dtos/Production/TemplateDtos.cs b/Backend/ERPCore/Dtos/Production/TemplateDtos.cs index d9185f1..f9975f1 100644 --- a/Backend/ERPCore/Dtos/Production/TemplateDtos.cs +++ b/Backend/ERPCore/Dtos/Production/TemplateDtos.cs @@ -35,10 +35,11 @@ public sealed record FieldDefDto( public sealed record StageInputDto( int InputId, StageInputSource Source, int? ItemId, - int? FromOutputId, string? FromOutputKey, int UomId, decimal QtyPerBatch); + int? FromOutputId, string? FromOutputKey, StageQtyUnit QtyUnit, decimal QtyPerBatch); +/// UomId is the WIP label and is null exactly when ItemId is set. public sealed record StageOutputDto( - int OutputId, string Key, int? ItemId, string Name, int UomId, decimal QtyPerBatch); + int OutputId, string Key, int? ItemId, string Name, int? UomId, decimal QtyPerBatch); public sealed record TemplateStageDto( int StageId, string Key, string Name, string? RoleLabel, int EstimatedMinutes, @@ -134,8 +135,12 @@ public sealed class SaveInputRequest [StringLength(60)] public string? FromOutputKey { get; set; } - [Range(1, int.MaxValue)] - public int UomId { get; set; } + /// + /// What means. Content (ml/g) is allowed only on a Stock + /// input whose item has a content size; Upstream inputs must be Pack. + /// + [EnumDataType(typeof(StageQtyUnit))] + public StageQtyUnit QtyUnit { get; set; } = StageQtyUnit.Pack; [Range(0.0001, double.MaxValue)] public decimal QtyPerBatch { get; set; } @@ -153,8 +158,12 @@ public sealed class SaveOutputRequest [Required, StringLength(150, MinimumLength = 1)] public string Name { get; set; } = string.Empty; + /// + /// The WIP display unit. Required when is null; must be null when it + /// is set, because a real item's unit is its own base UOM. + /// [Range(1, int.MaxValue)] - public int UomId { get; set; } + public int? UomId { get; set; } [Range(0.0001, double.MaxValue)] public decimal QtyPerBatch { get; set; } diff --git a/Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs b/Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs index 7d14af7..a31d3d4 100644 --- a/Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs +++ b/Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs @@ -4,7 +4,7 @@ using ERPCore.Domain.Enums; namespace ERPCore.Dtos.Sales; public sealed record BundleSaleLineDto( - int BundleSaleLineId, int ItemId, string Description, decimal Qty, int UomId, int WarehouseId, + int BundleSaleLineId, int ItemId, string Description, decimal Qty, int WarehouseId, decimal UnitPrice, decimal LineTotal, bool IncludeInBundle, bool IsComponent, int? ParentLineId); public sealed record BundleSaleDto( @@ -20,7 +20,7 @@ public sealed record BundleSaleSummaryDto( decimal ComponentSubtotal, decimal BundlePrice, decimal GrandTotal, DateTime CreatedAt); public sealed record BundleSaleTemplateLineDto( - int BundleSaleTemplateLineId, int ItemId, int UomId, int WarehouseId, decimal Qty, + int BundleSaleTemplateLineId, int ItemId, int WarehouseId, decimal Qty, decimal UnitPrice, bool IncludeInBundle, int SortOrder); public sealed record BundleSaleTemplateDto( @@ -42,7 +42,6 @@ public sealed record BundleSalePostingCheckDto( public sealed class CreateBundleSaleTemplateLineRequest { [Required] public int ItemId { get; set; } - [Required] public int UomId { get; set; } [Required] public int WarehouseId { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } [Range(0, double.MaxValue)] public decimal UnitPrice { get; set; } diff --git a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs index c541132..6682933 100644 --- a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs +++ b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs @@ -4,7 +4,7 @@ using ERPCore.Domain.Enums; namespace ERPCore.Dtos.Sales; public sealed record SalesInvoiceLineDto( - int SalesInvoiceLineId, int ItemId, string Description, decimal Qty, decimal FreeQty, int UomId, + int SalesInvoiceLineId, int ItemId, string Description, decimal Qty, decimal FreeQty, int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct, decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal, decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId); @@ -35,7 +35,6 @@ public sealed record SalesInvoicePostingCheckDto( public sealed class CreateSalesInvoiceLineRequest { [Required] public int ItemId { get; set; } - [Required] public int UomId { get; set; } [Required] public int WarehouseId { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } [Range(0, double.MaxValue)] public decimal FreeQty { get; set; } diff --git a/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs index 85ec177..629bb0d 100644 --- a/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs +++ b/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs @@ -4,7 +4,7 @@ using ERPCore.Domain.Enums; namespace ERPCore.Dtos.Sales; public sealed record SalesSlipLineDto( - int SalesSlipLineId, int ItemId, string Description, decimal Qty, decimal FreeQty, int UomId, + int SalesSlipLineId, int ItemId, string Description, decimal Qty, decimal FreeQty, int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct, decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal, decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId); @@ -34,7 +34,7 @@ public sealed record SalesSlipPostingCheckDto( public sealed record FreeIssueSummaryDto( int SalesSlipId, string SlipNo, SalesSlipStatus Status, DateTime CreatedAt, int WarehouseId, string WarehouseName, int ItemId, string ItemSku, string ItemName, - int UomId, string UomName, decimal Qty, decimal FreeQty, string SchemeLabel); + string UomName, decimal Qty, decimal FreeQty, string SchemeLabel); public sealed record FreeIssueDto( int SalesSlipId, string SlipNo, DateTime SlipDate, SalesSlipStatus Status, @@ -45,7 +45,6 @@ public sealed record FreeIssueDto( public sealed class CreateSalesSlipLineRequest { [Required] public int ItemId { get; set; } - [Required] public int UomId { get; set; } [Required] public int WarehouseId { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } [Range(0, double.MaxValue)] public decimal FreeQty { get; set; } diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleLineConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleLineConfiguration.cs index fac3e88..ff233fa 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleLineConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleLineConfiguration.cs @@ -18,7 +18,6 @@ public sealed class BundleSaleLineConfiguration : IEntityTypeConfiguration x.IsComponent).HasDefaultValue(true); builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict); - builder.HasOne(x => x.Uom).WithMany().HasForeignKey(x => x.UomId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict); } } diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleTemplateLineConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleTemplateLineConfiguration.cs index 113647e..2b2c176 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleTemplateLineConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleTemplateLineConfiguration.cs @@ -15,7 +15,6 @@ public sealed class BundleSaleTemplateLineConfiguration : IEntityTypeConfigurati builder.Property(x => x.SortOrder).HasDefaultValue(0); builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict); - builder.HasOne(x => x.Uom).WithMany().HasForeignKey(x => x.UomId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict); } } diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs index 0c47198..1dbd77f 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs @@ -49,7 +49,6 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration builder.HasOne(l => l.Grn).WithMany(g => g.Lines).HasForeignKey(l => l.GrnId).OnDelete(DeleteBehavior.Cascade); builder.HasOne(l => l.PoLine).WithMany().HasForeignKey(l => l.PoLineId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict); - builder.HasOne(l => l.Uom).WithMany().HasForeignKey(l => l.UomId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(l => l.Bin).WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict); } diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs index 53feb18..f519ae2 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs @@ -22,6 +22,16 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration // Sales-only fixed selling price; nullable (null ⇒ sell at stock/FIFO value). builder.Property(i => i.SalePrice).HasPrecision(18, 4); + // Optional content size (how much one stocked pack holds). All four are nullable + // together: null ⇒ the item has no measurable content. The base pair is derived + // server-side by ItemContent.Normalize and is only ever Ml or G. + builder.Property(i => i.ContentQty).HasPrecision(18, 4); + builder.Property(i => i.ContentBaseQty).HasPrecision(18, 4); + builder.Property(i => i.ContentUnit) + .HasConversion().HasMaxLength(20); + builder.Property(i => i.ContentBaseUnit) + .HasConversion().HasMaxLength(20); + builder.Property(i => i.StockNature) .HasConversion().HasMaxLength(20).IsRequired(); builder.Property(i => i.TrackingMode) diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ItemTypeConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ItemTypeConfiguration.cs index c26f97c..4e9476a 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/ItemTypeConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ItemTypeConfiguration.cs @@ -19,6 +19,11 @@ public sealed class ItemTypeConfiguration : IEntityTypeConfiguration builder.Property(t => t.Name).IsRequired().HasMaxLength(200); builder.HasIndex(t => t.Name).IsUnique(); + // false is the only safe default here: EF uses the CLR default as its "unset" sentinel, + // so if the store default were true, inserting an explicit false would be mistaken for + // "not set" and silently written as true. Sentinel and store default must agree. + builder.Property(t => t.IsMeasurable).IsRequired().HasDefaultValue(false); + builder.Property(t => t.Status) .HasConversion().HasMaxLength(20).IsRequired() .HasDefaultValue(EntityStatus.Active); diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ProductionConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ProductionConfiguration.cs index ca46d0f..d7f6c42 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/ProductionConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ProductionConfiguration.cs @@ -91,13 +91,13 @@ public sealed class StageInputConfiguration : IEntityTypeConfiguration i.InputId); builder.Property(i => i.Source).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(i => i.QtyUnit).HasConversion().HasMaxLength(20).IsRequired(); builder.Property(i => i.QtyPerBatch).HasPrecision(18, 4); builder.HasOne(i => i.Stage).WithMany(s => s.Inputs) .HasForeignKey(i => i.StageId).OnDelete(DeleteBehavior.Cascade); builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict); - builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(i => i.FromOutput).WithMany() .HasForeignKey(i => i.FromOutputId).OnDelete(DeleteBehavior.Restrict); } @@ -206,6 +206,7 @@ public sealed class RunStageInputConfiguration : IEntityTypeConfiguration i.RunInputId); builder.Property(i => i.Source).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(i => i.QtyUnit).HasConversion().HasMaxLength(20).IsRequired(); builder.Property(i => i.PlannedQty).HasPrecision(18, 4); builder.Property(i => i.ConsumedQty).HasPrecision(18, 4); builder.Property(i => i.ConsumedValue).HasPrecision(18, 4); @@ -217,7 +218,6 @@ public sealed class RunStageInputConfiguration : IEntityTypeConfiguration i.RunStageId).OnDelete(DeleteBehavior.Cascade); builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict); - builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(i => i.FromRunOutput).WithMany() .HasForeignKey(i => i.FromRunOutputId).OnDelete(DeleteBehavior.Restrict); diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseOrderConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseOrderConfiguration.cs index 8d452e0..d5f3cd3 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseOrderConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseOrderConfiguration.cs @@ -63,11 +63,6 @@ public sealed class PoLineConfiguration : IEntityTypeConfiguration .HasForeignKey(l => l.ItemId) .OnDelete(DeleteBehavior.Restrict); - builder.HasOne(l => l.Uom) - .WithMany() - .HasForeignKey(l => l.UomId) - .OnDelete(DeleteBehavior.Restrict); - builder.HasOne(l => l.Warehouse) .WithMany() .HasForeignKey(l => l.WarehouseId) diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs index e8f27ed..1d723a4 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs @@ -79,11 +79,6 @@ public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration x.TaxPct).HasPrecision(9, 4); builder.Property(x => x.TaxAmount).HasPrecision(18, 4); - builder.HasOne(x => x.Uom) - .WithMany() - .HasForeignKey(x => x.UomId) - .OnDelete(DeleteBehavior.Restrict); - builder.HasOne(x => x.Warehouse) .WithMany() .HasForeignKey(x => x.WarehouseId) diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SalesSlipConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SalesSlipConfiguration.cs index deeeed0..3d25507 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/SalesSlipConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/SalesSlipConfiguration.cs @@ -79,11 +79,6 @@ public sealed class SalesSlipLineConfiguration : IEntityTypeConfiguration x.TaxPct).HasPrecision(9, 4); builder.Property(x => x.TaxAmount).HasPrecision(18, 4); - builder.HasOne(x => x.Uom) - .WithMany() - .HasForeignKey(x => x.UomId) - .OnDelete(DeleteBehavior.Restrict); - builder.HasOne(x => x.Warehouse) .WithMany() .HasForeignKey(x => x.WarehouseId) diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/UomConversionConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/UomConversionConfiguration.cs deleted file mode 100644 index 15445d4..0000000 --- a/Backend/ERPCore/Infra/Persistence/Configurations/UomConversionConfiguration.cs +++ /dev/null @@ -1,34 +0,0 @@ -using ERPCore.Domain.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace ERPCore.Infra.Persistence.Configurations; - -public sealed class UomConversionConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("uom_conversions"); - builder.HasKey(c => c.ConversionId); - - builder.Property(c => c.Factor).HasPrecision(18, 6); - - builder.HasOne(c => c.Item) - .WithMany(i => i.UomConversions) - .HasForeignKey(c => c.ItemId) - .OnDelete(DeleteBehavior.Cascade); - - builder.HasOne(c => c.FromUom) - .WithMany() - .HasForeignKey(c => c.FromUomId) - .OnDelete(DeleteBehavior.Restrict); - - builder.HasOne(c => c.ToUom) - .WithMany() - .HasForeignKey(c => c.ToUomId) - .OnDelete(DeleteBehavior.Restrict); - - // One conversion per (item, from, to) triple. - builder.HasIndex(c => new { c.ItemId, c.FromUomId, c.ToUomId }).IsUnique(); - } -} diff --git a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs index 08dc206..ff802f6 100644 --- a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs +++ b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs @@ -285,13 +285,18 @@ public static class DataSeeder new Item { Sku = "SKU-DEMO-002", - Name = "Demo Item 2", - Description = "Secondary seeded sample item for sales documents", + Name = "Demo Item 2 (500 ml)", + Description = "Secondary seeded sample item; carries a content size so the " + + "production content-unit path has a fixture", CategoryId = category.CategoryId, BaseUomId = uom.UomId, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.None, SalePrice = 50m, + ContentQty = 500m, + ContentUnit = MeasureUnit.Ml, + ContentBaseQty = 500m, + ContentBaseUnit = MeasureUnit.Ml, Status = EntityStatus.Active, CreatedAt = now } @@ -438,7 +443,6 @@ public static class DataSeeder new BundleSaleTemplateLine { ItemId = items[0].ItemId, - UomId = uom.UomId, WarehouseId = warehouse.WarehouseId, Qty = 1m, UnitPrice = items[0].SalePrice.GetValueOrDefault(), @@ -448,7 +452,6 @@ public static class DataSeeder new BundleSaleTemplateLine { ItemId = items[1].ItemId, - UomId = uom.UomId, WarehouseId = warehouse.WarehouseId, Qty = 1m, UnitPrice = items[1].SalePrice.GetValueOrDefault(), @@ -489,7 +492,6 @@ public static class DataSeeder ItemId = items[0].ItemId, Description = items[0].Name, Qty = 1m, - UomId = uom.UomId, WarehouseId = warehouse.WarehouseId, UnitPrice = items[0].SalePrice.GetValueOrDefault(), LineTotal = items[0].SalePrice.GetValueOrDefault(), @@ -501,7 +503,6 @@ public static class DataSeeder ItemId = items[1].ItemId, Description = items[1].Name, Qty = 1m, - UomId = uom.UomId, WarehouseId = warehouse.WarehouseId, UnitPrice = items[1].SalePrice.GetValueOrDefault(), LineTotal = items[1].SalePrice.GetValueOrDefault(), @@ -537,7 +538,6 @@ public static class DataSeeder ItemId = items[0].ItemId, Description = items[0].Name, Qty = 1m, - UomId = uom.UomId, WarehouseId = warehouse.WarehouseId, UnitPrice = items[0].SalePrice.GetValueOrDefault(), LineTotal = items[0].SalePrice.GetValueOrDefault(), @@ -549,7 +549,6 @@ public static class DataSeeder ItemId = items[1].ItemId, Description = items[1].Name, Qty = 1m, - UomId = uom.UomId, WarehouseId = warehouse.WarehouseId, UnitPrice = items[1].SalePrice.GetValueOrDefault(), LineTotal = items[1].SalePrice.GetValueOrDefault(), @@ -585,7 +584,6 @@ public static class DataSeeder ItemId = items[0].ItemId, Description = items[0].Name, Qty = 1m, - UomId = uom.UomId, WarehouseId = secondaryWarehouse.WarehouseId, UnitPrice = items[0].SalePrice.GetValueOrDefault(), LineTotal = items[0].SalePrice.GetValueOrDefault(), @@ -662,7 +660,6 @@ public static class DataSeeder Description = postableItem.Name, Qty = 2m, FreeQty = 0m, - UomId = uom.UomId, WarehouseId = warehouse.WarehouseId, UnitPrice = 100m, BaseCost = 0m, @@ -706,7 +703,6 @@ public static class DataSeeder Description = shortageItem.Name, Qty = 6m, FreeQty = 0m, - UomId = uom.UomId, WarehouseId = secondaryWarehouse.WarehouseId, UnitPrice = 50m, BaseCost = 0m, @@ -751,7 +747,6 @@ public static class DataSeeder Description = postableItem.Name, Qty = 1m, FreeQty = 0m, - UomId = uom.UomId, WarehouseId = warehouse.WarehouseId, UnitPrice = 100m, BaseCost = 0m, @@ -794,7 +789,6 @@ public static class DataSeeder Description = postableItem.Name, Qty = 1m, FreeQty = 0m, - UomId = uom.UomId, WarehouseId = warehouse.WarehouseId, UnitPrice = 50m, BaseCost = 0m, @@ -834,7 +828,6 @@ public static class DataSeeder Description = shortageItem.Name, Qty = 3m, FreeQty = 0m, - UomId = uom.UomId, WarehouseId = secondaryWarehouse.WarehouseId, UnitPrice = 50m, BaseCost = 0m, diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs index 7ce7e29..47e402c 100644 --- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs +++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs @@ -30,7 +30,6 @@ public class ErpDbContext : DbContext /// Color/Size/Material dimension names. Unlinked to Item by design (docs/10 C.9). public DbSet ItemTypes => Set(); public DbSet Uoms => Set(); - public DbSet UomConversions => Set(); public DbSet Items => Set(); public DbSet ItemReorders => Set(); public DbSet Vendors => Set(); diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index 0e25398..83099de 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -99,7 +99,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); // Stock core + goods receipt (docs/11 §4–5) -builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Backend/ERPCore/Services/BundleSaleService.cs b/Backend/ERPCore/Services/BundleSaleService.cs index 1959a54..2620b39 100644 --- a/Backend/ERPCore/Services/BundleSaleService.cs +++ b/Backend/ERPCore/Services/BundleSaleService.cs @@ -19,11 +19,9 @@ public sealed class BundleSaleService : IBundleSaleService private readonly IRepository _bundles; private readonly IRepository _customers; private readonly IRepository _items; - private readonly IRepository _uoms; private readonly IRepository _warehouses; private readonly IRepository _users; private readonly ISalesDomainService _sales; - private readonly IUomConverter _uomConverter; private readonly ISalesPostingService _posting; private readonly ICurrentUser _currentUser; private readonly INumberSequenceService _numbers; @@ -34,11 +32,9 @@ public sealed class BundleSaleService : IBundleSaleService IRepository templates, IRepository customers, IRepository items, - IRepository uoms, IRepository warehouses, IRepository users, ISalesDomainService sales, - IUomConverter uomConverter, ISalesPostingService posting, ICurrentUser currentUser, INumberSequenceService numbers, @@ -48,11 +44,9 @@ public sealed class BundleSaleService : IBundleSaleService _bundles = bundles; _customers = customers; _items = items; - _uoms = uoms; _warehouses = warehouses; _users = users; _sales = sales; - _uomConverter = uomConverter; _posting = posting; _currentUser = currentUser; _numbers = numbers; @@ -85,7 +79,7 @@ public sealed class BundleSaleService : IBundleSaleService template.CreatedAt, template.UpdatedAt, template.Lines.OrderBy(x => x.SortOrder).Select(x => new BundleSaleTemplateLineDto( - x.BundleSaleTemplateLineId, x.ItemId, x.UomId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList()); + x.BundleSaleTemplateLineId, x.ItemId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList()); } public async Task> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default) @@ -196,7 +190,6 @@ public sealed class BundleSaleService : IBundleSaleService : template.Lines.OrderBy(x => x.SortOrder).Select(x => new CreateBundleSaleTemplateLineRequest { ItemId = x.ItemId, - UomId = x.UomId, WarehouseId = x.WarehouseId, Qty = x.Qty, UnitPrice = x.UnitPrice, @@ -214,17 +207,15 @@ public sealed class BundleSaleService : IBundleSaleService var lineWarehouseId = warehouseId; var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct); - await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, r.UomId, lineWarehouseId, r.Qty, 0m, null, ct); - var (qtyBase, unitCostBase) = await _uomConverter.ToBaseAsync(item, r.UomId, r.Qty, r.UnitPrice, ct); - var calc = _sales.ComputeLine(qtyBase, 0m, unitCostBase, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false); + await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, lineWarehouseId, r.Qty, 0m, null, ct); + var calc = _sales.ComputeLine(r.Qty, 0m, r.UnitPrice, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false); lines.Add(new BundleSaleLine { ItemId = r.ItemId, Description = item.Name, - Qty = qtyBase, - UomId = item.BaseUomId, + Qty = r.Qty, WarehouseId = lineWarehouseId, - UnitPrice = unitCostBase, + UnitPrice = r.UnitPrice, LineTotal = calc.LineTotal, IncludeInBundle = r.IncludeInBundle, IsComponent = true, @@ -251,6 +242,6 @@ public sealed class BundleSaleService : IBundleSaleService x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.CashierUserId, x.BundleSaleTemplateId, x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.MarginAmount, x.DiscountTotal, x.TaxTotal, x.GrandTotal, x.CreatedAt, x.UpdatedAt, - x.Lines.Select(l => new BundleSaleLineDto(l.BundleSaleLineId, l.ItemId, l.Description, l.Qty, l.UomId, l.WarehouseId, l.UnitPrice, l.LineTotal, l.IncludeInBundle, l.IsComponent, l.ParentLineId)).ToList()); + x.Lines.Select(l => new BundleSaleLineDto(l.BundleSaleLineId, l.ItemId, l.Description, l.Qty, l.WarehouseId, l.UnitPrice, l.LineTotal, l.IncludeInBundle, l.IsComponent, l.ParentLineId)).ToList()); } diff --git a/Backend/ERPCore/Services/GrnService.cs b/Backend/ERPCore/Services/GrnService.cs index 41b57ad..b526774 100644 --- a/Backend/ERPCore/Services/GrnService.cs +++ b/Backend/ERPCore/Services/GrnService.cs @@ -28,14 +28,12 @@ public sealed class GrnService : IGrnService private readonly IRepository _pos; private readonly IRepository _poLines; private readonly IRepository _items; - private readonly IRepository _uoms; private readonly IRepository _warehouses; private readonly IRepository _bins; private readonly IRepository _vendors; private readonly IRepository _batches; private readonly IRepository _layers; private readonly IRepository _ledger; - private readonly IUomConverter _uomConverter; private readonly IFifoCostingService _fifo; private readonly INumberSequenceService _numbers; private readonly ICurrentUser _currentUser; @@ -43,23 +41,21 @@ public sealed class GrnService : IGrnService public GrnService( IRepository grns, IRepository pos, IRepository poLines, - IRepository items, IRepository uoms, IRepository warehouses, + IRepository items, IRepository warehouses, IRepository bins, IRepository vendors, IRepository batches, - IRepository layers, IRepository ledger, IUomConverter uomConverter, + IRepository layers, IRepository ledger, IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow) { _grns = grns; _pos = pos; _poLines = poLines; _items = items; - _uoms = uoms; _warehouses = warehouses; _bins = bins; _vendors = vendors; _batches = batches; _layers = layers; _ledger = ledger; - _uomConverter = uomConverter; _fifo = fifo; _numbers = numbers; _currentUser = currentUser; @@ -133,8 +129,6 @@ public sealed class GrnService : IGrnService { var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == input.ItemId, ct) ?? throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422); - if (!await _uoms.Query().AnyAsync(u => u.UomId == input.UomId, ct)) - throw new DomainException(ErrorCodes.Validation, $"UOM {input.UomId} does not exist.", 422); if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct)) throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422); @@ -150,6 +144,8 @@ public sealed class GrnService : IGrnService if (poLine.ItemId != input.ItemId) throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is for a different item.", 422); + // Both sides are counts of the item's base UOM — the GRN line no longer carries + // a unit of its own — so this comparison and the accrual below are like-for-like. var openQty = poLine.Qty - poLine.QtyReceived; if (input.Qty > openQty * (1 + OverReceiptTolerance)) throw new DomainException(ErrorCodes.OverReceiptTolerance, @@ -174,7 +170,6 @@ public sealed class GrnService : IGrnService { PoLineId = input.PoLineId, ItemId = input.ItemId, - UomId = input.UomId, BinId = input.BinId, Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted Qty = input.Qty, @@ -234,10 +229,11 @@ public sealed class GrnService : IGrnService { foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId)) { - var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token); // FIFO layer costs at the after-discount net price; VAT is recoverable and never - // enters stock value (docs/10 FR-GRN-06, revised). - var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.NetUnitCost, token); + // enters stock value (docs/10 FR-GRN-06, revised). The line quantity is already + // a count of the item's base UOM, so it layers exactly as entered. + var qtyBase = line.Qty; + var unitCostBase = line.NetUnitCost; var layer = await _fifo.CreateInboundLayerAsync( line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId, @@ -345,15 +341,6 @@ public sealed class GrnService : IGrnService return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges } - /// - /// Delegates to the shared . This was a private method here - /// until manufacturing needed the same conversion for stage stock inputs; behaviour is - /// identical, so receive costing is unchanged. - /// - private Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync( - Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct) - => _uomConverter.ToBaseAsync(item, uomId, qty, unitCostPerUom, ct); - private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct) { if (poId is null) return; @@ -392,7 +379,7 @@ public sealed class GrnService : IGrnService private static GrnDto Map(Grn g) => new( g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto( - l.GrnLineId, l.PoLineId, l.ItemId, l.UomId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice, + l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice, l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal, l.PoUnitPrice is null ? 0m : Math.Round((l.UnitCost - l.PoUnitPrice.Value) * l.Qty, 4, MidpointRounding.AwayFromZero), l.HoldStatus, l.BatchId)).ToList()); diff --git a/Backend/ERPCore/Services/Interfaces/IItemMeasure.cs b/Backend/ERPCore/Services/Interfaces/IItemMeasure.cs new file mode 100644 index 0000000..b5211f2 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IItemMeasure.cs @@ -0,0 +1,25 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; + +namespace ERPCore.Services.Interfaces; + +/// +/// Resolves a production formula quantity to the only unit stock speaks: a count of the +/// item's base UOM. Replaces the old per-item UOM conversion table — everything needed is +/// on the item row, so this does no I/O. +/// +public interface IItemMeasure +{ + /// + /// Formula quantity → packs. passes straight through; + /// divides by the item's content size, so 2000 ml of + /// a 500 ml bottle is 4 bottles and 300 ml is 0.6 of one. + /// + decimal ToPacks(Item item, decimal formulaQty, StageQtyUnit unit); + + /// Packs → formula quantity. The exact inverse of , for display. + decimal FromPacks(Item item, decimal packs, StageQtyUnit unit); + + /// Whether the item carries a usable content size. + bool HasContent(Item item); +} diff --git a/Backend/ERPCore/Services/Interfaces/IItemService.cs b/Backend/ERPCore/Services/Interfaces/IItemService.cs index f73ee08..cb28706 100644 --- a/Backend/ERPCore/Services/Interfaces/IItemService.cs +++ b/Backend/ERPCore/Services/Interfaces/IItemService.cs @@ -24,6 +24,4 @@ public interface IItemService Task SetStatusAsync(int itemId, EntityStatus status, CancellationToken ct = default); Task UpdateReorderAsync(int itemId, UpdateReorderRequest request, CancellationToken ct = default); - - Task UpdateUomConversionsAsync(int itemId, UpdateUomConversionsRequest request, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/ISalesDomainService.cs b/Backend/ERPCore/Services/Interfaces/ISalesDomainService.cs index 99372be..69ffe56 100644 --- a/Backend/ERPCore/Services/Interfaces/ISalesDomainService.cs +++ b/Backend/ERPCore/Services/Interfaces/ISalesDomainService.cs @@ -15,7 +15,6 @@ public interface ISalesDomainService Task ValidateSalesLineAsync( int headerWarehouseId, int lineItemId, - int lineUomId, int lineWarehouseId, decimal qty, decimal freeQty, diff --git a/Backend/ERPCore/Services/Interfaces/IUomConverter.cs b/Backend/ERPCore/Services/Interfaces/IUomConverter.cs deleted file mode 100644 index 14f1a1a..0000000 --- a/Backend/ERPCore/Services/Interfaces/IUomConverter.cs +++ /dev/null @@ -1,35 +0,0 @@ -using ERPCore.Domain.Entities; - -namespace ERPCore.Services.Interfaces; - -/// -/// Converts a quantity and its per-UOM cost into the item's base UOM. -/// -/// -/// Everything in the FIFO engine — StockLayer, StockLedger, -/// IFifoCostingService.ConsumeAsync — works exclusively in base UOM, while -/// documents let a user enter a line in any UOM the item has a conversion for. This is the -/// one place that bridges the two. -/// Extracted from GrnService's private ToBaseAsync when manufacturing -/// needed the same conversion for stage stock inputs (docs/30 never mentions UOM -/// conversion, but STAGE_INPUT.uom_id is a free FK — without this, an input -/// specified in "Box of 12" would consume 1 base unit instead of 12 and silently -/// mis-cost the run). -/// -public interface IUomConverter -{ - /// - /// Returns the quantity and unit cost restated in 's base UOM. - /// A no-op when already is the base UOM. Throws 422 when no - /// conversion is defined for the item from that UOM to its base. - /// - Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync( - Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default); - - /// - /// Quantity-only conversion, for callers that have no per-UOM cost to restate (a - /// production stage input declares a quantity; its cost comes from the FIFO layers it - /// consumes, not from the document). - /// - Task ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default); -} diff --git a/Backend/ERPCore/Services/ItemContent.cs b/Backend/ERPCore/Services/ItemContent.cs new file mode 100644 index 0000000..ed5b6f9 --- /dev/null +++ b/Backend/ERPCore/Services/ItemContent.cs @@ -0,0 +1,61 @@ +using ERPCore.Domain.Enums; +using ERPCore.System.Errors; + +namespace ERPCore.Services; + +/// +/// Item content-size rules: validate the (qty, unit) pair and normalise it to a base +/// unit. Pure — no DI, no database — because everything it needs is on the item row. +/// +/// Litres and kilograms exist only at the point of entry. Everything stored and every +/// downstream calculation works in millilitres or grams, so no consumer ever has to ask +/// which unit it is holding. +/// +/// +public static class ItemContent +{ + /// + /// Rejects a half-filled pair. Both null is valid and means "this item has no + /// measurable content" — a screw, a label, a service. + /// + public static void ValidatePair(decimal? contentQty, MeasureUnit? contentUnit) + { + if (contentQty is null && contentUnit is null) return; + + if (contentQty is null || contentUnit is null) + throw new DomainException( + ErrorCodes.Validation, + "contentQty and contentUnit must be supplied together, or both left null.", 422); + + if (contentQty <= 0) + throw new DomainException( + ErrorCodes.Validation, "contentQty must be greater than zero.", 422); + } + + /// + /// Converts an entered content size to its base unit: L → Ml and Kg → G, both ×1000; + /// Ml and G pass through. Both-null in, both-null out. + /// + /// Rounded to 4dp AwayFromZero to match the quantity columns' (18,4) scale and + /// ProductionRunService.Scale, so a content size can never carry precision the + /// database would silently drop. + /// + /// + public static (decimal? BaseQty, MeasureUnit? BaseUnit) Normalize( + decimal? contentQty, MeasureUnit? contentUnit) + { + if (contentQty is null || contentUnit is null) return (null, null); + + var (factor, baseUnit) = contentUnit.Value switch + { + MeasureUnit.Ml => (1m, MeasureUnit.Ml), + MeasureUnit.L => (1000m, MeasureUnit.Ml), + MeasureUnit.G => (1m, MeasureUnit.G), + MeasureUnit.Kg => (1000m, MeasureUnit.G), + _ => throw new DomainException( + ErrorCodes.Validation, $"Unsupported content unit '{contentUnit}'.", 422) + }; + + return (Math.Round(contentQty.Value * factor, 4, MidpointRounding.AwayFromZero), baseUnit); + } +} diff --git a/Backend/ERPCore/Services/ItemService.cs b/Backend/ERPCore/Services/ItemService.cs index 79a24c5..ad2e353 100644 --- a/Backend/ERPCore/Services/ItemService.cs +++ b/Backend/ERPCore/Services/ItemService.cs @@ -31,6 +31,8 @@ public sealed class ItemService : IItemService private readonly IRepository _uoms; private readonly IRepository _vendors; private readonly IRepository _warehouses; + private readonly IRepository _stockLayers; + private readonly IRepository _stockLedger; private readonly IProductConfigService _config; private readonly IUnitOfWork _uow; @@ -42,6 +44,8 @@ public sealed class ItemService : IItemService IRepository uoms, IRepository vendors, IRepository warehouses, + IRepository stockLayers, + IRepository stockLedger, IProductConfigService config, IUnitOfWork uow) { @@ -52,6 +56,8 @@ public sealed class ItemService : IItemService _uoms = uoms; _vendors = vendors; _warehouses = warehouses; + _stockLayers = stockLayers; + _stockLedger = stockLedger; _config = config; _uow = uow; } @@ -79,7 +85,9 @@ public sealed class ItemService : IItemService .Select(i => new ItemListItemDto( i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.BaseUomId, i.DefaultVendorId, - i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice, i.Status)) + i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice, + i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit, + i.Status)) .ToListAsync(ct); return PagedResponse.Create(rows, query.Page, query.PageSize, total); @@ -89,7 +97,6 @@ public sealed class ItemService : IItemService { var item = await _items.Query().AsNoTracking() .Include(i => i.ReorderSettings) - .Include(i => i.UomConversions) .FirstOrDefaultAsync(i => i.ItemId == itemId, ct); return item is null ? null : new ETagged(ToDetail(item), item.RowVersion); @@ -104,6 +111,9 @@ public sealed class ItemService : IItemService request.CategoryId, request.SubCategoryId, request.BrandId, request.BaseUomId, request.DefaultVendorId, ct); + ItemContent.ValidatePair(request.ContentQty, request.ContentUnit); + var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit); + var item = new Item { Sku = request.Sku.Trim(), @@ -118,6 +128,10 @@ public sealed class ItemService : IItemService TrackingMode = request.TrackingMode, TaxClass = request.TaxClass, SalePrice = request.SalePrice, + ContentQty = request.ContentQty, + ContentUnit = request.ContentUnit, + ContentBaseQty = contentBaseQty, + ContentBaseUnit = contentBaseUnit, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow }; @@ -133,7 +147,6 @@ public sealed class ItemService : IItemService { var item = await _items.Query() .Include(i => i.ReorderSettings) - .Include(i => i.UomConversions) .FirstOrDefaultAsync(i => i.ItemId == itemId, ct) ?? throw new NotFoundException($"Item {itemId} was not found."); @@ -148,6 +161,18 @@ public sealed class ItemService : IItemService request.CategoryId, request.SubCategoryId, request.BrandId, request.BaseUomId, request.DefaultVendorId, ct); + // The base UOM is the sole meaning of every quantity recorded against this item — + // stock layers, ledger rows and document lines are all plain counts of it. Once any + // of that history exists, changing it would silently reinterpret every one of those + // numbers (240 bottles becoming 240 cases), so it is frozen instead. + if (item.BaseUomId != request.BaseUomId && await HasStockHistoryAsync(itemId, ct)) + throw new DomainException( + ErrorCodes.MasterInUse, + $"Item {itemId} has stock history; its base UOM can no longer be changed.", 409); + + ItemContent.ValidatePair(request.ContentQty, request.ContentUnit); + var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit); + item.Sku = request.Sku.Trim(); item.Name = request.Name.Trim(); item.Description = request.Description; @@ -160,6 +185,10 @@ public sealed class ItemService : IItemService item.TrackingMode = request.TrackingMode; item.TaxClass = request.TaxClass; item.SalePrice = request.SalePrice; + item.ContentQty = request.ContentQty; + item.ContentUnit = request.ContentUnit; + item.ContentBaseQty = contentBaseQty; + item.ContentBaseUnit = contentBaseUnit; item.UpdatedAt = DateTime.UtcNow; await SaveGuardingConcurrencyAsync(ct); @@ -224,52 +253,14 @@ public sealed class ItemService : IItemService return new ItemReorderSettingsDto(settings); } - public async Task UpdateUomConversionsAsync( - int itemId, UpdateUomConversionsRequest request, CancellationToken ct = default) - { - var pairs = request.Conversions.Select(c => (c.FromUom, c.ToUom)).ToList(); - if (pairs.Distinct().Count() != pairs.Count) - throw new DomainException(ErrorCodes.Validation, "Duplicate (fromUom, toUom) in conversions.", 400); - - var item = await _items.Query() - .Include(i => i.UomConversions) - .FirstOrDefaultAsync(i => i.ItemId == itemId, ct) - ?? throw new NotFoundException($"Item {itemId} was not found."); - - foreach (var uomId in request.Conversions.SelectMany(c => new[] { c.FromUom, c.ToUom }).Distinct()) - if (!await _uoms.Query().AnyAsync(u => u.UomId == uomId, ct)) - throw new DomainException(ErrorCodes.Validation, $"UOM {uomId} does not exist.", 422); - - foreach (var stale in item.UomConversions.Where(c => request.Conversions.All(r => r.FromUom != c.FromUomId || r.ToUom != c.ToUomId)).ToList()) - item.UomConversions.Remove(stale); - foreach (var input in request.Conversions) - { - var existing = item.UomConversions.FirstOrDefault(c => c.FromUomId == input.FromUom && c.ToUomId == input.ToUom); - if (existing is null) - { - item.UomConversions.Add(new UomConversion - { - ItemId = itemId, - FromUomId = input.FromUom, - ToUomId = input.ToUom, - Factor = input.Factor - }); - } - else - { - existing.Factor = input.Factor; - } - } - - item.UpdatedAt = DateTime.UtcNow; - await _uow.SaveChangesAsync(ct); - - var conversions = item.UomConversions - .OrderBy(c => c.ConversionId) - .Select(c => new UomConversionDto(c.ConversionId, c.FromUomId, c.ToUomId, c.Factor)) - .ToList(); - return new ItemUomConversionsDto(item.ItemId, item.BaseUomId, conversions); - } + /// + /// Whether anything has ever been recorded against this item's stock. Checks the ledger + /// as well as live layers, because a fully consumed item has no layer left but its + /// history still reads in the old unit. + /// + private async Task HasStockHistoryAsync(int itemId, CancellationToken ct) + => await _stockLayers.Query().AnyAsync(l => l.ItemId == itemId, ct) + || await _stockLedger.Query().AnyAsync(l => l.ItemId == itemId, ct); /// /// Validates every FK on an item write, and gates the optional ones on the product @@ -350,14 +341,12 @@ public sealed class ItemService : IItemService private static ItemDetailDto ToDetail(Item i) => new( i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId, i.BaseUomId, i.DefaultVendorId, - i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice, i.Status, + i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice, + i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit, + i.Status, i.ReorderSettings .OrderBy(r => r.WarehouseId) .Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty)) .ToList(), - i.UomConversions - .OrderBy(c => c.ConversionId) - .Select(c => new UomConversionDto(c.ConversionId, c.FromUomId, c.ToUomId, c.Factor)) - .ToList(), i.CreatedAt, i.UpdatedAt); } diff --git a/Backend/ERPCore/Services/ItemTypeService.cs b/Backend/ERPCore/Services/ItemTypeService.cs index 6297a54..015c662 100644 --- a/Backend/ERPCore/Services/ItemTypeService.cs +++ b/Backend/ERPCore/Services/ItemTypeService.cs @@ -41,7 +41,7 @@ public sealed class ItemTypeService : IItemTypeService var total = await q.CountAsync(ct); var rows = await q.OrderBy(t => t.Name) .Skip(query.Skip).Take(query.PageSize) - .Select(t => new ItemTypeDto(t.ItemTypeId, t.Name, t.Status, t.CreatedAt, t.UpdatedAt)) + .Select(t => new ItemTypeDto(t.ItemTypeId, t.Name, t.IsMeasurable, t.Status, t.CreatedAt, t.UpdatedAt)) .ToListAsync(ct); return PagedResponse.Create(rows, query.Page, query.PageSize, total); @@ -63,6 +63,7 @@ public sealed class ItemTypeService : IItemTypeService var itemType = new ItemType { Name = name, + IsMeasurable = request.IsMeasurable, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow }; @@ -90,6 +91,9 @@ public sealed class ItemTypeService : IItemTypeService // Renaming does not touch existing items: their SKUs already encode the values that // were chosen, and nothing joins back to this row (docs/10 Part C.9). itemType.Name = name; + // Omitted ⇒ keep what is stored. A plain bool would bind an absent property as false and + // so let a name-only PUT silently clear the flag on every rename. + itemType.IsMeasurable = request.IsMeasurable ?? itemType.IsMeasurable; itemType.UpdatedAt = DateTime.UtcNow; try @@ -114,5 +118,5 @@ public sealed class ItemTypeService : IItemTypeService await _uow.SaveChangesAsync(ct); } - private static ItemTypeDto Map(ItemType t) => new(t.ItemTypeId, t.Name, t.Status, t.CreatedAt, t.UpdatedAt); + private static ItemTypeDto Map(ItemType t) => new(t.ItemTypeId, t.Name, t.IsMeasurable, t.Status, t.CreatedAt, t.UpdatedAt); } diff --git a/Backend/ERPCore/Services/Production/ProductionGraphValidator.cs b/Backend/ERPCore/Services/Production/ProductionGraphValidator.cs index bc09891..8354390 100644 --- a/Backend/ERPCore/Services/Production/ProductionGraphValidator.cs +++ b/Backend/ERPCore/Services/Production/ProductionGraphValidator.cs @@ -21,9 +21,10 @@ namespace ERPCore.Services.Production; /// public static class ProductionGraphValidator { - public sealed record InputDraft(int Index, StageInputSource Source, int? ItemId, string? FromOutputKey); + public sealed record InputDraft( + int Index, StageInputSource Source, int? ItemId, string? FromOutputKey, StageQtyUnit QtyUnit); - public sealed record OutputDraft(string Key, string Name, int? ItemId); + public sealed record OutputDraft(string Key, string Name, int? ItemId, int? UomId); public sealed record StageDraft( string Key, string Name, IReadOnlyList Inputs, IReadOnlyList Outputs); @@ -171,6 +172,12 @@ public static class ProductionGraphValidator throw new DomainException(ErrorCodes.GraphInputSourceInvalid, $"Input {input.Index + 1} of stage '{s.Name}' is a Stock input and cannot reference an upstream output.", 422); } + + // WIP has no content size — it is counted in whatever unit its source output + // declares — so only a Stock input may be expressed in ml/g. + if (input.Source == StageInputSource.Upstream && input.QtyUnit != StageQtyUnit.Pack) + throw new DomainException(ErrorCodes.GraphInputSourceInvalid, + $"Input {input.Index + 1} of stage '{s.Name}' is Upstream, so its quantity must be in whole units of its source output, not content units.", 422); } } @@ -189,6 +196,20 @@ public static class ProductionGraphValidator foreach (var o in s.Outputs.Where(o => o.ItemId is not null)) throw Invalid( $"Output '{o.Name}' of stage '{s.Name}' is intermediate work-in-progress and cannot reference an item — only the final stage produces a stocked item."); + + // 7 — output units. An item-bearing output already has a unit (the item's base UOM), + // so carrying a second one could only contradict it. WIP has no item to ask, so it + // must name its own — the label the run board and any downstream input display. + foreach (var s in stages) + foreach (var o in s.Outputs) + { + if (o.ItemId is null && o.UomId is null) + throw new DomainException(ErrorCodes.WipUnitRequired, + $"Output '{o.Name}' of stage '{s.Name}' is work-in-progress and must declare a unit.", 422); + if (o.ItemId is not null && o.UomId is not null) + throw Invalid( + $"Output '{o.Name}' of stage '{s.Name}' references an item, so its unit comes from that item and must not be set."); + } } /// Set of keys reachable from following . diff --git a/Backend/ERPCore/Services/Production/ProductionRunService.cs b/Backend/ERPCore/Services/Production/ProductionRunService.cs index 2d25829..2a00825 100644 --- a/Backend/ERPCore/Services/Production/ProductionRunService.cs +++ b/Backend/ERPCore/Services/Production/ProductionRunService.cs @@ -26,7 +26,7 @@ public sealed class ProductionRunService : IProductionRunService private readonly IRepository _items; private readonly IRepository _reasonCodes; private readonly IFifoCostingService _fifo; - private readonly IUomConverter _uomConverter; + private readonly IItemMeasure _measure; private readonly INumberSequenceService _numbers; private readonly IUnitOfWork _uow; private readonly ICurrentUser _currentUser; @@ -35,7 +35,7 @@ public sealed class ProductionRunService : IProductionRunService IRepository runs, IRepository templates, IRepository warehouses, IRepository bins, IRepository items, IRepository reasonCodes, - IFifoCostingService fifo, IUomConverter uomConverter, + IFifoCostingService fifo, IItemMeasure measure, INumberSequenceService numbers, IUnitOfWork uow, ICurrentUser currentUser) { _runs = runs; @@ -45,7 +45,7 @@ public sealed class ProductionRunService : IProductionRunService _items = items; _reasonCodes = reasonCodes; _fifo = fifo; - _uomConverter = uomConverter; + _measure = measure; _numbers = numbers; _uow = uow; _currentUser = currentUser; @@ -245,7 +245,7 @@ public sealed class ProductionRunService : IProductionRunService Source = i.Source, ItemId = i.ItemId, FromRunOutput = i.FromOutputId is null ? null : outputByTemplateOutputId[i.FromOutputId.Value], - UomId = i.UomId, + QtyUnit = i.QtyUnit, PlannedQty = Scale(i.QtyPerBatch, ratio) }); } @@ -367,9 +367,11 @@ public sealed class ProductionRunService : IProductionRunService ?? throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} on stage '{stage.Name}' no longer exists.", 422); - // PlannedQty is in the input's declared UOM; ConsumedQty is in the item's - // base UOM (the only unit FIFO and the ledger speak). Compare in base. - var plannedBase = await _uomConverter.ToBaseQtyAsync(item, input.UomId, input.PlannedQty, token); + // PlannedQty is in the input's declared unit — content (ml/g) for an item with + // a content size, otherwise packs. ConsumedQty is always packs, the only unit + // FIFO and the ledger speak, so resolve before comparing. 2000 ml of a 500 ml + // bottle is 4 bottles; 300 ml is 0.6 of one, which (18,4) stores exactly. + var plannedBase = _measure.ToPacks(item, input.PlannedQty, input.QtyUnit); var delta = plannedBase - input.ConsumedQty; if (delta <= 0) continue; // rework restart with no increase — nothing to draw @@ -1057,7 +1059,9 @@ public sealed class ProductionRunService : IProductionRunService $"Item {item.Sku} is {item.TrackingMode}-tracked; batch/serial-tracked finished goods " + "are not supported in this phase.", 422); - var qtyBase = await _uomConverter.ToBaseQtyAsync(item, output.UomId, good, ct); + // Output quantities are always a count of the finished item's base UOM — produced and + // scrapped are recorded in whole bottles, not millilitres — so nothing to resolve. + var qtyBase = good; var consumedValue = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue); var returnedValue = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue); @@ -1223,7 +1227,7 @@ public sealed class ProductionRunService : IProductionRunService ProductionJson.Deserialize>(s.FieldDefs, []), ParseJson(s.FieldValues), s.Inputs.OrderBy(i => i.RunInputId).Select(i => new RunStageInputDto( - i.RunInputId, i.Source, i.ItemId, i.FromRunOutputId, i.UomId, + i.RunInputId, i.Source, i.ItemId, i.FromRunOutputId, i.QtyUnit, i.PlannedQty, i.ConsumedQty, i.ConsumedValue, i.DeliveredQty, i.ReturnedQty, i.ReturnedValue)).ToList(), s.Outputs.OrderBy(o => o.RunOutputId).Select(o => new RunStageOutputDto( diff --git a/Backend/ERPCore/Services/Production/ProductionTemplateService.cs b/Backend/ERPCore/Services/Production/ProductionTemplateService.cs index d63aaf6..3e304c2 100644 --- a/Backend/ERPCore/Services/Production/ProductionTemplateService.cs +++ b/Backend/ERPCore/Services/Production/ProductionTemplateService.cs @@ -253,19 +253,20 @@ public sealed class ProductionTemplateService : IProductionTemplateService ProductionGraphValidator.Validate( request.Stages.Select(s => new ProductionGraphValidator.StageDraft( s.Key, s.Name, - s.Inputs.Select((i, idx) => new ProductionGraphValidator.InputDraft(idx, i.Source, i.ItemId, i.FromOutputKey)).ToList(), - s.Outputs.Select(o => new ProductionGraphValidator.OutputDraft(o.Key, o.Name, o.ItemId)).ToList())).ToList(), + s.Inputs.Select((i, idx) => new ProductionGraphValidator.InputDraft(idx, i.Source, i.ItemId, i.FromOutputKey, i.QtyUnit)).ToList(), + s.Outputs.Select(o => new ProductionGraphValidator.OutputDraft(o.Key, o.Name, o.ItemId, o.UomId)).ToList())).ToList(), request.Edges.Select(e => new ProductionGraphValidator.EdgeDraft(e.ParentKey, e.ChildKey)).ToList()); var itemIds = request.Stages .SelectMany(s => s.Inputs.Select(i => i.ItemId).Concat(s.Outputs.Select(o => o.ItemId))) .OfType().Distinct().ToList(); + var contentByItem = new Dictionary(); if (itemIds.Count > 0) { var found = await _items.Query().AsNoTracking() .Where(i => itemIds.Contains(i.ItemId)) - .Select(i => new { i.ItemId, i.Status }) + .Select(i => new { i.ItemId, i.Status, i.ContentBaseQty }) .ToListAsync(ct); var missing = itemIds.Except(found.Select(f => f.ItemId)).ToList(); @@ -277,19 +278,42 @@ public sealed class ProductionTemplateService : IProductionTemplateService if (inactive.Count > 0) throw new DomainException(ErrorCodes.Validation, $"Item(s) {string.Join(", ", inactive)} are inactive and cannot be used in a template.", 422); + + contentByItem = found.ToDictionary(f => f.ItemId, f => f.ContentBaseQty); } + // A Content quantity is only meaningful against an item that declares a content size, + // and it must still land on a storable pack count: quantities persist at (18,4), so + // anything under 0.0001 packs would round to zero and consume nothing at stage start. + foreach (var s in request.Stages) + foreach (var i in s.Inputs.Where(i => i.QtyUnit == StageQtyUnit.Content)) + { + var contentBaseQty = contentByItem.GetValueOrDefault(i.ItemId ?? 0); + if (contentBaseQty is not > 0m) + throw new DomainException(ErrorCodes.Validation, + $"Stage '{s.Name}' has an input in content units, but item {i.ItemId} has no content size.", 422); + + if (i.QtyPerBatch / contentBaseQty.Value < 0.0001m) + throw new DomainException(ErrorCodes.Validation, + $"Stage '{s.Name}': {i.QtyPerBatch} is less than 0.0001 of item {i.ItemId}'s content size ({contentBaseQty}), which would round to no stock at all.", 422); + } + + // Only WIP outputs carry a UOM now; the validator has already rejected one on an + // item-bearing output, so every non-null id here belongs to real work-in-progress. var uomIds = request.Stages - .SelectMany(s => s.Inputs.Select(i => i.UomId).Concat(s.Outputs.Select(o => o.UomId))) - .Distinct().ToList(); + .SelectMany(s => s.Outputs.Select(o => o.UomId)) + .OfType().Distinct().ToList(); - var knownUoms = await _uoms.Query().AsNoTracking() - .Where(u => uomIds.Contains(u.UomId)).Select(u => u.UomId).ToListAsync(ct); + if (uomIds.Count > 0) + { + var knownUoms = await _uoms.Query().AsNoTracking() + .Where(u => uomIds.Contains(u.UomId)).Select(u => u.UomId).ToListAsync(ct); - var missingUoms = uomIds.Except(knownUoms).ToList(); - if (missingUoms.Count > 0) - throw new DomainException(ErrorCodes.Validation, - $"UOM(s) {string.Join(", ", missingUoms)} do not exist.", 422); + var missingUoms = uomIds.Except(knownUoms).ToList(); + if (missingUoms.Count > 0) + throw new DomainException(ErrorCodes.Validation, + $"UOM(s) {string.Join(", ", missingUoms)} do not exist.", 422); + } // Annotations go into jsonb unvalidated by anything else, so pin the one field the // client renders off. Unknown kinds would round-trip fine but draw nothing. @@ -428,7 +452,7 @@ public sealed class ProductionTemplateService : IProductionTemplateService Source = i.Source, ItemId = i.Source == StageInputSource.Stock ? i.ItemId : null, FromOutput = i.Source == StageInputSource.Upstream ? outputsByKey[i.FromOutputKey!] : null, - UomId = i.UomId, + QtyUnit = i.QtyUnit, QtyPerBatch = i.QtyPerBatch }); } @@ -476,7 +500,7 @@ public sealed class ProductionTemplateService : IProductionTemplateService s.Inputs.OrderBy(i => i.InputId).Select(i => new StageInputDto( i.InputId, i.Source, i.ItemId, i.FromOutputId, i.FromOutputId is null ? null : outputKeyById.GetValueOrDefault(i.FromOutputId.Value), - i.UomId, i.QtyPerBatch)).ToList(), + i.QtyUnit, i.QtyPerBatch)).ToList(), s.Outputs.OrderBy(o => o.OutputId).Select(o => new StageOutputDto( o.OutputId, o.OutputId.ToString(), o.ItemId, o.Name, o.UomId, o.QtyPerBatch)).ToList())) .ToList(); diff --git a/Backend/ERPCore/Services/PurchaseOrderService.cs b/Backend/ERPCore/Services/PurchaseOrderService.cs index 25c114e..e19aabd 100644 --- a/Backend/ERPCore/Services/PurchaseOrderService.cs +++ b/Backend/ERPCore/Services/PurchaseOrderService.cs @@ -222,7 +222,6 @@ public sealed class PurchaseOrderService : IPurchaseOrderService private static PoLine ToLine(CreatePoLineInput l) => new() { ItemId = l.ItemId, - UomId = l.UomId, WarehouseId = l.WarehouseId, Qty = l.Qty, UnitPrice = l.UnitPrice, @@ -258,7 +257,6 @@ public sealed class PurchaseOrderService : IPurchaseOrderService throw new DomainException(ErrorCodes.Validation, $"Requisition {requisitionId} does not exist.", 422); await EnsureAllExistAsync(_items.Query().Select(i => i.ItemId), lines.Select(l => l.ItemId), "Item", ct); - await EnsureAllExistAsync(_uoms.Query().Select(u => u.UomId), lines.Select(l => l.UomId), "UOM", ct); await EnsureAllExistAsync(_warehouses.Query().Select(w => w.WarehouseId), lines.Select(l => l.WarehouseId), "Warehouse", ct); } @@ -276,5 +274,5 @@ public sealed class PurchaseOrderService : IPurchaseOrderService p.PoId, p.DocNo, p.VendorId, p.RequisitionId, p.Status, p.ApprovalRequired, p.CreatedBy, p.CreatedAt, p.UpdatedAt, ComputeTotals(p.Lines), p.Lines.OrderBy(l => l.PoLineId).Select(l => new PoLineDto( - l.PoLineId, l.ItemId, l.UomId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived)).ToList()); + l.PoLineId, l.ItemId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived)).ToList()); } diff --git a/Backend/ERPCore/Services/SalesDomainService.cs b/Backend/ERPCore/Services/SalesDomainService.cs index 8fb1a93..646ba12 100644 --- a/Backend/ERPCore/Services/SalesDomainService.cs +++ b/Backend/ERPCore/Services/SalesDomainService.cs @@ -13,7 +13,6 @@ public sealed class SalesDomainService : ISalesDomainService private readonly IRepository _warehouses; private readonly IRepository _users; private readonly IRepository _items; - private readonly IRepository _uoms; private readonly ISalesPricingService _pricing; public SalesDomainService( @@ -21,14 +20,12 @@ public sealed class SalesDomainService : ISalesDomainService IRepository warehouses, IRepository users, IRepository items, - IRepository uoms, ISalesPricingService pricing) { _customers = customers; _warehouses = warehouses; _users = users; _items = items; - _uoms = uoms; _pricing = pricing; } @@ -48,7 +45,7 @@ public sealed class SalesDomainService : ISalesDomainService } public async Task ValidateSalesLineAsync( - int headerWarehouseId, int lineItemId, int lineUomId, int lineWarehouseId, decimal qty, decimal freeQty, int? parentLineId, CancellationToken ct = default) + int headerWarehouseId, int lineItemId, int lineWarehouseId, decimal qty, decimal freeQty, int? parentLineId, CancellationToken ct = default) { if (qty <= 0) throw new DomainException(ErrorCodes.Validation, "Sales line quantity must be greater than zero.", 422); @@ -60,8 +57,6 @@ public sealed class SalesDomainService : ISalesDomainService throw new DomainException(ErrorCodes.Validation, $"Sales line warehouse {lineWarehouseId} must match header warehouse {headerWarehouseId}.", 422); if (!await _items.Query().AnyAsync(x => x.ItemId == lineItemId, ct)) throw new NotFoundException($"Item {lineItemId} was not found."); - if (!await _uoms.Query().AnyAsync(x => x.UomId == lineUomId, ct)) - throw new NotFoundException($"UOM {lineUomId} was not found."); if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == lineWarehouseId, ct)) throw new NotFoundException($"Warehouse {lineWarehouseId} was not found."); } diff --git a/Backend/ERPCore/Services/SalesInvoiceService.cs b/Backend/ERPCore/Services/SalesInvoiceService.cs index 61d9e0e..8b99af0 100644 --- a/Backend/ERPCore/Services/SalesInvoiceService.cs +++ b/Backend/ERPCore/Services/SalesInvoiceService.cs @@ -145,7 +145,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService foreach (var r in requests) { var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct); - await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct); + await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct); var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct); var unitPrice = resolved.UnitPrice; var priceSource = resolved.PriceSource; @@ -158,7 +158,6 @@ public sealed class SalesInvoiceService : ISalesInvoiceService Description = item.Name, Qty = r.Qty, FreeQty = r.FreeQty, - UomId = r.UomId, WarehouseId = r.WarehouseId, UnitPrice = unitPrice, BaseCost = unitPrice, diff --git a/Backend/ERPCore/Services/SalesMappingService.cs b/Backend/ERPCore/Services/SalesMappingService.cs index 4d60780..464902c 100644 --- a/Backend/ERPCore/Services/SalesMappingService.cs +++ b/Backend/ERPCore/Services/SalesMappingService.cs @@ -35,7 +35,7 @@ public sealed class SalesMappingService : ISalesMappingService invoice.InvoiceType, invoice.Status, invoice.CreatedBy, invoice.CreatedAt, invoice.UpdatedAt, MapInvoiceTotals(invoice), invoice.Lines.Select(l => new SalesInvoiceLineDto( - l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId, + l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList()); @@ -45,7 +45,7 @@ public sealed class SalesMappingService : ISalesMappingService slip.WarehouseId, slip.CashierUserId, slip.Status, slip.CreatedAt, slip.UpdatedAt, MapSlipTotals(slip), slip.Lines.Select(l => new SalesSlipLineDto( - l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId, + l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList()); } diff --git a/Backend/ERPCore/Services/SalesPostingService.cs b/Backend/ERPCore/Services/SalesPostingService.cs index 1639b55..2569a43 100644 --- a/Backend/ERPCore/Services/SalesPostingService.cs +++ b/Backend/ERPCore/Services/SalesPostingService.cs @@ -20,7 +20,6 @@ public sealed class SalesPostingService : ISalesPostingService private readonly IRepository _items; private readonly IFifoCostingService _fifo; private readonly ISalesDomainService _sales; - private readonly IUomConverter _uomConverter; private readonly ICurrentUser _currentUser; private readonly IUnitOfWork _uow; @@ -31,7 +30,6 @@ public sealed class SalesPostingService : ISalesPostingService IRepository items, IFifoCostingService fifo, ISalesDomainService sales, - IUomConverter uomConverter, ICurrentUser currentUser, IUnitOfWork uow) { @@ -41,7 +39,6 @@ public sealed class SalesPostingService : ISalesPostingService _items = items; _fifo = fifo; _sales = sales; - _uomConverter = uomConverter; _currentUser = currentUser; _uow = uow; } @@ -141,7 +138,7 @@ public sealed class SalesPostingService : ISalesPostingService notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.", statusSelector: x => x.Status, ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.", - getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)), + getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)), setPosted: x => x.Status = SalesInvoiceStatus.Posted, setUpdated: x => x.UpdatedAt = DateTime.UtcNow, sourceDocType: DocumentTypes.SalesInvoice, @@ -154,7 +151,7 @@ public sealed class SalesPostingService : ISalesPostingService notFoundMessage: $"Sales slip {salesSlipId} was not found.", statusSelector: x => x.Status, ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.", - getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)), + getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)), setPosted: x => x.Status = SalesSlipStatus.Posted, setUpdated: x => x.UpdatedAt = DateTime.UtcNow, sourceDocType: DocumentTypes.SalesSlip, @@ -167,9 +164,7 @@ public sealed class SalesPostingService : ISalesPostingService notFoundMessage: $"Bundle sale {bundleSaleId} was not found.", statusSelector: x => x.Status, ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.", - // Bundle lines are normalized to base UOM on save, so posting should consume the - // stored base quantity directly instead of converting again. - getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty, l.Qty, 0m)), + getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m)), setPosted: x => x.Status = BundleSaleStatus.Posted, setUpdated: x => x.UpdatedAt = DateTime.UtcNow, sourceDocType: DocumentTypes.BundleSale, @@ -203,6 +198,8 @@ public sealed class SalesPostingService : ISalesPostingService if (!await _sales.IsStockedItemAsync(line.ItemId, token)) continue; + // Line quantities are already a count of the item's base UOM — sales documents + // carry no unit of their own — so this is the quantity FIFO consumes verbatim. var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty, token); var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty); await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId, @@ -214,5 +211,5 @@ public sealed class SalesPostingService : ISalesPostingService }, ct); } - private sealed record PostingLine(int ItemId, int WarehouseId, int UomId, decimal Qty, decimal PaidQty, decimal FreeQty); + private sealed record PostingLine(int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty); } diff --git a/Backend/ERPCore/Services/SalesSlipService.cs b/Backend/ERPCore/Services/SalesSlipService.cs index 9e8074d..0301d9d 100644 --- a/Backend/ERPCore/Services/SalesSlipService.cs +++ b/Backend/ERPCore/Services/SalesSlipService.cs @@ -169,7 +169,7 @@ public sealed class SalesSlipService : ISalesSlipService foreach (var r in requests) { var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct); - await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct); + await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct); var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct); var unitPrice = resolved.UnitPrice; var priceSource = resolved.PriceSource; @@ -182,7 +182,6 @@ public sealed class SalesSlipService : ISalesSlipService Description = item.Name, Qty = r.Qty, FreeQty = r.FreeQty, - UomId = r.UomId, WarehouseId = r.WarehouseId, UnitPrice = unitPrice, BaseCost = unitPrice, @@ -222,8 +221,10 @@ public sealed class SalesSlipService : ISalesSlipService .Where(i => i.ItemId == line.ItemId) .Select(i => new { i.ItemId, i.Sku, i.Name, i.BaseUomId }) .FirstOrDefault(); - var uom = line is null ? null : _uoms.Query().AsNoTracking() - .Where(u => u.UomId == line.UomId) + // The line carries no unit of its own — its quantity is a count of the item's base + // UOM — so the display name comes from there. + var uom = item is null ? null : _uoms.Query().AsNoTracking() + .Where(u => u.UomId == item.BaseUomId) .Select(u => new { u.UomId, u.Name }) .FirstOrDefault(); var warehouse = _warehouses.Query().AsNoTracking() @@ -240,8 +241,7 @@ public sealed class SalesSlipService : ISalesSlipService line?.ItemId ?? 0, item?.Sku ?? $"SKU-{line?.ItemId ?? 0}", item?.Name ?? line?.Description ?? "—", - line?.UomId ?? 0, - uom?.Name ?? $"UOM {line?.UomId ?? 0}", + uom?.Name ?? $"UOM {item?.BaseUomId ?? 0}", line?.Qty ?? 0m, line?.FreeQty ?? 0m, line is null ? "No line" : $"Buy {line.Qty} Get {line.FreeQty}"); @@ -263,7 +263,7 @@ public sealed class SalesSlipService : ISalesSlipService x.CreatedAt, x.UpdatedAt, summary, - x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList()); + x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList()); } private SalesSlipDto Map(SalesSlip x) => _mapping.MapSlip(x); diff --git a/Backend/ERPCore/Services/Stock/ItemMeasure.cs b/Backend/ERPCore/Services/Stock/ItemMeasure.cs new file mode 100644 index 0000000..691772d --- /dev/null +++ b/Backend/ERPCore/Services/Stock/ItemMeasure.cs @@ -0,0 +1,39 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; + +namespace ERPCore.Services.Stock; + +/// +/// Content ↔ pack arithmetic (see ). Stateless and I/O-free: +/// the content size is already on the every caller has loaded. +/// +public sealed class ItemMeasure : IItemMeasure +{ + public bool HasContent(Item item) => item.ContentBaseQty is > 0m; + + public decimal ToPacks(Item item, decimal formulaQty, StageQtyUnit unit) + { + if (unit == StageQtyUnit.Pack) return formulaQty; + + // Rounded to the quantity columns' (18,4) scale with the same mode as + // ProductionRunService.Scale, so what FIFO consumes and what the cost pool divides + // by are the same number to the last stored digit. + return Math.Round(formulaQty / RequireContent(item), 4, MidpointRounding.AwayFromZero); + } + + public decimal FromPacks(Item item, decimal packs, StageQtyUnit unit) + { + if (unit == StageQtyUnit.Pack) return packs; + + return Math.Round(packs * RequireContent(item), 4, MidpointRounding.AwayFromZero); + } + + private static decimal RequireContent(Item item) + => item.ContentBaseQty is > 0m + ? item.ContentBaseQty.Value + : throw new DomainException( + ErrorCodes.Validation, + $"Item {item.ItemId} has no content size, so its quantity cannot be expressed in content units.", 422); +} diff --git a/Backend/ERPCore/Services/Stock/UomConverter.cs b/Backend/ERPCore/Services/Stock/UomConverter.cs deleted file mode 100644 index c9fcacc..0000000 --- a/Backend/ERPCore/Services/Stock/UomConverter.cs +++ /dev/null @@ -1,38 +0,0 @@ -using ERPCore.Domain.Entities; -using ERPCore.Repositories.Interfaces; -using ERPCore.Services.Interfaces; -using ERPCore.System.Errors; -using Microsoft.EntityFrameworkCore; - -namespace ERPCore.Services.Stock; - -/// -/// Shared UOM → base-UOM conversion (see ). Behaviour is -/// unchanged from the GrnService.ToBaseAsync it was extracted from, so the GRN -/// receive path keeps costing exactly as before. -/// -public sealed class UomConverter : IUomConverter -{ - private readonly IRepository _conversions; - - public UomConverter(IRepository conversions) => _conversions = conversions; - - public async Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync( - Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default) - { - if (uomId == item.BaseUomId) - return (qty, unitCostPerUom); - - var conv = await _conversions.Query().AsNoTracking() - .FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct) - ?? throw new DomainException(ErrorCodes.Validation, - $"No UOM conversion from {uomId} to base UOM {item.BaseUomId} for item {item.ItemId}.", 422); - - // Quantity scales up by the factor, so the per-unit cost scales down by it — - // total value is preserved. - return (qty * conv.Factor, unitCostPerUom / conv.Factor); - } - - public async Task ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default) - => (await ToBaseAsync(item, uomId, qty, 0m, ct)).QtyBase; -} diff --git a/Backend/ERPCore/System/Errors/ErrorCodes.cs b/Backend/ERPCore/System/Errors/ErrorCodes.cs index bc439f3..27a4de1 100644 --- a/Backend/ERPCore/System/Errors/ErrorCodes.cs +++ b/Backend/ERPCore/System/Errors/ErrorCodes.cs @@ -56,6 +56,7 @@ public static class ErrorCodes public const string GraphDisconnected = "GRAPH_DISCONNECTED"; public const string GraphInputSourceInvalid = "GRAPH_INPUT_SOURCE_INVALID"; public const string TerminalOutputItemRequired = "TERMINAL_OUTPUT_ITEM_REQUIRED"; + public const string WipUnitRequired = "WIP_UNIT_REQUIRED"; public const string StageNotReady = "STAGE_NOT_READY"; public const string StageNotInProgress = "STAGE_NOT_IN_PROGRESS"; public const string StageNotDone = "STAGE_NOT_DONE"; diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index f65e8f9..b80f704 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -4,6 +4,73 @@ 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 +`IUomConverter`, `PUT /items/{id}/uom-conversions`, `uom_conversions`, or a line-level +`uomId` are historical and no longer describe the code. + +What replaced it: + +- **One unit per item.** `Item.BaseUomId` is the pack an item is stocked and counted in, and + every quantity in the system — stock layers, ledger rows, GRN/PO/sales/bundle/transfer lines + — is a plain count of it. A differently sized pack is a different item. `UomId` was dropped + from all six document-line entities; `Uom` itself survives as the lookup. +- **Optional content size on `Item`**: `ContentQty` + `ContentUnit` as entered (`Ml|L|G|Kg`), + normalised on write into `ContentBaseQty` + `ContentBaseUnit` (only ever `Ml` or `G`, L/Kg + ×1000). All four null ⇒ nothing measurable to hold. `ItemContent` is the pure normaliser. +- **Production is the only content consumer.** `StageInput.QtyUnit` (`Pack|Content`) says what + `QtyPerBatch` means; `IItemMeasure` divides a `Content` quantity by the item's content size + to get packs. **Fractional packs are legal** — 300 ml of a 500 ml bottle consumes 0.6000. + Outputs are always pack counts, so scrap stays in whole broken bottles. +- **WIP keeps a label.** `StageOutput.UomId`/`RunStageOutput.UomId` are now nullable and + required *only* when `ItemId` is null (`422 WIP_UNIT_REQUIRED`), since an item-bearing output + takes its unit from the item. WIP never touches stock, so the label is never converted. +- **Two live defects fixed as a consequence.** `SalesPostingService` injected `IUomConverter` + and never called it, so a sales line in a non-base UOM consumed the wrong quantity outright; + `GrnService` accrued `poLine.QtyReceived += line.Qty` and range-checked over-receipt across + the same unit boundary. Both are now like-for-like by construction. +- **`BaseUomId` is frozen once an item has stock history** (`409 MASTER_IN_USE`) — it is the + sole meaning of every recorded quantity, so changing it would silently reinterpret all of it. +- Smoke: `m4b_uom_conversion.py` deleted; `m4c_content_units.py` added (whole packs, fractional + packs, and the contentless-item guard). + ## 8. Sales - [x] Sales bootstrap data seeded locally for development: warehouses, UOMs, categories, items, customers, current-year `SI`/`SSL` sequences, plus sample invoice/slip headers and lines. Existing data is preserved. - [x] Sales report API consolidated into `GET /api/v1/reports/sales` (catalog), `GET /api/v1/reports/sales/{reportId}` (report metadata), and `POST /api/v1/reports/sales/query` (filtered data). Legacy per-report GET routes removed; invalid report/filter combinations now fail validation. @@ -29,7 +96,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [x] Warehouse + Bin (`/warehouses`, nested `/warehouses/{id}/bins`, bin code unique per warehouse) - [x] Item reorder settings (`PUT /items/{id}/reorder` full-replace upsert, warehouse-exists validation) - [x] Brand master (FR-MD-09) — CRUD + status + ETag; `Item.brandId` nullable FK -- [x] Item Type master (FR-MD-10) — CRUD + status + ETag; **unreferenced by design**, feeds the builder dropdown only +- [x] Item Type master (FR-MD-10) — CRUD + status + ETag; **unreferenced by design**. Feeds the builder's dimension list, and since 2026-08-11 carries `isMeasurable`, which decides whether its values are captured as free text or as a number + unit that becomes each item's content size (see the entry at the top of this file) - [x] SubCategory (FR-MD-04) — nested list/create under a category, `PUT`/`PATCH status` by id; `Item.subCategoryId` nullable FK, validated to belong to `categoryId` - [x] Product Configuration (FR-MD-11) — singleton `GET`/`PUT /product-config`; `CONFIG_DISABLED` gating on item writes - [x] Item **sale price** (FR-MD-01, 2026-07-22) — nullable `Item.SalePrice` (`numeric(18,4)`); on all Item DTOs (list/detail/create/update), validated `>= 0`. **Sales-only** — never enters GRN/FIFO/ledger. `null` ⇒ sell at stock value. Migration `AddItemSalePrice`. See the 2026-07-22 Done entry. diff --git a/Backend/smoke/__pycache__/m4c_content_units.cpython-313.pyc b/Backend/smoke/__pycache__/m4c_content_units.cpython-313.pyc new file mode 100644 index 0000000..eba3098 Binary files /dev/null and b/Backend/smoke/__pycache__/m4c_content_units.cpython-313.pyc differ diff --git a/Backend/smoke/__pycache__/smoke_common.cpython-313.pyc b/Backend/smoke/__pycache__/smoke_common.cpython-313.pyc index 6a93b70..3e7c7a8 100644 Binary files a/Backend/smoke/__pycache__/smoke_common.cpython-313.pyc and b/Backend/smoke/__pycache__/smoke_common.cpython-313.pyc differ diff --git a/Backend/smoke/m2_templates.py b/Backend/smoke/m2_templates.py index 54aaa7e..5405a7c 100644 --- a/Backend/smoke/m2_templates.py +++ b/Backend/smoke/m2_templates.py @@ -44,25 +44,26 @@ def diamond(raw_item, finished_item, uom): "estimatedMinutes": 60, "posX": 80, "posY": 120, "fieldDefs": [{"key": "moisture_ok", "label": "Moisture check", "type": "Checkbox", "required": True}], - "inputs": [{"source": "Stock", "itemId": raw_item, "uomId": uom, "qtyPerBatch": 8}], + "inputs": [{"source": "Stock", "itemId": raw_item, "qtyPerBatch": 8}], "outputs": [{"key": "tmp-frame", "name": "Frame set", "uomId": uom, "qtyPerBatch": 1}], }, { "key": "tmp-prep", "name": "Prep cushions", "roleLabel": "Upholstery", "estimatedMinutes": 30, "posX": 80, "posY": 320, "fieldDefs": [], - "inputs": [{"source": "Stock", "itemId": raw_item, "uomId": uom, "qtyPerBatch": 2}], + "inputs": [{"source": "Stock", "itemId": raw_item, "qtyPerBatch": 2}], "outputs": [{"key": "tmp-cushion", "name": "Cushion set", "uomId": uom, "qtyPerBatch": 1}], }, { "key": "tmp-asm", "name": "Assemble & QA", "roleLabel": "QA", "estimatedMinutes": 45, "posX": 560, "posY": 200, "fieldDefs": [], "inputs": [ - {"source": "Upstream", "fromOutputKey": "tmp-frame", "uomId": uom, "qtyPerBatch": 1}, - {"source": "Upstream", "fromOutputKey": "tmp-cushion", "uomId": uom, "qtyPerBatch": 1}, + {"source": "Upstream", "fromOutputKey": "tmp-frame", "qtyPerBatch": 1}, + {"source": "Upstream", "fromOutputKey": "tmp-cushion", "qtyPerBatch": 1}, ], - # Terminal output must name the finished item (FR-MFG-05). + # Terminal output must name the finished item (FR-MFG-05), and takes its unit + # from that item — sending a uomId as well is rejected. "outputs": [{"key": "tmp-chair", "name": "Chair", "itemId": finished_item, - "uomId": uom, "qtyPerBatch": 1}], + "qtyPerBatch": 1}], }, ], "edges": [ @@ -243,7 +244,7 @@ def main(): grandparent["stages"].append({ "key": "tmp-mid", "name": "Middle", "estimatedMinutes": 5, "posX": 320, "posY": 120, "fieldDefs": [], - "inputs": [{"source": "Upstream", "fromOutputKey": cut_output_key, "uomId": uom, "qtyPerBatch": 1}], + "inputs": [{"source": "Upstream", "fromOutputKey": cut_output_key, "qtyPerBatch": 1}], "outputs": [{"key": "tmp-mid-out", "name": "Mid part", "uomId": uom, "qtyPerBatch": 1}], }) grandparent["edges"] = [e for e in grandparent["edges"] if e["parentKey"] != cut_key] @@ -320,9 +321,11 @@ def rebuild_from_get(g: dict) -> dict: "inputs": [ {"source": i["source"], "itemId": i.get("itemId"), "fromOutputKey": i.get("fromOutputKey"), - "uomId": i["uomId"], "qtyPerBatch": i["qtyPerBatch"]} + "qtyUnit": i["qtyUnit"], "qtyPerBatch": i["qtyPerBatch"]} for i in s["inputs"] ], + # uomId round-trips as null on an item-bearing output and as the WIP label + # otherwise, so echoing it back verbatim is correct either way. "outputs": [ {"key": o["key"], "itemId": o.get("itemId"), "name": o["name"], "uomId": o["uomId"], "qtyPerBatch": o["qtyPerBatch"]} diff --git a/Backend/smoke/m3_runs.py b/Backend/smoke/m3_runs.py index de0dc52..313dedb 100644 --- a/Backend/smoke/m3_runs.py +++ b/Backend/smoke/m3_runs.py @@ -147,7 +147,7 @@ def main(): "estimatedMinutes": s["estimatedMinutes"], "posX": s["posX"], "posY": s["posY"], "fieldDefs": s["fieldDefs"], "inputs": [{"source": i["source"], "itemId": i.get("itemId"), - "fromOutputKey": i.get("fromOutputKey"), "uomId": i["uomId"], + "fromOutputKey": i.get("fromOutputKey"), "qtyUnit": i["qtyUnit"], "qtyPerBatch": i["qtyPerBatch"]} for i in s["inputs"]], "outputs": [{"key": o["key"], "itemId": o.get("itemId"), "name": o["name"], "uomId": o["uomId"], "qtyPerBatch": o["qtyPerBatch"]} for o in s["outputs"]]} diff --git a/Backend/smoke/m4_stage_actions.py b/Backend/smoke/m4_stage_actions.py index 140808b..e1dba3a 100644 --- a/Backend/smoke/m4_stage_actions.py +++ b/Backend/smoke/m4_stage_actions.py @@ -65,16 +65,16 @@ def ledger_rows(c, run_id, source): return rows -def seed_stock(c, wh, raw, pack, uom): +def seed_stock(c, wh, raw, pack): """ Seed on-hand at explicit unit costs. Two raw layers at different costs mean the FIFO consumption at start has to weight them, so `consumedValue` is a real number the assertions can check rather than the 0.00 a positive adjustment would produce. """ seed_costed_stock(c, wh, [ - (raw, uom, SEED_RAW * 0.4, RAW_COST_1), - (raw, uom, SEED_RAW * 0.6, RAW_COST_2), - (pack, uom, SEED_PACK, PACK_COST), + (raw, SEED_RAW * 0.4, RAW_COST_1), + (raw, SEED_RAW * 0.6, RAW_COST_2), + (pack, SEED_PACK, PACK_COST), ]) @@ -90,7 +90,7 @@ def build_template(c, raw, pack, finished, uom): "estimatedMinutes": 60, "posX": 80, "posY": 100, "fieldDefs": [{"key": "moisture_ok", "label": "Moisture check", "type": "Checkbox", "required": True}], - "inputs": [{"source": "Stock", "itemId": raw, "uomId": uom, "qtyPerBatch": 8}], + "inputs": [{"source": "Stock", "itemId": raw, "qtyPerBatch": 8}], "outputs": [{"key": "tmp-frame", "name": "Frame", "uomId": uom, "qtyPerBatch": 1}], }, { @@ -99,11 +99,11 @@ def build_template(c, raw, pack, finished, uom): "key": "tmp-asm", "name": "Assemble", "roleLabel": "QA", "estimatedMinutes": 45, "posX": 520, "posY": 100, "fieldDefs": [], "inputs": [ - {"source": "Upstream", "fromOutputKey": "tmp-frame", "uomId": uom, "qtyPerBatch": 1}, - {"source": "Stock", "itemId": pack, "uomId": uom, "qtyPerBatch": 2}, + {"source": "Upstream", "fromOutputKey": "tmp-frame", "qtyPerBatch": 1}, + {"source": "Stock", "itemId": pack, "qtyPerBatch": 2}, ], "outputs": [{"key": "tmp-chair", "name": "Chair", "itemId": finished, - "uomId": uom, "qtyPerBatch": 1}], + "qtyPerBatch": 1}], }, ], "edges": [{"parentKey": "tmp-cut", "childKey": "tmp-asm"}], @@ -141,7 +141,7 @@ def main(): drained = drain_stock(c, wh) if drained: print(f"drained {len(drained)} leftover item(s) from a previous execution") - seed_stock(c, wh, raw, pack, uom) + seed_stock(c, wh, raw, pack) raw_before = on_hand(c, raw, wh) pack_before = on_hand(c, pack, wh) print(f"warehouse={wh} raw={raw}(on-hand {raw_before}) pack={pack}(on-hand {pack_before}) finished={finished}") diff --git a/Backend/smoke/m4_state.json b/Backend/smoke/m4_state.json index 8c047c9..6658a4e 100644 --- a/Backend/smoke/m4_state.json +++ b/Backend/smoke/m4_state.json @@ -1 +1 @@ -{"runId": 64, "templateId": 2, "warehouseId": 4, "assembleStageId": 118, "finishedItemId": 13, "rawItemId": 18, "packItemId": 14} \ No newline at end of file +{"runId": 20, "templateId": 6, "warehouseId": 4, "assembleStageId": 30, "finishedItemId": 1, "rawItemId": 4, "packItemId": 3} \ No newline at end of file diff --git a/Backend/smoke/m4b_uom_conversion.py b/Backend/smoke/m4b_uom_conversion.py deleted file mode 100644 index 3351e3a..0000000 --- a/Backend/smoke/m4b_uom_conversion.py +++ /dev/null @@ -1,170 +0,0 @@ -"""M4b smoke test — UOM conversion on production stock inputs. - -This covers the single highest-risk correctness gap in the manufacturing phase. -`IFifoCostingService.ConsumeAsync` works exclusively in an item's BASE UOM, while -`STAGE_INPUT.uom_id` is a free FK — docs/30 never mentions conversion at all. Without the -shared `IUomConverter` (extracted from `GrnService.ToBaseAsync`), a stage input declared in -"box of 12" would consume 1 base unit instead of 12 and silently mis-cost the whole run. - -The dev database has no `uom_conversions` rows at all, so the non-base path was previously -unexercised by any data. This script creates a real conversion and proves: - - * a stage input in a non-base UOM consumes qtyPerBatch x scaleFactor x factor base units - * the ledger records the BASE quantity, not the declared one - * an input in a UOM with no conversion defined is refused with 422 rather than mis-consumed - - python Backend/smoke/m4b_uom_conversion.py -""" - -from __future__ import annotations - -import sys - -from smoke_common import bootstrap - -WAREHOUSE_CODE = "SMOKE-PRD" -TEMPLATE_CODE = "SMOKE-PT-M4B" -FACTOR = 12 # 1 case = 12 base units -QTY_PER_BATCH = 3 # cases per batch -TARGET_QTY = 10 # -> scale 10 -> 30 cases -> 360 base units -SEED = 5000 - - -def main(): - c, chk, args = bootstrap(__doc__) - print(f"API {args.api}") - - # --- fixtures --------------------------------------------------------- - wh = next((w["warehouseId"] for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"] - if w["code"] == WAREHOUSE_CODE), None) - if wh is None: - sys.exit("FATAL: run m4_stage_actions.py first (it creates the SMOKE-PRD warehouse).") - - items = c.get("/items?pageSize=5&status=Active").body["items"] - raw, finished = items[0], items[1] - base_uom = raw["baseUomId"] - - uoms = c.get("/uoms?pageSize=50").body["items"] - case_uom = next((u["uomId"] for u in uoms if u["uomId"] != base_uom), None) - if case_uom is None: - sys.exit("FATAL: need at least 2 UOMs to test conversion.") - print(f"item={raw['itemId']} baseUom={base_uom} caseUom={case_uom} factor={FACTOR}") - - # --- define the conversion ------------------------------------------- - chk.section("1. Define a non-base UOM conversion for the item") - conv = c.put(f"/items/{raw['itemId']}/uom-conversions", - {"conversions": [{"fromUom": case_uom, "toUom": base_uom, "factor": FACTOR}]}) - chk.status("PUT /items/{id}/uom-conversions", conv, 200) - if conv.status != 200: - return chk.finish("M4b") - chk.check("conversion stored", any(float(x["factor"]) == FACTOR for x in conv.body["conversions"]), True) - - reason = c.get("/reason-codes?context=Adjustment&pageSize=5").body["items"][0]["reasonCodeId"] - c.post("/stock-adjustments", {"warehouseId": wh, "reasonCodeId": reason, - "lines": [{"itemId": raw["itemId"], "qtyDelta": SEED}]}) - before = float(c.get(f"/stock/on-hand?itemId={raw['itemId']}&warehouseId={wh}").body["onHand"]) - print(f"on-hand before: {before}") - - # --- template whose stock input is declared in CASES ------------------ - chk.section("2. A stage input declared in the non-base UOM") - payload = { - "code": TEMPLATE_CODE, "name": "Smoke M4b conversion line", - "stages": [{ - "key": "tmp-only", "name": "Pack", "estimatedMinutes": 10, - "posX": 0, "posY": 0, "fieldDefs": [], - # Declared in cases, not base units. - "inputs": [{"source": "Stock", "itemId": raw["itemId"], - "uomId": case_uom, "qtyPerBatch": QTY_PER_BATCH}], - "outputs": [{"key": "tmp-out", "name": "Packed", "itemId": finished["itemId"], - "uomId": base_uom, "qtyPerBatch": 1}], - }], - "edges": [], - } - - existing = next((t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"] - if t["code"] == TEMPLATE_CODE), None) - if existing: - head = c.get(f"/production-templates/{existing['templateId']}") - res = c.put(f"/production-templates/{existing['templateId']}", payload, if_match=head.etag) - tid = existing["templateId"] if res.status in (200, 409) else None - chk.check("template ready", tid is not None, True) - else: - res = c.post("/production-templates", payload) - chk.status("create single-stage template", res, 201) - tid = res.body["templateId"] if res.status == 201 else None - - if tid is None: - return chk.finish("M4b") - - # A lone stage is both the entry and the terminal — worth asserting explicitly. - run = c.post("/production-runs", {"templateId": tid, "targetQty": TARGET_QTY, "warehouseId": wh}) - chk.status("create the run", run, 201) - if run.status != 201: - return chk.finish("M4b") - - stage = run.body["stages"][0] - chk.check("single stage is both entry and terminal", - (stage["isEntry"], stage["isTerminal"]), (True, True)) - chk.check("single stage starts Ready", stage["status"], "Ready") - chk.check("plannedQty stays in the DECLARED uom (3 x 10 cases)", - float(stage["inputs"][0]["plannedQty"]), float(QTY_PER_BATCH * TARGET_QTY)) - - # --- the actual conversion assertion --------------------------------- - chk.section("3. Consumption converts cases to base units") - expected_base = QTY_PER_BATCH * TARGET_QTY * FACTOR # 3 x 10 x 12 = 360 - started = c.post(f"/production-runs/{run.body['runId']}/stages/{stage['runStageId']}/start") - chk.status("start the stage", started, 200) - if started.status != 200: - return chk.finish("M4b") - - con = started.body["consumed"][0] - chk.check(f"consumed {expected_base} BASE units, not {QTY_PER_BATCH * TARGET_QTY}", - float(con["qty"]), float(expected_base)) - chk.check("on-hand fell by the base quantity", - float(c.get(f"/stock/on-hand?itemId={raw['itemId']}&warehouseId={wh}").body["onHand"]), - before - expected_base) - - rows = c.get(f"/stock/ledger?sourceDocType=PRDI&sourceDocId={run.body['runId']}&pageSize=50").body["items"] - chk.check("one PRDI row", len(rows), 1) - if rows: - chk.check("ledger qtyBase is the converted quantity", float(rows[0]["qtyBase"]), float(expected_base)) - - detail = c.get(f"/production-runs/{run.body['runId']}").body - chk.check("consumedQty stored in base units", - float(detail["stages"][0]["inputs"][0]["consumedQty"]), float(expected_base)) - - # --- missing conversion is refused, not silently mis-consumed -------- - chk.section("4. An undefined conversion is refused (422), never assumed 1:1") - third_uom = next((u["uomId"] for u in c.get("/uoms?pageSize=50").body["items"] - if u["uomId"] not in (base_uom, case_uom)), None) - if third_uom is None: - chk.check("skipped: need a third UOM", True, True) - else: - bad = dict(payload) - bad["code"] = TEMPLATE_CODE + "-BAD" - bad["stages"] = [dict(payload["stages"][0])] - bad["stages"][0] = {**payload["stages"][0], - "inputs": [{"source": "Stock", "itemId": raw["itemId"], - "uomId": third_uom, "qtyPerBatch": 1}]} - made = c.post("/production-templates", bad) - if made.status != 201: - head = c.get(f"/production-templates?q={TEMPLATE_CODE}-BAD") - tid2 = next((t["templateId"] for t in head.body["items"] - if t["code"] == TEMPLATE_CODE + "-BAD"), None) - else: - tid2 = made.body["templateId"] - - if tid2: - run2 = c.post("/production-runs", {"templateId": tid2, "targetQty": 1, "warehouseId": wh}) - if run2.status == 201: - s2 = run2.body["stages"][0]["runStageId"] - chk.status("start a stage whose input UOM has no conversion", - c.post(f"/production-runs/{run2.body['runId']}/stages/{s2}/start"), 422) - else: - chk.check("could create the second run", run2.status, 201) - - return chk.finish("M4b") - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/Backend/smoke/m4c_content_units.py b/Backend/smoke/m4c_content_units.py new file mode 100644 index 0000000..0542686 --- /dev/null +++ b/Backend/smoke/m4c_content_units.py @@ -0,0 +1,226 @@ +"""M4c smoke test — production stage inputs expressed in content units (FR-MFG-04). + +Replaces m4b_uom_conversion.py, which tested the per-item UOM conversion table that no longer +exists. Stock is now always a count of the item's base UOM (a bottle, a packet), and the only +place another unit appears is a production formula: a stage input may be written as an amount +of the item's *content* (ml or g), which the server divides by the item's content size to get +the pack count FIFO actually consumes. + +What this pins down, none of which any other script covers: + + * 2000 ml against a 500 ml bottle consumes exactly 4.0000 bottles, and the ledger agrees; + * 300 ml against the same item consumes 0.6000 — **fractional packs are legal**, which is + the whole reason the quantity columns are (18,4); + * a Content input on an item with no content size is refused at template save (422), not + silently treated as packs at stage start. + +Self-contained: creates its own item, warehouse, template and runs. + + python Backend/smoke/m4c_content_units.py +""" + +from __future__ import annotations + +import sys + +from smoke_common import bootstrap, drain_stock, seed_costed_stock + +WAREHOUSE_CODE = "SMOKE-CONTENT" +TEMPLATE_CODE = "SMOKE-PT-M4C" +SKU = "SMOKE-BOTTLE-500ML" +PLAIN_SKU = "SMOKE-NO-CONTENT" + +CONTENT_QTY = 500 # ml held by one bottle +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: + return w["warehouseId"] + created = c.post("/warehouses", {"code": WAREHOUSE_CODE, "name": "Content-unit smoke warehouse"}) + if created.status != 201: + sys.exit(f"FATAL: could not create the smoke warehouse: {created.status} {created.body}") + return created.body["warehouseId"] + + +def ensure_item(c, sku, name, content_qty, content_unit): + """Find-or-create; content fields are only sent when the item is meant to have them.""" + for i in c.get(f"/items?q={sku}&pageSize=50").body["items"]: + if i["sku"] == sku: + return i + + category = c.get("/categories?pageSize=1").body["items"] + uoms = c.get("/uoms?pageSize=1").body["items"] + if not category or not uoms: + sys.exit("FATAL: need at least one category and one UOM seeded.") + + body = { + "sku": sku, "name": name, + "categoryId": category[0]["categoryId"], + "baseUomId": uoms[0]["uomId"], + "stockNature": "Stocked", "trackingMode": "None", + } + if content_qty is not None: + body["contentQty"] = content_qty + body["contentUnit"] = content_unit + + created = c.post("/items", body) + if created.status != 201: + sys.exit(f"FATAL: could not create item {sku}: {created.status} {created.body}") + return created.body + + +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": code, "name": f"Content-unit smoke line ({suffix})", + "stages": [{ + "key": "tmp-mix", "name": "Mix", "estimatedMinutes": 5, "posX": 0, "posY": 0, + "fieldDefs": [], + "inputs": [{"source": "Stock", "itemId": raw_item, + "qtyUnit": qty_unit, "qtyPerBatch": qty_per_batch}], + "outputs": [{"key": "tmp-out", "name": "Mixed", "itemId": finished_item, + "qtyPerBatch": 1}], + }], + "edges": [], + } + 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) + return c.post("/production-templates", payload) + + +def saved_ok(chk, label, response): + """Template save is find-or-create, so a POST gives 201 and a PUT gives 200.""" + ok = chk.check(f"{label} -> 200/201", response.status in (200, 201), True) + if not ok: + print(f" server said: {response.body}") + return ok + + +def consume_once(c, chk, tid, wh, target_qty, label, expected_packs): + """ + Create a run, start its only stage, and assert what FIFO actually took. + + `expected_packs` is compared against the RAW formula quantity resolved to packs, which is + only valid while the run scale factor is 1. Run creation computes + `ratio = targetQty / terminalOutput.qtyPerBatch` and pre-scales every planned quantity, so + this holds because `save_template` pins the terminal output to `qtyPerBatch: 1` and every + caller here passes `target_qty=1`. Change either and these numbers move by that ratio. + """ + created = c.post("/production-runs", {"templateId": tid, "targetQty": target_qty, "warehouseId": wh}) + if not chk.status(f"{label}: create the run", created, 201): + return + run = created.body + stage_id = run["stages"][0]["runStageId"] + + started = c.post(f"/production-runs/{run['runId']}/stages/{stage_id}/start", + idempotency_key=f"m4c-{run['runId']}") + if not chk.status(f"{label}: POST .../start", started, 200): + return + + consumed = started.body["consumed"] + if not chk.check(f"{label}: exactly one input consumed", len(consumed), 1): + return + chk.check(f"{label}: consumed {expected_packs} base units", + round(float(consumed[0]["qty"]), 4), expected_packs) + + rows = c.get(f"/stock/ledger?sourceDocType=PRDI&sourceDocId={run['runId']}&pageSize=50").body["items"] + chk.check(f"{label}: one PRDI ledger row", len(rows), 1) + if rows: + 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) + finished = c.get("/items?pageSize=5&status=Active").body["items"][0] + print(f"bottle={bottle['itemId']} contentBaseQty={bottle.get('contentBaseQty')} " + f"plain={plain['itemId']} finished={finished['itemId']}") + + chk.section("0. Content size normalises to a base unit on the item") + chk.check("contentBaseQty is the entered ml", float(bottle["contentBaseQty"]), float(CONTENT_QTY)) + chk.check("contentBaseUnit is Ml", bottle["contentBaseUnit"], "Ml") + + wh = ensure_warehouse(c) + drain_stock(c, wh) + seed_costed_stock(c, wh, [(bottle["itemId"], SEED_BOTTLES, UNIT_COST)]) + + # ------------------------------------------------- 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", "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", "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", "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", "pack") + if saved_ok(chk, "save the Pack template", saved): + consume_once(c, chk, saved.body["templateId"], wh, 1, "3 bottles", 3.0) + + return chk.finish("M4c") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Backend/smoke/m5_receipt.py b/Backend/smoke/m5_receipt.py index e4ea40d..5775c09 100644 --- a/Backend/smoke/m5_receipt.py +++ b/Backend/smoke/m5_receipt.py @@ -157,18 +157,18 @@ def main(): if i["itemId"] == raw) finished_b = state["finishedItemId"] - # A fresh single-stage template: base-UOM input so no conversion muddies the arithmetic, - # qtyPerBatch 3.5 so the consumed quantity is NOT a multiple of the target (which is what + # A fresh single-stage template: a pack-counted input, so no content division muddies + # the arithmetic; qtyPerBatch 3.5 so consumed is NOT a multiple of the target (which is what # forces pool / goodQty to repeat). payload_b = { "code": BIG_TEMPLATE_CODE, "name": "Smoke M5 rounding line", "stages": [{ "key": "tmp-b", "name": "Mix", "estimatedMinutes": 5, "posX": 0, "posY": 0, "fieldDefs": [], - "inputs": [{"source": "Stock", "itemId": raw, "uomId": raw_uom, + "inputs": [{"source": "Stock", "itemId": raw, "qtyPerBatch": BIG_QTY_PER_BATCH}], "outputs": [{"key": "tmp-bo", "name": "Mixed", "itemId": finished_b, - "uomId": raw_uom, "qtyPerBatch": 1}], + "qtyPerBatch": 1}], }], "edges": [], } @@ -190,7 +190,7 @@ def main(): # and the pool would not match the figures this section reasons about — the assertions # would still "pass" while testing something else entirely. drain_stock(c, wh) - seed_costed_stock(c, wh, [(raw, raw_uom, 5000, BIG_UNIT_COST)]) + seed_costed_stock(c, wh, [(raw, 5000, BIG_UNIT_COST)]) big = c.post("/production-runs", {"templateId": tid, "targetQty": BIG_TARGET, "warehouseId": wh}) if big.status != 201: @@ -243,7 +243,7 @@ def main(): "key": "tmp-one", "name": "Make", "estimatedMinutes": 1, "posX": 0, "posY": 0, "fieldDefs": [], "inputs": [], "outputs": [{"key": "tmp-o", "name": "Tracked", "itemId": tracked["itemId"], - "uomId": tracked["baseUomId"], "qtyPerBatch": 1}], + "qtyPerBatch": 1}], }], "edges": [], } diff --git a/Backend/smoke/m6_m7_leftover_rework_cancel.py b/Backend/smoke/m6_m7_leftover_rework_cancel.py index 74f2b3b..347b13f 100644 --- a/Backend/smoke/m6_m7_leftover_rework_cancel.py +++ b/Backend/smoke/m6_m7_leftover_rework_cancel.py @@ -54,13 +54,13 @@ def ensure_template(c, raw, finished, uom): "stages": [ {"key": "tmp-cut", "name": "Cut", "estimatedMinutes": 10, "posX": 0, "posY": 0, "fieldDefs": [], - "inputs": [{"source": "Stock", "itemId": raw, "uomId": uom, "qtyPerBatch": RAW_QPB}], + "inputs": [{"source": "Stock", "itemId": raw, "qtyPerBatch": RAW_QPB}], "outputs": [{"key": "tmp-f", "name": "Frame", "uomId": uom, "qtyPerBatch": 1}]}, {"key": "tmp-asm", "name": "Assemble", "estimatedMinutes": 10, "posX": 400, "posY": 0, "fieldDefs": [], - "inputs": [{"source": "Upstream", "fromOutputKey": "tmp-f", "uomId": uom, "qtyPerBatch": 1}], + "inputs": [{"source": "Upstream", "fromOutputKey": "tmp-f", "qtyPerBatch": 1}], "outputs": [{"key": "tmp-c", "name": "Chair", "itemId": finished, - "uomId": uom, "qtyPerBatch": 1}]}, + "qtyPerBatch": 1}]}, ], "edges": [{"parentKey": "tmp-cut", "childKey": "tmp-asm"}], } @@ -101,7 +101,7 @@ def main(): uom = items[0]["baseUomId"] drain_stock(c, wh) - seed_costed_stock(c, wh, [(raw, uom, SEED, UNIT_COST)]) + seed_costed_stock(c, wh, [(raw, SEED, UNIT_COST)]) tid = ensure_template(c, raw, finished, uom) consumed_units = RAW_QPB * TARGET # 50 consumed_value = consumed_units * UNIT_COST # 150.00 diff --git a/Backend/smoke/run_all.py b/Backend/smoke/run_all.py index ea6cb51..e97b7c9 100644 --- a/Backend/smoke/run_all.py +++ b/Backend/smoke/run_all.py @@ -22,7 +22,7 @@ SCRIPTS = [ ("M2 templates + graph validation", "m2_templates.py"), ("M3 run creation / board / quantities", "m3_runs.py"), ("M4 stage start / complete / approve / transfer", "m4_stage_actions.py"), - ("M4b UOM conversion on stock inputs", "m4b_uom_conversion.py"), + ("M4c content-unit stage inputs + fractional packs", "m4c_content_units.py"), ("M5 terminal receipt + cost pool", "m5_receipt.py"), ("M6+M7 leftover / rework / cancel", "m6_m7_leftover_rework_cancel.py"), ] diff --git a/Backend/smoke/smoke_common.py b/Backend/smoke/smoke_common.py index c3ed12a..f8e5175 100644 --- a/Backend/smoke/smoke_common.py +++ b/Backend/smoke/smoke_common.py @@ -240,15 +240,16 @@ def seed_costed_stock(c, warehouse_id: int, lines, vendor_id: int | None = None) """ Create on-hand at explicit unit costs via a direct GRN + confirm. - `lines` is an iterable of (item_id, uom_id, qty, unit_cost). + `lines` is an iterable of (item_id, qty, unit_cost). Quantities are counts of the item's + base UOM — document lines carry no unit of their own. """ vendor_id = vendor_id or ensure_vendor(c) grn = c.post("/grns", { "vendorId": vendor_id, "warehouseId": warehouse_id, "lines": [ - {"itemId": i, "uomId": u, "qty": q, "unitCost": cost, "discountPct": 0, "vatPct": 0} - for (i, u, q, cost) in lines + {"itemId": i, "qty": q, "unitCost": cost, "discountPct": 0, "vatPct": 0} + for (i, q, cost) in lines ], }) if grn.status != 201: diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx index 3c928ab..1a2fb03 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx @@ -8,6 +8,7 @@ import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders" import { warehousesApi } from "@/lib/api/warehouses" import { itemsApi } from "@/lib/api/items" import { uomsApi } from "@/lib/api/uoms" +import { baseUomLabel } from "@/lib/uom-label" import { vendorsApi } from "@/lib/api/vendors" import { errorMessage } from "@/lib/error-map" import { validatePoLine } from "@/lib/validations/procurement" @@ -28,7 +29,6 @@ interface DraftLine { key: string poLineId: number | null itemId: number | null - uomId: number | null warehouseId: number | null qty: string unitPrice: string @@ -72,7 +72,6 @@ export default function PurchaseOrderDetailPage() { key: newKey(), poLineId: l.poLineId, itemId: l.itemId, - uomId: l.uomId, warehouseId: l.warehouseId, qty: String(l.qty), unitPrice: String(l.unitPrice), @@ -111,9 +110,6 @@ export default function PurchaseOrderDetailPage() { function itemFor(itemId: number | null) { return items.find((i) => i.itemId === itemId) ?? null } - function uomName(uomId: number) { - return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}` - } function warehouseCode(warehouseId: number) { return warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? `#${warehouseId}` } @@ -141,7 +137,6 @@ export default function PurchaseOrderDetailPage() { for (const line of lines) { const errors = validatePoLine({ itemId: line.itemId, - uomId: line.uomId, warehouseId: line.warehouseId, qty: line.qty, unitPrice: line.unitPrice, @@ -157,7 +152,6 @@ export default function PurchaseOrderDetailPage() { const payloadLines: CreatePoLineInput[] = lines.map((l) => ({ itemId: l.itemId as number, - uomId: l.uomId as number, warehouseId: l.warehouseId as number, qty: Number(l.qty), unitPrice: Number(l.unitPrice), @@ -347,7 +341,7 @@ export default function PurchaseOrderDetailPage() {

Lines

{editable && ( - @@ -376,7 +370,7 @@ export default function PurchaseOrderDetailPage() { return ( {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} - {line.uomId ? uomName(line.uomId) : "—"} + {baseUomLabel(items, uoms, line.itemId)} {line.warehouseId ? warehouseCode(line.warehouseId) : "—"} {line.qty} {line.qtyReceived} @@ -403,19 +397,9 @@ export default function PurchaseOrderDetailPage() { - value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}> - - - - - {uoms.map((u) => ( - - {u.name} - - ))} - - - +
+ {baseUomLabel(items, uoms, line.itemId)} +
value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}> diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx index 03bf970..03f99f2 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx @@ -12,6 +12,7 @@ import { vendorsApi } from "@/lib/api/vendors" import { warehousesApi } from "@/lib/api/warehouses" import { itemsApi } from "@/lib/api/items" import { uomsApi } from "@/lib/api/uoms" +import { baseUomLabel } from "@/lib/uom-label" import { errorMessage } from "@/lib/error-map" import { validatePoLine } from "@/lib/validations/procurement" import { cn } from "@/lib/utils" @@ -39,7 +40,6 @@ import { toast } from "@/components/ui/toast" interface DraftLine { key: string itemId: number | null - uomId: number | null warehouseId: number | null qty: string unitPrice: string @@ -57,7 +57,7 @@ function newKey() { // DTO still requires it. Unit price *is* entered here; a PO prefilled from an RFQ starts // from its negotiated price (below) but stays editable. function emptyLine(): DraftLine { - return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "0", tax: "0" } + return { key: newKey(), itemId: null, warehouseId: null, qty: "", unitPrice: "0", tax: "0" } } function NewPurchaseOrderContent() { @@ -169,7 +169,6 @@ function NewPurchaseOrderContent() { (l): DraftLine => ({ key: newKey(), itemId: l.itemId, - uomId: null, warehouseId: null, qty: String(l.qty), unitPrice: "0", @@ -195,7 +194,6 @@ function NewPurchaseOrderContent() { return { key: newKey(), itemId: l.itemId, - uomId: null, warehouseId: null, qty: String(l.qty), unitPrice: cell ? String(cell.unitPrice) : "0", @@ -242,7 +240,6 @@ function NewPurchaseOrderContent() { for (const line of lines) { const errors = validatePoLine({ itemId: line.itemId, - uomId: line.uomId, warehouseId: line.warehouseId, qty: line.qty, unitPrice: line.unitPrice, @@ -258,7 +255,6 @@ function NewPurchaseOrderContent() { const payloadLines: CreatePoLineInput[] = lines.map((l) => ({ itemId: l.itemId as number, - uomId: l.uomId as number, warehouseId: l.warehouseId as number, qty: Number(l.qty), unitPrice: Number(l.unitPrice), @@ -449,19 +445,9 @@ function NewPurchaseOrderContent() { )} - value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}> - - - - - {(uoms ?? []).map((u) => ( - - {u.name} - - ))} - - - +
+ {baseUomLabel(items ?? [], uoms ?? [], line.itemId)} +
value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}> diff --git a/Frontend/erp-system/app/dashboard/production/runs/[id]/StageDrawer.tsx b/Frontend/erp-system/app/dashboard/production/runs/[id]/StageDrawer.tsx index ba69571..c3ce5aa 100644 --- a/Frontend/erp-system/app/dashboard/production/runs/[id]/StageDrawer.tsx +++ b/Frontend/erp-system/app/dashboard/production/runs/[id]/StageDrawer.tsx @@ -18,6 +18,7 @@ import { TransferLine, } from "@/types/production" import { ItemListItem, Uom } from "@/types/master-data" +import { baseUomLabel, contentUnitLabel, uomLabel } from "@/lib/uom-label" import { ReasonCode } from "@/types/stock" import { AlertDialog, AlertDialogContent } from "@/components/ui/alert-dialog" @@ -154,10 +155,24 @@ export function StageDrawer({ return (id: number | null) => (id === null ? "—" : byId.get(id) ?? `Item #${id}`) }, [items]) - const uomName = useMemo(() => { - const byId = new Map(uoms.map((u) => [u.uomId, u.name])) - return (id: number) => byId.get(id) ?? `#${id}` - }, [uoms]) + // Nothing on a run line carries a unit of its own any more. An output that references a + // real item shows that item's base UOM; only intermediate WIP falls back to its own label. + const outputUnit = useMemo( + () => (output: RunStageOutput) => + output.itemId !== null ? baseUomLabel(items, uoms, output.itemId) : uomLabel(uoms, output.uomId), + [items, uoms], + ) + + // An input's unit follows how its quantity was expressed: ml/g for a content formula, + // otherwise a count of the item's packs. Upstream inputs are WIP from a parent stage. + const inputUnit = useMemo( + () => (input: RunStageInput) => { + if (input.source === "Upstream") return "WIP" + if (input.qtyUnit === "Content") return contentUnitLabel(items, input.itemId) ?? "—" + return baseUomLabel(items, uoms, input.itemId) + }, + [items, uoms], + ) const stageName = useMemo(() => { const byId = new Map(run.stages.map((s) => [s.runStageId, s.name])) @@ -323,7 +338,7 @@ export function StageDrawer({ value={plannedInputs[input.runInputId] ?? String(input.plannedQty)} onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))} itemName={itemName} - uomName={uomName} + unitLabel={inputUnit} /> ))} @@ -337,7 +352,7 @@ export function StageDrawer({

{output.name}

- {uomName(output.uomId)} + {outputUnit(output)}
{output.itemId !== null && (

Finished good: {itemName(output.itemId)}

@@ -522,7 +537,7 @@ export function StageDrawer({

Finish the run

- {fmt(goodQty)} {uomName(terminalOutput.uomId)} + {fmt(goodQty)} {outputUnit(terminalOutput)} {money(run.costPool.consumed)} −{money(run.costPool.returned)} @@ -653,14 +668,14 @@ function InputCard({ value, onValueChange, itemName, - uomName, + unitLabel, }: { input: RunStageInput editable: boolean value: string onValueChange: (value: string) => void itemName: (id: number | null) => string - uomName: (id: number) => string + unitLabel: (input: RunStageInput) => string }) { const isUpstream = input.source === "Upstream" const short = isUpstream && input.deliveredQty < input.plannedQty @@ -671,7 +686,7 @@ function InputCard({

{isUpstream ? "Upstream work in progress" : itemName(input.itemId)}

- {uomName(input.uomId)} + {unitLabel(input)}
@@ -698,9 +713,10 @@ function InputCard({ )} {/* - Consumed/returned figures are in the item's BASE uom, while `plannedQty` above is in the - input's declared uom — an input declared in "box of 12" shows planned 3 and consumed 36. - Labelled explicitly so the two are never read as the same unit. + Consumed/returned figures are always a count of the item's packs, while `plannedQty` + above is in the input's declared unit — a formula written as 2000 ml against a 500 ml + bottle shows planned 2000 and consumed 4. Labelled explicitly so the two are never + read as the same unit. */} {input.consumedQty > 0 && ( <> diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx index 613f95b..2f8d589 100644 --- a/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx +++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx @@ -1,9 +1,11 @@ "use client" +import type { ReactNode } from "react" import { Plus, Trash2 } from "lucide-react" import { cn } from "@/lib/utils" -import { CustomFieldType, StageInputSource } from "@/types/production" +import { baseUomLabel, contentUnitLabel } from "@/lib/uom-label" +import { CustomFieldType, StageInputSource, StageQtyUnit } from "@/types/production" import { ItemListItem, Uom } from "@/types/master-data" import { BuilderFieldDef, @@ -35,21 +37,24 @@ export interface UpstreamOutputOption { outputName: string } -/** One row per input/output quantity — UOM select plus qty, used three times below. */ +/** + * One quantity row: the number, plus whatever names its unit. + * + * The unit is no longer a free choice. An input's is decided by its item (and, when that item + * has a content size, by the Pack/Content toggle); an output's comes from its item, or from a + * WIP label when it has none. So each caller supplies its own `unit` control and this row only + * owns the number. + */ function QtyRow({ qty, - uomId, - uoms, readOnly, onQtyChange, - onUomChange, + unit, }: { qty: number - uomId: number | null - uoms: Uom[] readOnly: boolean onQtyChange: (qty: number) => void - onUomChange: (uomId: number) => void + unit: ReactNode }) { return (
@@ -64,22 +69,59 @@ function QtyRow({ placeholder="Qty per batch" aria-label="Quantity per batch" /> - value={uomId} onValueChange={(v) => v && onUomChange(v)}> - - - - - {uoms.map((u) => ( - - {u.name} - - ))} - - + {unit}
) } +/** Static unit name, for the rows whose unit is derived and therefore not editable. */ +function UnitLabel({ children }: { children: ReactNode }) { + return ( + {children} + ) +} + +/** + * Names the unit of a stage input's quantity. + * + * Only one case is a choice: a Stock input whose item declares a content size can be written + * either as an amount of that content (2000 ml) or as a pack count (4 bottles). Everything + * else has exactly one possible unit, so it renders as a label rather than a control. + */ +function InputUnitControl({ + input, + items, + uoms, + readOnly, + onChange, +}: { + input: BuilderInput + items: ItemListItem[] + uoms: Uom[] + readOnly: boolean + onChange: (unit: StageQtyUnit) => void +}) { + if (input.source === "Upstream") return WIP + + const item = items.find((candidate) => candidate.itemId === input.itemId) + const packName = baseUomLabel(items, uoms, input.itemId) + const contentName = contentUnitLabel(items, input.itemId) + + if (!item?.contentBaseQty || !contentName) return {packName} + + return ( + value={input.qtyUnit} onValueChange={(v) => v && onChange(v as StageQtyUnit)}> + + + + + {contentName} + {packName} + + + ) +} + export function StageEditorPanel({ data, isTerminal, @@ -108,7 +150,7 @@ export function StageEditorPanel({ onChange({ inputs: [ ...data.inputs, - { localId: newLocalId(), source: "Stock", itemId: null, fromOutputKey: null, uomId: null, qtyPerBatch: 1 }, + { localId: newLocalId(), source: "Stock", itemId: null, fromOutputKey: null, qtyUnit: "Pack", qtyPerBatch: 1 }, ], }) } @@ -122,13 +164,20 @@ export function StageEditorPanel({ * the validator rejects an input that carries both. */ function changeInputSource(localId: string, source: StageInputSource) { - updateInput(localId, source === "Stock" ? { source, fromOutputKey: null } : { source, itemId: null }) + updateInput( + localId, + // WIP has no content size, so an Upstream input can only ever be counted in whole units. + source === "Stock" ? { source, fromOutputKey: null } : { source, itemId: null, qtyUnit: "Pack" }, + ) } - /** Default the UOM to the item's base unit — right most of the time, still overridable. */ + /** + * Default to content units when the item has a content size — a recipe is far more often + * written as "2000 ml of syrup" than "4 bottles" — and to packs otherwise. Still switchable. + */ function pickInputItem(input: BuilderInput, itemId: number) { const item = items.find((i) => i.itemId === itemId) - updateInput(input.localId, { itemId, uomId: input.uomId ?? item?.baseUomId ?? null }) + updateInput(input.localId, { itemId, qtyUnit: item?.contentBaseQty ? "Content" : "Pack" }) } function updateOutput(key: string, patch: Partial) { @@ -287,11 +336,15 @@ export function StageEditorPanel({ updateInput(input.localId, { qtyPerBatch })} - onUomChange={(uomId) => updateInput(input.localId, { uomId })} + unit={ updateInput(input.localId, { qtyUnit })} + />} />
@@ -347,11 +400,27 @@ export function StageEditorPanel({
updateOutput(output.key, { qtyPerBatch })} - onUomChange={(uomId) => updateOutput(output.key, { uomId })} + unit={ + // A finished good is counted in its item's own unit; only WIP names one. + output.itemId !== null ? ( + {baseUomLabel(items, uoms, output.itemId)} + ) : ( + value={output.uomId} onValueChange={(v) => v && updateOutput(output.key, { uomId: v })}> + + + + + {uoms.map((u) => ( + + {u.name} + + ))} + + + ) + } /> ))} diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx index 6f4b71c..0b4c68c 100644 --- a/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx @@ -77,7 +77,7 @@ function graphToFlow(graph: ProductionTemplateGraph): { nodes: Node[]; edges: Ed source: i.source, itemId: i.itemId, fromOutputKey: i.fromOutputKey, - uomId: i.uomId, + qtyUnit: i.qtyUnit, qtyPerBatch: i.qtyPerBatch, })), outputs: s.outputs.map((o) => ({ @@ -414,14 +414,21 @@ function TemplateBuilderContent() { const where = `Input ${i + 1} of "${label}"` if (input.source === "Stock" && input.itemId === null) list.push(`${where} needs an item.`) if (input.source === "Upstream" && !input.fromOutputKey) list.push(`${where} needs an upstream output.`) - if (input.uomId === null) list.push(`${where} needs a UOM.`) + // Content quantities divide by the item's content size, so the item must declare one. + if (input.qtyUnit === "Content") { + const item = items.find((candidate) => candidate.itemId === input.itemId) + if (item && !item.contentBaseQty) { + list.push(`${where} is in content units, but ${item.sku} has no content size.`) + } + } if (input.qtyPerBatch <= 0) list.push(`${where} needs a quantity greater than zero.`) }) data.outputs.forEach((output, i) => { const where = `Output ${i + 1} of "${label}"` if (!isTerminal && !output.name.trim()) list.push(`${where} needs a name.`) - if (output.uomId === null) list.push(`${where} needs a UOM.`) + // Only work-in-progress declares its own unit; a finished good takes its item's. + if (!isTerminal && output.uomId === null) list.push(`${where} needs a UOM.`) if (output.qtyPerBatch <= 0) list.push(`${where} needs a quantity greater than zero.`) }) @@ -502,7 +509,7 @@ function TemplateBuilderContent() { source: i.source, itemId: i.source === "Stock" ? i.itemId : null, fromOutputKey: i.source === "Upstream" ? i.fromOutputKey : null, - uomId: i.uomId!, + qtyUnit: i.source === "Upstream" ? "Pack" : i.qtyUnit, qtyPerBatch: i.qtyPerBatch, })), // Only the terminal stage's output may name an item (FR-MFG-05). A stage that *was* @@ -513,7 +520,8 @@ function TemplateBuilderContent() { key: o.key, itemId: isTerminal ? o.itemId : null, name: o.name.trim(), - uomId: o.uomId!, + // An item-bearing output takes its unit from the item, so it must send none. + uomId: isTerminal ? null : o.uomId, qtyPerBatch: o.qtyPerBatch, })), } diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/types.ts b/Frontend/erp-system/app/dashboard/production/templates/[id]/types.ts index 3825fab..f7a29f9 100644 --- a/Frontend/erp-system/app/dashboard/production/templates/[id]/types.ts +++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/types.ts @@ -7,15 +7,15 @@ // the contract (only outputs do, because Upstream inputs reference them by key). Rendering // them by array index would make React reuse the wrong when a row is removed, so // every editable row carries a throwaway `localId` that is stripped on save. -// * **Half-filled rows.** `uomId` is `number | null` here but `number` on the wire: a row the -// user just added has nothing picked yet. `page.tsx` blocks the save until every one is set, -// which is what makes the non-null assertions in its payload builder sound. +// * **Half-filled rows.** A row the user just added has nothing picked yet, so `itemId` and a +// WIP output's `uomId` are nullable here. `page.tsx` blocks the save until the required ones +// are set, which is what makes the non-null assertions in its payload builder sound. // // A stage's identity IS its React Flow node id, which is its server key — the stringified // stage id, or `tmp-` for a stage drawn in this session. That is why edges need no // translation on save: `edge.source`/`edge.target` are already `parentKey`/`childKey`. -import { CustomFieldType, StageInputSource } from "@/types/production" +import { CustomFieldType, StageInputSource, StageQtyUnit } from "@/types/production" /** `tmp-` prefixed so the server can tell a newly drawn stage/output from one it already has. */ export function newKey(): string { @@ -34,7 +34,11 @@ export interface BuilderInput { itemId: number | null /** Upstream inputs only — an output key belonging to a *direct* parent stage. */ fromOutputKey: string | null - uomId: number | null + /** + * Whether `qtyPerBatch` is a pack count or an amount of the item's content (ml/g). + * `Content` is only offered for a Stock input whose item declares a content size. + */ + qtyUnit: StageQtyUnit qtyPerBatch: number } @@ -44,6 +48,7 @@ export interface BuilderOutput { /** Terminal stage only — the finished good. Must stay null on WIP outputs (FR-MFG-05). */ itemId: number | null name: string + /** WIP label — required when `itemId` is null, and must stay null when it is set. */ uomId: number | null qtyPerBatch: number } diff --git a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx index cc34d0e..f634204 100644 --- a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx @@ -12,7 +12,10 @@ import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage, fieldErrors } from "@/lib/error-map" import { validateItemForm } from "@/lib/validations/master-data" import { cn } from "@/lib/utils" -import { Item, StockNature, TrackingMode } from "@/types/master-data" +import { Item, MeasureUnit, StockNature, TrackingMode } from "@/types/master-data" + +/** Entry units. Only ml/g are stored — the server normalises L and Kg ×1000 on write. */ +const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"] import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" @@ -52,6 +55,9 @@ export default function ItemDetailPage() { const [defaultVendorId, setDefaultVendorId] = useState(null) const [trackingMode, setTrackingMode] = useState("None") const [taxClass, setTaxClass] = useState("") + // Raw string: an empty box means "no content size", which is not the same as 0. + const [contentQty, setContentQty] = useState("") + const [contentUnit, setContentUnit] = useState(null) // Frontend-only: there's no warehouse field anywhere on the Item contract, so this // isn't sent on save — nothing to wire it to server-side. const [warehouseId, setWarehouseId] = useState(null) @@ -75,6 +81,8 @@ export default function ItemDetailPage() { setStockNature(data.stockNature) setTrackingMode(data.trackingMode) setTaxClass(data.taxClass ?? "") + setContentQty(data.contentQty === null ? "" : String(data.contentQty)) + setContentUnit(data.contentUnit) } function load() { @@ -105,7 +113,7 @@ export default function ItemDetailPage() { async function handleSave() { if (!item || !etag) return setSaveError(null) - const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId }) + const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId, contentQty, contentUnit }) setErrors(nextErrors) if (Object.keys(nextErrors).length > 0) return @@ -117,6 +125,8 @@ export default function ItemDetailPage() { sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId, baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, taxClass: taxClass || null, + contentQty: contentQty.trim() ? Number(contentQty) : null, + contentUnit: contentQty.trim() ? contentUnit : null, }, etag ) @@ -273,6 +283,53 @@ export default function ItemDetailPage() { + {/* Content size (FR-MD-02): how much one pack holds. Optional, and independent of the + base UOM above — stock is counted in packs either way. */} +
+ +
+ setContentQty(e.target.value)} + placeholder="e.g. 500" + aria-invalid={!!errors.contentQty} + className="h-12! text-base" + /> + + value={contentUnit} + onValueChange={setContentUnit} + disabled={conflict} + items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))} + > + + + + + {CONTENT_UNITS.map((u) => ( + + {u.toLowerCase()} + + ))} + + +
+ + {/* The stored value, so the ×1000 normalisation is never a surprise. */} +

+ {item.contentBaseQty !== null && item.contentBaseUnit + ? `Stored as ${item.contentBaseQty} ${item.contentBaseUnit.toLowerCase()} per ${uomName(item.baseUomId)}.` + : "No content size — leave blank for items with nothing measurable to hold."} +

+
{/* "Item type" now means a Color/Size dimension master — this field is the stock-nature one it used to be confused with (docs/11 §8). */} @@ -321,7 +378,8 @@ export default function ItemDetailPage() {

- {uomName(item.baseUomId)} is the base UOM — every transaction converts to and stores quantities in base UOM (FR-MD-03). + {uomName(item.baseUomId)} is the base UOM — every transaction records quantities as a count of it (FR-MD-03). + There are no conversions: a differently sized pack is a different item.

) diff --git a/Frontend/erp-system/app/dashboard/products/item-types/page.tsx b/Frontend/erp-system/app/dashboard/products/item-types/page.tsx index eac3119..35ce009 100644 --- a/Frontend/erp-system/app/dashboard/products/item-types/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/item-types/page.tsx @@ -10,6 +10,7 @@ import { ItemType } from "@/types/master-data" import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" import { Badge } from "@/components/ui/badge" +import { Switch } from "@/components/ui/switch" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" @@ -33,6 +34,7 @@ export default function ItemTypesPage() { const [open, setOpen] = useState(false) const [editing, setEditing] = useState(null) const [name, setName] = useState("") + const [isMeasurable, setIsMeasurable] = useState(false) const [errors, setErrors] = useState>({}) const [submitting, setSubmitting] = useState(false) const [togglingId, setTogglingId] = useState(null) @@ -50,6 +52,7 @@ export default function ItemTypesPage() { function openCreateDialog() { setEditing(null) setName("") + setIsMeasurable(false) setErrors({}) setOpen(true) } @@ -57,6 +60,7 @@ export default function ItemTypesPage() { function openEditDialog(itemType: ItemType) { setEditing(itemType) setName(itemType.name) + setIsMeasurable(itemType.isMeasurable) setErrors({}) setOpen(true) } @@ -71,13 +75,16 @@ export default function ItemTypesPage() { if (editing) { // Re-read for a fresh If-Match; a concurrent edit surfaces as 412. const current = await itemTypesApi.get(editing.itemTypeId) - await itemTypesApi.update(editing.itemTypeId, { name }, current.etag ?? "") + // isMeasurable must travel on every PUT: the server preserves it when omitted, so a + // name-only body would leave the switch the user just flipped unsaved. + await itemTypesApi.update(editing.itemTypeId, { name, isMeasurable }, current.etag ?? "") } else { - await itemTypesApi.create({ name }) + await itemTypesApi.create({ name, isMeasurable }) } toast.success(editing ? "Item type updated" : "Item type created", name) setOpen(false) setName("") + setIsMeasurable(false) setEditing(null) setErrors({}) load() @@ -112,6 +119,7 @@ export default function ItemTypesPage() {

Item Types

Dimensions the item builder offers (e.g. Color, Size, Material). Values are captured per item and encoded in its SKU. + A measurement dimension captures a number plus a unit instead, which becomes each item's content size.

@@ -135,6 +143,23 @@ export default function ItemTypesPage() { /> + +
+ +
+ Values are measurements + + Values are entered as a number plus a unit (500 ml, 1 L) and become each item's + content size. Leave off for plain labels like Red or Small. + +
+
+
-
+ {t.isMeasurable ? ( + // A measurement is entered as a number + unit; the chip label, the SKU + // segment and the item's stored content size all derive from this pair. +
+ setQtyByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + addValue(t.itemTypeId) + } + }} + placeholder="e.g. 500" + aria-invalid={!!valueErrors[t.itemTypeId]} + className="h-11 text-base" + /> + + value={unitByCategory[t.itemTypeId] ?? null} + onValueChange={(v) => setUnitByCategory((prev) => ({ ...prev, [t.itemTypeId]: v }))} + items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))} + > + + + + + {CONTENT_UNITS.map((u) => ( + + {UNIT_LABEL[u]} + + ))} + + + +
+ ) : ( +
+ setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + addValue(t.itemTypeId) + } + }} + placeholder={t.name} + className="h-11 text-base" + /> + +
+ )} +
{(valuesByCategory[t.itemTypeId] ?? []).map((v) => ( - - {v} + + {v.label} @@ -526,6 +825,10 @@ export default function NewItemPage() {
)} + {/* Form-level, not per-row: a variant's content is derived, so there is no cell to + attach this to. Should never appear — addValue rejects a bad pair at entry. */} + + {variants.length > 0 && (
@@ -535,6 +838,7 @@ export default function NewItemPage() { {cat.name} ))} SKU + Content {priceMode === "fixed" && ( Sale price )} @@ -550,10 +854,15 @@ export default function NewItemPage() { {variant.parts.map((part, i) => ( - {part.value} + {part.value.label} ))} {variant.sku} + {/* Read-only: content is derived, so an editable cell here could only + disagree with the value that produced it. */} + + {contentLabelFor(variant)} + {priceMode === "fixed" && (

Units of Measure

-

Flat UOM master, used as item base UOMs and in per-item conversions (FR-MD-02).

+

Flat UOM master, used as item base UOMs and as work-in-progress labels in production (FR-MD-02).

diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx index 4d66b46..ab634b6 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx @@ -9,6 +9,7 @@ import { grnsApi } from "@/lib/api/grns" import { warehousesApi } from "@/lib/api/warehouses" import { itemsApi } from "@/lib/api/items" import { uomsApi } from "@/lib/api/uoms" +import { baseUomLabel } from "@/lib/uom-label" import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" import { ConfirmGrnResponse, Grn } from "@/types/grn" @@ -56,9 +57,6 @@ export default function GrnDetailPage() { function itemFor(itemId: number) { return items.find((i) => i.itemId === itemId) } - function uomFor(uomId: number) { - return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}` - } function binFor(binId: number | null) { if (!binId) return "—" return bins.find((b) => b.binId === binId)?.code ?? `#${binId}` @@ -179,7 +177,7 @@ export default function GrnDetailPage() { return ( {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} - {uomFor(line.uomId)} + {baseUomLabel(items, uoms, line.itemId)} {binFor(line.binId)} {line.qty} diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx index fa9ff39..30c40dc 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx @@ -11,6 +11,7 @@ import { vendorsApi } from "@/lib/api/vendors" import { warehousesApi } from "@/lib/api/warehouses" import { itemsApi } from "@/lib/api/items" import { uomsApi } from "@/lib/api/uoms" +import { baseUomLabel } from "@/lib/uom-label" import { errorMessage } from "@/lib/error-map" import { validateLine, splitSerials, grnHeaderSchema } from "@/lib/validations/grn" import { cn } from "@/lib/utils" @@ -33,7 +34,6 @@ interface DraftLine { key: string poLineId: number | null itemId: number | null - uomId: number | null binId: number | null qty: string unitCost: string @@ -70,7 +70,6 @@ function emptyLine(): DraftLine { key: newKey(), poLineId: null, itemId: null, - uomId: null, binId: null, qty: "", unitCost: "", @@ -168,7 +167,6 @@ export default function NewGrnPage() { key: newKey(), poLineId: l.poLineId, itemId: l.itemId, - uomId: l.uomId, binId: null, qty: String(l.qty - l.qtyReceived), unitCost: String(l.unitPrice), @@ -246,7 +244,6 @@ export default function NewGrnPage() { for (const line of lines) { const errors = validateLine({ itemId: line.itemId, - uomId: line.uomId, qty: line.qty, unitCost: line.unitCost, discountPct: line.discountPct, @@ -268,7 +265,6 @@ export default function NewGrnPage() { return { poLineId: l.poLineId, itemId: l.itemId as number, - uomId: l.uomId as number, binId: l.binId, qty: Number(l.qty), unitCost: Number(l.unitCost), @@ -486,27 +482,9 @@ export default function NewGrnPage() { )} - {line.poLineId ? ( -
- {uoms?.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`} -
- ) : ( - <> - value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}> - - - - - {(uoms ?? []).map((u) => ( - - {u.name} - - ))} - - - - - )} +
+ {baseUomLabel(items ?? [], uoms ?? [], line.itemId)} +
value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}> diff --git a/Frontend/erp-system/app/dashboard/sales/bundles/[id]/page.tsx b/Frontend/erp-system/app/dashboard/sales/bundles/[id]/page.tsx index c22f692..36cad7f 100644 --- a/Frontend/erp-system/app/dashboard/sales/bundles/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/bundles/[id]/page.tsx @@ -8,6 +8,7 @@ import { ArrowLeft, CheckCircle2, Edit, ExternalLink, Minus, Plus, Printer, Save import { bundleApi } from "@/lib/api/bundles" import { itemsApi } from "@/lib/api/items" import { uomsApi } from "@/lib/api/uoms" +import { baseUomLabel } from "@/lib/uom-label" import { errorMessage } from "@/lib/error-map" import { Button, buttonVariants } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" @@ -30,7 +31,6 @@ const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine => key: crypto.randomUUID(), bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0, itemId: templateLine?.itemId ?? 0, - uomId: templateLine?.uomId ?? 0, warehouseId: templateLine?.warehouseId ?? 0, qty: templateLine?.qty ?? 1, unitPrice: templateLine?.unitPrice ?? 0, @@ -107,7 +107,6 @@ export default function BundleSaleDetailPage() { key: `${line.bundleSaleLineId}`, bundleSaleTemplateLineId: line.bundleSaleLineId, itemId: line.itemId, - uomId: items.find((candidate) => candidate.itemId === line.itemId)?.baseUomId ?? line.uomId, warehouseId: line.warehouseId, qty: line.qty, unitPrice: line.unitPrice, @@ -377,7 +376,7 @@ export default function BundleSaleDetailPage() { - - + + {baseUomLabel(items, uoms, line.itemId)} updateLine(line.key, { qty: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /> updateLine(line.key, { unitPrice: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /> diff --git a/Frontend/erp-system/app/dashboard/sales/bundles/new/page.tsx b/Frontend/erp-system/app/dashboard/sales/bundles/new/page.tsx index ce49664..b0c27f8 100644 --- a/Frontend/erp-system/app/dashboard/sales/bundles/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/bundles/new/page.tsx @@ -9,6 +9,7 @@ import { bundleApi } from "@/lib/api/bundles" import { customersApi } from "@/lib/api/customers" import { itemsApi } from "@/lib/api/items" import { uomsApi } from "@/lib/api/uoms" +import { baseUomLabel } from "@/lib/uom-label" import { warehousesApi } from "@/lib/api/warehouses" import { usersApi } from "@/lib/api/users" import { errorMessage } from "@/lib/error-map" @@ -31,7 +32,6 @@ const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine => key: crypto.randomUUID(), bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0, itemId: templateLine?.itemId ?? 0, - uomId: templateLine?.uomId ?? 0, warehouseId: templateLine?.warehouseId ?? 0, qty: templateLine?.qty ?? 1, unitPrice: templateLine?.unitPrice ?? 0, @@ -97,7 +97,6 @@ function NewBundleSaleContent() { const item = items.find((candidate) => candidate.itemId === line.itemId) return createBlankLine({ ...line, - uomId: item?.baseUomId ?? line.uomId, }) }) : [createBlankLine()] @@ -261,7 +260,6 @@ function NewBundleSaleContent() { const item = items.find((candidate) => candidate.itemId === itemId) updateLine(line.key, { itemId, - uomId: item?.baseUomId ?? line.uomId, unitPrice: item?.salePrice ?? line.unitPrice, }) }}> @@ -277,19 +275,8 @@ function NewBundleSaleContent() { - - + + {baseUomLabel(items, uoms, line.itemId)} updateLine(line.key, { qty: Number(e.target.value) })} className="text-right" /> updateLine(line.key, { unitPrice: Number(e.target.value) })} className="text-right" /> diff --git a/Frontend/erp-system/app/dashboard/sales/free-issues/new/page.tsx b/Frontend/erp-system/app/dashboard/sales/free-issues/new/page.tsx index 2dced6d..37b019e 100644 --- a/Frontend/erp-system/app/dashboard/sales/free-issues/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/free-issues/new/page.tsx @@ -14,6 +14,7 @@ import { salesApi } from "@/lib/api/sales" import { customersApi } from "@/lib/api/customers" import { itemsApi } from "@/lib/api/items" import { uomsApi } from "@/lib/api/uoms" +import { baseUomLabel } from "@/lib/uom-label" import { warehousesApi } from "@/lib/api/warehouses" import { usersApi } from "@/lib/api/users" import { toast } from "@/components/ui/toast" @@ -40,7 +41,6 @@ type FreeIssueRow = { const blankLine = (key: string): Line => ({ key, itemId: 0, - uomId: 0, warehouseId: 0, qty: 1, freeQty: 0, @@ -78,7 +78,6 @@ export default function NewFreeIssuePage() { const detail = await salesApi.getFreeIssue(summary.salesSlipId) const firstLine = detail.data.lines[0] const item = items.find((x) => x.itemId === firstLine?.itemId) - const uom = uoms.find((x) => x.uomId === firstLine?.uomId) const warehouse = warehouses.find((x) => x.warehouseId === detail.data.warehouseId) return { salesSlipId: detail.data.salesSlipId, @@ -88,7 +87,7 @@ export default function NewFreeIssuePage() { warehouseName: warehouse?.name ?? `Warehouse ${detail.data.warehouseId}`, itemName: item?.name ?? firstLine?.description ?? "—", itemSku: item?.sku ?? `SKU-${firstLine?.itemId ?? 0}`, - uomName: uom?.name ?? `UOM ${firstLine?.uomId ?? 0}`, + uomName: baseUomLabel(items, uoms, firstLine?.itemId), qty: firstLine?.qty ?? 0, freeQty: firstLine?.freeQty ?? 0, } satisfies FreeIssueRow @@ -118,7 +117,6 @@ export default function NewFreeIssuePage() { { ...blankLine("line-1"), itemId: itemRes.items[0]?.itemId ?? 0, - uomId: itemRes.items[0]?.baseUomId ?? uomRes.items[0]?.uomId ?? 0, warehouseId: whRes.items[0]?.warehouseId ?? 0, }, ]) @@ -145,20 +143,17 @@ export default function NewFreeIssuePage() { } function selectItem(key: string, itemId: number) { - const item = items.find((candidate) => candidate.itemId === itemId) - updateLine(key, { itemId, uomId: item?.baseUomId ?? 0 }) + updateLine(key, { itemId }) } function selectEditingItem(key: string, itemId: number) { - const item = items.find((candidate) => candidate.itemId === itemId) - updateEditingLine(key, { itemId, uomId: item?.baseUomId ?? 0 }) + updateEditingLine(key, { itemId }) } async function submit() { const activeLines = editingRowId ? editingLines : lines if (!customerId || !warehouseId || !cashierUserId) return setError("Select customer, warehouse, and cashier.") if (activeLines.some((line) => !line.itemId)) return setError("Select an item for every line.") - if (activeLines.some((line) => !line.uomId)) return setError("Select a valid UOM for every line.") if (activeLines.some((line) => !line.warehouseId)) return setError("Select a warehouse for every line.") setSaving(true) @@ -170,7 +165,6 @@ export default function NewFreeIssuePage() { cashierUserId, lines: activeLines.map((line) => ({ itemId: Number(line.itemId), - uomId: Number(line.uomId), warehouseId: Number(line.warehouseId), qty: Number(line.qty), freeQty: Number(line.freeQty), @@ -219,7 +213,6 @@ export default function NewFreeIssuePage() { { key: "edit-line-1", itemId: detailLine?.itemId ?? 0, - uomId: detailLine?.uomId ?? 0, warehouseId: detailLine?.warehouseId ?? detail.data.warehouseId, qty: detailLine?.qty ?? 1, freeQty: detailLine?.freeQty ?? 0, @@ -319,19 +312,8 @@ export default function NewFreeIssuePage() { - - + + {baseUomLabel(items, uoms, line.itemId)} updateEditingLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" /> @@ -385,18 +367,7 @@ export default function NewFreeIssuePage() { - + {baseUomLabel(items, uoms, line.itemId)} updateLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" /> diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx index 0a79b03..f803b62 100644 --- a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx @@ -9,6 +9,7 @@ import { salesApi } from "@/lib/api/sales" import { customersApi } from "@/lib/api/customers" import { itemsApi } from "@/lib/api/items" import { uomsApi } from "@/lib/api/uoms" +import { baseUomLabel } from "@/lib/uom-label" import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" @@ -30,7 +31,6 @@ const money = new Intl.NumberFormat("en-LK", { const blankLine = (key: string): Line => ({ key, itemId: 0, - uomId: 0, warehouseId: 0, qty: 1, freeQty: 0, @@ -103,7 +103,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i doc.data.lines.map((line) => ({ key: String(line.salesInvoiceLineId), itemId: line.itemId, - uomId: line.uomId, warehouseId: line.warehouseId, qty: line.qty, freeQty: line.freeQty, @@ -152,7 +151,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i const item = items.find((candidate) => candidate.itemId === itemId) updateLine(key, { itemId, - uomId: item?.baseUomId ?? 0, unitPrice: getSuggestedUnitPrice(items, itemId), }) } @@ -172,7 +170,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i async function save() { if (!customerId || !warehouseId || !etag) return - if (lines.some((line) => !line.itemId || !line.uomId || !line.warehouseId)) { + if (lines.some((line) => !line.itemId || !line.warehouseId)) { setError("Select item, UOM and warehouse for every line.") return } @@ -188,7 +186,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i invoiceType, lines: lines.map((line) => ({ itemId: Number(line.itemId), - uomId: Number(line.uomId), warehouseId: Number(line.warehouseId), qty: Number(line.qty), freeQty: Number(line.freeQty), @@ -353,7 +350,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
{line.description}
SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}
-
+ @@ -496,19 +493,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i ))} -
{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}{baseUomLabel(items, uoms, line.itemId)} {line.qty.toFixed(0)} {line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"} {money.format(line.unitPrice)} - + + {baseUomLabel(items, uoms, line.itemId)} {line.description}
SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}
- {uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`} + {baseUomLabel(items, uoms, line.itemId)} {line.qty.toFixed(2)} {line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"} {line.unitPrice.toFixed(2)} diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx index ad3e323..55955af 100644 --- a/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx @@ -13,6 +13,7 @@ import { Badge } from "@/components/ui/badge" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { cn } from "@/lib/utils" import { getSuggestedUnitPrice } from "@/lib/sales-line-utils" +import { baseUomLabel } from "@/lib/uom-label" import { errorMessage } from "@/lib/error-map" import { salesApi } from "@/lib/api/sales" import { customersApi } from "@/lib/api/customers" @@ -36,7 +37,6 @@ type ActiveFocScheme = { const blankLine = (key: string): Line => ({ key, itemId: 0, - uomId: 0, warehouseId: 0, qty: 1, freeQty: 0, @@ -92,7 +92,6 @@ export default function NewSalesInvoicePage() { { ...blankLine("line-1"), itemId: itemRes.items[0]?.itemId ?? 0, - uomId: itemRes.items[0]?.baseUomId ?? uomRes.items[0]?.uomId ?? 0, warehouseId: defaultWarehouseId ?? 0, unitPrice: getSuggestedUnitPrice(itemRes.items, itemRes.items[0]?.itemId), }, @@ -131,7 +130,6 @@ export default function NewSalesInvoicePage() { const item = items.find((candidate) => candidate.itemId === itemId) updateLine(key, { itemId, - uomId: item?.baseUomId ?? 0, unitPrice: getSuggestedUnitPrice(items, itemId), }) } @@ -181,7 +179,6 @@ export default function NewSalesInvoicePage() { if (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.") if (lines.some((line) => !line.itemId)) return setSubmitError("Select an item for every line.") if (lines.some((line) => !line.warehouseId)) return setSubmitError("Select a warehouse for every line.") - if (lines.some((line) => !line.uomId)) return setSubmitError("Select a valid UOM for every line.") const payload: CreateSalesInvoiceRequest = { customerId, @@ -189,7 +186,6 @@ export default function NewSalesInvoicePage() { invoiceType, lines: lines.map((line) => ({ itemId: Number(line.itemId), - uomId: Number(line.uomId), warehouseId: Number(line.warehouseId), qty: Number(line.qty), freeQty: Number(line.freeQty), @@ -357,19 +353,8 @@ export default function NewSalesInvoicePage() { - - + + {baseUomLabel(items, uoms, line.itemId)} ({ key, itemId: 0, - uomId: 0, warehouseId: 0, qty: 1, freeQty: 0, @@ -101,7 +101,6 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id: doc.data.lines.map((line) => ({ key: String(line.salesSlipLineId), itemId: line.itemId, - uomId: line.uomId, warehouseId: line.warehouseId, qty: line.qty, freeQty: line.freeQty, @@ -179,7 +178,6 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id: cashierUserId, lines: lines.map((line) => ({ itemId: Number(line.itemId), - uomId: Number(line.uomId), warehouseId: Number(line.warehouseId), qty: Number(line.qty), freeQty: Number(line.freeQty), @@ -346,7 +344,7 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id: : "No price suggestion available"} - {uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`} + {baseUomLabel(items, uoms, line.itemId)} {line.qty.toFixed(0)} {line.freeQty > 0 ? line.freeQty.toFixed(0) : "-"} {money.format(line.unitPrice)} @@ -454,12 +452,7 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id: {items.map((i) => {i.sku} - {i.name})} - - - + {baseUomLabel(items, uoms, line.itemId)} updateLine(line.key, { qty: Number(e.target.value) })} disabled={locked} /> updateLine(line.key, { freeQty: Number(e.target.value) })} disabled={locked} /> updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })} disabled={locked} /> diff --git a/Frontend/erp-system/app/dashboard/sales/slips/new/page.tsx b/Frontend/erp-system/app/dashboard/sales/slips/new/page.tsx index b7a2403..37f6588 100644 --- a/Frontend/erp-system/app/dashboard/sales/slips/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/slips/new/page.tsx @@ -18,6 +18,7 @@ import { salesApi } from "@/lib/api/sales" import { customersApi } from "@/lib/api/customers" import { itemsApi } from "@/lib/api/items" import { uomsApi } from "@/lib/api/uoms" +import { baseUomLabel } from "@/lib/uom-label" import { warehousesApi } from "@/lib/api/warehouses" import { usersApi } from "@/lib/api/users" import { Customer } from "@/types/customers" @@ -31,7 +32,6 @@ type Line = CreateSalesSlipLineRequest & { key: string } const blankLine = (key: string): Line => ({ key, itemId: 0, - uomId: 0, warehouseId: 0, qty: 1, freeQty: 0, @@ -88,7 +88,6 @@ export default function NewSalesSlipPage() { { ...blankLine("line-1"), itemId: itemRes.items[0]?.itemId ?? 0, - uomId: itemRes.items[0]?.baseUomId ?? uomRes.items[0]?.uomId ?? 0, warehouseId: whRes.items[0]?.warehouseId ?? 0, unitPrice: getSuggestedUnitPrice(itemRes.items, itemRes.items[0]?.itemId), }, @@ -106,7 +105,6 @@ export default function NewSalesSlipPage() { const item = items.find((candidate) => candidate.itemId === itemId) updateLine(key, { itemId, - uomId: item?.baseUomId ?? 0, unitPrice: getSuggestedUnitPrice(items, itemId), }) } @@ -155,7 +153,6 @@ export default function NewSalesSlipPage() { if (!customerId || !warehouseId || !cashierUserId) return setSubmitError("Select customer, warehouse, and cashier.") if (lines.some((line) => Number(line.itemId) === 0)) return setSubmitError("Select an item for every line.") if (lines.some((line) => Number(line.warehouseId) === 0)) return setSubmitError("Select a warehouse for every line.") - if (lines.some((line) => Number(line.uomId) === 0)) return setSubmitError("Select a valid UOM for every line.") const payload: CreateSalesSlipRequest = { customerId, @@ -163,7 +160,6 @@ export default function NewSalesSlipPage() { cashierUserId, lines: lines.map((line) => ({ itemId: Number(line.itemId), - uomId: Number(line.uomId), warehouseId: Number(line.warehouseId), qty: Number(line.qty), freeQty: Number(line.freeQty), @@ -328,19 +324,8 @@ export default function NewSalesSlipPage() { - - + + {baseUomLabel(items, uoms, line.itemId)} {line.description}
SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}
- {uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`} + {baseUomLabel(items, uoms, line.itemId)} {line.qty.toFixed(2)} {line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"} {line.unitPrice.toFixed(2)} diff --git a/Frontend/erp-system/app/print/sales/slips/[id]/page.tsx b/Frontend/erp-system/app/print/sales/slips/[id]/page.tsx index 24ca5db..a86f7e3 100644 --- a/Frontend/erp-system/app/print/sales/slips/[id]/page.tsx +++ b/Frontend/erp-system/app/print/sales/slips/[id]/page.tsx @@ -7,6 +7,7 @@ import { salesApi } from "@/lib/api/sales" import { customersApi } from "@/lib/api/customers" import { itemsApi } from "@/lib/api/items" import { uomsApi } from "@/lib/api/uoms" +import { baseUomLabel } from "@/lib/uom-label" import { warehousesApi } from "@/lib/api/warehouses" import { usersApi } from "@/lib/api/users" import { errorMessage } from "@/lib/error-map" @@ -123,7 +124,7 @@ export default function SalesSlipPrintPage({ params }: { params: Promise<{ id: s
{line.description}
SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}
- {uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`} + {baseUomLabel(items, uoms, line.itemId)} {line.qty.toFixed(0)} {line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"} {line.unitPrice.toFixed(2)} diff --git a/Frontend/erp-system/lib/api/items.ts b/Frontend/erp-system/lib/api/items.ts index a1471e4..524b7a9 100644 --- a/Frontend/erp-system/lib/api/items.ts +++ b/Frontend/erp-system/lib/api/items.ts @@ -10,8 +10,6 @@ import { TrackingMode, UpdateItemReorderRequest, UpdateItemRequest, - UpdateUomConversionsRequest, - UpdateUomConversionsResponse, } from "@/types/master-data" export interface ListItemsParams { @@ -59,11 +57,4 @@ export const itemsApi = { body: request, }) }, - - updateUomConversions(itemId: number, request: UpdateUomConversionsRequest): Promise { - return apiRequest(`/items/${itemId}/uom-conversions`, { - method: "PUT", - body: request, - }) - }, } diff --git a/Frontend/erp-system/lib/uom-label.ts b/Frontend/erp-system/lib/uom-label.ts new file mode 100644 index 0000000..8903c52 --- /dev/null +++ b/Frontend/erp-system/lib/uom-label.ts @@ -0,0 +1,46 @@ +// Document lines carry no UOM of their own: every quantity in the system is a count of the +// item's base UOM (FR-MD-02/03). Screens that used to render a per-line UOM picker now show +// the unit as a derived, read-only label — the user still needs to know that "12" means +// 12 bottles, they just cannot change it. + +import { ItemListItem, Uom } from "@/types/master-data" + +/** Minimal shapes so this works with both `Item` and `ItemListItem`. */ +type ItemLike = Pick + +/** + * Display name of an item's base UOM, e.g. "BOTTLE". Returns an em dash when no item is + * selected yet, and falls back to the raw id if the UOM list has not loaded. + */ +export function baseUomLabel( + items: readonly ItemLike[], + uoms: readonly Uom[], + itemId: number | null | undefined, +): string { + if (!itemId) return "—" + const item = items.find((candidate) => candidate.itemId === itemId) + if (!item) return "—" + return uoms.find((u) => u.uomId === item.baseUomId)?.name ?? `#${item.baseUomId}` +} + +/** + * Display name for a UOM id that may be absent — an intermediate production output's WIP + * label, which is null on any output that references a real item. + */ +export function uomLabel(uoms: readonly Uom[], uomId: number | null | undefined): string { + if (!uomId) return "—" + return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}` +} + +/** + * The unit an item's content is measured in — "ml" or "g" — or null when the item has no + * content size. Used to label a production input entered in content units. + */ +export function contentUnitLabel( + items: readonly Pick[], + itemId: number | null | undefined, +): string | null { + if (!itemId) return null + const unit = items.find((candidate) => candidate.itemId === itemId)?.contentBaseUnit + return unit ? unit.toLowerCase() : null +} diff --git a/Frontend/erp-system/lib/validations/grn.ts b/Frontend/erp-system/lib/validations/grn.ts index 6d4debb..bb55166 100644 --- a/Frontend/erp-system/lib/validations/grn.ts +++ b/Frontend/erp-system/lib/validations/grn.ts @@ -13,7 +13,6 @@ export const grnHeaderSchema = z.object({ export function validateLine(input: { itemId: number | null - uomId: number | null qty: string unitCost: string discountPct: string @@ -25,7 +24,6 @@ export function validateLine(input: { const errors: Record = {} if (!input.itemId) errors.itemId = "Select an item" - if (!input.uomId) errors.uomId = "Select a UOM" const qty = Number(input.qty) if (!input.qty || Number.isNaN(qty) || qty <= 0) errors.qty = "Quantity must be greater than 0" diff --git a/Frontend/erp-system/lib/validations/master-data.ts b/Frontend/erp-system/lib/validations/master-data.ts index c2a5571..1593426 100644 --- a/Frontend/erp-system/lib/validations/master-data.ts +++ b/Frontend/erp-system/lib/validations/master-data.ts @@ -10,12 +10,38 @@ export function validateItemForm(input: { name: string categoryId: number | null baseUomId: number | null + /** Raw field value; empty means "no measurable content", which is valid. */ + contentQty?: string + contentUnit?: string | null }): Record { const errors: Record = {} if (!input.sku.trim()) errors.sku = "SKU is required" if (!input.name.trim()) errors.name = "Item name is required" if (!input.categoryId) errors.categoryId = "Select a category" if (!input.baseUomId) errors.baseUomId = "Select a base UOM" + Object.assign(errors, contentPairErrors(input.contentQty, input.contentUnit)) + return errors +} + +/** + * An item's content size is optional, but half of it is not: the server rejects a lone + * quantity or a lone unit with a 422. Shared because the variant builder validates its own + * shape and so cannot go through `validateItemForm`. + */ +export function contentPairErrors( + contentQty: string | undefined, + contentUnit: string | null | undefined, +): Record { + const errors: Record = {} + const rawQty = (contentQty ?? "").trim() + const unit = contentUnit ?? null + + if (rawQty && !unit) errors.contentUnit = "Select a unit for the content size" + if (!rawQty && unit) errors.contentQty = "Enter a content size, or clear the unit" + if (rawQty) { + const qty = Number(rawQty) + if (Number.isNaN(qty) || qty <= 0) errors.contentQty = "Content size must be greater than 0" + } return errors } @@ -29,16 +55,6 @@ export function validateReorderLine(input: { warehouseId: number | null; reorder return errors } -export function validateConversionLine(input: { fromUom: number | null; toUom: number | null; factor: string }): Record { - const errors: Record = {} - if (!input.fromUom) errors.fromUom = "Select a UOM" - if (!input.toUom) errors.toUom = "Select a UOM" - if (input.fromUom && input.toUom && input.fromUom === input.toUom) errors.toUom = "From and to UOM must differ" - const factor = Number(input.factor) - if (!input.factor || Number.isNaN(factor) || factor <= 0) errors.factor = "Factor must be greater than 0" - return errors -} - export function validateUomName(name: string): Record { const errors: Record = {} if (!name.trim()) errors.name = "UOM name is required" @@ -65,10 +81,14 @@ export function validateItemTypeName(name: string): Record { } export function validateVariantItemForm(input: { + productName: string categoryId: number | null hasVariants: boolean }): Record { const errors: Record = {} + // Required: it names every generated item and leads its SKU, and neither reads sensibly + // when derived from the category instead ("Beverages - 500ml"). + if (!input.productName.trim()) errors.productName = "Enter a product name" if (!input.categoryId) errors.categoryId = "Select a category" if (!input.hasVariants) errors.variants = "Check at least one variant category and add its values" return errors @@ -79,6 +99,29 @@ export function validateVariantItemForm(input: { * (docs/20 §3.1). Returns a map keyed by variant key → message; empty when valid. * In "stock" mode there is nothing to validate (prices are sent as null). */ +/** + * Belt-and-braces sweep over the generated variants: each one's resolved content pair must be + * whole or wholly absent. It should never fire — `addValue` rejects a half or duplicate pair at + * entry — so it exists to catch stale state, not to guide the user. + * + * Keyed by variant key to mirror {@link validateVariantPrices}, but surfaced as ONE form-level + * message: content is *derived*, so unlike a price there is no per-row control to attach an + * error to. + */ +export function validateVariantContent( + variantKeys: string[], + contentFor: (key: string) => { qty: string; unit: string | null }, +): Record { + const errors: Record = {} + for (const key of variantKeys) { + const { qty, unit } = contentFor(key) + const pair = contentPairErrors(qty, unit) + const message = pair.contentQty ?? pair.contentUnit + if (message) errors[key] = message + } + return errors +} + export function validateVariantPrices( variantKeys: string[], priceFor: (key: string) => string, diff --git a/Frontend/erp-system/lib/validations/procurement.ts b/Frontend/erp-system/lib/validations/procurement.ts index faceee6..951d851 100644 --- a/Frontend/erp-system/lib/validations/procurement.ts +++ b/Frontend/erp-system/lib/validations/procurement.ts @@ -32,7 +32,6 @@ export function validateQuotationLine(input: { unitPrice: string; leadDays: stri export function validatePoLine(input: { itemId: number | null - uomId: number | null warehouseId: number | null qty: string unitPrice: string @@ -40,7 +39,6 @@ export function validatePoLine(input: { }): Record { const errors: Record = {} if (!input.itemId) errors.itemId = "Select an item" - if (!input.uomId) errors.uomId = "Select a UOM" if (!input.warehouseId) errors.warehouseId = "Select a warehouse" const qty = Number(input.qty) if (!input.qty || Number.isNaN(qty) || qty <= 0) errors.qty = "Quantity must be greater than 0" diff --git a/Frontend/erp-system/types/bundles.ts b/Frontend/erp-system/types/bundles.ts index d5d8ae7..6e243e1 100644 --- a/Frontend/erp-system/types/bundles.ts +++ b/Frontend/erp-system/types/bundles.ts @@ -5,7 +5,6 @@ export type BundleSaleStatus = "Draft" | "Posted" | "Cancelled" export interface BundleSaleTemplateLine { bundleSaleTemplateLineId: number itemId: number - uomId: number warehouseId: number qty: number unitPrice: number @@ -40,7 +39,6 @@ export interface BundleSaleLine { itemId: number description: string qty: number - uomId: number warehouseId: number unitPrice: number lineTotal: number diff --git a/Frontend/erp-system/types/grn.ts b/Frontend/erp-system/types/grn.ts index 904b217..2e612a9 100644 --- a/Frontend/erp-system/types/grn.ts +++ b/Frontend/erp-system/types/grn.ts @@ -26,7 +26,6 @@ export interface BatchInput { export interface CreateGrnLineInput { poLineId?: number | null itemId: number - uomId: number binId?: number | null qty: number /** @@ -55,7 +54,6 @@ export interface GrnLine { grnLineId: number poLineId: number | null itemId: number - uomId: number binId: number | null qty: number /** Gross unit cost received at. */ diff --git a/Frontend/erp-system/types/master-data.ts b/Frontend/erp-system/types/master-data.ts index 98363da..3232d37 100644 --- a/Frontend/erp-system/types/master-data.ts +++ b/Frontend/erp-system/types/master-data.ts @@ -10,7 +10,26 @@ import { EntityStatus } from "@/types/common" export type StockNature = "Stocked" | "NonStocked" | "Service" export type TrackingMode = "None" | "Batch" | "Serial" -export interface ItemListItem { +/** + * Unit of an item's content size — how much one stocked pack holds. + * + * Not a stocking unit: stock is always counted in packs (`baseUomId`). Only `Ml` and `G` + * are ever returned as a *base* content unit; `L` and `Kg` are entry conveniences the + * server normalises ×1000 on write. + */ +export type MeasureUnit = "Ml" | "L" | "G" | "Kg" + +/** The four content fields, which are always all set or all null. */ +export interface ItemContent { + /** Content per pack as entered, e.g. 500 with `Ml`, or 1.5 with `L`. */ + contentQty: number | null + contentUnit: MeasureUnit | null + /** Server-derived normalisation of the pair above; never sent on write. */ + contentBaseQty: number | null + contentBaseUnit: MeasureUnit | null +} + +export interface ItemListItem extends ItemContent { itemId: number sku: string name: string @@ -33,13 +52,6 @@ export interface ItemReorderSetting { reorderQty: number } -export interface UomConversion { - conversionId: number - fromUom: number - toUom: number - factor: number -} - /** * Full Item resource (docs/11 §2.1 `GET /items/{itemId}`). * @@ -47,7 +59,7 @@ export interface UomConversion { * client-generated `sku` and never stored server-side (docs/10 Part C.9). The SKU is the * only record of which colour/size an item is. */ -export interface Item { +export interface Item extends ItemContent { itemId: number sku: string name: string @@ -65,7 +77,6 @@ export interface Item { salePrice: number | null status: EntityStatus reorder: ItemReorderSetting[] - conversions: UomConversion[] createdAt: string updatedAt: string | null } @@ -87,6 +98,12 @@ export interface CreateItemRequest { taxClass?: string | null /** Optional fixed sale price (Sales only). Null/omitted ⇒ sell at stock/FIFO value. */ salePrice?: number | null + /** + * Content per pack. Send both or neither — a half-filled pair is a 422. The base pair is + * derived server-side and is deliberately not accepted here. + */ + contentQty?: number | null + contentUnit?: MeasureUnit | null } export type UpdateItemRequest = CreateItemRequest @@ -95,16 +112,6 @@ export interface UpdateItemReorderRequest { settings: ItemReorderSetting[] } -export interface UpdateUomConversionsRequest { - conversions: { fromUom: number; toUom: number; factor: number }[] -} - -export interface UpdateUomConversionsResponse { - itemId: number - baseUomId: number - conversions: UomConversion[] -} - export interface Warehouse { warehouseId: number code: string @@ -216,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 @@ -230,10 +244,14 @@ export interface ItemType { export interface CreateItemTypeRequest { name: string + /** Omitted ⇒ false. */ + isMeasurable?: boolean } export interface UpdateItemTypeRequest { name: string + /** Omitted ⇒ the stored value is preserved. Always send it from a form that shows it. */ + isMeasurable?: boolean } /** diff --git a/Frontend/erp-system/types/procurement.ts b/Frontend/erp-system/types/procurement.ts index 5d95563..2cfdfe2 100644 --- a/Frontend/erp-system/types/procurement.ts +++ b/Frontend/erp-system/types/procurement.ts @@ -136,7 +136,6 @@ export type PurchaseOrderStatus = export interface PoLine { poLineId: number itemId: number - uomId: number warehouseId: number qty: number unitPrice: number @@ -178,7 +177,6 @@ export interface PurchaseOrderSummary { export interface CreatePoLineInput { itemId: number - uomId: number warehouseId: number qty: number unitPrice: number diff --git a/Frontend/erp-system/types/production.ts b/Frontend/erp-system/types/production.ts index 14c0b8e..cdb7a33 100644 --- a/Frontend/erp-system/types/production.ts +++ b/Frontend/erp-system/types/production.ts @@ -4,7 +4,9 @@ // Three things changed when the real backend landed, and they are worth knowing if you are // reading old code or docs/21-FRONTEND-PHASE2.md: // * a template is identified by `code`, not `docNo` (only runs carry a document number) -// * quantities reference `itemId`/`uomId` numeric FKs with `qtyPerBatch` — not free text +// * quantities reference an `itemId` numeric FK with `qtyPerBatch` — not free text. Lines +// carry no UOM: an input's unit is `qtyUnit` (packs or the item's content unit) and an +// output's is its item's base UOM, or `uomId` when it is intermediate WIP // * stages carry `posX`/`posY`, so canvas layout round-trips through the server // // The `key` vocabulary (§D.1): every stage and output has a client-facing string key @@ -17,6 +19,16 @@ export type TemplateStatus = "Active" | "Inactive" export type ProductionRunStatus = "InProgress" | "Completed" | "Cancelled" export type RunStageStatus = "Waiting" | "Ready" | "InProgress" | "Done" | "Approved" export type StageInputSource = "Stock" | "Upstream" + +/** + * What a stage input's quantity is expressed in. + * + * `Pack` is a count of the item's base UOM. `Content` is an amount of the item's content in + * its base content unit (ml or g), which the server divides by the item's content size to get + * packs — 2000 ml of a 500 ml bottle consumes 4, and 300 ml consumes 0.6. `Content` requires + * the item to have a content size, and Upstream inputs must always be `Pack`. + */ +export type StageQtyUnit = "Pack" | "Content" export type CustomFieldType = "Text" | "Number" | "Checkbox" | "Date" | "Select" export type RunStageEventType = @@ -50,7 +62,7 @@ export interface StageInput { /** Set when `source` is `Upstream` — an output of a *direct* parent stage. */ fromOutputId: number | null fromOutputKey: string | null - uomId: number + qtyUnit: StageQtyUnit qtyPerBatch: number } @@ -60,7 +72,8 @@ export interface StageOutput { /** Null on intermediate (WIP) outputs; required on the terminal stage's single output. */ itemId: number | null name: string - uomId: number + /** WIP display label — set exactly when `itemId` is null; the unit otherwise comes from the item. */ + uomId: number | null qtyPerBatch: number } @@ -137,7 +150,7 @@ export interface SaveStageInputInput { source: StageInputSource itemId?: number | null fromOutputKey?: string | null - uomId: number + qtyUnit: StageQtyUnit qtyPerBatch: number } @@ -145,7 +158,8 @@ export interface SaveStageOutputInput { key: string itemId?: number | null name: string - uomId: number + /** Required when `itemId` is null (WIP); must be null when it is set. */ + uomId?: number | null qtyPerBatch: number } @@ -200,13 +214,14 @@ export interface RunStageInput { source: StageInputSource itemId: number | null fromRunOutputId: number | null - uomId: number - /** In the input's *declared* UOM. Editable until the stage starts. */ + qtyUnit: StageQtyUnit + /** Expressed in `qtyUnit`. Editable until the stage starts. */ plannedQty: number /** - * The four figures below are in the item's *base* UOM — the only unit the FIFO engine and - * the ledger speak. A stage input declared in "box of 12" therefore shows plannedQty 3 and - * consumedQty 36. Do not compare them to plannedQty without converting. + * The four figures below are always a count of the item's *base* UOM — the only unit the + * FIFO engine and the ledger speak. For a `Content` input they are therefore in different + * units from plannedQty: 2000 ml planned against a 500 ml bottle shows consumedQty 4. Do + * not compare them to plannedQty without dividing by the item's content size. */ consumedQty: number consumedValue: number @@ -220,7 +235,9 @@ export interface RunStageOutput { runOutputId: number itemId: number | null name: string - uomId: number + /** WIP display label; null on the terminal output, whose unit is the finished item's base UOM. */ + uomId: number | null + /** Always a pack count — scrap is recorded in whole units, never in ml or g. */ plannedQty: number producedQty: number scrappedQty: number diff --git a/Frontend/erp-system/types/sales.ts b/Frontend/erp-system/types/sales.ts index b8b1cfb..69bb168 100644 --- a/Frontend/erp-system/types/sales.ts +++ b/Frontend/erp-system/types/sales.ts @@ -11,7 +11,6 @@ export interface SalesInvoiceLine { description: string qty: number freeQty: number - uomId: number warehouseId: number unitPrice: number baseCost: number @@ -101,7 +100,6 @@ export interface SalesInvoice extends SalesInvoiceSummary { export interface CreateSalesInvoiceLineRequest { itemId: number - uomId: number warehouseId: number qty: number freeQty: number @@ -131,7 +129,6 @@ export interface SalesSlipLine { description: string qty: number freeQty: number - uomId: number warehouseId: number unitPrice: number baseCost: number @@ -186,7 +183,6 @@ export interface SalesSlip { export interface CreateSalesSlipLineRequest { itemId: number - uomId: number warehouseId: number qty: number freeQty: number @@ -257,7 +253,6 @@ export interface FreeIssueSummary { itemId: number itemSku: string itemName: string - uomId: number uomName: string qty: number freeQty: number diff --git a/Testing/e2e/pages/GrnPages.ts b/Testing/e2e/pages/GrnPages.ts index 14f6e1c..e28622d 100644 --- a/Testing/e2e/pages/GrnPages.ts +++ b/Testing/e2e/pages/GrnPages.ts @@ -54,15 +54,17 @@ export class GrnNewPage { } /** - * Item and UOM are each only a combobox when the row is NOT tied to a PO line - * (`line.poLineId` gates both cells identically in the source - a PO line renders them as - * plain text instead). Checked per-cell (td:nth(0) for Item, td:nth(1) for UOM) rather than - * "row has any combobox", since the Bin/Hold-status cells always have one regardless of PO - * mode - a row-wide check would false-positive on a PO line and select the wrong control. - * Selecting the app doesn't auto-fill UOM from the chosen item, so a direct-receipt/off-PO - * line needs it set explicitly or submit blocks with "Select a UOM". + * Item is only a combobox when the row is NOT tied to a PO line (`line.poLineId` gates the + * cell in the source - a PO line renders it as plain text instead). Checked per-cell + * (td:nth(0)) rather than "row has any combobox", since the Bin/Hold-status cells always + * have one regardless of PO mode - a row-wide check would false-positive on a PO line and + * select the wrong control. + * + * td:nth(1) is still the UOM column, but it is now a read-only label showing the chosen + * item's base UOM: lines carry no unit of their own, so there is nothing to pick. The + * column was kept rather than removed, which is why every index below is unchanged. */ - async fillFirstLine(opts: { item?: string; uom?: string; qty: number; unitCost?: number }) { + async fillFirstLine(opts: { item?: string; qty: number; unitCost?: number }) { const row = this.firstRow() const cells = row.locator("td") if (opts.item) { @@ -71,12 +73,6 @@ export class GrnNewPage { await selectOption(this.page, itemCombo, opts.item) } } - if (opts.uom) { - const uomCombo = cells.nth(1).getByRole("combobox") - if (await uomCombo.count()) { - await selectOption(this.page, uomCombo, opts.uom) - } - } const numberInputs = row.locator('input[type="number"]') await numberInputs.nth(0).fill(String(opts.qty)) // Qty if (opts.unitCost !== undefined) { diff --git a/Testing/e2e/specs/chained-flow.spec.ts b/Testing/e2e/specs/chained-flow.spec.ts index c4cf11c..244a061 100644 --- a/Testing/e2e/specs/chained-flow.spec.ts +++ b/Testing/e2e/specs/chained-flow.spec.ts @@ -1,5 +1,5 @@ import { test, expect, APIRequestContext } from "@playwright/test" -import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api" +import { ApiSeeder, newApiContext, Warehouse, Item, Vendor } from "../support/api" import { GrnNewPage, GrnDetailPage } from "../pages/GrnPages" import { ProductionRunListPage, ProductionRunDetailPage } from "../pages/ProductionRunPages" import { StockTransferNewPage, StockTransferDetailPage } from "../pages/StockPages" @@ -16,7 +16,6 @@ test.describe("Chained flow: GRN -> Production -> Stock Transfer", () => { let sourceWarehouse: Warehouse let destWarehouse: Warehouse let vendor: Vendor - let uom: Uom let rawItem: Item let finishedItem: Item let templateName: string @@ -26,7 +25,6 @@ test.describe("Chained flow: GRN -> Production -> Stock Transfer", () => { seeder = new ApiSeeder(api) sourceWarehouse = await seeder.firstWarehouse() destWarehouse = await seeder.secondWarehouse() - uom = await seeder.firstUom() vendor = await seeder.createVendor("Chained Flow Vendor") rawItem = await seeder.createItem({ namePrefix: "Chained Raw Material" }) finishedItem = await seeder.createItem({ namePrefix: "Chained Finished Good" }) @@ -34,7 +32,7 @@ test.describe("Chained flow: GRN -> Production -> Stock Transfer", () => { const template = await seeder.createSingleStageTemplate({ rawItemId: rawItem.itemId, finishedItemId: finishedItem.itemId, - uomId: uom.uomId, + }) templateName = template.name }) @@ -50,7 +48,7 @@ test.describe("Chained flow: GRN -> Production -> Stock Transfer", () => { await grnNew.useDirectReceipt() await grnNew.selectVendor(vendor.name) await grnNew.selectWarehouse(sourceWarehouse.name) - await grnNew.fillFirstLine({ item: rawItem.name, uom: uom.name, qty: 100, unitCost: 20 }) + await grnNew.fillFirstLine({ item: rawItem.name, qty: 100, unitCost: 20 }) await grnNew.submit() await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/\d+/) diff --git a/Testing/e2e/specs/grn.spec.ts b/Testing/e2e/specs/grn.spec.ts index 2d8187d..7940321 100644 --- a/Testing/e2e/specs/grn.spec.ts +++ b/Testing/e2e/specs/grn.spec.ts @@ -1,5 +1,5 @@ import { test, expect, APIRequestContext } from "@playwright/test" -import { ApiSeeder, newApiContext, Vendor, Warehouse, Item, Uom } from "../support/api" +import { ApiSeeder, newApiContext, Vendor, Warehouse, Item } from "../support/api" import { GrnNewPage, GrnDetailPage } from "../pages/GrnPages" // GRN receiving flow (Backend/ERPCore/Controllers/GrnsController.cs, Frontend @@ -10,14 +10,12 @@ test.describe("GRN", () => { let seeder: ApiSeeder let warehouse: Warehouse let vendor: Vendor - let uom: Uom let item: Item test.beforeAll(async () => { api = await newApiContext() seeder = new ApiSeeder(api) warehouse = await seeder.firstWarehouse() - uom = await seeder.firstUom() vendor = await seeder.createVendor() item = await seeder.createItem({ namePrefix: "GRN Test Item" }) }) @@ -32,7 +30,7 @@ test.describe("GRN", () => { await grnNew.useDirectReceipt() await grnNew.selectVendor(vendor.name) await grnNew.selectWarehouse(warehouse.name) - await grnNew.fillFirstLine({ item: item.name, uom: uom.name, qty: 10, unitCost: 50 }) + await grnNew.fillFirstLine({ item: item.name, qty: 10, unitCost: 50 }) await grnNew.submit() await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/\d+/) @@ -53,7 +51,7 @@ test.describe("GRN", () => { vendorId: vendor.vendorId, warehouseId: warehouse.warehouseId, itemId: item.itemId, - uomId: uom.uomId, + qty: 5, unitPrice: 40, }) @@ -76,7 +74,7 @@ test.describe("GRN", () => { await grnNew.useDirectReceipt() await grnNew.selectVendor(vendor.name) // Warehouse intentionally left unselected. - await grnNew.fillFirstLine({ item: item.name, uom: uom.name, qty: 1, unitCost: 10 }) + await grnNew.fillFirstLine({ item: item.name, qty: 1, unitCost: 10 }) await grnNew.submit() await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/new/) @@ -87,7 +85,7 @@ test.describe("GRN", () => { warehouseId: warehouse.warehouseId, vendorId: vendor.vendorId, itemId: item.itemId, - uomId: uom.uomId, + qty: 8, unitCost: 12, }) @@ -110,7 +108,7 @@ test.describe("GRN", () => { warehouseId: warehouse.warehouseId, vendorId: vendor.vendorId, itemId: item.itemId, - uomId: uom.uomId, + qty: 3, unitCost: 12, }) diff --git a/Testing/e2e/specs/production.spec.ts b/Testing/e2e/specs/production.spec.ts index db6f16c..c51f405 100644 --- a/Testing/e2e/specs/production.spec.ts +++ b/Testing/e2e/specs/production.spec.ts @@ -1,5 +1,5 @@ import { test, expect, APIRequestContext } from "@playwright/test" -import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api" +import { ApiSeeder, newApiContext, Warehouse, Item, Vendor } from "../support/api" import { ProductionRunListPage, ProductionRunDetailPage } from "../pages/ProductionRunPages" // Production run lifecycle (Backend/ERPCore/Controllers/ProductionRunsController.cs, @@ -12,7 +12,6 @@ test.describe("Production runs", () => { let seeder: ApiSeeder let warehouse: Warehouse let vendor: Vendor - let uom: Uom let rawItem: Item let finishedItem: Item let templateName: string @@ -21,7 +20,6 @@ test.describe("Production runs", () => { api = await newApiContext() seeder = new ApiSeeder(api) warehouse = await seeder.firstWarehouse() - uom = await seeder.firstUom() vendor = await seeder.createVendor() rawItem = await seeder.createItem({ namePrefix: "PROD Raw Material" }) finishedItem = await seeder.createItem({ namePrefix: "PROD Finished Good" }) @@ -31,7 +29,7 @@ test.describe("Production runs", () => { warehouseId: warehouse.warehouseId, vendorId: vendor.vendorId, itemId: rawItem.itemId, - uomId: uom.uomId, + qty: 100, unitCost: 20, }) @@ -39,7 +37,7 @@ test.describe("Production runs", () => { const template = await seeder.createSingleStageTemplate({ rawItemId: rawItem.itemId, finishedItemId: finishedItem.itemId, - uomId: uom.uomId, + }) templateName = template.name }) diff --git a/Testing/e2e/specs/stock-adjustments.spec.ts b/Testing/e2e/specs/stock-adjustments.spec.ts index 127349c..e5cdd51 100644 --- a/Testing/e2e/specs/stock-adjustments.spec.ts +++ b/Testing/e2e/specs/stock-adjustments.spec.ts @@ -1,5 +1,5 @@ import { test, expect, APIRequestContext } from "@playwright/test" -import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api" +import { ApiSeeder, newApiContext, Warehouse, Item, Vendor } from "../support/api" import { StockAdjustmentNewPage } from "../pages/StockPages" // Stock adjustment flow (Backend/ERPCore/Controllers/StockAdjustmentsController.cs, @@ -10,14 +10,12 @@ test.describe("Stock adjustments", () => { let seeder: ApiSeeder let warehouse: Warehouse let vendor: Vendor - let uom: Uom let item: Item test.beforeAll(async () => { api = await newApiContext() seeder = new ApiSeeder(api) warehouse = await seeder.firstWarehouse() - uom = await seeder.firstUom() vendor = await seeder.createVendor() item = await seeder.createItem({ namePrefix: "Adjustment Test Item" }) @@ -25,7 +23,7 @@ test.describe("Stock adjustments", () => { warehouseId: warehouse.warehouseId, vendorId: vendor.vendorId, itemId: item.itemId, - uomId: uom.uomId, + qty: 20, unitCost: 30, }) diff --git a/Testing/e2e/specs/stock-transfers.spec.ts b/Testing/e2e/specs/stock-transfers.spec.ts index d671511..601d3f0 100644 --- a/Testing/e2e/specs/stock-transfers.spec.ts +++ b/Testing/e2e/specs/stock-transfers.spec.ts @@ -1,5 +1,5 @@ import { test, expect, APIRequestContext } from "@playwright/test" -import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api" +import { ApiSeeder, newApiContext, Warehouse, Item, Vendor } from "../support/api" import { StockTransferNewPage, StockTransferDetailPage } from "../pages/StockPages" // Stock transfer flow (Backend/ERPCore/Controllers/StockTransfersController.cs, Frontend @@ -11,7 +11,6 @@ test.describe("Stock transfers", () => { let srcWarehouse: Warehouse let destWarehouse: Warehouse let vendor: Vendor - let uom: Uom let item: Item test.beforeAll(async () => { @@ -19,7 +18,6 @@ test.describe("Stock transfers", () => { seeder = new ApiSeeder(api) srcWarehouse = await seeder.firstWarehouse() destWarehouse = await seeder.secondWarehouse() - uom = await seeder.firstUom() vendor = await seeder.createVendor() item = await seeder.createItem({ namePrefix: "Transfer Test Item" }) @@ -27,7 +25,7 @@ test.describe("Stock transfers", () => { warehouseId: srcWarehouse.warehouseId, vendorId: vendor.vendorId, itemId: item.itemId, - uomId: uom.uomId, + qty: 50, unitCost: 15, }) diff --git a/Testing/e2e/support/api.ts b/Testing/e2e/support/api.ts index cfb20f0..d8f90b4 100644 --- a/Testing/e2e/support/api.ts +++ b/Testing/e2e/support/api.ts @@ -118,14 +118,14 @@ export class ApiSeeder { } /** Direct (no-PO) GRN, confirmed immediately, so the item has on-hand stock to test against. */ - async receiveStock(opts: { warehouseId: number; vendorId: number; itemId: number; uomId: number; qty: number; unitCost: number }) { + async receiveStock(opts: { warehouseId: number; vendorId: number; itemId: number; qty: number; unitCost: number }) { const grn = await this.post<{ grnId: number }>("/grns", { vendorId: opts.vendorId, warehouseId: opts.warehouseId, lines: [ { itemId: opts.itemId, - uomId: opts.uomId, + qty: opts.qty, unitCost: opts.unitCost, discountPct: 0, @@ -139,14 +139,14 @@ export class ApiSeeder { } /** Direct GRN with the line held for inspection, confirmed - gives the detail page a line with Release/Reject actions. */ - async receiveStockOnHold(opts: { warehouseId: number; vendorId: number; itemId: number; uomId: number; qty: number; unitCost: number }) { + async receiveStockOnHold(opts: { warehouseId: number; vendorId: number; itemId: number; qty: number; unitCost: number }) { const grn = await this.post<{ grnId: number; lines: { grnLineId: number }[] }>("/grns", { vendorId: opts.vendorId, warehouseId: opts.warehouseId, lines: [ { itemId: opts.itemId, - uomId: opts.uomId, + qty: opts.qty, unitCost: opts.unitCost, discountPct: 0, @@ -159,13 +159,13 @@ export class ApiSeeder { return grn } - async createPurchaseOrder(opts: { vendorId: number; warehouseId: number; itemId: number; uomId: number; qty: number; unitPrice: number }) { + async createPurchaseOrder(opts: { vendorId: number; warehouseId: number; itemId: number; qty: number; unitPrice: number }) { return this.post<{ poId: number; docNo: string }>("/purchase-orders", { vendorId: opts.vendorId, lines: [ { itemId: opts.itemId, - uomId: opts.uomId, + warehouseId: opts.warehouseId, qty: opts.qty, unitPrice: opts.unitPrice, @@ -181,7 +181,7 @@ export class ApiSeeder { * smallest graph ProductionGraphValidator accepts (Backend/ERPCore/Services/Production/ * ProductionGraphValidator.cs: exactly one terminal, terminal has exactly one item output). */ - async createSingleStageTemplate(opts: { rawItemId: number; finishedItemId: number; uomId: number }) { + async createSingleStageTemplate(opts: { rawItemId: number; finishedItemId: number }) { const suffix = uniqueSuffix() return this.post<{ templateId: number; code: string; name: string }>("/production-templates", { code: `E2E-TPL-${suffix}`, @@ -194,8 +194,10 @@ export class ApiSeeder { posX: 0, posY: 0, fieldDefs: [], - inputs: [{ source: "Stock", itemId: opts.rawItemId, uomId: opts.uomId, qtyPerBatch: 1 }], - outputs: [{ key: "out-1", itemId: opts.finishedItemId, name: "Finished good", uomId: opts.uomId, qtyPerBatch: 1 }], + // No unit on either row: an input is a pack count unless it says otherwise, and the + // terminal output takes its unit from the finished item. + inputs: [{ source: "Stock", itemId: opts.rawItemId, qtyPerBatch: 1 }], + outputs: [{ key: "out-1", itemId: opts.finishedItemId, name: "Finished good", qtyPerBatch: 1 }], }, ], edges: [], diff --git a/docs/10-BACKEND-PHASE1.md b/docs/10-BACKEND-PHASE1.md index 081b938..16d983d 100644 --- a/docs/10-BACKEND-PHASE1.md +++ b/docs/10-BACKEND-PHASE1.md @@ -119,8 +119,8 @@ One base currency; invoicing/3-way match in Accounting (GRN carries data); users | ID | Requirement | Pri | |---|---|---| | FR-MD-01 | Maintain **Item master**: SKU (unique), name, description, category (+ optional subcategory), optional brand, **stock nature** (Stocked/NonStocked/Service), tracking mode (None/Batch/Serial), status, tax class, default vendor, **optional fixed sale price** (nullable; Sales-only — never enters costing/GRN/FIFO; `null` ⇒ item is sold at its stock/FIFO value). | M | -| FR-MD-02 | Maintain **UOM master** with base UOM per item and **conversion factors** (purchase→stock→base). | M | -| FR-MD-03 | Convert quantities between UOMs on every transaction; store base-UOM quantity in the ledger. | M | +| FR-MD-02 | Maintain **UOM master**, with one base UOM per item — the pack it is stocked and counted in. An item may also carry an optional **content size** (how much one pack holds: 500 ml, 50 kg), entered in ml/L/g/kg and stored normalised to ml or g. *(Revised 2026-08-11: per-item conversion factors are gone — see the note below.)* | M | +| FR-MD-03 | Record every transaction quantity as a count of the item's base UOM. There is **no conversion**: a differently sized pack is a different item. The one exception is a production stage input, which may be written in the item's content unit and is divided by the content size to get the pack count (FR-MFG-04). | M | | FR-MD-04 | Maintain **item categories with one optional subcategory level**. An item references a category (required) and a subcategory (optional) that must belong to it. Deeper nesting is not supported. | S | | FR-MD-05 | Hold **reorder point** and **reorder quantity** per item, optionally per warehouse. | M | | FR-MD-06 | Maintain **Vendor master**: code, name, contact, terms, tax reg, status, currency. | M | @@ -264,11 +264,12 @@ 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) -UOM_CONVERSION(conversion_id PK, item_id FK→ITEM, from_uom FK→UOM, to_uom FK→UOM, factor) 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, + content_qty [nullable], content_unit [nullable], -- as entered (Ml|L|G|Kg) + content_base_qty [nullable], content_base_unit [nullable], -- normalised, Ml|G only default_vendor_id FK→VENDOR, stock_nature, tracking_mode, tax_class, sale_price [nullable], status) -- sale_price: Sales-only selling price; NULL ⇒ sell at stock (FIFO) value ITEM_REORDER(reorder_id PK, item_id FK→ITEM, warehouse_id FK→WAREHOUSE, reorder_point, reorder_qty) @@ -364,7 +365,8 @@ USER(..., role_id FK→ROLE [nullable]) -- added to the existing USER shadow (s Note: `USER_ROLE` from the original placeholder sketch was dropped — a user has at most one role (`USER.role_id`), matching AuthHex's own `User.RoleId` being a single scalar FK, not a many-to-many. ## C.9 Modeling notes (load-bearing) -- **Item types are a dropdown, not a relationship.** `ITEM_TYPE` (Color, Size, Material) exists **only** to populate the frontend item-builder's dropdown via `GET /item-types`. Nothing references it and it references nothing — there is no value table and no join to `ITEM`. The builder cross-products the checked types into **one standalone item per combination**; the chosen values (Red, S, M) are encoded by the **client** into the generated SKU (`BL-0002` for one type, `BL-100-0003` for two) and the server only checks that SKU for uniqueness. **The item list is the record of what was built.** This is not a product-variation model: there is no parent-product entity and no variant hierarchy. +- **Item types are a dropdown, not a relationship.** `ITEM_TYPE` (Color, Size, Material) populates the frontend item-builder's dimension list via `GET /item-types`. Nothing references it and it references nothing — there is no value table and no join to `ITEM`. The builder cross-products the checked types into **one standalone item per combination**; the chosen values (Red, S, M) are encoded by the **client** into the generated SKU (`BL-0002` for one type, `BL-100-0003` for two) and the server only checks that SKU for uniqueness. **The item list is the record of what was built.** This is not a product-variation model: there is no parent-product entity and no variant hierarchy. + - *One exception, added 2026-08-11:* the master carries a single semantic the client acts on — **`is_measurable`**. A dimension flagged measurable has its values entered as a number + unit (500 ml, 1 L) instead of free text, and that pair is written to each generated item's `content_qty`/`content_unit` (FR-MD-02). So "exists only to feed a dropdown" is no longer accurate; "is unreferenced by `ITEM`" still is, and the trade-off below is unchanged. The flag is what lets an apparel `Size` (S/M/L) stay plain text while a `Volume` dimension carries units. The server does not read it when writing an item — each item's content pair is validated and normalised on its own. - *Accepted trade-off (a decision, not an oversight):* the backend cannot answer "list all blue items", cannot filter or report by colour/size, and cannot validate that a SKU's segments correspond to real item types. Renaming an item type (`Color` → `Colour`) does **not** touch existing SKUs, which keep their old segments — the two are permanently decoupled the moment an item is created. If value-level querying is ever needed, an `ITEM_TYPE_VALUE` table plus a link table can be added additively, but existing SKUs will not be back-fillable without parsing them by hand. - **Sale price is a per-item scalar, not a variant/price table.** Because each "variant" is its own `ITEM` row (above), the optional selling price lives directly on `ITEM.sale_price` (nullable). `NULL` means "use stock value" — Sales values the item at its FIFO stock cost at sale time (FR-STK-04 / `STOCK_LAYER`); a value is a fixed selling price. It is **Sales-only**: it never participates in GRN, FIFO layering, or the stock ledger, so receipt/costing behaviour is identical whether the item is fixed-priced or not. The create-time "fixed price vs use stock value" choice is a **frontend UX toggle** — the contract is simply the nullable column, and the item builder requires a price on every generated variant when the user picks fixed pricing. - **Two-level categories.** `CATEGORY` no longer self-nests; `SUBCATEGORY` is the single optional level below it. An item stores both FKs rather than pointing only at the deepest node, so the parent is never inferred or lost. A subcategory cannot be reparented (it would silently invalidate the category of every item referencing it) — deactivate and recreate instead. diff --git a/docs/11-BACKEND-PHASE1.md b/docs/11-BACKEND-PHASE1.md index 5c04b97..01a3aa1 100644 --- a/docs/11-BACKEND-PHASE1.md +++ b/docs/11-BACKEND-PHASE1.md @@ -185,11 +185,23 @@ Query: `q`, `status` (`Active|Inactive`), `categoryId`, `subCategoryId`, `brandI "brandId": 2, "baseUomId": 1, "defaultVendorId": 5, "stockNature": "Stocked", "trackingMode": "Batch", "taxClass": "STD", "salePrice": 12.5000, "status": "Active", + "contentQty": 500, "contentUnit": "Ml", + "contentBaseQty": 500, "contentBaseUnit": "Ml", "reorder": [ { "warehouseId": 1, "reorderPoint": 500, "reorderQty": 2000 } ], - "conversions": [ { "conversionId": 33, "fromUom": 7, "toUom": 1, "factor": 12 } ], "createdAt": "2026-06-01T08:00:00Z", "updatedAt": "2026-07-01T10:15:00Z" } ``` -`conversions` is inlined (added 2026-07-17) because they are otherwise unreadable: `PUT /items/{id}/uom-conversions` returns them but nothing reads them back, so a detail screen could never show current state before editing. +> **`conversions` was removed 2026-08-11**, together with the `PUT /items/{id}/uom-conversions` +> endpoint and the `uom_conversions` table. Stock is counted in `baseUomId` and nothing converts. +> +> **Content size.** All four fields are null together when the item has nothing measurable to +> hold (a screw, a label). `contentQty`/`contentUnit` are what the user entered and are the only +> two accepted on write — send both or neither, or the server returns 422. +> `contentBaseQty`/`contentBaseUnit` are derived (L→Ml, Kg→G, both ×1000) and read-only, so +> `contentBaseUnit` is only ever `Ml` or `G`. +> +> **`baseUomId` is frozen once the item has stock history** — a layer or a ledger row — because +> it is the sole meaning of every quantity already recorded. Changing it then returns +> `409 MASTER_IN_USE`. #### `POST /items` The `sku` is **generated by the client** (it encodes the chosen item-type values, e.g. `BL-100-0003`); the server only enforces uniqueness. `subCategoryId`/`brandId` are optional. @@ -232,15 +244,9 @@ Full update; requires `If-Match`. → **200 OK** updated resource; `412` on ETag ``` **201 Created** → `{ "uomId": 7, "name": "Box-12" }` -#### `PUT /items/{itemId}/uom-conversions` -```json -{ "conversions": [ { "fromUom": 7, "toUom": 1, "factor": 12 } ] } -``` -**200 OK** -```json -{ "itemId": 1001, "baseUomId": 1, - "conversions": [ { "conversionId": 33, "fromUom": 7, "toUom": 1, "factor": 12 } ] } -``` +> **`PUT /items/{itemId}/uom-conversions` was removed 2026-08-11.** Per-item conversion factors +> no longer exist: an item is stocked in exactly one unit, and a differently sized pack is a +> different item. Document lines no longer carry a `uomId` at all — see §2.1. ### 2.3 Categories & Subcategories > **Two-level hierarchy (2026-07-16).** Categories no longer self-nest: `parentId` and `GET /categories?tree=true` are **gone**, replaced by a dedicated Subcategory resource one level below. Categories also gained `status` + an `ETag` (they previously had neither, so there was no update path at all). @@ -353,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). diff --git a/docs/30-BACKEND-PHASE2.md b/docs/30-BACKEND-PHASE2.md index eeb1fb5..1f39566 100644 --- a/docs/30-BACKEND-PHASE2.md +++ b/docs/30-BACKEND-PHASE2.md @@ -245,7 +245,8 @@ All are `POST /production-runs/{id}/stages/{sid}/…`, transactional, and return > **AS BUILT — clarifications and deviations, all verified by `Backend/smoke/`:** > > - **`Idempotency-Key` is accepted and ignored**, matching the Phase-1 posture exactly (`GrnsController` takes the header, `GrnService.ConfirmAsync` ignores it). Replay safety comes from the status guards this section already specifies: a double-fire finds the stage already moved on and gets a `409`, which `21-FRONTEND-PHASE2 §6` already tells the client to treat as a silent refetch. No key store was built. `RunStage` additionally carries an `xmin` `RowVersion` so two genuinely concurrent terminal approves cannot both read `Done` and post two receipts. -> - **UOM conversion on Stock inputs.** Not mentioned anywhere in this doc, but `STAGE_INPUT.uom_id` is a free FK while the FIFO engine works exclusively in the item's **base** UOM. All consumption therefore converts through the shared `IUomConverter` (extracted from `GrnService.ToBaseAsync`). Consequence for the contract: **`plannedQty` is in the input's declared UOM, while `consumedQty`/`consumedValue`/`returnedQty`/`returnedValue` are in the item's base UOM.** An input whose UOM has no conversion defined for the item is refused with `422`, never assumed 1:1. +> - **Content units on Stock inputs (revised 2026-08-11).** `STAGE_INPUT.uom_id` is gone, along with the per-item UOM conversion table it depended on. An input now carries `qtyUnit` — `Pack` or `Content` — and the FIFO engine still works exclusively in the item's base UOM (packs). A `Content` quantity is an amount of the item's content in its base content unit (ml or g) and is divided by `Item.ContentBaseQty` to get packs: 2000 ml of a 500 ml bottle consumes 4, and 300 ml consumes **0.6 — fractional packs are legal**, which is why the quantity columns are `(18,4)`. Consequence for the contract, unchanged in spirit: **`plannedQty` is in `qtyUnit`, while `consumedQty`/`consumedValue`/`returnedQty`/`returnedValue` are always a pack count.** `Content` on an item with no content size is refused with `422` at template save, never assumed 1:1. Upstream inputs must be `Pack` — WIP has no content size. +> - **Output units.** Outputs are **always** pack counts, so scrap is recorded in whole broken bottles rather than in millilitres. An output that references an item takes its unit from that item and must send `uomId: null`; an intermediate WIP output has no item and therefore **must** name its own `uomId` as a display label (`422 WIP_UNIT_REQUIRED` otherwise). That label is never converted — WIP touches neither stock nor the ledger. > - **Transfers route by input, not by edge.** FR-MFG-12 says "per outbound edge", but the model connects an output to a specific *input* (`RUN_STAGE_INPUT.from_run_output_id`): an edge can exist with no input drawing from it, and one output can feed inputs on several children. Delivery therefore routes by `fromRunOutputId`; `RUN_EDGE` is display and validation only. When one output feeds several inputs and no explicit target is given, they fill in `runInputId` order up to each one's outstanding need with any overflow to the last; the request accepts an optional `runInputId` to remove the ambiguity. > - **A re-complete overwrites, it does not accumulate.** Completing a stage that already has `producedQty > 0` (a rework re-complete) *replaces* the figures. Adding would double the produced quantity on every rework pass. A re-complete that would drop the good quantity below what has already been transferred is refused with `422 TRANSFER_EXCEEDS_AVAILABLE`. > - **Start consumes only the delta.** Every start consumes `max(0, plannedBase − consumedQty)`. A rework restart with an unchanged planned quantity therefore makes **no FIFO call at all**, and one after a raise consumes only the increase. This is what makes FR-MFG-16's "edit planned Stock-input qty upward" work.