Merge branch 'Dev' into sales-return

This commit is contained in:
2026-08-12 06:26:49 +00:00
116 changed files with 1762 additions and 1067 deletions
@@ -81,11 +81,4 @@ public sealed class ItemsController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemReorderSettingsDto>> UpdateReorder(int itemId, [FromBody] UpdateReorderRequest request, CancellationToken ct)
=> Ok(await _items.UpdateReorderAsync(itemId, request, ct));
/// <summary>Replace the item's UOM conversions (FR-MD-02).</summary>
[HttpPut("{itemId:int}/uom-conversions")]
[ProducesResponseType(typeof(ItemUomConversionsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemUomConversionsDto>> UpdateUomConversions(int itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct)
=> Ok(await _items.UpdateUomConversionsAsync(itemId, request, ct));
}
@@ -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; }
@@ -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; }
@@ -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; }
+34 -1
View File
@@ -24,6 +24,12 @@ public class Item
public int? BrandId { get; set; }
public Brand? Brand { get; set; }
/// <summary>
/// The stocking unit — the pack the item is counted in (BOTTLE, PACKET, BOX, PCS).
/// <b>Every</b> 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.
/// </summary>
public int BaseUomId { get; set; }
public Uom? BaseUom { get; set; }
@@ -41,6 +47,34 @@ public class Item
/// </summary>
public decimal? SalePrice { get; set; }
/// <summary>
/// How much one pack holds, as the user entered it — <c>500</c> with
/// <see cref="ContentUnit"/> <c>Ml</c> for a 500 ml bottle, <c>1.5</c> with <c>L</c>
/// 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.
/// <para>
/// 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 <c>IItemMeasure</c>).
/// </para>
/// <para>
/// A loose bulk item bought by weight is modelled the same way:
/// <c>BaseUom = KG, ContentQty = 1, ContentUnit = Kg</c> ⇒ 1000 g per stocked unit.
/// </para>
/// </summary>
public decimal? ContentQty { get; set; }
public MeasureUnit? ContentUnit { get; set; }
/// <summary>
/// <see cref="ContentQty"/>/<see cref="ContentUnit"/> normalised to a base unit
/// (L→Ml, Kg→G, both ×1000) at write time by <c>ItemContent.Normalize</c>. Server-derived
/// and never accepted from a client. <see cref="ContentBaseUnit"/> is therefore only ever
/// <see cref="MeasureUnit.Ml"/> or <see cref="MeasureUnit.G"/>.
/// <para>Stored rather than recomputed so every consumer reads one settled number.</para>
/// </summary>
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<ItemReorder> ReorderSettings { get; set; } = new List<ItemReorder>();
public ICollection<UomConversion> UomConversions { get; set; } = new List<UomConversion>();
}
+23 -2
View File
@@ -7,12 +7,15 @@ namespace ERPCore.Domain.Entities;
/// Material.
/// <para>
/// <b>Deliberately unlinked.</b> Nothing references this entity and it references
/// nothing: there is no value table and no join to <see cref="Item"/>. Its only job is
/// to feed the frontend's item-builder dropdown via <c>GET /item-types</c>. The chosen
/// nothing: there is no value table and no join to <see cref="Item"/>. The chosen
/// values (Red, S, M) are encoded by the client into the generated SKU
/// (e.g. <c>BL-100-0003</c>) and are never stored or parsed server-side — the item list
/// is the record of what was built. See the accepted trade-off in docs/10 Part C.9.
/// </para>
/// <para>
/// It does, however, carry one piece of meaning the client acts on:
/// <see cref="IsMeasurable"/>. So this is no longer purely a dropdown source.
/// </para>
/// Not to be confused with <see cref="Enums.StockNature"/> (Stocked/NonStocked/Service),
/// which is what the old <c>ItemType</c> enum became.
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
@@ -21,6 +24,24 @@ public class ItemType
{
public int ItemTypeId { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>
/// When true, this dimension's values are content <b>measurements</b> (500 ml, 1 L) rather
/// than plain labels (Red, S). The item builder then captures a number + unit per value and
/// stamps that pair onto each generated item's <see cref="Item.ContentQty"/> /
/// <see cref="Item.ContentUnit"/>, instead of copying one form-level pair into every variant
/// — which is what makes "Coca-Cola in 500 ml / 1 L / 250 ml" three correctly sized items.
/// <para>
/// This is what lets an apparel <c>Size</c> (S/M/L) stay plain text while a
/// <c>Pack Size</c>/<c>Volume</c> dimension carries ml/g/L/kg.
/// </para>
/// <para>
/// A client hint only: the server never reads it when writing an item. Each item's pair is
/// still validated and normalised on its own by <c>ItemContent</c>.
/// </para>
/// </summary>
public bool IsMeasurable { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
@@ -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; }
@@ -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; }
/// <summary>Copied from the template input: what <see cref="PlannedQty"/> is expressed in.</summary>
public StageQtyUnit QtyUnit { get; set; } = StageQtyUnit.Pack;
/// <summary>Scaled at creation; per-run editable until the stage starts (FR-MFG-08, <c>409 STAGE_NOT_EDITABLE</c>).</summary>
/// <summary>
/// Scaled at creation; per-run editable until the stage starts (FR-MFG-08,
/// <c>409 STAGE_NOT_EDITABLE</c>). Expressed in <see cref="QtyUnit"/> — so unlike the
/// consumption figures below it is <b>not</b> necessarily a pack count.
/// </summary>
public decimal PlannedQty { get; set; }
/// <summary>
@@ -23,10 +23,17 @@ public class RunStageOutput
public string Name { get; set; } = string.Empty;
public int UomId { get; set; }
/// <summary>
/// Display label for intermediate WIP; null on the terminal output, whose unit is the
/// finished item's base UOM. Never converted — see <see cref="StageOutput.UomId"/>.
/// </summary>
public int? UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>Scaled at creation; per-run editable until the stage starts.</summary>
/// <summary>
/// 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.
/// </summary>
public decimal PlannedQty { get; set; }
/// <summary>Recorded at complete. A re-complete after a rework <b>overwrites</b> this, never adds to it.</summary>
@@ -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; }
@@ -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; }
@@ -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; }
/// <summary>
/// What <see cref="QtyPerBatch"/> is expressed in. Stock inputs may use
/// <see cref="StageQtyUnit.Content"/> (ml/g) when the item has a content size; Upstream
/// inputs are always <see cref="StageQtyUnit.Pack"/> — WIP is counted in the unit its
/// source output declares.
/// </summary>
public StageQtyUnit QtyUnit { get; set; } = StageQtyUnit.Pack;
public decimal QtyPerBatch { get; set; }
}
@@ -20,8 +20,14 @@ public class StageOutput
public string Name { get; set; } = string.Empty;
public int UomId { get; set; }
/// <summary>
/// Display label for intermediate work-in-progress. Required when <see cref="ItemId"/>
/// 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.
/// </summary>
public int? UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>Always a pack count: of the WIP unit above, or of the item's base UOM.</summary>
public decimal QtyPerBatch { get; set; }
}
+4 -2
View File
@@ -1,8 +1,10 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Unit of Measure (FR-MD-02). Referenced as an item's base UOM and as the
/// endpoints of a <see cref="UomConversion"/>. 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.
/// </summary>
public class Uom
{
@@ -1,22 +0,0 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Per-item conversion factor between two UOMs (FR-MD-02/03): quantity in
/// <see cref="FromUomId"/> × <see cref="Factor"/> = quantity in <see cref="ToUomId"/>.
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
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; }
}
@@ -0,0 +1,23 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Unit of an item's <b>content size</b> — how much a single stocked pack holds
/// (a 500 ml bottle, a 50 kg sack). Stored as a string in the database.
/// <para>
/// This is <b>not</b> a stocking unit. Stock is always counted in packs
/// (<c>Item.BaseUomId</c>); content is a separate, optional attribute used by
/// production to turn "2000 ml of syrup" into a pack count.
/// </para>
/// <para>
/// Only <see cref="Ml"/> and <see cref="G"/> are ever stored as a <i>base</i> content
/// unit. <see cref="L"/> and <see cref="Kg"/> are entry conveniences normalised ×1000
/// on write by <c>ItemContent.Normalize</c>, so nothing downstream has to convert.
/// </para>
/// </summary>
public enum MeasureUnit
{
Ml,
L,
G,
Kg
}
@@ -0,0 +1,22 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// What a stage input's quantity is expressed in (FR-MFG-04). Stored as a string.
/// <para>
/// 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.
/// </para>
/// </summary>
public enum StageQtyUnit
{
/// <summary>A count of the item's base UOM — bottles, packets, pieces.</summary>
Pack,
/// <summary>
/// An amount of the item's content in its base content unit (ml or g), resolved to
/// packs by <c>IItemMeasure</c> at stage start. Requires the item to have a content size.
/// </summary>
Content
}
+1 -2
View File
@@ -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
/// <summary>Set for a PO-based receipt; cost is then derived from the PO line (02-SECURITY C.3).</summary>
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; }
/// <summary>
+17 -3
View File
@@ -5,12 +5,16 @@ namespace ERPCore.Dtos.ItemTypes;
/// <summary>
/// Item type resource (docs/11-BACKEND-PHASE1.md §2.7) — a dimension name such as Color
/// or Size. Carries no values and no item linkage: <c>GET /item-types</c> exists to
/// populate the frontend builder's dropdown, and the chosen values are encoded into the
/// or Size. Carries no values and no item linkage: the chosen values are encoded into the
/// client-generated SKU rather than stored (docs/10 Part C.9).
/// <para>
/// <c>IsMeasurable</c> marks a dimension whose values are content measurements (500 ml, 1 L)
/// rather than plain labels; the builder captures a number + unit per value and writes it to
/// each generated item's content size.
/// </para>
/// </summary>
public sealed record ItemTypeDto(
int ItemTypeId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
int ItemTypeId, string Name, bool IsMeasurable, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
@@ -18,11 +22,21 @@ public sealed record ItemTypeDto(
public sealed class CreateItemTypeRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
/// <summary>Omitted ⇒ false, i.e. plain-text values. See <see cref="ItemTypeDto"/>.</summary>
public bool IsMeasurable { get; set; }
}
public sealed class UpdateItemTypeRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
/// <summary>
/// Nullable on purpose: a plain <c>bool</c> binds an absent property as <c>false</c>, so any
/// client that PUT only a name — as the item-types screen used to — would silently clear the
/// flag on every rename. Omitting this field <b>preserves</b> the stored value.
/// </summary>
public bool? IsMeasurable { get; set; }
}
public sealed class UpdateItemTypeStatusRequest
+27 -25
View File
@@ -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);
/// <summary>A single per-warehouse reorder policy row.</summary>
public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
@@ -17,26 +20,21 @@ public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decim
/// <summary>
/// Full item resource for <c>GET /items/{id}</c> and create/update responses.
/// <para>
/// <see cref="Conversions"/> is embedded because they are otherwise unreadable: they can
/// only be written via <c>PUT /items/{id}/uom-conversions</c>, which returns them, but no
/// endpoint reads them back — so a detail screen could never show current state before
/// editing. Mirrors how <see cref="Reorder"/> is already inlined.
/// <c>ContentBaseQty</c>/<c>ContentBaseUnit</c> 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.
/// </para>
/// </summary>
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<ItemReorderDto> Reorder,
IReadOnlyList<UomConversionDto> Conversions,
string? TaxClass, decimal? SalePrice,
decimal? ContentQty, MeasureUnit? ContentUnit,
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
DateTime CreatedAt, DateTime? UpdatedAt);
/// <summary>UOM conversion row (docs/11 §2.2).</summary>
public sealed record UomConversionDto(int ConversionId, int FromUom, int ToUom, decimal Factor);
/// <summary>Response body for <c>PUT /items/{id}/uom-conversions</c>.</summary>
public sealed record ItemUomConversionsDto(int ItemId, int BaseUomId, IReadOnlyList<UomConversionDto> Conversions);
/// <summary>Response body for <c>PUT /items/{id}/reorder</c>.</summary>
public sealed record ItemReorderSettingsDto(IReadOnlyList<ItemReorderDto> Settings);
@@ -64,6 +62,14 @@ public sealed class CreateItemRequest
[StringLength(20)] public string? TaxClass { get; set; }
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
/// <summary>
/// How much one pack holds. Supply with <see cref="ContentUnit"/> 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.
/// </summary>
[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; }
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
/// <summary>
/// How much one pack holds. Supply with <see cref="ContentUnit"/> 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.
/// </summary>
[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<ReorderSettingInput> 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<UomConversionInput> Conversions { get; set; } = new();
}
@@ -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; }
+9 -3
View File
@@ -29,17 +29,23 @@ public sealed record RunSummaryDto(
/// </summary>
public sealed record CostPoolDto(decimal Consumed, decimal Returned, decimal Net);
/// <summary>
/// One input of a run stage. <c>PlannedQty</c> is expressed in <c>QtyUnit</c> — 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.
/// </summary>
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);
/// <summary>
/// One output of a run stage. <c>AvailableToTransfer</c> is derived — produced scrapped
/// transferred (FR-MFG-12) — and never stored.
/// transferred (FR-MFG-12) — and never stored. <c>UomId</c> is the WIP label and is null on
/// the terminal output, whose unit is the finished item's base UOM.
/// </summary>
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);
@@ -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);
/// <summary><c>UomId</c> is the WIP label and is null exactly when <c>ItemId</c> is set.</summary>
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; }
/// <summary>
/// What <see cref="QtyPerBatch"/> means. <c>Content</c> (ml/g) is allowed only on a Stock
/// input whose item has a content size; Upstream inputs must be <c>Pack</c>.
/// </summary>
[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;
/// <summary>
/// The WIP display unit. Required when <see cref="ItemId"/> is null; must be null when it
/// is set, because a real item's unit is its own base UOM.
/// </summary>
[Range(1, int.MaxValue)]
public int UomId { get; set; }
public int? UomId { get; set; }
[Range(0.0001, double.MaxValue)]
public decimal QtyPerBatch { get; set; }
+2 -3
View File
@@ -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; }
@@ -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; }
+2 -3
View File
@@ -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; }
@@ -18,7 +18,6 @@ public sealed class BundleSaleLineConfiguration : IEntityTypeConfiguration<Bundl
builder.Property(x => 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);
}
}
@@ -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);
}
}
@@ -49,7 +49,6 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
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);
}
@@ -22,6 +22,16 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
// 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<string>().HasMaxLength(20);
builder.Property(i => i.ContentBaseUnit)
.HasConversion<string>().HasMaxLength(20);
builder.Property(i => i.StockNature)
.HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.TrackingMode)
@@ -19,6 +19,11 @@ public sealed class ItemTypeConfiguration : IEntityTypeConfiguration<ItemType>
builder.Property(t => t.Name).IsRequired().HasMaxLength(200);
builder.HasIndex(t => t.Name).IsUnique();
// false is the only safe default here: EF uses the CLR default as its "unset" sentinel,
// so if the store default were true, inserting an explicit false would be mistaken for
// "not set" and silently written as true. Sentinel and store default must agree.
builder.Property(t => t.IsMeasurable).IsRequired().HasDefaultValue(false);
builder.Property(t => t.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
@@ -91,13 +91,13 @@ public sealed class StageInputConfiguration : IEntityTypeConfiguration<StageInpu
builder.HasKey(i => i.InputId);
builder.Property(i => i.Source).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.QtyUnit).HasConversion<string>().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<RunSta
builder.HasKey(i => i.RunInputId);
builder.Property(i => i.Source).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.QtyUnit).HasConversion<string>().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<RunSta
.HasForeignKey(i => 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);
@@ -63,11 +63,6 @@ public sealed class PoLineConfiguration : IEntityTypeConfiguration<PoLine>
.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)
@@ -79,11 +79,6 @@ public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration<Sal
builder.Property(x => 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)
@@ -79,11 +79,6 @@ public sealed class SalesSlipLineConfiguration : IEntityTypeConfiguration<SalesS
builder.Property(x => 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)
@@ -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<UomConversion>
{
public void Configure(EntityTypeBuilder<UomConversion> 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();
}
}
@@ -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,
@@ -30,7 +30,6 @@ public class ErpDbContext : DbContext
/// <summary>Color/Size/Material dimension names. Unlinked to Item by design (docs/10 C.9).</summary>
public DbSet<ItemType> ItemTypes => Set<ItemType>();
public DbSet<Uom> Uoms => Set<Uom>();
public DbSet<UomConversion> UomConversions => Set<UomConversion>();
public DbSet<Item> Items => Set<Item>();
public DbSet<ItemReorder> ItemReorders => Set<ItemReorder>();
public DbSet<Vendor> Vendors => Set<Vendor>();
+1 -1
View File
@@ -99,7 +99,7 @@ builder.Services.AddScoped<IRfqService, RfqService>();
builder.Services.AddScoped<IPurchaseOrderService, PurchaseOrderService>();
// Stock core + goods receipt (docs/11 §45)
builder.Services.AddScoped<IUomConverter, UomConverter>();
builder.Services.AddScoped<IItemMeasure, ItemMeasure>();
builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
builder.Services.AddScoped<IStockService, StockService>();
builder.Services.AddScoped<IGrnService, GrnService>();
+6 -15
View File
@@ -19,11 +19,9 @@ public sealed class BundleSaleService : IBundleSaleService
private readonly IRepository<BundleSale> _bundles;
private readonly IRepository<Customer> _customers;
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<User> _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<BundleSaleTemplate> templates,
IRepository<Customer> customers,
IRepository<Item> items,
IRepository<Uom> uoms,
IRepository<Warehouse> warehouses,
IRepository<User> 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<PagedResponse<BundleSaleSummaryDto>> 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());
}
+9 -22
View File
@@ -28,14 +28,12 @@ public sealed class GrnService : IGrnService
private readonly IRepository<PurchaseOrder> _pos;
private readonly IRepository<PoLine> _poLines;
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Bin> _bins;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Batch> _batches;
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<StockLedger> _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<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<Warehouse> warehouses,
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IUomConverter uomConverter,
IRepository<StockLayer> layers, IRepository<StockLedger> 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
}
/// <summary>
/// Delegates to the shared <see cref="IUomConverter"/>. This was a private method here
/// until manufacturing needed the same conversion for stage stock inputs; behaviour is
/// identical, so receive costing is unchanged.
/// </summary>
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());
@@ -0,0 +1,25 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// 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.
/// </summary>
public interface IItemMeasure
{
/// <summary>
/// Formula quantity → packs. <see cref="StageQtyUnit.Pack"/> passes straight through;
/// <see cref="StageQtyUnit.Content"/> 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.
/// </summary>
decimal ToPacks(Item item, decimal formulaQty, StageQtyUnit unit);
/// <summary>Packs → formula quantity. The exact inverse of <see cref="ToPacks"/>, for display.</summary>
decimal FromPacks(Item item, decimal packs, StageQtyUnit unit);
/// <summary>Whether the item carries a usable content size.</summary>
bool HasContent(Item item);
}
@@ -24,6 +24,4 @@ public interface IItemService
Task SetStatusAsync(int itemId, EntityStatus status, CancellationToken ct = default);
Task<ItemReorderSettingsDto> UpdateReorderAsync(int itemId, UpdateReorderRequest request, CancellationToken ct = default);
Task<ItemUomConversionsDto> UpdateUomConversionsAsync(int itemId, UpdateUomConversionsRequest request, CancellationToken ct = default);
}
@@ -15,7 +15,6 @@ public interface ISalesDomainService
Task ValidateSalesLineAsync(
int headerWarehouseId,
int lineItemId,
int lineUomId,
int lineWarehouseId,
decimal qty,
decimal freeQty,
@@ -1,35 +0,0 @@
using ERPCore.Domain.Entities;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Converts a quantity and its per-UOM cost into the item's <b>base</b> UOM.
/// </summary>
/// <remarks>
/// <para>Everything in the FIFO engine — <c>StockLayer</c>, <c>StockLedger</c>,
/// <c>IFifoCostingService.ConsumeAsync</c> — 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.</para>
/// <para>Extracted from <c>GrnService</c>'s private <c>ToBaseAsync</c> when manufacturing
/// needed the same conversion for stage stock inputs (docs/30 never mentions UOM
/// conversion, but <c>STAGE_INPUT.uom_id</c> 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).</para>
/// </remarks>
public interface IUomConverter
{
/// <summary>
/// Returns the quantity and unit cost restated in <paramref name="item"/>'s base UOM.
/// A no-op when <paramref name="uomId"/> already is the base UOM. Throws 422 when no
/// conversion is defined for the item from that UOM to its base.
/// </summary>
Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default);
/// <summary>
/// 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).
/// </summary>
Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default);
}
+61
View File
@@ -0,0 +1,61 @@
using ERPCore.Domain.Enums;
using ERPCore.System.Errors;
namespace ERPCore.Services;
/// <summary>
/// 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.
/// <para>
/// 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.
/// </para>
/// </summary>
public static class ItemContent
{
/// <summary>
/// Rejects a half-filled pair. Both null is valid and means "this item has no
/// measurable content" — a screw, a label, a service.
/// </summary>
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);
}
/// <summary>
/// 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.
/// <para>
/// Rounded to 4dp AwayFromZero to match the quantity columns' <c>(18,4)</c> scale and
/// <c>ProductionRunService.Scale</c>, so a content size can never carry precision the
/// database would silently drop.
/// </para>
/// </summary>
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);
}
}
+43 -54
View File
@@ -31,6 +31,8 @@ public sealed class ItemService : IItemService
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<StockLayer> _stockLayers;
private readonly IRepository<StockLedger> _stockLedger;
private readonly IProductConfigService _config;
private readonly IUnitOfWork _uow;
@@ -42,6 +44,8 @@ public sealed class ItemService : IItemService
IRepository<Uom> uoms,
IRepository<Vendor> vendors,
IRepository<Warehouse> warehouses,
IRepository<StockLayer> stockLayers,
IRepository<StockLedger> 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<ItemListItemDto>.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<ItemDetailDto>(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<ItemUomConversionsDto> 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);
}
/// <summary>
/// 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.
/// </summary>
private async Task<bool> HasStockHistoryAsync(int itemId, CancellationToken ct)
=> await _stockLayers.Query().AnyAsync(l => l.ItemId == itemId, ct)
|| await _stockLedger.Query().AnyAsync(l => l.ItemId == itemId, ct);
/// <summary>
/// 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);
}
+6 -2
View File
@@ -41,7 +41,7 @@ public sealed class ItemTypeService : IItemTypeService
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(t => t.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(t => new ItemTypeDto(t.ItemTypeId, t.Name, t.Status, t.CreatedAt, t.UpdatedAt))
.Select(t => new ItemTypeDto(t.ItemTypeId, t.Name, t.IsMeasurable, t.Status, t.CreatedAt, t.UpdatedAt))
.ToListAsync(ct);
return PagedResponse<ItemTypeDto>.Create(rows, query.Page, query.PageSize, total);
@@ -63,6 +63,7 @@ public sealed class ItemTypeService : IItemTypeService
var itemType = new ItemType
{
Name = name,
IsMeasurable = request.IsMeasurable,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
@@ -90,6 +91,9 @@ public sealed class ItemTypeService : IItemTypeService
// Renaming does not touch existing items: their SKUs already encode the values that
// were chosen, and nothing joins back to this row (docs/10 Part C.9).
itemType.Name = name;
// Omitted ⇒ keep what is stored. A plain bool would bind an absent property as false and
// so let a name-only PUT silently clear the flag on every rename.
itemType.IsMeasurable = request.IsMeasurable ?? itemType.IsMeasurable;
itemType.UpdatedAt = DateTime.UtcNow;
try
@@ -114,5 +118,5 @@ public sealed class ItemTypeService : IItemTypeService
await _uow.SaveChangesAsync(ct);
}
private static ItemTypeDto Map(ItemType t) => new(t.ItemTypeId, t.Name, t.Status, t.CreatedAt, t.UpdatedAt);
private static ItemTypeDto Map(ItemType t) => new(t.ItemTypeId, t.Name, t.IsMeasurable, t.Status, t.CreatedAt, t.UpdatedAt);
}
@@ -21,9 +21,10 @@ namespace ERPCore.Services.Production;
/// </remarks>
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<InputDraft> Inputs, IReadOnlyList<OutputDraft> 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.");
}
}
/// <summary>Set of keys reachable from <paramref name="roots"/> following <paramref name="next"/>.</summary>
@@ -26,7 +26,7 @@ public sealed class ProductionRunService : IProductionRunService
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _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<ProductionRun> runs, IRepository<ProductionTemplate> templates,
IRepository<Warehouse> warehouses, IRepository<Bin> bins,
IRepository<Item> items, IRepository<ReasonCode> 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<List<FieldDefDto>>(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(
@@ -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<int>().Distinct().ToList();
var contentByItem = new Dictionary<int, decimal?>();
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,12 +278,34 @@ 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);
}
var uomIds = request.Stages
.SelectMany(s => s.Inputs.Select(i => i.UomId).Concat(s.Outputs.Select(o => o.UomId)))
.Distinct().ToList();
// 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.Outputs.Select(o => o.UomId))
.OfType<int>().Distinct().ToList();
if (uomIds.Count > 0)
{
var knownUoms = await _uoms.Query().AsNoTracking()
.Where(u => uomIds.Contains(u.UomId)).Select(u => u.UomId).ToListAsync(ct);
@@ -290,6 +313,7 @@ public sealed class ProductionTemplateService : IProductionTemplateService
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();
@@ -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());
}
@@ -13,7 +13,6 @@ public sealed class SalesDomainService : ISalesDomainService
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<User> _users;
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
private readonly ISalesPricingService _pricing;
public SalesDomainService(
@@ -21,14 +20,12 @@ public sealed class SalesDomainService : ISalesDomainService
IRepository<Warehouse> warehouses,
IRepository<User> users,
IRepository<Item> items,
IRepository<Uom> 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.");
}
@@ -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,
@@ -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());
}
@@ -20,7 +20,6 @@ public sealed class SalesPostingService : ISalesPostingService
private readonly IRepository<Item> _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<Item> 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);
}
+7 -7
View File
@@ -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);
@@ -0,0 +1,39 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
namespace ERPCore.Services.Stock;
/// <summary>
/// Content ↔ pack arithmetic (see <see cref="IItemMeasure"/>). Stateless and I/O-free:
/// the content size is already on the <see cref="Item"/> every caller has loaded.
/// </summary>
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);
}
@@ -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;
/// <summary>
/// Shared UOM → base-UOM conversion (see <see cref="IUomConverter"/>). Behaviour is
/// unchanged from the <c>GrnService.ToBaseAsync</c> it was extracted from, so the GRN
/// receive path keeps costing exactly as before.
/// </summary>
public sealed class UomConverter : IUomConverter
{
private readonly IRepository<UomConversion> _conversions;
public UomConverter(IRepository<UomConversion> 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<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default)
=> (await ToBaseAsync(item, uomId, qty, 0m, ct)).QtyBase;
}
@@ -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";
+68 -1
View File
@@ -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.
+11 -8
View File
@@ -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"]}
+1 -1
View File
@@ -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"]]}
+9 -9
View File
@@ -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}")
+1 -1
View File
@@ -1 +1 @@
{"runId": 64, "templateId": 2, "warehouseId": 4, "assembleStageId": 118, "finishedItemId": 13, "rawItemId": 18, "packItemId": 14}
{"runId": 20, "templateId": 6, "warehouseId": 4, "assembleStageId": 30, "finishedItemId": 1, "rawItemId": 4, "packItemId": 3}
-170
View File
@@ -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())
+226
View File
@@ -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())
+6 -6
View File
@@ -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": [],
}
@@ -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
+1 -1
View File
@@ -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"),
]
+4 -3
View File
@@ -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:
@@ -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() {
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">Lines</h2>
{editable && (
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, { key: newKey(), poLineId: null, itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18", qtyReceived: 0 }])}>
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, { key: newKey(), poLineId: null, itemId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18", qtyReceived: 0 }])}>
<Plus className="size-5" />
Add line
</Button>
@@ -376,7 +370,7 @@ export default function PurchaseOrderDetailPage() {
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3.5">{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}</TableCell>
<TableCell className="px-3 py-3.5">{line.uomId ? uomName(line.uomId) : "—"}</TableCell>
<TableCell className="px-3 py-3.5">{baseUomLabel(items, uoms, line.itemId)}</TableCell>
<TableCell className="px-3 py-3.5">{line.warehouseId ? warehouseCode(line.warehouseId) : "—"}</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
<TableCell className="px-3 py-3.5">{line.qtyReceived}</TableCell>
@@ -403,19 +397,9 @@ export default function PurchaseOrderDetailPage() {
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
<div className="flex h-11 items-center text-base text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</div>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
@@ -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() {
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{(uoms ?? []).map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
<div className="flex h-11 items-center text-base text-muted-foreground">
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
</div>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
@@ -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}
/>
))}
</section>
@@ -337,7 +352,7 @@ export function StageDrawer({
<div key={output.runOutputId} className="rounded-lg border border-border p-3">
<div className="flex items-baseline justify-between gap-2">
<p className="min-w-0 truncate text-sm font-medium text-foreground">{output.name}</p>
<span className="shrink-0 text-xs text-muted-foreground">{uomName(output.uomId)}</span>
<span className="shrink-0 text-xs text-muted-foreground">{outputUnit(output)}</span>
</div>
{output.itemId !== null && (
<p className="text-xs text-muted-foreground">Finished good: {itemName(output.itemId)}</p>
@@ -522,7 +537,7 @@ export function StageDrawer({
<section className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-foreground">Finish the run</h3>
<div className="flex flex-col gap-1 rounded-lg border border-info/30 bg-info/5 p-3">
<Row label="Good quantity">{fmt(goodQty)} {uomName(terminalOutput.uomId)}</Row>
<Row label="Good quantity">{fmt(goodQty)} {outputUnit(terminalOutput)}</Row>
<Row label="Materials consumed">{money(run.costPool.consumed)}</Row>
<Row label="Leftovers returned">{money(run.costPool.returned)}</Row>
<Separator className="my-1" />
@@ -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({
<p className="min-w-0 truncate text-sm font-medium text-foreground">
{isUpstream ? "Upstream work in progress" : itemName(input.itemId)}
</p>
<span className="shrink-0 text-xs text-muted-foreground">{uomName(input.uomId)}</span>
<span className="shrink-0 text-xs text-muted-foreground">{unitLabel(input)}</span>
</div>
<div className="mt-2 flex flex-col gap-1">
@@ -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 && (
<>
@@ -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 (
<div className="flex items-center gap-2">
@@ -64,19 +69,56 @@ function QtyRow({
placeholder="Qty per batch"
aria-label="Quantity per batch"
/>
<Select<number> value={uomId} onValueChange={(v) => v && onUomChange(v)}>
<SelectTrigger className="h-8! w-24 shrink-0 text-sm" disabled={readOnly} aria-label="UOM">
<SelectValue placeholder="UOM" />
{unit}
</div>
)
}
/** Static unit name, for the rows whose unit is derived and therefore not editable. */
function UnitLabel({ children }: { children: ReactNode }) {
return (
<span className="flex h-8 w-24 shrink-0 items-center text-sm text-muted-foreground">{children}</span>
)
}
/**
* 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 <UnitLabel>WIP</UnitLabel>
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 <UnitLabel>{packName}</UnitLabel>
return (
<Select<string> value={input.qtyUnit} onValueChange={(v) => v && onChange(v as StageQtyUnit)}>
<SelectTrigger className="h-8! w-24 shrink-0 text-sm" disabled={readOnly} aria-label="Quantity unit">
<SelectValue />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-sm">
{u.name}
</SelectItem>
))}
<SelectItem value="Content" className="text-sm">{contentName}</SelectItem>
<SelectItem value="Pack" className="text-sm">{packName}</SelectItem>
</SelectContent>
</Select>
</div>
)
}
@@ -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<BuilderOutput>) {
@@ -287,11 +336,15 @@ export function StageEditorPanel({
<QtyRow
qty={input.qtyPerBatch}
uomId={input.uomId}
uoms={uoms}
readOnly={readOnly}
onQtyChange={(qtyPerBatch) => updateInput(input.localId, { qtyPerBatch })}
onUomChange={(uomId) => updateInput(input.localId, { uomId })}
unit={<InputUnitControl
input={input}
items={items}
uoms={uoms}
readOnly={readOnly}
onChange={(qtyUnit) => updateInput(input.localId, { qtyUnit })}
/>}
/>
</div>
</div>
@@ -347,11 +400,27 @@ export function StageEditorPanel({
</div>
<QtyRow
qty={output.qtyPerBatch}
uomId={output.uomId}
uoms={uoms}
readOnly={readOnly}
onQtyChange={(qtyPerBatch) => 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 ? (
<UnitLabel>{baseUomLabel(items, uoms, output.itemId)}</UnitLabel>
) : (
<Select<number> value={output.uomId} onValueChange={(v) => v && updateOutput(output.key, { uomId: v })}>
<SelectTrigger className="h-8! w-24 shrink-0 text-sm" disabled={readOnly} aria-label="WIP unit">
<SelectValue placeholder="Unit" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-sm">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
/>
</div>
))}
@@ -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,
})),
}
@@ -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 <input> 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-<uuid>` 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
}
@@ -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<number | null>(null)
const [trackingMode, setTrackingMode] = useState<TrackingMode>("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<MeasureUnit | null>(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<number | null>(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() {
</Select>
<FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
</div>
{/* 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. */}
<div className="flex flex-col gap-2">
<Label className="text-base">Content size</Label>
<div className="flex gap-2">
<Input
type="number"
min="0"
step="any"
value={contentQty}
disabled={conflict}
onChange={(e) => setContentQty(e.target.value)}
placeholder="e.g. 500"
aria-invalid={!!errors.contentQty}
className="h-12! text-base"
/>
<Select<MeasureUnit | null>
value={contentUnit}
onValueChange={setContentUnit}
disabled={conflict}
items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}
>
<SelectTrigger className="h-12! w-28 shrink-0 text-base" aria-invalid={!!errors.contentUnit}>
<SelectValue placeholder="Unit" />
</SelectTrigger>
<SelectContent>
{CONTENT_UNITS.map((u) => (
<SelectItem key={u} value={u} className="text-base">
{u.toLowerCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<FieldError
errors={[
errors.contentQty ? { message: errors.contentQty } : undefined,
errors.contentUnit ? { message: errors.contentUnit } : undefined,
]}
/>
{/* The stored value, so the ×1000 normalisation is never a surprise. */}
<p className="text-sm text-muted-foreground">
{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."}
</p>
</div>
<div className="flex flex-col gap-2">
{/* "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() {
</div>
<p className="text-sm text-muted-foreground">
{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.
</p>
</div>
)
@@ -10,6 +10,7 @@ import { ItemType } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Switch } from "@/components/ui/switch"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -33,6 +34,7 @@ export default function ItemTypesPage() {
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<ItemType | null>(null)
const [name, setName] = useState("")
const [isMeasurable, setIsMeasurable] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
@@ -50,6 +52,7 @@ export default function ItemTypesPage() {
function openCreateDialog() {
setEditing(null)
setName("")
setIsMeasurable(false)
setErrors({})
setOpen(true)
}
@@ -57,6 +60,7 @@ export default function ItemTypesPage() {
function openEditDialog(itemType: ItemType) {
setEditing(itemType)
setName(itemType.name)
setIsMeasurable(itemType.isMeasurable)
setErrors({})
setOpen(true)
}
@@ -71,13 +75,16 @@ export default function ItemTypesPage() {
if (editing) {
// Re-read for a fresh If-Match; a concurrent edit surfaces as 412.
const current = await itemTypesApi.get(editing.itemTypeId)
await itemTypesApi.update(editing.itemTypeId, { name }, current.etag ?? "")
// isMeasurable must travel on every PUT: the server preserves it when omitted, so a
// name-only body would leave the switch the user just flipped unsaved.
await itemTypesApi.update(editing.itemTypeId, { name, isMeasurable }, current.etag ?? "")
} else {
await itemTypesApi.create({ name })
await itemTypesApi.create({ name, isMeasurable })
}
toast.success(editing ? "Item type updated" : "Item type created", name)
setOpen(false)
setName("")
setIsMeasurable(false)
setEditing(null)
setErrors({})
load()
@@ -112,6 +119,7 @@ export default function ItemTypesPage() {
<h1 className="text-2xl font-bold text-foreground">Item Types</h1>
<p className="text-base text-muted-foreground">
Dimensions the item builder offers (e.g. Color, Size, Material). Values are captured per item and encoded in its SKU.
A <span className="font-medium">measurement</span> dimension captures a number plus a unit instead, which becomes each item&apos;s content size.
</p>
</div>
</div>
@@ -135,6 +143,23 @@ export default function ItemTypesPage() {
/>
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<Field>
<div className="flex items-start gap-4">
<Switch
checked={isMeasurable}
onCheckedChange={setIsMeasurable}
aria-label="Values are measurements"
className="mt-1 shrink-0"
/>
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Values are measurements</span>
<span className="text-sm text-muted-foreground">
Values are entered as a number plus a unit (500 ml, 1 L) and become each item&apos;s
content size. Leave off for plain labels like Red or Small.
</span>
</div>
</div>
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
@@ -173,6 +198,7 @@ export default function ItemTypesPage() {
<TableRow>
<TableHead className="h-12 px-3 text-sm">ID</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Values</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
@@ -183,6 +209,9 @@ export default function ItemTypesPage() {
<TableRow key={t.itemTypeId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{t.itemTypeId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{t.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline">{t.isMeasurable ? "Measurement" : "Text"}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant={t.status === "Active" ? "default" : "secondary"}>{t.status}</Badge>
</TableCell>
@@ -13,9 +13,17 @@ import { productConfig } from "@/lib/api/product-config"
import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { validateVariantItemForm, validateVariantPrices } from "@/lib/validations/master-data"
import {
contentPairErrors,
validateVariantContent,
validateVariantItemForm,
validateVariantPrices,
} from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Brand, Category, ItemType, ProductConfig, StockNature, SubCategory } from "@/types/master-data"
import { Brand, Category, ItemType, MeasureUnit, ProductConfig, StockNature, SubCategory } 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"
@@ -33,15 +41,43 @@ function skuSegment(text: string, maxLen: number): string {
return cleaned.slice(0, maxLen) || "GEN"
}
function buildVariantSku(categoryLabel: string, values: string[]): string {
return [skuSegment(categoryLabel, 3), ...values.map((v) => skuSegment(v, 3))].join("-")
/**
* A value on a builder dimension. `qty`/`unit` are set only on a measurable dimension;
* `label` is the single source of truth for the chip, the item name and the SKU segment.
*/
type DimensionValue = { label: string; qty: number | null; unit: MeasureUnit | null }
const isMeasured = (v: DimensionValue): v is DimensionValue & { qty: number; unit: MeasureUnit } =>
v.qty !== null && v.unit !== null
/** Canonical casing, so 1.5 L renders as "1.5L" and not "1.5l". */
const UNIT_LABEL: Record<MeasureUnit, string> = { Ml: "ml", L: "L", G: "g", Kg: "kg" }
const measuredLabel = (qty: number, unit: MeasureUnit) => `${qty}${UNIT_LABEL[unit]}`
/**
* Normalised identity of a measurement, mirroring the server's `ItemContent.Normalize`
* (L/Kg ×1000 ml/g). Used for BOTH the SKU segment and value dedupe, so the client's
* notion of "same size" is the server's.
*
* The decimal point becomes `P` rather than being stripped: `skuSegment` deletes it, which
* would make 2.5ml and 25ml and 1.5L and 15L produce identical SKUs and fail the
* create loop partway with SKU_DUPLICATE.
*/
function measureKey(qty: number, unit: MeasureUnit): string {
const factor = unit === "L" || unit === "Kg" ? 1000 : 1
const base = unit === "L" || unit === "Ml" ? "ML" : "G"
const n = Math.round(qty * factor * 10000) / 10000
const digits = n.toFixed(4).replace(/0+$/, "").replace(/\.$/, "")
return `${digits.replace(".", "P")}${base}`
}
/** The item builder only ever offers these two dimensions, regardless of what else exists
* in the Item Types master list. */
const BUILDER_ITEM_TYPES = ["color", "size"]
function isBuilderItemType(name: string): boolean {
return BUILDER_ITEM_TYPES.includes(name.trim().toLowerCase())
function buildVariantSku(categoryLabel: string, values: DimensionValue[]): string {
return [
skuSegment(categoryLabel, 3),
// Measurement segments are deliberately NOT truncated — truncation is what collides.
...values.map((v) => (isMeasured(v) ? measureKey(v.qty, v.unit) : skuSegment(v.label, 3))),
].join("-")
}
export default function NewItemPage() {
@@ -54,9 +90,13 @@ export default function NewItemPage() {
const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
/** Defaults to the first UOM once loaded; null only means none exist yet. */
const [baseUomId, setBaseUomId] = useState<number | null>(null)
// Kept as a raw string: an empty box means "no content size", which is different from 0.
const [contentQty, setContentQty] = useState("")
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
const [loadError, setLoadError] = useState<string | null>(null)
const [productName, setProductName] = useState("")
const [categoryId, setCategoryId] = useState<number | null>(null)
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
@@ -67,8 +107,12 @@ export default function NewItemPage() {
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [checkedItemTypeIds, setCheckedItemTypeIds] = useState<number[]>([])
const [valuesByCategory, setValuesByCategory] = useState<Record<number, string[]>>({})
const [valuesByCategory, setValuesByCategory] = useState<Record<number, DimensionValue[]>>({})
const [inputByCategory, setInputByCategory] = useState<Record<number, string>>({})
// Measurable dimensions enter a number + unit instead of free text.
const [qtyByCategory, setQtyByCategory] = useState<Record<number, string>>({})
const [unitByCategory, setUnitByCategory] = useState<Record<number, MeasureUnit | null>>({})
const [valueErrors, setValueErrors] = useState<Record<number, string>>({})
// Lets a specific generated combination be dropped from the preview table before
// submit, without having to remove and re-add the whole value that produced it.
const [removedVariantKeys, setRemovedVariantKeys] = useState<Set<string>>(new Set())
@@ -122,11 +166,15 @@ export default function NewItemPage() {
.catch(() => setSubCategories([]))
}, [categoryId, config?.subcategoriesEnabled])
const categoryLabel = (categories ?? []).find((c) => c.categoryId === categoryId)?.name ?? ""
const subCategoryLabel = subCategories.find((s) => s.subCategoryId === subCategoryId)?.name ?? ""
/** SKU/name read best off the most specific level, but BOTH ids are sent to the server. */
const effectiveLabel = subCategoryLabel || categoryLabel
const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? ""
/**
* The product being built ("Coca Cola"), typed rather than derived. It names every generated
* item and supplies the SKU's leading segment.
*
* Category and subcategory still classify the item and are both sent to the server; they just
* no longer name it, which is what produced labels like "Beverages - 500ml".
*/
const productLabel = productName.trim()
function handleCategoryChange(value: number | null) {
setCategoryId(value)
@@ -139,22 +187,60 @@ export default function NewItemPage() {
)
}
/**
* Add is the real validation gate for a measurement rejecting here means a half or
* duplicate pair never reaches state, so nothing downstream has to defend against one.
*/
function addValue(itemTypeId: number) {
const value = (inputByCategory[itemTypeId] ?? "").trim()
if (value) {
const measurable = (itemTypes ?? []).find((t) => t.itemTypeId === itemTypeId)?.isMeasurable ?? false
if (!measurable) {
const label = (inputByCategory[itemTypeId] ?? "").trim()
if (label) {
setValuesByCategory((prev) => {
const existing = prev[itemTypeId] ?? []
if (existing.some((v) => v.toLowerCase() === value.toLowerCase())) return prev
return { ...prev, [itemTypeId]: [...existing, value] }
if (existing.some((v) => v.label.toLowerCase() === label.toLowerCase())) return prev
return { ...prev, [itemTypeId]: [...existing, { label, qty: null, unit: null }] }
})
}
setInputByCategory((prev) => ({ ...prev, [itemTypeId]: "" }))
return
}
const raw = (qtyByCategory[itemTypeId] ?? "").trim()
const unit = unitByCategory[itemTypeId] ?? null
const qty = Number(raw)
if (!raw || Number.isNaN(qty) || qty <= 0) {
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "Enter a size greater than 0" }))
return
}
if (!unit) {
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "Select a unit" }))
return
}
// Dedupe on the NORMALISED size, not the label: 500 ml and 0.5 L read differently but
// store identically, so a label-only check would create two items of the same size.
const key = measureKey(qty, unit)
const existing = valuesByCategory[itemTypeId] ?? []
if (existing.some((v) => isMeasured(v) && measureKey(v.qty, v.unit) === key)) {
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "That size is already in the list" }))
return
}
function removeValue(itemTypeId: number, value: string) {
setValuesByCategory((prev) => ({
...prev,
[itemTypeId]: (prev[itemTypeId] ?? []).filter((v) => v !== value),
[itemTypeId]: [...(prev[itemTypeId] ?? []), { label: measuredLabel(qty, unit), qty, unit }],
}))
setQtyByCategory((prev) => ({ ...prev, [itemTypeId]: "" }))
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "" }))
}
function removeValue(itemTypeId: number, label: string) {
setValuesByCategory((prev) => ({
...prev,
[itemTypeId]: (prev[itemTypeId] ?? []).filter((v) => v.label !== label),
}))
}
@@ -169,13 +255,13 @@ export default function NewItemPage() {
const allVariants = useMemo(() => {
if (activeCategories.length === 0) return []
let combinations: { key: string; parts: { name: string; value: string }[] }[] = [{ key: "", parts: [] }]
let combinations: { key: string; parts: { name: string; value: DimensionValue }[] }[] = [{ key: "", parts: [] }]
for (const cat of activeCategories) {
const next: typeof combinations = []
for (const combo of combinations) {
for (const value of cat.values) {
next.push({
key: combo.key ? `${combo.key}::${value}` : value,
key: combo.key ? `${combo.key}::${value.label}` : value.label,
parts: [...combo.parts, { name: cat.name, value }],
})
}
@@ -184,9 +270,9 @@ export default function NewItemPage() {
}
return combinations.map((c) => ({
...c,
sku: buildVariantSku(effectiveLabel, c.parts.map((p) => p.value)),
sku: buildVariantSku(productLabel, c.parts.map((p) => p.value)),
}))
}, [activeCategories, effectiveLabel])
}, [activeCategories, productLabel])
const variants = useMemo(
() => allVariants.filter((v) => !removedVariantKeys.has(v.key)),
@@ -197,9 +283,76 @@ export default function NewItemPage() {
setRemovedVariantKeys((prev) => new Set(prev).add(key))
}
/**
* True once a checked dimension supplies each variant's content size. The form-level
* content field is then hidden and cleared, so the two can never disagree.
*/
const measurableCheckedCount = (itemTypes ?? []).filter(
(t) => checkedItemTypeIds.includes(t.itemTypeId) && t.isMeasurable
).length
// Keyed off *checked*, not "checked and already has values": the moment a measurement
// dimension is ticked the form-level pair is irrelevant, so it should not linger while the
// first value is being typed.
const hasMeasurableDimension = measurableCheckedCount > 0
/**
* The shared content pair is offered only when it is the ONLY way to set a content size:
* dimensions are chosen, and none of them measures anything (a Colour-only product, whose
* variants really do all hold the same amount).
*
* Deliberately hidden on the untouched page. Showing it there invites someone to fill in one
* size before discovering that ticking Size would have captured a size per value which
* reads as though sizes still apply to every variant at once.
*/
const showSharedContent = checkedItemTypeIds.length > 0 && !hasMeasurableDimension
useEffect(() => {
if (!hasMeasurableDimension) return
setContentQty("")
setContentUnit(null)
}, [hasMeasurableDimension])
/** A variant's own measured part, else the form-level fallback pair. */
function contentForVariant(variant: { parts: { value: DimensionValue }[] }) {
const measured = variant.parts.find((p) => isMeasured(p.value))
if (measured && isMeasured(measured.value)) {
return { qty: measured.value.qty, unit: measured.value.unit }
}
// Only fall back to the shared pair when that pair is the one on offer, so a value left
// behind by an earlier selection can never be applied invisibly.
if (!showSharedContent || !contentQty.trim() || !contentUnit) return null
return { qty: Number(contentQty), unit: contentUnit }
}
function contentLabelFor(variant: { parts: { value: DimensionValue }[] }) {
const content = contentForVariant(variant)
return content ? measuredLabel(content.qty, content.unit) : "—"
}
async function handleSubmit() {
setSubmitError(null)
const nextErrors = validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 })
const nextErrors: Record<string, string> = {
...validateVariantItemForm({ productName, categoryId, hasVariants: variants.length > 0 }),
// Validate the shared pair exactly when it is on screen. Validating it while hidden
// would block submit with a message the user cannot see.
...(showSharedContent ? contentPairErrors(contentQty, contentUnit) : {}),
}
if (measurableCheckedCount > 1) {
nextErrors.measurable = "Only one measurement dimension can be used at a time."
}
// Should never fire — addValue is the real gate — so it catches stale state only.
const contentSweep = validateVariantContent(
variants.map((v) => v.key),
(key) => {
const variant = variants.find((v) => v.key === key)
const content = variant ? contentForVariant(variant) : null
return content
? { qty: String(content.qty), unit: content.unit }
: { qty: "", unit: null }
},
)
const sweepMessage = Object.values(contentSweep)[0]
if (sweepMessage) nextErrors.variantContent = sweepMessage
setErrors(nextErrors)
// In fixed mode, block the whole submit until every variant has a price > 0.
const nextPriceErrors =
@@ -215,9 +368,10 @@ export default function NewItemPage() {
let created = 0
try {
for (const variant of variants) {
const content = contentForVariant(variant)
await itemsApi.create({
sku: variant.sku,
name: `${brandLabel ? brandLabel + " " : ""}${effectiveLabel} - ${variant.parts.map((p) => p.value).join("/")}`,
name: `${brandLabel ? brandLabel + " " : ""}${productLabel} - ${variant.parts.map((p) => p.value.label).join("/")}`,
// Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the
// category, which lost the parent entirely. The server rejects a mismatched
// pair with 422.
@@ -228,6 +382,11 @@ export default function NewItemPage() {
stockNature,
trackingMode: "None",
salePrice: priceMode === "fixed" ? Number(priceFor(variant.key)) : null,
// Each variant carries its OWN size when a measurement dimension supplied one;
// otherwise the shared form-level pair, which is correct when the varying dimension
// isn't size (colour variants of one 500 ml bottle all hold 500 ml).
contentQty: content?.qty ?? null,
contentUnit: content?.unit ?? null,
})
created += 1
}
@@ -279,6 +438,22 @@ export default function NewItemPage() {
{!loading && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">Product name</Label>
<Input
value={productName}
onChange={(e) => setProductName(e.target.value)}
placeholder="e.g. Coca Cola"
aria-invalid={!!errors.productName}
className="h-12! text-base"
/>
<FieldError errors={[errors.productName ? { message: errors.productName } : undefined]} />
<p className="text-sm text-muted-foreground">
Names every variant and starts its SKU
{productLabel ? ` — "${brandLabel ? brandLabel + " " : ""}${productLabel} - 500ml", SKU ${skuSegment(productLabel, 3)}-…` : "."}
</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Category</Label>
<Select<number | null>
@@ -382,6 +557,53 @@ export default function NewItemPage() {
</SelectContent>
</Select>
</div>
{/* Content size (FR-MD-02): how much one stocked pack holds. Optional a screw
or a label has none. Litres/kilograms are normalised to ml/g by the server.
Hidden once a measurement dimension is in play, because that dimension then
gives each variant its own size and this pair would only contradict it. */}
{showSharedContent && (
<div className="flex flex-col gap-2">
<Label className="text-base">Content size</Label>
<div className="flex gap-2">
<Input
type="number"
min="0"
step="any"
value={contentQty}
onChange={(e) => setContentQty(e.target.value)}
placeholder="e.g. 500"
aria-invalid={!!errors.contentQty}
className="h-12! text-base"
/>
<Select<MeasureUnit | null>
value={contentUnit}
onValueChange={setContentUnit}
items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}
>
<SelectTrigger className="h-12! w-28 shrink-0 text-base" aria-invalid={!!errors.contentUnit}>
<SelectValue placeholder="Unit" />
</SelectTrigger>
<SelectContent>
{CONTENT_UNITS.map((u) => (
<SelectItem key={u} value={u} className="text-base">
{u.toLowerCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<FieldError
errors={[
errors.contentQty ? { message: errors.contentQty } : undefined,
errors.contentUnit ? { message: errors.contentUnit } : undefined,
]}
/>
<p className="text-sm text-muted-foreground">
Applies to every variant. Leave blank for items with no measurable content stock is
counted in the base UOM either way.
</p>
</div>
)}
<div className="flex flex-col gap-2">
<Label className="text-base">Stock nature</Label>
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)}>
@@ -459,23 +681,54 @@ export default function NewItemPage() {
<p className="text-sm text-muted-foreground">
Check the item types that apply, then add their values to generate a SKU per combination.
</p>
{/* Without this, the per-size content feature is invisible: nothing is flagged as a
measurement out of the box, so the builder silently falls back to one shared
content size and the user has no reason to suspect another page is involved. */}
{(itemTypes ?? []).length > 0 && !(itemTypes ?? []).some((t) => t.isMeasurable) && (
<p className="mt-2 text-sm text-muted-foreground">
Selling the same product in several sizes (500 ml, 1 L)?{" "}
<Link href="/dashboard/products/item-types" className="font-medium underline">
Mark that item type as a measurement
</Link>{" "}
and each size will capture its own number and unit, giving every variant its own content size.
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-4">
{(itemTypes ?? [])
.filter((t) => isBuilderItemType(t.name))
.map((t) => (
<label key={t.itemTypeId} className="flex items-center gap-2.5 rounded-lg border px-3 py-2 hover:bg-muted/50">
{(itemTypes ?? []).map((t) => {
const checked = checkedItemTypeIds.includes(t.itemTypeId)
// Only one dimension may supply the content size, or a variant would have two.
const blocked = t.isMeasurable && !checked && measurableCheckedCount > 0
return (
<label
key={t.itemTypeId}
title={blocked ? "Only one measurement dimension can be used at a time." : undefined}
className={cn(
"flex items-center gap-2.5 rounded-lg border px-3 py-2",
blocked ? "opacity-50" : "hover:bg-muted/50"
)}
>
<Checkbox
checked={checkedItemTypeIds.includes(t.itemTypeId)}
checked={checked}
disabled={blocked}
onCheckedChange={() => toggleItemType(t.itemTypeId)}
/>
<span className="text-base font-medium">{t.name}</span>
{t.isMeasurable && (
<Badge variant="outline" className="text-xs">measurement</Badge>
)}
</label>
))}
)
})}
</div>
<FieldError errors={[errors.variants ? { message: errors.variants } : undefined]} />
<FieldError
errors={[
errors.variants ? { message: errors.variants } : undefined,
errors.measurable ? { message: errors.measurable } : undefined,
]}
/>
{checkedItemTypeIds.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -487,6 +740,48 @@ export default function NewItemPage() {
return (
<div key={t.itemTypeId} className="flex flex-col gap-2">
<Label className="text-base">{t.name} values</Label>
{t.isMeasurable ? (
// A measurement is entered as a number + unit; the chip label, the SKU
// segment and the item's stored content size all derive from this pair.
<div className="flex gap-2">
<Input
type="number"
min="0"
step="any"
value={qtyByCategory[t.itemTypeId] ?? ""}
onChange={(e) => setQtyByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
addValue(t.itemTypeId)
}
}}
placeholder="e.g. 500"
aria-invalid={!!valueErrors[t.itemTypeId]}
className="h-11 text-base"
/>
<Select<MeasureUnit | null>
value={unitByCategory[t.itemTypeId] ?? null}
onValueChange={(v) => setUnitByCategory((prev) => ({ ...prev, [t.itemTypeId]: v }))}
items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}
>
<SelectTrigger className="h-11! w-24 shrink-0 text-base">
<SelectValue placeholder="Unit" />
</SelectTrigger>
<SelectContent>
{CONTENT_UNITS.map((u) => (
<SelectItem key={u} value={u} className="text-base">
{UNIT_LABEL[u]}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}>
<Plus className="size-4" />
Add
</Button>
</div>
) : (
<div className="flex gap-2">
<Input
value={currentInput}
@@ -505,15 +800,19 @@ export default function NewItemPage() {
Add {t.name}
</Button>
</div>
)}
<FieldError
errors={[valueErrors[t.itemTypeId] ? { message: valueErrors[t.itemTypeId] } : undefined]}
/>
<div className="flex flex-wrap gap-2">
{(valuesByCategory[t.itemTypeId] ?? []).map((v) => (
<Badge key={v} variant="outline" className="h-7 gap-1 pr-1 text-sm">
{v}
<Badge key={v.label} variant="outline" className="h-7 gap-1 pr-1 text-sm">
{v.label}
<button
type="button"
onClick={() => removeValue(t.itemTypeId, v)}
onClick={() => removeValue(t.itemTypeId, v.label)}
className="rounded-full p-0.5 hover:bg-muted"
aria-label={`Remove ${v}`}
aria-label={`Remove ${v.label}`}
>
<X className="size-3" />
</button>
@@ -526,6 +825,10 @@ export default function NewItemPage() {
</div>
)}
{/* Form-level, not per-row: a variant's content is derived, so there is no cell to
attach this to. Should never appear addValue rejects a bad pair at entry. */}
<FieldError errors={[errors.variantContent ? { message: errors.variantContent } : undefined]} />
{variants.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-base">
@@ -535,6 +838,7 @@ export default function NewItemPage() {
<TableHead key={cat.itemTypeId} className="h-11 px-3 text-sm">{cat.name}</TableHead>
))}
<TableHead className="h-11 px-3 text-sm">SKU</TableHead>
<TableHead className="h-11 px-3 text-sm">Content</TableHead>
{priceMode === "fixed" && (
<TableHead className="h-11 px-3 text-sm">Sale price</TableHead>
)}
@@ -550,10 +854,15 @@ export default function NewItemPage() {
<TableRow key={variant.key}>
{variant.parts.map((part, i) => (
<TableCell key={i} className="px-3 py-2.5">
{part.value}
{part.value.label}
</TableCell>
))}
<TableCell className="py-2.5 pr-1 pl-3 font-medium">{variant.sku}</TableCell>
{/* Read-only: content is derived, so an editable cell here could only
disagree with the value that produced it. */}
<TableCell className="px-3 py-2.5 text-muted-foreground">
{contentLabelFor(variant)}
</TableCell>
{priceMode === "fixed" && (
<TableCell className="px-3 py-2.5">
<Input
@@ -59,7 +59,7 @@ export default function UomsPage() {
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground">Units of Measure</h1>
<p className="text-base text-muted-foreground">Flat UOM master, used as item base UOMs and in per-item conversions (FR-MD-02).</p>
<p className="text-base text-muted-foreground">Flat UOM master, used as item base UOMs and as work-in-progress labels in production (FR-MD-02).</p>
</div>
</div>
@@ -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 (
<TableRow key={line.grnLineId}>
<TableCell className="px-3 py-3.5">{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}</TableCell>
<TableCell className="px-3 py-3.5">{uomFor(line.uomId)}</TableCell>
<TableCell className="px-3 py-3.5">{baseUomLabel(items, uoms, line.itemId)}</TableCell>
<TableCell className="px-3 py-3.5">{binFor(line.binId)}</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">
@@ -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() {
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
{line.poLineId ? (
<div className="flex h-11 items-center text-base">
{uoms?.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}
<div className="flex h-11 items-center text-base text-muted-foreground">
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
</div>
) : (
<>
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{(uoms ?? []).map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
</>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
@@ -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() {
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
const itemId = Number(v)
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(line.key, { itemId, uomId: item?.baseUomId ?? line.uomId, unitPrice: item?.salePrice ?? line.unitPrice })
updateLine(line.key, { itemId, unitPrice: item?.salePrice ?? line.unitPrice })
}} disabled={!editing || !isDraft}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select item" />
@@ -391,19 +390,8 @@ export default function BundleSaleDetailPage() {
</SelectContent>
</Select>
</TableCell>
<TableCell className="min-w-40">
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((uom) => (
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
{uom.name}
</SelectItem>
))}
</SelectContent>
</Select>
<TableCell className="min-w-40 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
@@ -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() {
</SelectContent>
</Select>
</TableCell>
<TableCell className="min-w-40">
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((uom) => (
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
{uom.name}
</SelectItem>
))}
</SelectContent>
</Select>
<TableCell className="min-w-40 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="text-right" /></TableCell>
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} className="text-right" /></TableCell>
@@ -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() {
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-4 py-2 min-w-44">
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateEditingLine(line.key, { uomId: Number(v) })}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={String(u.uomId)}>
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<TableCell className="px-4 py-2 min-w-44 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="px-4 py-2">
<Input type="number" min="0" step="1" value={line.qty} onChange={(e) => 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() {
</Select>
</TableCell>
<TableCell className="px-4 py-2 min-w-44">
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateLine(line.key, { uomId: Number(v) })}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={String(u.uomId)}>
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="px-4 py-2">
<Input type="number" min="0" step="1" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
@@ -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
<div className="font-medium text-foreground">{line.description}</div>
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
</td>
<td className="px-4 py-3">{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</td>
<td className="px-4 py-3">{baseUomLabel(items, uoms, line.itemId)}</td>
<td className="px-4 py-3 text-right">{line.qty.toFixed(0)}</td>
<td className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</td>
<td className="px-4 py-3 text-right">{money.format(line.unitPrice)}</td>
@@ -496,19 +493,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
))}
</select>
</td>
<td className="px-4 py-3 min-w-40">
<select
value={line.uomId ? String(line.uomId) : ""}
onChange={(e) => updateLine(line.key, { uomId: Number(e.target.value) })}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
<option value="">UOM</option>
{uoms.map((u) => (
<option key={u.uomId} value={String(u.uomId)}>
{u.name}
</option>
))}
</select>
<td className="px-4 py-3 min-w-40 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</td>
<td className="px-4 py-3 w-28">
<input
@@ -8,6 +8,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 { Button, buttonVariants } from "@/components/ui/button"
@@ -143,7 +144,7 @@ export default function SalesInvoicePrintPage({ params }: { params: { id: string
<div className="font-medium text-foreground">{line.description}</div>
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
</TableCell>
<TableCell>{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
<TableCell>{baseUomLabel(items, uoms, line.itemId)}</TableCell>
<TableCell className="text-right">{line.qty.toFixed(2)}</TableCell>
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"}</TableCell>
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
@@ -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() {
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-4 py-2 min-w-36">
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateLine(line.key, { uomId: Number(v) })}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={String(u.uomId)}>
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<TableCell className="px-4 py-2 min-w-36 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="px-4 py-2">
<Input
@@ -8,6 +8,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"
@@ -37,7 +38,6 @@ const money = new Intl.NumberFormat("en-LK", {
const blankLine = (key: string): Line => ({
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"}
</div>
</TableCell>
<TableCell className="px-4 py-3">{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
<TableCell className="px-4 py-3">{baseUomLabel(items, uoms, line.itemId)}</TableCell>
<TableCell className="px-4 py-3 text-right">{line.qty.toFixed(0)}</TableCell>
<TableCell className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "-"}</TableCell>
<TableCell className="px-4 py-3 text-right">{money.format(line.unitPrice)}</TableCell>
@@ -454,12 +452,7 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
<SelectContent>{items.map((i) => <SelectItem key={i.itemId} value={String(i.itemId)}>{i.sku} - {i.name}</SelectItem>)}</SelectContent>
</Select>
</TableCell>
<TableCell>
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateLine(line.key, { uomId: Number(v) })} disabled={locked}>
<SelectTrigger className="h-11!"><SelectValue placeholder="UOM" /></SelectTrigger>
<SelectContent>{uoms.map((u) => <SelectItem key={u.uomId} value={String(u.uomId)}>{u.name}</SelectItem>)}</SelectContent>
</Select>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{baseUomLabel(items, uoms, line.itemId)}</TableCell>
<TableCell><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={locked} /></TableCell>
<TableCell><Input type="number" min="0" step="0.01" value={line.freeQty} onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })} disabled={locked} /></TableCell>
<TableCell><Input type="number" min="0" step="0.01" value={line.unitPrice ?? ""} onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })} disabled={locked} /></TableCell>
@@ -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() {
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-4 py-2 min-w-36">
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateLine(line.key, { uomId: Number(v) })}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={String(u.uomId)}>
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<TableCell className="px-4 py-2 min-w-36 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="px-4 py-2">
<Input
@@ -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 { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
@@ -131,7 +132,7 @@ export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id
<div className="font-medium text-foreground">{line.description}</div>
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
</TableCell>
<TableCell>{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
<TableCell>{baseUomLabel(items, uoms, line.itemId)}</TableCell>
<TableCell className="text-right">{line.qty.toFixed(2)}</TableCell>
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"}</TableCell>
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
@@ -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
<div className="font-medium text-foreground">{line.description}</div>
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
</TableCell>
<TableCell>{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
<TableCell>{baseUomLabel(items, uoms, line.itemId)}</TableCell>
<TableCell className="text-right">{line.qty.toFixed(0)}</TableCell>
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</TableCell>
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
-9
View File
@@ -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<UpdateUomConversionsResponse> {
return apiRequest<UpdateUomConversionsResponse>(`/items/${itemId}/uom-conversions`, {
method: "PUT",
body: request,
})
},
}
+46
View File
@@ -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<ItemListItem, "itemId" | "baseUomId">
/**
* 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<ItemListItem, "itemId" | "contentBaseUnit">[],
itemId: number | null | undefined,
): string | null {
if (!itemId) return null
const unit = items.find((candidate) => candidate.itemId === itemId)?.contentBaseUnit
return unit ? unit.toLowerCase() : null
}
@@ -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<string, string> = {}
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"
@@ -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<string, string> {
const errors: Record<string, string> = {}
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<string, string> {
const errors: Record<string, string> = {}
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<string, string> {
const errors: Record<string, string> = {}
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<string, string> {
const errors: Record<string, string> = {}
if (!name.trim()) errors.name = "UOM name is required"
@@ -65,10 +81,14 @@ export function validateItemTypeName(name: string): Record<string, string> {
}
export function validateVariantItemForm(input: {
productName: string
categoryId: number | null
hasVariants: boolean
}): Record<string, string> {
const errors: Record<string, string> = {}
// Required: it names every generated item and leads its SKU, and neither reads sensibly
// when derived from the category instead ("Beverages - 500ml").
if (!input.productName.trim()) errors.productName = "Enter a product name"
if (!input.categoryId) errors.categoryId = "Select a category"
if (!input.hasVariants) errors.variants = "Check at least one variant category and add its values"
return errors
@@ -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<string, string> {
const errors: Record<string, string> = {}
for (const key of variantKeys) {
const { qty, unit } = contentFor(key)
const pair = contentPairErrors(qty, unit)
const message = pair.contentQty ?? pair.contentUnit
if (message) errors[key] = message
}
return errors
}
export function validateVariantPrices(
variantKeys: string[],
priceFor: (key: string) => string,
@@ -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<string, string> {
const errors: Record<string, string> = {}
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"

Some files were not shown because too many files have changed in this diff Show More