Refactor production and stock tests to remove UOM dependency

- Updated production.spec.ts, stock-adjustments.spec.ts, and stock-transfers.spec.ts to eliminate UOM references in API seeder and test cases.
- Adjusted ApiSeeder methods to remove UOM parameters from stock receiving and production template creation.
- Revised documentation to reflect changes in UOM handling, emphasizing that stock is counted in base UOM only.
- Introduced new enums for MeasureUnit and StageQtyUnit to clarify content size and stage input quantities.
- Implemented ItemContent service to validate and normalize content sizes.
- Updated smoke tests to validate production stage inputs expressed in content units, ensuring correct consumption calculations.
- Modified frontend UOM label handling to reflect the removal of per-line UOMs in document lines.
This commit is contained in:
2026-08-11 11:32:40 +05:30
parent d37824cecc
commit 15ddac178c
108 changed files with 1221 additions and 987 deletions
@@ -81,11 +81,4 @@ public sealed class ItemsController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemReorderSettingsDto>> UpdateReorder(int itemId, [FromBody] UpdateReorderRequest request, CancellationToken ct)
=> Ok(await _items.UpdateReorderAsync(itemId, request, ct));
/// <summary>Replace the item's UOM conversions (FR-MD-02).</summary>
[HttpPut("{itemId:int}/uom-conversions")]
[ProducesResponseType(typeof(ItemUomConversionsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemUomConversionsDto>> UpdateUomConversions(int itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct)
=> Ok(await _items.UpdateUomConversionsAsync(itemId, request, ct));
}
@@ -12,8 +12,6 @@ public class BundleSaleLine
public Item? Item { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Qty { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public decimal UnitPrice { get; set; }
@@ -8,8 +8,6 @@ public class BundleSaleTemplateLine
public int ItemId { get; set; }
public Item? Item { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
@@ -25,9 +25,6 @@ public class GrnLine
public int ItemId { get; set; }
public Item? Item { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public int? BinId { get; set; }
public Bin? Bin { get; set; }
+34 -1
View File
@@ -24,6 +24,12 @@ public class Item
public int? BrandId { get; set; }
public Brand? Brand { get; set; }
/// <summary>
/// The stocking unit — the pack the item is counted in (BOTTLE, PACKET, BOX, PCS).
/// <b>Every</b> quantity in the system is a count of these: stock layers, the ledger,
/// and every document line. Nothing converts, so this is the sole meaning of a
/// quantity and cannot be changed once the item has stock history.
/// </summary>
public int BaseUomId { get; set; }
public Uom? BaseUom { get; set; }
@@ -41,6 +47,34 @@ public class Item
/// </summary>
public decimal? SalePrice { get; set; }
/// <summary>
/// How much one pack holds, as the user entered it — <c>500</c> with
/// <see cref="ContentUnit"/> <c>Ml</c> for a 500 ml bottle, <c>1.5</c> with <c>L</c>
/// for a 1.5 L one. Null (together with the other three) when the item has no
/// measurable content: a screw, a label, a service.
/// <para>
/// Content never affects stock — that is always a pack count. It exists so production
/// can express a formula in millilitres or grams and resolve it to packs
/// (see <c>IItemMeasure</c>).
/// </para>
/// <para>
/// A loose bulk item bought by weight is modelled the same way:
/// <c>BaseUom = KG, ContentQty = 1, ContentUnit = Kg</c> ⇒ 1000 g per stocked unit.
/// </para>
/// </summary>
public decimal? ContentQty { get; set; }
public MeasureUnit? ContentUnit { get; set; }
/// <summary>
/// <see cref="ContentQty"/>/<see cref="ContentUnit"/> normalised to a base unit
/// (L→Ml, Kg→G, both ×1000) at write time by <c>ItemContent.Normalize</c>. Server-derived
/// and never accepted from a client. <see cref="ContentBaseUnit"/> is therefore only ever
/// <see cref="MeasureUnit.Ml"/> or <see cref="MeasureUnit.G"/>.
/// <para>Stored rather than recomputed so every consumer reads one settled number.</para>
/// </summary>
public decimal? ContentBaseQty { get; set; }
public MeasureUnit? ContentBaseUnit { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
@@ -50,5 +84,4 @@ public class Item
public uint RowVersion { get; set; }
public ICollection<ItemReorder> ReorderSettings { get; set; } = new List<ItemReorder>();
public ICollection<UomConversion> UomConversions { get; set; } = new List<UomConversion>();
}
@@ -15,9 +15,6 @@ public class PoLine
public int ItemId { get; set; }
public Item? Item { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
@@ -31,10 +31,14 @@ public class RunStageInput
public int? FromRunOutputId { get; set; }
public RunStageOutput? FromRunOutput { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>Copied from the template input: what <see cref="PlannedQty"/> is expressed in.</summary>
public StageQtyUnit QtyUnit { get; set; } = StageQtyUnit.Pack;
/// <summary>Scaled at creation; per-run editable until the stage starts (FR-MFG-08, <c>409 STAGE_NOT_EDITABLE</c>).</summary>
/// <summary>
/// Scaled at creation; per-run editable until the stage starts (FR-MFG-08,
/// <c>409 STAGE_NOT_EDITABLE</c>). Expressed in <see cref="QtyUnit"/> — so unlike the
/// consumption figures below it is <b>not</b> necessarily a pack count.
/// </summary>
public decimal PlannedQty { get; set; }
/// <summary>
@@ -23,10 +23,17 @@ public class RunStageOutput
public string Name { get; set; } = string.Empty;
public int UomId { get; set; }
/// <summary>
/// Display label for intermediate WIP; null on the terminal output, whose unit is the
/// finished item's base UOM. Never converted — see <see cref="StageOutput.UomId"/>.
/// </summary>
public int? UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>Scaled at creation; per-run editable until the stage starts.</summary>
/// <summary>
/// Scaled at creation; per-run editable until the stage starts. Every quantity on an
/// output is a pack count, so scrap is recorded in whole broken bottles rather than ml.
/// </summary>
public decimal PlannedQty { get; set; }
/// <summary>Recorded at complete. A re-complete after a rework <b>overwrites</b> this, never adds to it.</summary>
@@ -16,8 +16,6 @@ public class SalesInvoiceLine
public decimal Qty { get; set; }
public decimal FreeQty { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
@@ -16,8 +16,6 @@ public class SalesSlipLine
public decimal Qty { get; set; }
public decimal FreeQty { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
@@ -32,8 +32,13 @@ public class StageInput
public int? FromOutputId { get; set; }
public StageOutput? FromOutput { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>
/// What <see cref="QtyPerBatch"/> is expressed in. Stock inputs may use
/// <see cref="StageQtyUnit.Content"/> (ml/g) when the item has a content size; Upstream
/// inputs are always <see cref="StageQtyUnit.Pack"/> — WIP is counted in the unit its
/// source output declares.
/// </summary>
public StageQtyUnit QtyUnit { get; set; } = StageQtyUnit.Pack;
public decimal QtyPerBatch { get; set; }
}
@@ -20,8 +20,14 @@ public class StageOutput
public string Name { get; set; } = string.Empty;
public int UomId { get; set; }
/// <summary>
/// Display label for intermediate work-in-progress. Required when <see cref="ItemId"/>
/// is null and must be null when it is set — a real item's unit is its own base UOM.
/// WIP never touches stock or the ledger, so this is never converted, only shown.
/// </summary>
public int? UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>Always a pack count: of the WIP unit above, or of the item's base UOM.</summary>
public decimal QtyPerBatch { get; set; }
}
+4 -2
View File
@@ -1,8 +1,10 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Unit of Measure (FR-MD-02). Referenced as an item's base UOM and as the
/// endpoints of a <see cref="UomConversion"/>. Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// Unit of Measure (FR-MD-02). A flat lookup, used as an item's base UOM — the pack every
/// quantity in the system counts — and as the display label on an intermediate production
/// output. There are no conversions between UOMs: an item is stocked in exactly one, and a
/// differently sized pack is a different item. Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class Uom
{
@@ -1,22 +0,0 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Per-item conversion factor between two UOMs (FR-MD-02/03): quantity in
/// <see cref="FromUomId"/> × <see cref="Factor"/> = quantity in <see cref="ToUomId"/>.
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class UomConversion
{
public int ConversionId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public int FromUomId { get; set; }
public Uom? FromUom { get; set; }
public int ToUomId { get; set; }
public Uom? ToUom { get; set; }
public decimal Factor { get; set; }
}
@@ -0,0 +1,23 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Unit of an item's <b>content size</b> — how much a single stocked pack holds
/// (a 500 ml bottle, a 50 kg sack). Stored as a string in the database.
/// <para>
/// This is <b>not</b> a stocking unit. Stock is always counted in packs
/// (<c>Item.BaseUomId</c>); content is a separate, optional attribute used by
/// production to turn "2000 ml of syrup" into a pack count.
/// </para>
/// <para>
/// Only <see cref="Ml"/> and <see cref="G"/> are ever stored as a <i>base</i> content
/// unit. <see cref="L"/> and <see cref="Kg"/> are entry conveniences normalised ×1000
/// on write by <c>ItemContent.Normalize</c>, so nothing downstream has to convert.
/// </para>
/// </summary>
public enum MeasureUnit
{
Ml,
L,
G,
Kg
}
@@ -0,0 +1,22 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// What a stage input's quantity is expressed in (FR-MFG-04). Stored as a string.
/// <para>
/// Deliberately explicit rather than inferred from whether the item happens to have a
/// content size: templates outlive item edits, so an inferred unit would let adding a
/// content size to an existing item silently reinterpret every saved formula — "300"
/// meaning 300 packs would become 300 ml.
/// </para>
/// </summary>
public enum StageQtyUnit
{
/// <summary>A count of the item's base UOM — bottles, packets, pieces.</summary>
Pack,
/// <summary>
/// An amount of the item's content in its base content unit (ml or g), resolved to
/// packs by <c>IItemMeasure</c> at stage start. Requires the item to have a content size.
/// </summary>
Content
}
+1 -2
View File
@@ -6,7 +6,7 @@ namespace ERPCore.Dtos.Grn;
// Responses (docs/11 §4) --------------------------------------------------------
public sealed record GrnLineDto(
int GrnLineId, int? PoLineId, int ItemId, int UomId, int? BinId,
int GrnLineId, int? PoLineId, int ItemId, int? BinId,
decimal Qty, decimal UnitCost, decimal? PoUnitPrice,
decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount,
decimal ReceivedValue, decimal LineTotal, decimal PriceVariance,
@@ -44,7 +44,6 @@ public sealed class CreateGrnLineInput
/// <summary>Set for a PO-based receipt; cost is then derived from the PO line (02-SECURITY C.3).</summary>
public int? PoLineId { get; set; }
[Required] public int ItemId { get; set; }
[Required] public int UomId { get; set; }
public int? BinId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
/// <summary>
+27 -25
View File
@@ -9,7 +9,10 @@ namespace ERPCore.Dtos.Items;
public sealed record ItemListItemDto(
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
string? TaxClass, decimal? SalePrice, EntityStatus Status);
string? TaxClass, decimal? SalePrice,
decimal? ContentQty, MeasureUnit? ContentUnit,
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
EntityStatus Status);
/// <summary>A single per-warehouse reorder policy row.</summary>
public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
@@ -17,26 +20,21 @@ public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decim
/// <summary>
/// Full item resource for <c>GET /items/{id}</c> and create/update responses.
/// <para>
/// <see cref="Conversions"/> is embedded because they are otherwise unreadable: they can
/// only be written via <c>PUT /items/{id}/uom-conversions</c>, which returns them, but no
/// endpoint reads them back — so a detail screen could never show current state before
/// editing. Mirrors how <see cref="Reorder"/> is already inlined.
/// <c>ContentBaseQty</c>/<c>ContentBaseUnit</c> are echoed back so a detail screen can show
/// what the entered size normalised to (1.5 L ⇒ 1500 ml) — they are server-derived and are
/// not accepted on write.
/// </para>
/// </summary>
public sealed record ItemDetailDto(
int ItemId, string Sku, string Name, string? Description, int CategoryId,
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
StockNature StockNature, TrackingMode TrackingMode,
string? TaxClass, decimal? SalePrice, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
IReadOnlyList<UomConversionDto> Conversions,
string? TaxClass, decimal? SalePrice,
decimal? ContentQty, MeasureUnit? ContentUnit,
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
DateTime CreatedAt, DateTime? UpdatedAt);
/// <summary>UOM conversion row (docs/11 §2.2).</summary>
public sealed record UomConversionDto(int ConversionId, int FromUom, int ToUom, decimal Factor);
/// <summary>Response body for <c>PUT /items/{id}/uom-conversions</c>.</summary>
public sealed record ItemUomConversionsDto(int ItemId, int BaseUomId, IReadOnlyList<UomConversionDto> Conversions);
/// <summary>Response body for <c>PUT /items/{id}/reorder</c>.</summary>
public sealed record ItemReorderSettingsDto(IReadOnlyList<ItemReorderDto> Settings);
@@ -64,6 +62,14 @@ public sealed class CreateItemRequest
[StringLength(20)] public string? TaxClass { get; set; }
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
/// <summary>
/// How much one pack holds. Supply with <see cref="ContentUnit"/> or leave both null
/// for items with no measurable content. The normalised base pair is derived by the
/// server and is deliberately not accepted here.
/// </summary>
[Range(0.0001, double.MaxValue)] public decimal? ContentQty { get; set; }
[EnumDataType(typeof(MeasureUnit))] public MeasureUnit? ContentUnit { get; set; }
}
public sealed class UpdateItemRequest
@@ -83,6 +89,14 @@ public sealed class UpdateItemRequest
[StringLength(20)] public string? TaxClass { get; set; }
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
/// <summary>
/// How much one pack holds. Supply with <see cref="ContentUnit"/> or leave both null
/// for items with no measurable content. The normalised base pair is derived by the
/// server and is deliberately not accepted here.
/// </summary>
[Range(0.0001, double.MaxValue)] public decimal? ContentQty { get; set; }
[EnumDataType(typeof(MeasureUnit))] public MeasureUnit? ContentUnit { get; set; }
}
public sealed class UpdateItemStatusRequest
@@ -101,15 +115,3 @@ public sealed class UpdateReorderRequest
{
[Required, MinLength(1)] public List<ReorderSettingInput> Settings { get; set; } = new();
}
public sealed class UomConversionInput
{
[Required] public int FromUom { get; set; }
[Required] public int ToUom { get; set; }
[Range(0.000001, double.MaxValue)] public decimal Factor { get; set; }
}
public sealed class UpdateUomConversionsRequest
{
[Required, MinLength(1)] public List<UomConversionInput> Conversions { get; set; } = new();
}
@@ -6,7 +6,7 @@ namespace ERPCore.Dtos.Procurement;
// Responses (docs/11 §3.3) ------------------------------------------------------
public sealed record PoLineDto(
int PoLineId, int ItemId, int UomId, int WarehouseId,
int PoLineId, int ItemId, int WarehouseId,
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived);
public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency);
@@ -25,7 +25,6 @@ public sealed record PurchaseOrderSummaryDto(
public sealed class CreatePoLineInput
{
[Required] public int ItemId { get; set; }
[Required] public int UomId { get; set; }
[Required] public int WarehouseId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
[Range(0, double.MaxValue)] public decimal UnitPrice { get; set; }
+9 -3
View File
@@ -29,17 +29,23 @@ public sealed record RunSummaryDto(
/// </summary>
public sealed record CostPoolDto(decimal Consumed, decimal Returned, decimal Net);
/// <summary>
/// One input of a run stage. <c>PlannedQty</c> is expressed in <c>QtyUnit</c> — content
/// (ml/g) or packs — while every consumption figure is always a pack count, so the two are
/// not directly comparable for a Content input.
/// </summary>
public sealed record RunStageInputDto(
int RunInputId, StageInputSource Source, int? ItemId, int? FromRunOutputId, int UomId,
int RunInputId, StageInputSource Source, int? ItemId, int? FromRunOutputId, StageQtyUnit QtyUnit,
decimal PlannedQty, decimal ConsumedQty, decimal ConsumedValue,
decimal DeliveredQty, decimal ReturnedQty, decimal ReturnedValue);
/// <summary>
/// One output of a run stage. <c>AvailableToTransfer</c> is derived — produced scrapped
/// transferred (FR-MFG-12) — and never stored.
/// transferred (FR-MFG-12) — and never stored. <c>UomId</c> is the WIP label and is null on
/// the terminal output, whose unit is the finished item's base UOM.
/// </summary>
public sealed record RunStageOutputDto(
int RunOutputId, int? ItemId, string Name, int UomId,
int RunOutputId, int? ItemId, string Name, int? UomId,
decimal PlannedQty, decimal ProducedQty, decimal ScrappedQty, int? ScrapReasonCodeId,
decimal TransferredQty, decimal AvailableToTransfer);
@@ -35,10 +35,11 @@ public sealed record FieldDefDto(
public sealed record StageInputDto(
int InputId, StageInputSource Source, int? ItemId,
int? FromOutputId, string? FromOutputKey, int UomId, decimal QtyPerBatch);
int? FromOutputId, string? FromOutputKey, StageQtyUnit QtyUnit, decimal QtyPerBatch);
/// <summary><c>UomId</c> is the WIP label and is null exactly when <c>ItemId</c> is set.</summary>
public sealed record StageOutputDto(
int OutputId, string Key, int? ItemId, string Name, int UomId, decimal QtyPerBatch);
int OutputId, string Key, int? ItemId, string Name, int? UomId, decimal QtyPerBatch);
public sealed record TemplateStageDto(
int StageId, string Key, string Name, string? RoleLabel, int EstimatedMinutes,
@@ -134,8 +135,12 @@ public sealed class SaveInputRequest
[StringLength(60)]
public string? FromOutputKey { get; set; }
[Range(1, int.MaxValue)]
public int UomId { get; set; }
/// <summary>
/// What <see cref="QtyPerBatch"/> means. <c>Content</c> (ml/g) is allowed only on a Stock
/// input whose item has a content size; Upstream inputs must be <c>Pack</c>.
/// </summary>
[EnumDataType(typeof(StageQtyUnit))]
public StageQtyUnit QtyUnit { get; set; } = StageQtyUnit.Pack;
[Range(0.0001, double.MaxValue)]
public decimal QtyPerBatch { get; set; }
@@ -153,8 +158,12 @@ public sealed class SaveOutputRequest
[Required, StringLength(150, MinimumLength = 1)]
public string Name { get; set; } = string.Empty;
/// <summary>
/// The WIP display unit. Required when <see cref="ItemId"/> is null; must be null when it
/// is set, because a real item's unit is its own base UOM.
/// </summary>
[Range(1, int.MaxValue)]
public int UomId { get; set; }
public int? UomId { get; set; }
[Range(0.0001, double.MaxValue)]
public decimal QtyPerBatch { get; set; }
+2 -3
View File
@@ -4,7 +4,7 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Sales;
public sealed record BundleSaleLineDto(
int BundleSaleLineId, int ItemId, string Description, decimal Qty, int UomId, int WarehouseId,
int BundleSaleLineId, int ItemId, string Description, decimal Qty, int WarehouseId,
decimal UnitPrice, decimal LineTotal, bool IncludeInBundle, bool IsComponent, int? ParentLineId);
public sealed record BundleSaleDto(
@@ -20,7 +20,7 @@ public sealed record BundleSaleSummaryDto(
decimal ComponentSubtotal, decimal BundlePrice, decimal GrandTotal, DateTime CreatedAt);
public sealed record BundleSaleTemplateLineDto(
int BundleSaleTemplateLineId, int ItemId, int UomId, int WarehouseId, decimal Qty,
int BundleSaleTemplateLineId, int ItemId, int WarehouseId, decimal Qty,
decimal UnitPrice, bool IncludeInBundle, int SortOrder);
public sealed record BundleSaleTemplateDto(
@@ -42,7 +42,6 @@ public sealed record BundleSalePostingCheckDto(
public sealed class CreateBundleSaleTemplateLineRequest
{
[Required] public int ItemId { get; set; }
[Required] public int UomId { get; set; }
[Required] public int WarehouseId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
[Range(0, double.MaxValue)] public decimal UnitPrice { get; set; }
@@ -4,7 +4,7 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Sales;
public sealed record SalesInvoiceLineDto(
int SalesInvoiceLineId, int ItemId, string Description, decimal Qty, decimal FreeQty, int UomId,
int SalesInvoiceLineId, int ItemId, string Description, decimal Qty, decimal FreeQty,
int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal,
decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId);
@@ -35,7 +35,6 @@ public sealed record SalesInvoicePostingCheckDto(
public sealed class CreateSalesInvoiceLineRequest
{
[Required] public int ItemId { get; set; }
[Required] public int UomId { get; set; }
[Required] public int WarehouseId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
[Range(0, double.MaxValue)] public decimal FreeQty { get; set; }
+2 -3
View File
@@ -4,7 +4,7 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Sales;
public sealed record SalesSlipLineDto(
int SalesSlipLineId, int ItemId, string Description, decimal Qty, decimal FreeQty, int UomId,
int SalesSlipLineId, int ItemId, string Description, decimal Qty, decimal FreeQty,
int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal,
decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId);
@@ -34,7 +34,7 @@ public sealed record SalesSlipPostingCheckDto(
public sealed record FreeIssueSummaryDto(
int SalesSlipId, string SlipNo, SalesSlipStatus Status, DateTime CreatedAt,
int WarehouseId, string WarehouseName, int ItemId, string ItemSku, string ItemName,
int UomId, string UomName, decimal Qty, decimal FreeQty, string SchemeLabel);
string UomName, decimal Qty, decimal FreeQty, string SchemeLabel);
public sealed record FreeIssueDto(
int SalesSlipId, string SlipNo, DateTime SlipDate, SalesSlipStatus Status,
@@ -45,7 +45,6 @@ public sealed record FreeIssueDto(
public sealed class CreateSalesSlipLineRequest
{
[Required] public int ItemId { get; set; }
[Required] public int UomId { get; set; }
[Required] public int WarehouseId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
[Range(0, double.MaxValue)] public decimal FreeQty { get; set; }
@@ -18,7 +18,6 @@ public sealed class BundleSaleLineConfiguration : IEntityTypeConfiguration<Bundl
builder.Property(x => x.IsComponent).HasDefaultValue(true);
builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Uom).WithMany().HasForeignKey(x => x.UomId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -15,7 +15,6 @@ public sealed class BundleSaleTemplateLineConfiguration : IEntityTypeConfigurati
builder.Property(x => x.SortOrder).HasDefaultValue(0);
builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Uom).WithMany().HasForeignKey(x => x.UomId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -49,7 +49,6 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
builder.HasOne(l => l.Grn).WithMany(g => g.Lines).HasForeignKey(l => l.GrnId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.PoLine).WithMany().HasForeignKey(l => l.PoLineId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Uom).WithMany().HasForeignKey(l => l.UomId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Bin).WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
}
@@ -22,6 +22,16 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
// Sales-only fixed selling price; nullable (null ⇒ sell at stock/FIFO value).
builder.Property(i => i.SalePrice).HasPrecision(18, 4);
// Optional content size (how much one stocked pack holds). All four are nullable
// together: null ⇒ the item has no measurable content. The base pair is derived
// server-side by ItemContent.Normalize and is only ever Ml or G.
builder.Property(i => i.ContentQty).HasPrecision(18, 4);
builder.Property(i => i.ContentBaseQty).HasPrecision(18, 4);
builder.Property(i => i.ContentUnit)
.HasConversion<string>().HasMaxLength(20);
builder.Property(i => i.ContentBaseUnit)
.HasConversion<string>().HasMaxLength(20);
builder.Property(i => i.StockNature)
.HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.TrackingMode)
@@ -91,13 +91,13 @@ public sealed class StageInputConfiguration : IEntityTypeConfiguration<StageInpu
builder.HasKey(i => i.InputId);
builder.Property(i => i.Source).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.QtyUnit).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.QtyPerBatch).HasPrecision(18, 4);
builder.HasOne(i => i.Stage).WithMany(s => s.Inputs)
.HasForeignKey(i => i.StageId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.FromOutput).WithMany()
.HasForeignKey(i => i.FromOutputId).OnDelete(DeleteBehavior.Restrict);
}
@@ -206,6 +206,7 @@ public sealed class RunStageInputConfiguration : IEntityTypeConfiguration<RunSta
builder.HasKey(i => i.RunInputId);
builder.Property(i => i.Source).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.QtyUnit).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.PlannedQty).HasPrecision(18, 4);
builder.Property(i => i.ConsumedQty).HasPrecision(18, 4);
builder.Property(i => i.ConsumedValue).HasPrecision(18, 4);
@@ -217,7 +218,6 @@ public sealed class RunStageInputConfiguration : IEntityTypeConfiguration<RunSta
.HasForeignKey(i => i.RunStageId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.FromRunOutput).WithMany()
.HasForeignKey(i => i.FromRunOutputId).OnDelete(DeleteBehavior.Restrict);
@@ -63,11 +63,6 @@ public sealed class PoLineConfiguration : IEntityTypeConfiguration<PoLine>
.HasForeignKey(l => l.ItemId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Uom)
.WithMany()
.HasForeignKey(l => l.UomId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Warehouse)
.WithMany()
.HasForeignKey(l => l.WarehouseId)
@@ -79,11 +79,6 @@ public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration<Sal
builder.Property(x => x.TaxPct).HasPrecision(9, 4);
builder.Property(x => x.TaxAmount).HasPrecision(18, 4);
builder.HasOne(x => x.Uom)
.WithMany()
.HasForeignKey(x => x.UomId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Warehouse)
.WithMany()
.HasForeignKey(x => x.WarehouseId)
@@ -79,11 +79,6 @@ public sealed class SalesSlipLineConfiguration : IEntityTypeConfiguration<SalesS
builder.Property(x => x.TaxPct).HasPrecision(9, 4);
builder.Property(x => x.TaxAmount).HasPrecision(18, 4);
builder.HasOne(x => x.Uom)
.WithMany()
.HasForeignKey(x => x.UomId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Warehouse)
.WithMany()
.HasForeignKey(x => x.WarehouseId)
@@ -1,34 +0,0 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class UomConversionConfiguration : IEntityTypeConfiguration<UomConversion>
{
public void Configure(EntityTypeBuilder<UomConversion> builder)
{
builder.ToTable("uom_conversions");
builder.HasKey(c => c.ConversionId);
builder.Property(c => c.Factor).HasPrecision(18, 6);
builder.HasOne(c => c.Item)
.WithMany(i => i.UomConversions)
.HasForeignKey(c => c.ItemId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(c => c.FromUom)
.WithMany()
.HasForeignKey(c => c.FromUomId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(c => c.ToUom)
.WithMany()
.HasForeignKey(c => c.ToUomId)
.OnDelete(DeleteBehavior.Restrict);
// One conversion per (item, from, to) triple.
builder.HasIndex(c => new { c.ItemId, c.FromUomId, c.ToUomId }).IsUnique();
}
}
@@ -285,13 +285,18 @@ public static class DataSeeder
new Item
{
Sku = "SKU-DEMO-002",
Name = "Demo Item 2",
Description = "Secondary seeded sample item for sales documents",
Name = "Demo Item 2 (500 ml)",
Description = "Secondary seeded sample item; carries a content size so the "
+ "production content-unit path has a fixture",
CategoryId = category.CategoryId,
BaseUomId = uom.UomId,
StockNature = StockNature.Stocked,
TrackingMode = TrackingMode.None,
SalePrice = 50m,
ContentQty = 500m,
ContentUnit = MeasureUnit.Ml,
ContentBaseQty = 500m,
ContentBaseUnit = MeasureUnit.Ml,
Status = EntityStatus.Active,
CreatedAt = now
}
@@ -438,7 +443,6 @@ public static class DataSeeder
new BundleSaleTemplateLine
{
ItemId = items[0].ItemId,
UomId = uom.UomId,
WarehouseId = warehouse.WarehouseId,
Qty = 1m,
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
@@ -448,7 +452,6 @@ public static class DataSeeder
new BundleSaleTemplateLine
{
ItemId = items[1].ItemId,
UomId = uom.UomId,
WarehouseId = warehouse.WarehouseId,
Qty = 1m,
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
@@ -489,7 +492,6 @@ public static class DataSeeder
ItemId = items[0].ItemId,
Description = items[0].Name,
Qty = 1m,
UomId = uom.UomId,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
LineTotal = items[0].SalePrice.GetValueOrDefault(),
@@ -501,7 +503,6 @@ public static class DataSeeder
ItemId = items[1].ItemId,
Description = items[1].Name,
Qty = 1m,
UomId = uom.UomId,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
LineTotal = items[1].SalePrice.GetValueOrDefault(),
@@ -537,7 +538,6 @@ public static class DataSeeder
ItemId = items[0].ItemId,
Description = items[0].Name,
Qty = 1m,
UomId = uom.UomId,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
LineTotal = items[0].SalePrice.GetValueOrDefault(),
@@ -549,7 +549,6 @@ public static class DataSeeder
ItemId = items[1].ItemId,
Description = items[1].Name,
Qty = 1m,
UomId = uom.UomId,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
LineTotal = items[1].SalePrice.GetValueOrDefault(),
@@ -585,7 +584,6 @@ public static class DataSeeder
ItemId = items[0].ItemId,
Description = items[0].Name,
Qty = 1m,
UomId = uom.UomId,
WarehouseId = secondaryWarehouse.WarehouseId,
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
LineTotal = items[0].SalePrice.GetValueOrDefault(),
@@ -662,7 +660,6 @@ public static class DataSeeder
Description = postableItem.Name,
Qty = 2m,
FreeQty = 0m,
UomId = uom.UomId,
WarehouseId = warehouse.WarehouseId,
UnitPrice = 100m,
BaseCost = 0m,
@@ -706,7 +703,6 @@ public static class DataSeeder
Description = shortageItem.Name,
Qty = 6m,
FreeQty = 0m,
UomId = uom.UomId,
WarehouseId = secondaryWarehouse.WarehouseId,
UnitPrice = 50m,
BaseCost = 0m,
@@ -751,7 +747,6 @@ public static class DataSeeder
Description = postableItem.Name,
Qty = 1m,
FreeQty = 0m,
UomId = uom.UomId,
WarehouseId = warehouse.WarehouseId,
UnitPrice = 100m,
BaseCost = 0m,
@@ -794,7 +789,6 @@ public static class DataSeeder
Description = postableItem.Name,
Qty = 1m,
FreeQty = 0m,
UomId = uom.UomId,
WarehouseId = warehouse.WarehouseId,
UnitPrice = 50m,
BaseCost = 0m,
@@ -834,7 +828,6 @@ public static class DataSeeder
Description = shortageItem.Name,
Qty = 3m,
FreeQty = 0m,
UomId = uom.UomId,
WarehouseId = secondaryWarehouse.WarehouseId,
UnitPrice = 50m,
BaseCost = 0m,
@@ -30,7 +30,6 @@ public class ErpDbContext : DbContext
/// <summary>Color/Size/Material dimension names. Unlinked to Item by design (docs/10 C.9).</summary>
public DbSet<ItemType> ItemTypes => Set<ItemType>();
public DbSet<Uom> Uoms => Set<Uom>();
public DbSet<UomConversion> UomConversions => Set<UomConversion>();
public DbSet<Item> Items => Set<Item>();
public DbSet<ItemReorder> ItemReorders => Set<ItemReorder>();
public DbSet<Vendor> Vendors => Set<Vendor>();
+1 -1
View File
@@ -99,7 +99,7 @@ builder.Services.AddScoped<IRfqService, RfqService>();
builder.Services.AddScoped<IPurchaseOrderService, PurchaseOrderService>();
// Stock core + goods receipt (docs/11 §45)
builder.Services.AddScoped<IUomConverter, UomConverter>();
builder.Services.AddScoped<IItemMeasure, ItemMeasure>();
builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
builder.Services.AddScoped<IStockService, StockService>();
builder.Services.AddScoped<IGrnService, GrnService>();
+6 -15
View File
@@ -19,11 +19,9 @@ public sealed class BundleSaleService : IBundleSaleService
private readonly IRepository<BundleSale> _bundles;
private readonly IRepository<Customer> _customers;
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<User> _users;
private readonly ISalesDomainService _sales;
private readonly IUomConverter _uomConverter;
private readonly ISalesPostingService _posting;
private readonly ICurrentUser _currentUser;
private readonly INumberSequenceService _numbers;
@@ -34,11 +32,9 @@ public sealed class BundleSaleService : IBundleSaleService
IRepository<BundleSaleTemplate> templates,
IRepository<Customer> customers,
IRepository<Item> items,
IRepository<Uom> uoms,
IRepository<Warehouse> warehouses,
IRepository<User> users,
ISalesDomainService sales,
IUomConverter uomConverter,
ISalesPostingService posting,
ICurrentUser currentUser,
INumberSequenceService numbers,
@@ -48,11 +44,9 @@ public sealed class BundleSaleService : IBundleSaleService
_bundles = bundles;
_customers = customers;
_items = items;
_uoms = uoms;
_warehouses = warehouses;
_users = users;
_sales = sales;
_uomConverter = uomConverter;
_posting = posting;
_currentUser = currentUser;
_numbers = numbers;
@@ -85,7 +79,7 @@ public sealed class BundleSaleService : IBundleSaleService
template.CreatedAt,
template.UpdatedAt,
template.Lines.OrderBy(x => x.SortOrder).Select(x => new BundleSaleTemplateLineDto(
x.BundleSaleTemplateLineId, x.ItemId, x.UomId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
x.BundleSaleTemplateLineId, x.ItemId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
}
public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
@@ -196,7 +190,6 @@ public sealed class BundleSaleService : IBundleSaleService
: template.Lines.OrderBy(x => x.SortOrder).Select(x => new CreateBundleSaleTemplateLineRequest
{
ItemId = x.ItemId,
UomId = x.UomId,
WarehouseId = x.WarehouseId,
Qty = x.Qty,
UnitPrice = x.UnitPrice,
@@ -214,17 +207,15 @@ public sealed class BundleSaleService : IBundleSaleService
var lineWarehouseId = warehouseId;
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, r.UomId, lineWarehouseId, r.Qty, 0m, null, ct);
var (qtyBase, unitCostBase) = await _uomConverter.ToBaseAsync(item, r.UomId, r.Qty, r.UnitPrice, ct);
var calc = _sales.ComputeLine(qtyBase, 0m, unitCostBase, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, lineWarehouseId, r.Qty, 0m, null, ct);
var calc = _sales.ComputeLine(r.Qty, 0m, r.UnitPrice, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
lines.Add(new BundleSaleLine
{
ItemId = r.ItemId,
Description = item.Name,
Qty = qtyBase,
UomId = item.BaseUomId,
Qty = r.Qty,
WarehouseId = lineWarehouseId,
UnitPrice = unitCostBase,
UnitPrice = r.UnitPrice,
LineTotal = calc.LineTotal,
IncludeInBundle = r.IncludeInBundle,
IsComponent = true,
@@ -251,6 +242,6 @@ public sealed class BundleSaleService : IBundleSaleService
x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.CashierUserId, x.BundleSaleTemplateId,
x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.MarginAmount, x.DiscountTotal, x.TaxTotal, x.GrandTotal,
x.CreatedAt, x.UpdatedAt,
x.Lines.Select(l => new BundleSaleLineDto(l.BundleSaleLineId, l.ItemId, l.Description, l.Qty, l.UomId, l.WarehouseId, l.UnitPrice, l.LineTotal, l.IncludeInBundle, l.IsComponent, l.ParentLineId)).ToList());
x.Lines.Select(l => new BundleSaleLineDto(l.BundleSaleLineId, l.ItemId, l.Description, l.Qty, l.WarehouseId, l.UnitPrice, l.LineTotal, l.IncludeInBundle, l.IsComponent, l.ParentLineId)).ToList());
}
+9 -22
View File
@@ -28,14 +28,12 @@ public sealed class GrnService : IGrnService
private readonly IRepository<PurchaseOrder> _pos;
private readonly IRepository<PoLine> _poLines;
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Bin> _bins;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Batch> _batches;
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger;
private readonly IUomConverter _uomConverter;
private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
@@ -43,23 +41,21 @@ public sealed class GrnService : IGrnService
public GrnService(
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<Warehouse> warehouses,
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IUomConverter uomConverter,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_grns = grns;
_pos = pos;
_poLines = poLines;
_items = items;
_uoms = uoms;
_warehouses = warehouses;
_bins = bins;
_vendors = vendors;
_batches = batches;
_layers = layers;
_ledger = ledger;
_uomConverter = uomConverter;
_fifo = fifo;
_numbers = numbers;
_currentUser = currentUser;
@@ -133,8 +129,6 @@ public sealed class GrnService : IGrnService
{
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == input.ItemId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422);
if (!await _uoms.Query().AnyAsync(u => u.UomId == input.UomId, ct))
throw new DomainException(ErrorCodes.Validation, $"UOM {input.UomId} does not exist.", 422);
if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422);
@@ -150,6 +144,8 @@ public sealed class GrnService : IGrnService
if (poLine.ItemId != input.ItemId)
throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is for a different item.", 422);
// Both sides are counts of the item's base UOM — the GRN line no longer carries
// a unit of its own — so this comparison and the accrual below are like-for-like.
var openQty = poLine.Qty - poLine.QtyReceived;
if (input.Qty > openQty * (1 + OverReceiptTolerance))
throw new DomainException(ErrorCodes.OverReceiptTolerance,
@@ -174,7 +170,6 @@ public sealed class GrnService : IGrnService
{
PoLineId = input.PoLineId,
ItemId = input.ItemId,
UomId = input.UomId,
BinId = input.BinId,
Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted
Qty = input.Qty,
@@ -234,10 +229,11 @@ public sealed class GrnService : IGrnService
{
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
{
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token);
// FIFO layer costs at the after-discount net price; VAT is recoverable and never
// enters stock value (docs/10 FR-GRN-06, revised).
var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.NetUnitCost, token);
// enters stock value (docs/10 FR-GRN-06, revised). The line quantity is already
// a count of the item's base UOM, so it layers exactly as entered.
var qtyBase = line.Qty;
var unitCostBase = line.NetUnitCost;
var layer = await _fifo.CreateInboundLayerAsync(
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId,
@@ -345,15 +341,6 @@ public sealed class GrnService : IGrnService
return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges
}
/// <summary>
/// Delegates to the shared <see cref="IUomConverter"/>. This was a private method here
/// until manufacturing needed the same conversion for stage stock inputs; behaviour is
/// identical, so receive costing is unchanged.
/// </summary>
private Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct)
=> _uomConverter.ToBaseAsync(item, uomId, qty, unitCostPerUom, ct);
private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
{
if (poId is null) return;
@@ -392,7 +379,7 @@ public sealed class GrnService : IGrnService
private static GrnDto Map(Grn g) => new(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
l.GrnLineId, l.PoLineId, l.ItemId, l.UomId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal,
l.PoUnitPrice is null ? 0m : Math.Round((l.UnitCost - l.PoUnitPrice.Value) * l.Qty, 4, MidpointRounding.AwayFromZero),
l.HoldStatus, l.BatchId)).ToList());
@@ -0,0 +1,25 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Resolves a production formula quantity to the only unit stock speaks: a count of the
/// item's base UOM. Replaces the old per-item UOM conversion table — everything needed is
/// on the item row, so this does no I/O.
/// </summary>
public interface IItemMeasure
{
/// <summary>
/// Formula quantity → packs. <see cref="StageQtyUnit.Pack"/> passes straight through;
/// <see cref="StageQtyUnit.Content"/> divides by the item's content size, so 2000 ml of
/// a 500 ml bottle is 4 bottles and 300 ml is 0.6 of one.
/// </summary>
decimal ToPacks(Item item, decimal formulaQty, StageQtyUnit unit);
/// <summary>Packs → formula quantity. The exact inverse of <see cref="ToPacks"/>, for display.</summary>
decimal FromPacks(Item item, decimal packs, StageQtyUnit unit);
/// <summary>Whether the item carries a usable content size.</summary>
bool HasContent(Item item);
}
@@ -24,6 +24,4 @@ public interface IItemService
Task SetStatusAsync(int itemId, EntityStatus status, CancellationToken ct = default);
Task<ItemReorderSettingsDto> UpdateReorderAsync(int itemId, UpdateReorderRequest request, CancellationToken ct = default);
Task<ItemUomConversionsDto> UpdateUomConversionsAsync(int itemId, UpdateUomConversionsRequest request, CancellationToken ct = default);
}
@@ -15,7 +15,6 @@ public interface ISalesDomainService
Task ValidateSalesLineAsync(
int headerWarehouseId,
int lineItemId,
int lineUomId,
int lineWarehouseId,
decimal qty,
decimal freeQty,
@@ -1,35 +0,0 @@
using ERPCore.Domain.Entities;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Converts a quantity and its per-UOM cost into the item's <b>base</b> UOM.
/// </summary>
/// <remarks>
/// <para>Everything in the FIFO engine — <c>StockLayer</c>, <c>StockLedger</c>,
/// <c>IFifoCostingService.ConsumeAsync</c> — works exclusively in base UOM, while
/// documents let a user enter a line in any UOM the item has a conversion for. This is the
/// one place that bridges the two.</para>
/// <para>Extracted from <c>GrnService</c>'s private <c>ToBaseAsync</c> when manufacturing
/// needed the same conversion for stage stock inputs (docs/30 never mentions UOM
/// conversion, but <c>STAGE_INPUT.uom_id</c> is a free FK — without this, an input
/// specified in "Box of 12" would consume 1 base unit instead of 12 and silently
/// mis-cost the run).</para>
/// </remarks>
public interface IUomConverter
{
/// <summary>
/// Returns the quantity and unit cost restated in <paramref name="item"/>'s base UOM.
/// A no-op when <paramref name="uomId"/> already is the base UOM. Throws 422 when no
/// conversion is defined for the item from that UOM to its base.
/// </summary>
Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default);
/// <summary>
/// Quantity-only conversion, for callers that have no per-UOM cost to restate (a
/// production stage input declares a quantity; its cost comes from the FIFO layers it
/// consumes, not from the document).
/// </summary>
Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default);
}
+61
View File
@@ -0,0 +1,61 @@
using ERPCore.Domain.Enums;
using ERPCore.System.Errors;
namespace ERPCore.Services;
/// <summary>
/// Item content-size rules: validate the (qty, unit) pair and normalise it to a base
/// unit. Pure — no DI, no database — because everything it needs is on the item row.
/// <para>
/// Litres and kilograms exist only at the point of entry. Everything stored and every
/// downstream calculation works in millilitres or grams, so no consumer ever has to ask
/// which unit it is holding.
/// </para>
/// </summary>
public static class ItemContent
{
/// <summary>
/// Rejects a half-filled pair. Both null is valid and means "this item has no
/// measurable content" — a screw, a label, a service.
/// </summary>
public static void ValidatePair(decimal? contentQty, MeasureUnit? contentUnit)
{
if (contentQty is null && contentUnit is null) return;
if (contentQty is null || contentUnit is null)
throw new DomainException(
ErrorCodes.Validation,
"contentQty and contentUnit must be supplied together, or both left null.", 422);
if (contentQty <= 0)
throw new DomainException(
ErrorCodes.Validation, "contentQty must be greater than zero.", 422);
}
/// <summary>
/// Converts an entered content size to its base unit: L → Ml and Kg → G, both ×1000;
/// Ml and G pass through. Both-null in, both-null out.
/// <para>
/// Rounded to 4dp AwayFromZero to match the quantity columns' <c>(18,4)</c> scale and
/// <c>ProductionRunService.Scale</c>, so a content size can never carry precision the
/// database would silently drop.
/// </para>
/// </summary>
public static (decimal? BaseQty, MeasureUnit? BaseUnit) Normalize(
decimal? contentQty, MeasureUnit? contentUnit)
{
if (contentQty is null || contentUnit is null) return (null, null);
var (factor, baseUnit) = contentUnit.Value switch
{
MeasureUnit.Ml => (1m, MeasureUnit.Ml),
MeasureUnit.L => (1000m, MeasureUnit.Ml),
MeasureUnit.G => (1m, MeasureUnit.G),
MeasureUnit.Kg => (1000m, MeasureUnit.G),
_ => throw new DomainException(
ErrorCodes.Validation, $"Unsupported content unit '{contentUnit}'.", 422)
};
return (Math.Round(contentQty.Value * factor, 4, MidpointRounding.AwayFromZero), baseUnit);
}
}
+43 -54
View File
@@ -31,6 +31,8 @@ public sealed class ItemService : IItemService
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<StockLayer> _stockLayers;
private readonly IRepository<StockLedger> _stockLedger;
private readonly IProductConfigService _config;
private readonly IUnitOfWork _uow;
@@ -42,6 +44,8 @@ public sealed class ItemService : IItemService
IRepository<Uom> uoms,
IRepository<Vendor> vendors,
IRepository<Warehouse> warehouses,
IRepository<StockLayer> stockLayers,
IRepository<StockLedger> stockLedger,
IProductConfigService config,
IUnitOfWork uow)
{
@@ -52,6 +56,8 @@ public sealed class ItemService : IItemService
_uoms = uoms;
_vendors = vendors;
_warehouses = warehouses;
_stockLayers = stockLayers;
_stockLedger = stockLedger;
_config = config;
_uow = uow;
}
@@ -79,7 +85,9 @@ public sealed class ItemService : IItemService
.Select(i => new ItemListItemDto(
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId,
i.BaseUomId, i.DefaultVendorId,
i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice, i.Status))
i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice,
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
i.Status))
.ToListAsync(ct);
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
@@ -89,7 +97,6 @@ public sealed class ItemService : IItemService
{
var item = await _items.Query().AsNoTracking()
.Include(i => i.ReorderSettings)
.Include(i => i.UomConversions)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
return item is null ? null : new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
@@ -104,6 +111,9 @@ public sealed class ItemService : IItemService
request.CategoryId, request.SubCategoryId, request.BrandId,
request.BaseUomId, request.DefaultVendorId, ct);
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
var item = new Item
{
Sku = request.Sku.Trim(),
@@ -118,6 +128,10 @@ public sealed class ItemService : IItemService
TrackingMode = request.TrackingMode,
TaxClass = request.TaxClass,
SalePrice = request.SalePrice,
ContentQty = request.ContentQty,
ContentUnit = request.ContentUnit,
ContentBaseQty = contentBaseQty,
ContentBaseUnit = contentBaseUnit,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
@@ -133,7 +147,6 @@ public sealed class ItemService : IItemService
{
var item = await _items.Query()
.Include(i => i.ReorderSettings)
.Include(i => i.UomConversions)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
?? throw new NotFoundException($"Item {itemId} was not found.");
@@ -148,6 +161,18 @@ public sealed class ItemService : IItemService
request.CategoryId, request.SubCategoryId, request.BrandId,
request.BaseUomId, request.DefaultVendorId, ct);
// The base UOM is the sole meaning of every quantity recorded against this item —
// stock layers, ledger rows and document lines are all plain counts of it. Once any
// of that history exists, changing it would silently reinterpret every one of those
// numbers (240 bottles becoming 240 cases), so it is frozen instead.
if (item.BaseUomId != request.BaseUomId && await HasStockHistoryAsync(itemId, ct))
throw new DomainException(
ErrorCodes.MasterInUse,
$"Item {itemId} has stock history; its base UOM can no longer be changed.", 409);
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
item.Sku = request.Sku.Trim();
item.Name = request.Name.Trim();
item.Description = request.Description;
@@ -160,6 +185,10 @@ public sealed class ItemService : IItemService
item.TrackingMode = request.TrackingMode;
item.TaxClass = request.TaxClass;
item.SalePrice = request.SalePrice;
item.ContentQty = request.ContentQty;
item.ContentUnit = request.ContentUnit;
item.ContentBaseQty = contentBaseQty;
item.ContentBaseUnit = contentBaseUnit;
item.UpdatedAt = DateTime.UtcNow;
await SaveGuardingConcurrencyAsync(ct);
@@ -224,52 +253,14 @@ public sealed class ItemService : IItemService
return new ItemReorderSettingsDto(settings);
}
public async Task<ItemUomConversionsDto> UpdateUomConversionsAsync(
int itemId, UpdateUomConversionsRequest request, CancellationToken ct = default)
{
var pairs = request.Conversions.Select(c => (c.FromUom, c.ToUom)).ToList();
if (pairs.Distinct().Count() != pairs.Count)
throw new DomainException(ErrorCodes.Validation, "Duplicate (fromUom, toUom) in conversions.", 400);
var item = await _items.Query()
.Include(i => i.UomConversions)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
?? throw new NotFoundException($"Item {itemId} was not found.");
foreach (var uomId in request.Conversions.SelectMany(c => new[] { c.FromUom, c.ToUom }).Distinct())
if (!await _uoms.Query().AnyAsync(u => u.UomId == uomId, ct))
throw new DomainException(ErrorCodes.Validation, $"UOM {uomId} does not exist.", 422);
foreach (var stale in item.UomConversions.Where(c => request.Conversions.All(r => r.FromUom != c.FromUomId || r.ToUom != c.ToUomId)).ToList())
item.UomConversions.Remove(stale);
foreach (var input in request.Conversions)
{
var existing = item.UomConversions.FirstOrDefault(c => c.FromUomId == input.FromUom && c.ToUomId == input.ToUom);
if (existing is null)
{
item.UomConversions.Add(new UomConversion
{
ItemId = itemId,
FromUomId = input.FromUom,
ToUomId = input.ToUom,
Factor = input.Factor
});
}
else
{
existing.Factor = input.Factor;
}
}
item.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
var conversions = item.UomConversions
.OrderBy(c => c.ConversionId)
.Select(c => new UomConversionDto(c.ConversionId, c.FromUomId, c.ToUomId, c.Factor))
.ToList();
return new ItemUomConversionsDto(item.ItemId, item.BaseUomId, conversions);
}
/// <summary>
/// Whether anything has ever been recorded against this item's stock. Checks the ledger
/// as well as live layers, because a fully consumed item has no layer left but its
/// history still reads in the old unit.
/// </summary>
private async Task<bool> HasStockHistoryAsync(int itemId, CancellationToken ct)
=> await _stockLayers.Query().AnyAsync(l => l.ItemId == itemId, ct)
|| await _stockLedger.Query().AnyAsync(l => l.ItemId == itemId, ct);
/// <summary>
/// Validates every FK on an item write, and gates the optional ones on the product
@@ -350,14 +341,12 @@ public sealed class ItemService : IItemService
private static ItemDetailDto ToDetail(Item i) => new(
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId,
i.BaseUomId, i.DefaultVendorId,
i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice, i.Status,
i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice,
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
i.Status,
i.ReorderSettings
.OrderBy(r => r.WarehouseId)
.Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty))
.ToList(),
i.UomConversions
.OrderBy(c => c.ConversionId)
.Select(c => new UomConversionDto(c.ConversionId, c.FromUomId, c.ToUomId, c.Factor))
.ToList(),
i.CreatedAt, i.UpdatedAt);
}
@@ -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,19 +278,42 @@ public sealed class ProductionTemplateService : IProductionTemplateService
if (inactive.Count > 0)
throw new DomainException(ErrorCodes.Validation,
$"Item(s) {string.Join(", ", inactive)} are inactive and cannot be used in a template.", 422);
contentByItem = found.ToDictionary(f => f.ItemId, f => f.ContentBaseQty);
}
// A Content quantity is only meaningful against an item that declares a content size,
// and it must still land on a storable pack count: quantities persist at (18,4), so
// anything under 0.0001 packs would round to zero and consume nothing at stage start.
foreach (var s in request.Stages)
foreach (var i in s.Inputs.Where(i => i.QtyUnit == StageQtyUnit.Content))
{
var contentBaseQty = contentByItem.GetValueOrDefault(i.ItemId ?? 0);
if (contentBaseQty is not > 0m)
throw new DomainException(ErrorCodes.Validation,
$"Stage '{s.Name}' has an input in content units, but item {i.ItemId} has no content size.", 422);
if (i.QtyPerBatch / contentBaseQty.Value < 0.0001m)
throw new DomainException(ErrorCodes.Validation,
$"Stage '{s.Name}': {i.QtyPerBatch} is less than 0.0001 of item {i.ItemId}'s content size ({contentBaseQty}), which would round to no stock at all.", 422);
}
// Only WIP outputs carry a UOM now; the validator has already rejected one on an
// item-bearing output, so every non-null id here belongs to real work-in-progress.
var uomIds = request.Stages
.SelectMany(s => s.Inputs.Select(i => i.UomId).Concat(s.Outputs.Select(o => o.UomId)))
.Distinct().ToList();
.SelectMany(s => s.Outputs.Select(o => o.UomId))
.OfType<int>().Distinct().ToList();
var knownUoms = await _uoms.Query().AsNoTracking()
.Where(u => uomIds.Contains(u.UomId)).Select(u => u.UomId).ToListAsync(ct);
if (uomIds.Count > 0)
{
var knownUoms = await _uoms.Query().AsNoTracking()
.Where(u => uomIds.Contains(u.UomId)).Select(u => u.UomId).ToListAsync(ct);
var missingUoms = uomIds.Except(knownUoms).ToList();
if (missingUoms.Count > 0)
throw new DomainException(ErrorCodes.Validation,
$"UOM(s) {string.Join(", ", missingUoms)} do not exist.", 422);
var missingUoms = uomIds.Except(knownUoms).ToList();
if (missingUoms.Count > 0)
throw new DomainException(ErrorCodes.Validation,
$"UOM(s) {string.Join(", ", missingUoms)} do not exist.", 422);
}
// Annotations go into jsonb unvalidated by anything else, so pin the one field the
// client renders off. Unknown kinds would round-trip fine but draw nothing.
@@ -428,7 +452,7 @@ public sealed class ProductionTemplateService : IProductionTemplateService
Source = i.Source,
ItemId = i.Source == StageInputSource.Stock ? i.ItemId : null,
FromOutput = i.Source == StageInputSource.Upstream ? outputsByKey[i.FromOutputKey!] : null,
UomId = i.UomId,
QtyUnit = i.QtyUnit,
QtyPerBatch = i.QtyPerBatch
});
}
@@ -476,7 +500,7 @@ public sealed class ProductionTemplateService : IProductionTemplateService
s.Inputs.OrderBy(i => i.InputId).Select(i => new StageInputDto(
i.InputId, i.Source, i.ItemId, i.FromOutputId,
i.FromOutputId is null ? null : outputKeyById.GetValueOrDefault(i.FromOutputId.Value),
i.UomId, i.QtyPerBatch)).ToList(),
i.QtyUnit, i.QtyPerBatch)).ToList(),
s.Outputs.OrderBy(o => o.OutputId).Select(o => new StageOutputDto(
o.OutputId, o.OutputId.ToString(), o.ItemId, o.Name, o.UomId, o.QtyPerBatch)).ToList()))
.ToList();
@@ -222,7 +222,6 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
private static PoLine ToLine(CreatePoLineInput l) => new()
{
ItemId = l.ItemId,
UomId = l.UomId,
WarehouseId = l.WarehouseId,
Qty = l.Qty,
UnitPrice = l.UnitPrice,
@@ -258,7 +257,6 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
throw new DomainException(ErrorCodes.Validation, $"Requisition {requisitionId} does not exist.", 422);
await EnsureAllExistAsync(_items.Query().Select(i => i.ItemId), lines.Select(l => l.ItemId), "Item", ct);
await EnsureAllExistAsync(_uoms.Query().Select(u => u.UomId), lines.Select(l => l.UomId), "UOM", ct);
await EnsureAllExistAsync(_warehouses.Query().Select(w => w.WarehouseId), lines.Select(l => l.WarehouseId), "Warehouse", ct);
}
@@ -276,5 +274,5 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
p.PoId, p.DocNo, p.VendorId, p.RequisitionId, p.Status, p.ApprovalRequired,
p.CreatedBy, p.CreatedAt, p.UpdatedAt, ComputeTotals(p.Lines),
p.Lines.OrderBy(l => l.PoLineId).Select(l => new PoLineDto(
l.PoLineId, l.ItemId, l.UomId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived)).ToList());
l.PoLineId, l.ItemId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived)).ToList());
}
@@ -13,7 +13,6 @@ public sealed class SalesDomainService : ISalesDomainService
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<User> _users;
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
private readonly ISalesPricingService _pricing;
public SalesDomainService(
@@ -21,14 +20,12 @@ public sealed class SalesDomainService : ISalesDomainService
IRepository<Warehouse> warehouses,
IRepository<User> users,
IRepository<Item> items,
IRepository<Uom> uoms,
ISalesPricingService pricing)
{
_customers = customers;
_warehouses = warehouses;
_users = users;
_items = items;
_uoms = uoms;
_pricing = pricing;
}
@@ -48,7 +45,7 @@ public sealed class SalesDomainService : ISalesDomainService
}
public async Task ValidateSalesLineAsync(
int headerWarehouseId, int lineItemId, int lineUomId, int lineWarehouseId, decimal qty, decimal freeQty, int? parentLineId, CancellationToken ct = default)
int headerWarehouseId, int lineItemId, int lineWarehouseId, decimal qty, decimal freeQty, int? parentLineId, CancellationToken ct = default)
{
if (qty <= 0)
throw new DomainException(ErrorCodes.Validation, "Sales line quantity must be greater than zero.", 422);
@@ -60,8 +57,6 @@ public sealed class SalesDomainService : ISalesDomainService
throw new DomainException(ErrorCodes.Validation, $"Sales line warehouse {lineWarehouseId} must match header warehouse {headerWarehouseId}.", 422);
if (!await _items.Query().AnyAsync(x => x.ItemId == lineItemId, ct))
throw new NotFoundException($"Item {lineItemId} was not found.");
if (!await _uoms.Query().AnyAsync(x => x.UomId == lineUomId, ct))
throw new NotFoundException($"UOM {lineUomId} was not found.");
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == lineWarehouseId, ct))
throw new NotFoundException($"Warehouse {lineWarehouseId} was not found.");
}
@@ -145,7 +145,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
foreach (var r in requests)
{
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct);
await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct);
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
var unitPrice = resolved.UnitPrice;
var priceSource = resolved.PriceSource;
@@ -158,7 +158,6 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
Description = item.Name,
Qty = r.Qty,
FreeQty = r.FreeQty,
UomId = r.UomId,
WarehouseId = r.WarehouseId,
UnitPrice = unitPrice,
BaseCost = unitPrice,
@@ -35,7 +35,7 @@ public sealed class SalesMappingService : ISalesMappingService
invoice.InvoiceType, invoice.Status, invoice.CreatedBy, invoice.CreatedAt, invoice.UpdatedAt,
MapInvoiceTotals(invoice),
invoice.Lines.Select(l => new SalesInvoiceLineDto(
l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId,
l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId,
l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode,
l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
@@ -45,7 +45,7 @@ public sealed class SalesMappingService : ISalesMappingService
slip.WarehouseId, slip.CashierUserId, slip.Status, slip.CreatedAt, slip.UpdatedAt,
MapSlipTotals(slip),
slip.Lines.Select(l => new SalesSlipLineDto(
l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId,
l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId,
l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode,
l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
}
@@ -20,7 +20,6 @@ public sealed class SalesPostingService : ISalesPostingService
private readonly IRepository<Item> _items;
private readonly IFifoCostingService _fifo;
private readonly ISalesDomainService _sales;
private readonly IUomConverter _uomConverter;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
@@ -31,7 +30,6 @@ public sealed class SalesPostingService : ISalesPostingService
IRepository<Item> items,
IFifoCostingService fifo,
ISalesDomainService sales,
IUomConverter uomConverter,
ICurrentUser currentUser,
IUnitOfWork uow)
{
@@ -41,7 +39,6 @@ public sealed class SalesPostingService : ISalesPostingService
_items = items;
_fifo = fifo;
_sales = sales;
_uomConverter = uomConverter;
_currentUser = currentUser;
_uow = uow;
}
@@ -141,7 +138,7 @@ public sealed class SalesPostingService : ISalesPostingService
notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.",
statusSelector: x => x.Status,
ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.",
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
setPosted: x => x.Status = SalesInvoiceStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: DocumentTypes.SalesInvoice,
@@ -154,7 +151,7 @@ public sealed class SalesPostingService : ISalesPostingService
notFoundMessage: $"Sales slip {salesSlipId} was not found.",
statusSelector: x => x.Status,
ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.",
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
setPosted: x => x.Status = SalesSlipStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: DocumentTypes.SalesSlip,
@@ -167,9 +164,7 @@ public sealed class SalesPostingService : ISalesPostingService
notFoundMessage: $"Bundle sale {bundleSaleId} was not found.",
statusSelector: x => x.Status,
ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.",
// Bundle lines are normalized to base UOM on save, so posting should consume the
// stored base quantity directly instead of converting again.
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty, l.Qty, 0m)),
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m)),
setPosted: x => x.Status = BundleSaleStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: DocumentTypes.BundleSale,
@@ -203,6 +198,8 @@ public sealed class SalesPostingService : ISalesPostingService
if (!await _sales.IsStockedItemAsync(line.ItemId, token))
continue;
// Line quantities are already a count of the item's base UOM — sales documents
// carry no unit of their own — so this is the quantity FIFO consumes verbatim.
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty, token);
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId,
@@ -214,5 +211,5 @@ public sealed class SalesPostingService : ISalesPostingService
}, ct);
}
private sealed record PostingLine(int ItemId, int WarehouseId, int UomId, decimal Qty, decimal PaidQty, decimal FreeQty);
private sealed record PostingLine(int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty);
}
+7 -7
View File
@@ -169,7 +169,7 @@ public sealed class SalesSlipService : ISalesSlipService
foreach (var r in requests)
{
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct);
await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct);
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
var unitPrice = resolved.UnitPrice;
var priceSource = resolved.PriceSource;
@@ -182,7 +182,6 @@ public sealed class SalesSlipService : ISalesSlipService
Description = item.Name,
Qty = r.Qty,
FreeQty = r.FreeQty,
UomId = r.UomId,
WarehouseId = r.WarehouseId,
UnitPrice = unitPrice,
BaseCost = unitPrice,
@@ -222,8 +221,10 @@ public sealed class SalesSlipService : ISalesSlipService
.Where(i => i.ItemId == line.ItemId)
.Select(i => new { i.ItemId, i.Sku, i.Name, i.BaseUomId })
.FirstOrDefault();
var uom = line is null ? null : _uoms.Query().AsNoTracking()
.Where(u => u.UomId == line.UomId)
// The line carries no unit of its own — its quantity is a count of the item's base
// UOM — so the display name comes from there.
var uom = item is null ? null : _uoms.Query().AsNoTracking()
.Where(u => u.UomId == item.BaseUomId)
.Select(u => new { u.UomId, u.Name })
.FirstOrDefault();
var warehouse = _warehouses.Query().AsNoTracking()
@@ -240,8 +241,7 @@ public sealed class SalesSlipService : ISalesSlipService
line?.ItemId ?? 0,
item?.Sku ?? $"SKU-{line?.ItemId ?? 0}",
item?.Name ?? line?.Description ?? "—",
line?.UomId ?? 0,
uom?.Name ?? $"UOM {line?.UomId ?? 0}",
uom?.Name ?? $"UOM {item?.BaseUomId ?? 0}",
line?.Qty ?? 0m,
line?.FreeQty ?? 0m,
line is null ? "No line" : $"Buy {line.Qty} Get {line.FreeQty}");
@@ -263,7 +263,7 @@ public sealed class SalesSlipService : ISalesSlipService
x.CreatedAt,
x.UpdatedAt,
summary,
x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
}
private SalesSlipDto Map(SalesSlip x) => _mapping.MapSlip(x);
@@ -0,0 +1,39 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
namespace ERPCore.Services.Stock;
/// <summary>
/// Content ↔ pack arithmetic (see <see cref="IItemMeasure"/>). Stateless and I/O-free:
/// the content size is already on the <see cref="Item"/> every caller has loaded.
/// </summary>
public sealed class ItemMeasure : IItemMeasure
{
public bool HasContent(Item item) => item.ContentBaseQty is > 0m;
public decimal ToPacks(Item item, decimal formulaQty, StageQtyUnit unit)
{
if (unit == StageQtyUnit.Pack) return formulaQty;
// Rounded to the quantity columns' (18,4) scale with the same mode as
// ProductionRunService.Scale, so what FIFO consumes and what the cost pool divides
// by are the same number to the last stored digit.
return Math.Round(formulaQty / RequireContent(item), 4, MidpointRounding.AwayFromZero);
}
public decimal FromPacks(Item item, decimal packs, StageQtyUnit unit)
{
if (unit == StageQtyUnit.Pack) return packs;
return Math.Round(packs * RequireContent(item), 4, MidpointRounding.AwayFromZero);
}
private static decimal RequireContent(Item item)
=> item.ContentBaseQty is > 0m
? item.ContentBaseQty.Value
: throw new DomainException(
ErrorCodes.Validation,
$"Item {item.ItemId} has no content size, so its quantity cannot be expressed in content units.", 422);
}
@@ -1,38 +0,0 @@
using ERPCore.Domain.Entities;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Stock;
/// <summary>
/// Shared UOM → base-UOM conversion (see <see cref="IUomConverter"/>). Behaviour is
/// unchanged from the <c>GrnService.ToBaseAsync</c> it was extracted from, so the GRN
/// receive path keeps costing exactly as before.
/// </summary>
public sealed class UomConverter : IUomConverter
{
private readonly IRepository<UomConversion> _conversions;
public UomConverter(IRepository<UomConversion> conversions) => _conversions = conversions;
public async Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default)
{
if (uomId == item.BaseUomId)
return (qty, unitCostPerUom);
var conv = await _conversions.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct)
?? throw new DomainException(ErrorCodes.Validation,
$"No UOM conversion from {uomId} to base UOM {item.BaseUomId} for item {item.ItemId}.", 422);
// Quantity scales up by the factor, so the per-unit cost scales down by it —
// total value is preserved.
return (qty * conv.Factor, unitCostPerUom / conv.Factor);
}
public async Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default)
=> (await ToBaseAsync(item, uomId, qty, 0m, ct)).QtyBase;
}
@@ -56,6 +56,7 @@ public static class ErrorCodes
public const string GraphDisconnected = "GRAPH_DISCONNECTED";
public const string GraphInputSourceInvalid = "GRAPH_INPUT_SOURCE_INVALID";
public const string TerminalOutputItemRequired = "TERMINAL_OUTPUT_ITEM_REQUIRED";
public const string WipUnitRequired = "WIP_UNIT_REQUIRED";
public const string StageNotReady = "STAGE_NOT_READY";
public const string StageNotInProgress = "STAGE_NOT_IN_PROGRESS";
public const string StageNotDone = "STAGE_NOT_DONE";