Merge pull request 'Refactor production and stock tests to remove UOM dependency' (#38) from fix/uom-overall into Dev
Reviewed-on: #38
This commit was merged in pull request #38.
This commit is contained in:
@@ -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; }
|
||||
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -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)
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -99,7 +99,7 @@ builder.Services.AddScoped<IRfqService, RfqService>();
|
||||
builder.Services.AddScoped<IPurchaseOrderService, PurchaseOrderService>();
|
||||
|
||||
// Stock core + goods receipt (docs/11 §4–5)
|
||||
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>();
|
||||
|
||||
@@ -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());
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -4,6 +4,37 @@ 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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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"]]}
|
||||
|
||||
@@ -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,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())
|
||||
@@ -0,0 +1,182 @@
|
||||
"""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 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):
|
||||
payload = {
|
||||
"code": TEMPLATE_CODE, "name": "Content-unit smoke line",
|
||||
"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={TEMPLATE_CODE}").body["items"]
|
||||
if t["code"] == TEMPLATE_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)
|
||||
|
||||
|
||||
def main():
|
||||
c, chk, args = bootstrap(__doc__)
|
||||
print(f"API {args.api}")
|
||||
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
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())
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -13,9 +13,12 @@ import { productConfig } from "@/lib/api/product-config"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateVariantItemForm, validateVariantPrices } from "@/lib/validations/master-data"
|
||||
import { contentPairErrors, 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"
|
||||
@@ -54,6 +57,9 @@ 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)
|
||||
|
||||
@@ -199,7 +205,10 @@ export default function NewItemPage() {
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitError(null)
|
||||
const nextErrors = validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 })
|
||||
const nextErrors = {
|
||||
...validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 }),
|
||||
...contentPairErrors(contentQty, contentUnit),
|
||||
}
|
||||
setErrors(nextErrors)
|
||||
// In fixed mode, block the whole submit until every variant has a price > 0.
|
||||
const nextPriceErrors =
|
||||
@@ -228,6 +237,8 @@ export default function NewItemPage() {
|
||||
stockNature,
|
||||
trackingMode: "None",
|
||||
salePrice: priceMode === "fixed" ? Number(priceFor(variant.key)) : null,
|
||||
contentQty: contentQty.trim() ? Number(contentQty) : null,
|
||||
contentUnit: contentQty.trim() ? contentUnit : null,
|
||||
})
|
||||
created += 1
|
||||
}
|
||||
@@ -382,6 +393,48 @@ 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. */}
|
||||
<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">
|
||||
Leave blank for items with no measurable content. Stock is still counted in the base UOM.
|
||||
</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)}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -5,7 +5,6 @@ export type BundleSaleStatus = "Draft" | "Posted" | "Cancelled"
|
||||
export interface BundleSaleTemplateLine {
|
||||
bundleSaleTemplateLineId: number
|
||||
itemId: number
|
||||
uomId: number
|
||||
warehouseId: number
|
||||
qty: number
|
||||
unitPrice: number
|
||||
@@ -40,7 +39,6 @@ export interface BundleSaleLine {
|
||||
itemId: number
|
||||
description: string
|
||||
qty: number
|
||||
uomId: number
|
||||
warehouseId: number
|
||||
unitPrice: number
|
||||
lineTotal: number
|
||||
|
||||
@@ -26,7 +26,6 @@ export interface BatchInput {
|
||||
export interface CreateGrnLineInput {
|
||||
poLineId?: number | null
|
||||
itemId: number
|
||||
uomId: number
|
||||
binId?: number | null
|
||||
qty: number
|
||||
/**
|
||||
@@ -55,7 +54,6 @@ export interface GrnLine {
|
||||
grnLineId: number
|
||||
poLineId: number | null
|
||||
itemId: number
|
||||
uomId: number
|
||||
binId: number | null
|
||||
qty: number
|
||||
/** Gross unit cost received at. */
|
||||
|
||||
@@ -10,7 +10,26 @@ import { EntityStatus } from "@/types/common"
|
||||
export type StockNature = "Stocked" | "NonStocked" | "Service"
|
||||
export type TrackingMode = "None" | "Batch" | "Serial"
|
||||
|
||||
export interface ItemListItem {
|
||||
/**
|
||||
* Unit of an item's content size — how much one stocked pack holds.
|
||||
*
|
||||
* Not a stocking unit: stock is always counted in packs (`baseUomId`). Only `Ml` and `G`
|
||||
* are ever returned as a *base* content unit; `L` and `Kg` are entry conveniences the
|
||||
* server normalises ×1000 on write.
|
||||
*/
|
||||
export type MeasureUnit = "Ml" | "L" | "G" | "Kg"
|
||||
|
||||
/** The four content fields, which are always all set or all null. */
|
||||
export interface ItemContent {
|
||||
/** Content per pack as entered, e.g. 500 with `Ml`, or 1.5 with `L`. */
|
||||
contentQty: number | null
|
||||
contentUnit: MeasureUnit | null
|
||||
/** Server-derived normalisation of the pair above; never sent on write. */
|
||||
contentBaseQty: number | null
|
||||
contentBaseUnit: MeasureUnit | null
|
||||
}
|
||||
|
||||
export interface ItemListItem extends ItemContent {
|
||||
itemId: number
|
||||
sku: string
|
||||
name: string
|
||||
@@ -33,13 +52,6 @@ export interface ItemReorderSetting {
|
||||
reorderQty: number
|
||||
}
|
||||
|
||||
export interface UomConversion {
|
||||
conversionId: number
|
||||
fromUom: number
|
||||
toUom: number
|
||||
factor: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Full Item resource (docs/11 §2.1 `GET /items/{itemId}`).
|
||||
*
|
||||
@@ -47,7 +59,7 @@ export interface UomConversion {
|
||||
* client-generated `sku` and never stored server-side (docs/10 Part C.9). The SKU is the
|
||||
* only record of which colour/size an item is.
|
||||
*/
|
||||
export interface Item {
|
||||
export interface Item extends ItemContent {
|
||||
itemId: number
|
||||
sku: string
|
||||
name: string
|
||||
@@ -65,7 +77,6 @@ export interface Item {
|
||||
salePrice: number | null
|
||||
status: EntityStatus
|
||||
reorder: ItemReorderSetting[]
|
||||
conversions: UomConversion[]
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
@@ -87,6 +98,12 @@ export interface CreateItemRequest {
|
||||
taxClass?: string | null
|
||||
/** Optional fixed sale price (Sales only). Null/omitted ⇒ sell at stock/FIFO value. */
|
||||
salePrice?: number | null
|
||||
/**
|
||||
* Content per pack. Send both or neither — a half-filled pair is a 422. The base pair is
|
||||
* derived server-side and is deliberately not accepted here.
|
||||
*/
|
||||
contentQty?: number | null
|
||||
contentUnit?: MeasureUnit | null
|
||||
}
|
||||
|
||||
export type UpdateItemRequest = CreateItemRequest
|
||||
@@ -95,16 +112,6 @@ export interface UpdateItemReorderRequest {
|
||||
settings: ItemReorderSetting[]
|
||||
}
|
||||
|
||||
export interface UpdateUomConversionsRequest {
|
||||
conversions: { fromUom: number; toUom: number; factor: number }[]
|
||||
}
|
||||
|
||||
export interface UpdateUomConversionsResponse {
|
||||
itemId: number
|
||||
baseUomId: number
|
||||
conversions: UomConversion[]
|
||||
}
|
||||
|
||||
export interface Warehouse {
|
||||
warehouseId: number
|
||||
code: string
|
||||
|
||||
@@ -136,7 +136,6 @@ export type PurchaseOrderStatus =
|
||||
export interface PoLine {
|
||||
poLineId: number
|
||||
itemId: number
|
||||
uomId: number
|
||||
warehouseId: number
|
||||
qty: number
|
||||
unitPrice: number
|
||||
@@ -178,7 +177,6 @@ export interface PurchaseOrderSummary {
|
||||
|
||||
export interface CreatePoLineInput {
|
||||
itemId: number
|
||||
uomId: number
|
||||
warehouseId: number
|
||||
qty: number
|
||||
unitPrice: number
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
// Three things changed when the real backend landed, and they are worth knowing if you are
|
||||
// reading old code or docs/21-FRONTEND-PHASE2.md:
|
||||
// * a template is identified by `code`, not `docNo` (only runs carry a document number)
|
||||
// * quantities reference `itemId`/`uomId` numeric FKs with `qtyPerBatch` — not free text
|
||||
// * quantities reference an `itemId` numeric FK with `qtyPerBatch` — not free text. Lines
|
||||
// carry no UOM: an input's unit is `qtyUnit` (packs or the item's content unit) and an
|
||||
// output's is its item's base UOM, or `uomId` when it is intermediate WIP
|
||||
// * stages carry `posX`/`posY`, so canvas layout round-trips through the server
|
||||
//
|
||||
// The `key` vocabulary (§D.1): every stage and output has a client-facing string key
|
||||
@@ -17,6 +19,16 @@ export type TemplateStatus = "Active" | "Inactive"
|
||||
export type ProductionRunStatus = "InProgress" | "Completed" | "Cancelled"
|
||||
export type RunStageStatus = "Waiting" | "Ready" | "InProgress" | "Done" | "Approved"
|
||||
export type StageInputSource = "Stock" | "Upstream"
|
||||
|
||||
/**
|
||||
* What a stage input's quantity is expressed in.
|
||||
*
|
||||
* `Pack` is a count of the item's base UOM. `Content` is an amount of the item's content in
|
||||
* its base content unit (ml or g), which the server divides by the item's content size to get
|
||||
* packs — 2000 ml of a 500 ml bottle consumes 4, and 300 ml consumes 0.6. `Content` requires
|
||||
* the item to have a content size, and Upstream inputs must always be `Pack`.
|
||||
*/
|
||||
export type StageQtyUnit = "Pack" | "Content"
|
||||
export type CustomFieldType = "Text" | "Number" | "Checkbox" | "Date" | "Select"
|
||||
|
||||
export type RunStageEventType =
|
||||
@@ -50,7 +62,7 @@ export interface StageInput {
|
||||
/** Set when `source` is `Upstream` — an output of a *direct* parent stage. */
|
||||
fromOutputId: number | null
|
||||
fromOutputKey: string | null
|
||||
uomId: number
|
||||
qtyUnit: StageQtyUnit
|
||||
qtyPerBatch: number
|
||||
}
|
||||
|
||||
@@ -60,7 +72,8 @@ export interface StageOutput {
|
||||
/** Null on intermediate (WIP) outputs; required on the terminal stage's single output. */
|
||||
itemId: number | null
|
||||
name: string
|
||||
uomId: number
|
||||
/** WIP display label — set exactly when `itemId` is null; the unit otherwise comes from the item. */
|
||||
uomId: number | null
|
||||
qtyPerBatch: number
|
||||
}
|
||||
|
||||
@@ -137,7 +150,7 @@ export interface SaveStageInputInput {
|
||||
source: StageInputSource
|
||||
itemId?: number | null
|
||||
fromOutputKey?: string | null
|
||||
uomId: number
|
||||
qtyUnit: StageQtyUnit
|
||||
qtyPerBatch: number
|
||||
}
|
||||
|
||||
@@ -145,7 +158,8 @@ export interface SaveStageOutputInput {
|
||||
key: string
|
||||
itemId?: number | null
|
||||
name: string
|
||||
uomId: number
|
||||
/** Required when `itemId` is null (WIP); must be null when it is set. */
|
||||
uomId?: number | null
|
||||
qtyPerBatch: number
|
||||
}
|
||||
|
||||
@@ -200,13 +214,14 @@ export interface RunStageInput {
|
||||
source: StageInputSource
|
||||
itemId: number | null
|
||||
fromRunOutputId: number | null
|
||||
uomId: number
|
||||
/** In the input's *declared* UOM. Editable until the stage starts. */
|
||||
qtyUnit: StageQtyUnit
|
||||
/** Expressed in `qtyUnit`. Editable until the stage starts. */
|
||||
plannedQty: number
|
||||
/**
|
||||
* The four figures below are in the item's *base* UOM — the only unit the FIFO engine and
|
||||
* the ledger speak. A stage input declared in "box of 12" therefore shows plannedQty 3 and
|
||||
* consumedQty 36. Do not compare them to plannedQty without converting.
|
||||
* The four figures below are always a count of the item's *base* UOM — the only unit the
|
||||
* FIFO engine and the ledger speak. For a `Content` input they are therefore in different
|
||||
* units from plannedQty: 2000 ml planned against a 500 ml bottle shows consumedQty 4. Do
|
||||
* not compare them to plannedQty without dividing by the item's content size.
|
||||
*/
|
||||
consumedQty: number
|
||||
consumedValue: number
|
||||
@@ -220,7 +235,9 @@ export interface RunStageOutput {
|
||||
runOutputId: number
|
||||
itemId: number | null
|
||||
name: string
|
||||
uomId: number
|
||||
/** WIP display label; null on the terminal output, whose unit is the finished item's base UOM. */
|
||||
uomId: number | null
|
||||
/** Always a pack count — scrap is recorded in whole units, never in ml or g. */
|
||||
plannedQty: number
|
||||
producedQty: number
|
||||
scrappedQty: number
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface SalesInvoiceLine {
|
||||
description: string
|
||||
qty: number
|
||||
freeQty: number
|
||||
uomId: number
|
||||
warehouseId: number
|
||||
unitPrice: number
|
||||
baseCost: number
|
||||
@@ -101,7 +100,6 @@ export interface SalesInvoice extends SalesInvoiceSummary {
|
||||
|
||||
export interface CreateSalesInvoiceLineRequest {
|
||||
itemId: number
|
||||
uomId: number
|
||||
warehouseId: number
|
||||
qty: number
|
||||
freeQty: number
|
||||
@@ -131,7 +129,6 @@ export interface SalesSlipLine {
|
||||
description: string
|
||||
qty: number
|
||||
freeQty: number
|
||||
uomId: number
|
||||
warehouseId: number
|
||||
unitPrice: number
|
||||
baseCost: number
|
||||
@@ -186,7 +183,6 @@ export interface SalesSlip {
|
||||
|
||||
export interface CreateSalesSlipLineRequest {
|
||||
itemId: number
|
||||
uomId: number
|
||||
warehouseId: number
|
||||
qty: number
|
||||
freeQty: number
|
||||
@@ -257,7 +253,6 @@ export interface FreeIssueSummary {
|
||||
itemId: number
|
||||
itemSku: string
|
||||
itemName: string
|
||||
uomId: number
|
||||
uomName: string
|
||||
qty: number
|
||||
freeQty: number
|
||||
|
||||
@@ -54,15 +54,17 @@ export class GrnNewPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Item and UOM are each only a combobox when the row is NOT tied to a PO line
|
||||
* (`line.poLineId` gates both cells identically in the source - a PO line renders them as
|
||||
* plain text instead). Checked per-cell (td:nth(0) for Item, td:nth(1) for UOM) rather than
|
||||
* "row has any combobox", since the Bin/Hold-status cells always have one regardless of PO
|
||||
* mode - a row-wide check would false-positive on a PO line and select the wrong control.
|
||||
* Selecting the app doesn't auto-fill UOM from the chosen item, so a direct-receipt/off-PO
|
||||
* line needs it set explicitly or submit blocks with "Select a UOM".
|
||||
* Item is only a combobox when the row is NOT tied to a PO line (`line.poLineId` gates the
|
||||
* cell in the source - a PO line renders it as plain text instead). Checked per-cell
|
||||
* (td:nth(0)) rather than "row has any combobox", since the Bin/Hold-status cells always
|
||||
* have one regardless of PO mode - a row-wide check would false-positive on a PO line and
|
||||
* select the wrong control.
|
||||
*
|
||||
* td:nth(1) is still the UOM column, but it is now a read-only label showing the chosen
|
||||
* item's base UOM: lines carry no unit of their own, so there is nothing to pick. The
|
||||
* column was kept rather than removed, which is why every index below is unchanged.
|
||||
*/
|
||||
async fillFirstLine(opts: { item?: string; uom?: string; qty: number; unitCost?: number }) {
|
||||
async fillFirstLine(opts: { item?: string; qty: number; unitCost?: number }) {
|
||||
const row = this.firstRow()
|
||||
const cells = row.locator("td")
|
||||
if (opts.item) {
|
||||
@@ -71,12 +73,6 @@ export class GrnNewPage {
|
||||
await selectOption(this.page, itemCombo, opts.item)
|
||||
}
|
||||
}
|
||||
if (opts.uom) {
|
||||
const uomCombo = cells.nth(1).getByRole("combobox")
|
||||
if (await uomCombo.count()) {
|
||||
await selectOption(this.page, uomCombo, opts.uom)
|
||||
}
|
||||
}
|
||||
const numberInputs = row.locator('input[type="number"]')
|
||||
await numberInputs.nth(0).fill(String(opts.qty)) // Qty
|
||||
if (opts.unitCost !== undefined) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect, APIRequestContext } from "@playwright/test"
|
||||
import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api"
|
||||
import { ApiSeeder, newApiContext, Warehouse, Item, Vendor } from "../support/api"
|
||||
import { GrnNewPage, GrnDetailPage } from "../pages/GrnPages"
|
||||
import { ProductionRunListPage, ProductionRunDetailPage } from "../pages/ProductionRunPages"
|
||||
import { StockTransferNewPage, StockTransferDetailPage } from "../pages/StockPages"
|
||||
@@ -16,7 +16,6 @@ test.describe("Chained flow: GRN -> Production -> Stock Transfer", () => {
|
||||
let sourceWarehouse: Warehouse
|
||||
let destWarehouse: Warehouse
|
||||
let vendor: Vendor
|
||||
let uom: Uom
|
||||
let rawItem: Item
|
||||
let finishedItem: Item
|
||||
let templateName: string
|
||||
@@ -26,7 +25,6 @@ test.describe("Chained flow: GRN -> Production -> Stock Transfer", () => {
|
||||
seeder = new ApiSeeder(api)
|
||||
sourceWarehouse = await seeder.firstWarehouse()
|
||||
destWarehouse = await seeder.secondWarehouse()
|
||||
uom = await seeder.firstUom()
|
||||
vendor = await seeder.createVendor("Chained Flow Vendor")
|
||||
rawItem = await seeder.createItem({ namePrefix: "Chained Raw Material" })
|
||||
finishedItem = await seeder.createItem({ namePrefix: "Chained Finished Good" })
|
||||
@@ -34,7 +32,7 @@ test.describe("Chained flow: GRN -> Production -> Stock Transfer", () => {
|
||||
const template = await seeder.createSingleStageTemplate({
|
||||
rawItemId: rawItem.itemId,
|
||||
finishedItemId: finishedItem.itemId,
|
||||
uomId: uom.uomId,
|
||||
|
||||
})
|
||||
templateName = template.name
|
||||
})
|
||||
@@ -50,7 +48,7 @@ test.describe("Chained flow: GRN -> Production -> Stock Transfer", () => {
|
||||
await grnNew.useDirectReceipt()
|
||||
await grnNew.selectVendor(vendor.name)
|
||||
await grnNew.selectWarehouse(sourceWarehouse.name)
|
||||
await grnNew.fillFirstLine({ item: rawItem.name, uom: uom.name, qty: 100, unitCost: 20 })
|
||||
await grnNew.fillFirstLine({ item: rawItem.name, qty: 100, unitCost: 20 })
|
||||
await grnNew.submit()
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/\d+/)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user