Compare commits

..

17 Commits

Author SHA1 Message Date
ImanThiyanga 8555702a76 Merge branch 'production' into Dev 2026-08-13 06:52:48 +00:00
ImanThiyanga fd95e92eb1 Merge pull request 'feat: add warranty tracking for items and GRN lines' (#44) from warranty-fileds-and-ui-updates into Dev
Reviewed-on: #44
2026-08-13 06:51:10 +00:00
ImanThiyanga 6e008773db Merge pull request 'feat: add warranty tracking for items and GRN lines' (#43) from warranty-fileds-and-ui-updates into production
Reviewed-on: #43
2026-08-13 06:42:51 +00:00
Sasanka 179d5b0803 feat: add warranty tracking for items and GRN lines
- Enhanced ItemService to include warranty and warranty period months in item DTOs.
- Implemented validation for warranty periods in ItemService.
- Updated frontend item detail and new item pages to handle warranty selection and input.
- Added warranty number handling in GRN creation and detail pages.
- Introduced new GrnLineWarrantyNumber entity to capture warranty numbers for received items.
- Created Warranty enum and associated allowed months for warranty coverage.
- Updated validation logic for GRN lines to ensure warranty numbers are captured correctly.
2026-08-13 12:01:11 +05:30
ImanThiyanga 342012a321 Merge pull request 'Dev' (#42) from Dev into production
Reviewed-on: #42
2026-08-12 19:45:10 +00:00
ImanThiyanga ee6ac913f1 Merge branch 'production' into Dev 2026-08-12 19:44:58 +00:00
ImanThiyanga 2661169351 Merge pull request 'pushing migration changes from now -on' (#41) from fix/migration-issue into Dev
Reviewed-on: #41
2026-08-12 07:05:09 +00:00
ImanThiyanga 80213cf47d pushing migration changes from now -on 2026-08-12 12:32:25 +05:30
ImanThiyanga 18475fdc9f Merge pull request 'feat: add sales return functionality' (#40) from sales-return into Dev
Reviewed-on: #40
2026-08-12 06:28:09 +00:00
ImanThiyanga 9a32c5c609 Merge branch 'Dev' into sales-return 2026-08-12 06:26:49 +00:00
ImanThiyanga 32f40e9d1a Merge pull request 'sku and item fix' (#39) from fix/uom-overall into Dev
Reviewed-on: #39
2026-08-12 05:32:49 +00:00
ImanThiyanga b2a218e2f8 Merge branch 'Dev' into fix/uom-overall 2026-08-12 05:32:39 +00:00
Sasanka d16a227b54 feat: add sales return functionality
- Introduced Sales Return types and interfaces in the frontend for managing sales returns.
- Implemented SalesReturnsController in the backend to handle sales return endpoints.
- Created SalesReturn and SalesReturnLine entities to represent sales return data.
- Added DTOs for sales return responses and requests.
- Configured Entity Framework for SalesReturn and SalesReturnLine entities.
- Developed ISalesReturnService interface and its implementation for business logic.
- Added API methods for listing sales returns, creating new returns, and fetching remaining returnable quantities.
- Created frontend components for creating and listing sales returns, including validation logic.
- Implemented UI for selecting invoices, reason codes, and managing return lines.
2026-08-12 10:18:56 +05:30
ImanThiyanga a7ba3d3e04 sku and item fix 2026-08-11 21:55:04 +05:30
ImanThiyanga 0ae80395cf Merge pull request 'Refactor production and stock tests to remove UOM dependency' (#38) from fix/uom-overall into Dev
Reviewed-on: #38
2026-08-11 06:03:30 +00:00
ImanThiyanga 15ddac178c 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.
2026-08-11 11:32:40 +05:30
ImanThiyanga 7219480ca0 Merge pull request 'Merge pull request 'Fix/general ledger v01' (#30) from fix/general-ledger-v01 into Dev' (#31) from Dev into production
Reviewed-on: #31
2026-08-05 07:22:32 +00:00
155 changed files with 28833 additions and 2648 deletions
+1 -4
View File
@@ -36,7 +36,4 @@ Testing/e2e/test-results/
Testing/e2e/.auth/
Testing/e2e/blob-report/
# ── Migrations ─────────────────────────────────────────────────────────
# Each dev keeps EF Core migrations local; DB schema changes are announced
# to the team instead of committed, so migration files aren't shared here.
Migrations/
+1 -25
View File
@@ -1,7 +1,6 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Items;
using ERPCore.Dtos.Uoms;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -12,13 +11,8 @@ namespace ERPCore.Controllers;
public sealed class ItemsController : ApiControllerBase
{
private readonly IItemService _items;
private readonly IUomConverter _uomConverter;
public ItemsController(IItemService items, IUomConverter uomConverter)
{
_items = items;
_uomConverter = uomConverter;
}
public ItemsController(IItemService items) => _items = items;
/// <summary>List items with optional filters and paging.</summary>
[HttpGet]
@@ -87,22 +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));
/// <summary>
/// The UOMs this item may be transacted in — its base UOM plus every UOM it has a
/// conversion from, each with the factor to base. Document line forms use this to offer
/// only units that will survive posting, instead of the whole global UOM list (FR-MD-02/03).
/// </summary>
[HttpGet("{itemId:int}/uoms")]
[ProducesResponseType(typeof(IReadOnlyList<AllowedUomDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<AllowedUomDto>>> GetAllowedUoms(int itemId, CancellationToken ct)
=> Ok(await _uomConverter.GetAllowedUomsAsync(itemId, ct));
}
@@ -0,0 +1,49 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Sales-return endpoints — customer returns of previously sold goods.</summary>
[Route("api/v1/sales-returns")]
public sealed class SalesReturnsController : ApiControllerBase
{
private readonly ISalesReturnService _returns;
public SalesReturnsController(ISalesReturnService returns) => _returns = returns;
/// <summary>List posted returns, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalesReturnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SalesReturnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? customerId, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _returns.ListAsync(query, customerId, warehouseId, ct));
/// <summary>Remaining returnable qty per line of one sales invoice (invoiced qty minus already-returned).</summary>
[HttpGet("remaining")]
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoiceLineRemainingDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SalesInvoiceLineRemainingDto>>> GetRemaining([FromQuery] int salesInvoiceId, CancellationToken ct)
=> Ok(await _returns.GetRemainingByInvoiceAsync(salesInvoiceId, ct));
/// <summary>Get one return with its lines and the ledger entries it posted.</summary>
[HttpGet("{returnId:int}")]
[ProducesResponseType(typeof(SalesReturnDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesReturnDto>> GetById(int returnId, CancellationToken ct)
{
var dto = await _returns.GetAsync(returnId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Create + auto-post a return (inbound movement).</summary>
[HttpPost]
[ProducesResponseType(typeof(SalesReturnDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<SalesReturnDto>> Create([FromBody] CreateSalesReturnRequest request, CancellationToken ct)
{
var dto = await _returns.CreateAsync(request, ct);
return Created($"/api/v1/sales-returns/{dto.ReturnId}", dto);
}
}
+1
View File
@@ -20,4 +20,5 @@ public static class DocumentTypes
public const string SalesInvoice = "SI";
public const string SalesSlip = "SSL";
public const string BundleSale = "BND";
public const string SalesReturn = "SRET";
}
@@ -11,20 +11,7 @@ public class BundleSaleLine
public int ItemId { get; set; }
public Item? Item { get; set; }
public string Description { get; set; } = string.Empty;
/// <summary>Component quantity, in <see cref="UomId"/> — what the user entered.</summary>
public decimal Qty { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>
/// <see cref="Qty"/> restated in the item's base UOM, resolved once at save. Posting
/// consumes this. <see cref="Qty"/> and <see cref="UnitPrice"/> are always a matching
/// pair in <see cref="UomId"/>, so <see cref="LineTotal"/> stays value-correct.
/// </summary>
public decimal QtyBase { get; set; }
/// <summary>The factor used to derive <see cref="QtyBase"/>; 1 when the line is in base UOM.</summary>
public decimal ConversionFactor { get; set; } = 1m;
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; }
+7 -15
View File
@@ -25,29 +25,14 @@ 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; }
public int? BatchId { get; set; }
public Batch? Batch { get; set; }
/// <summary>Quantity received, in <see cref="UomId"/> — what the user entered.</summary>
public decimal Qty { get; set; }
/// <summary>
/// <see cref="Qty"/> restated in the item's base UOM, resolved once at line creation.
/// Confirm reads this snapshot rather than re-converting, so a conversion factor edited
/// between create and confirm cannot change what a saved GRN posts, and a later reversal
/// reproduces the original layer exactly.
/// </summary>
public decimal QtyBase { get; set; }
/// <summary>The factor used to derive <see cref="QtyBase"/>; 1 when the line is in base UOM.</summary>
public decimal ConversionFactor { get; set; } = 1m;
/// <summary>Gross unit cost received at (entered, or PO price when omitted).</summary>
public decimal UnitCost { get; set; }
@@ -73,4 +58,11 @@ public class GrnLine
public decimal LineTotal { get; set; }
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
/// <summary>
/// One row per received unit when <see cref="Item.Warranty"/> is
/// <see cref="Enums.Warranty.Warranty"/> — count must equal <see cref="Qty"/>.
/// Empty for a non-warranty item.
/// </summary>
public ICollection<GrnLineWarrantyNumber> WarrantyNumbers { get; set; } = new List<GrnLineWarrantyNumber>();
}
@@ -0,0 +1,20 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One warranty number captured against a single received unit of a warranty-tracked
/// item (<see cref="Item.Warranty"/> = <see cref="Enums.Warranty.Warranty"/>). A GRN line
/// for such an item must carry exactly <see cref="GrnLine.Qty"/> of these — one per unit —
/// mirroring how a Serial-tracked item requires one serial per unit (docs/10 Part C.3).
/// </summary>
public class GrnLineWarrantyNumber
{
public int GrnLineWarrantyNumberId { get; set; }
public int GrnLineId { get; set; }
public GrnLine? GrnLine { get; set; }
public string WarrantyNo { get; set; } = string.Empty;
/// <summary>Warranty coverage length in months, selected at receipt (e.g. 3/6/12/18).</summary>
public int WarrantyPeriodMonths { get; set; }
}
+37 -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; }
@@ -32,6 +38,9 @@ public class Item
public StockNature StockNature { get; set; }
public TrackingMode TrackingMode { get; set; }
public Warranty Warranty { get; set; } = Warranty.NonWarranty;
/// <summary>Coverage length in months (see <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; null otherwise.</summary>
public int? WarrantyPeriodMonths { get; set; }
public string? TaxClass { get; set; }
/// <summary>
@@ -41,6 +50,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 +87,4 @@ public class Item
public uint RowVersion { get; set; }
public ICollection<ItemReorder> ReorderSettings { get; set; } = new List<ItemReorder>();
public ICollection<UomConversion> UomConversions { get; set; } = new List<UomConversion>();
}
+23 -2
View File
@@ -7,12 +7,15 @@ namespace ERPCore.Domain.Entities;
/// Material.
/// <para>
/// <b>Deliberately unlinked.</b> Nothing references this entity and it references
/// nothing: there is no value table and no join to <see cref="Item"/>. Its only job is
/// to feed the frontend's item-builder dropdown via <c>GET /item-types</c>. The chosen
/// nothing: there is no value table and no join to <see cref="Item"/>. The chosen
/// values (Red, S, M) are encoded by the client into the generated SKU
/// (e.g. <c>BL-100-0003</c>) and are never stored or parsed server-side — the item list
/// is the record of what was built. See the accepted trade-off in docs/10 Part C.9.
/// </para>
/// <para>
/// It does, however, carry one piece of meaning the client acts on:
/// <see cref="IsMeasurable"/>. So this is no longer purely a dropdown source.
/// </para>
/// Not to be confused with <see cref="Enums.StockNature"/> (Stocked/NonStocked/Service),
/// which is what the old <c>ItemType</c> enum became.
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
@@ -21,6 +24,24 @@ public class ItemType
{
public int ItemTypeId { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>
/// When true, this dimension's values are content <b>measurements</b> (500 ml, 1 L) rather
/// than plain labels (Red, S). The item builder then captures a number + unit per value and
/// stamps that pair onto each generated item's <see cref="Item.ContentQty"/> /
/// <see cref="Item.ContentUnit"/>, instead of copying one form-level pair into every variant
/// — which is what makes "Coca-Cola in 500 ml / 1 L / 250 ml" three correctly sized items.
/// <para>
/// This is what lets an apparel <c>Size</c> (S/M/L) stay plain text while a
/// <c>Pack Size</c>/<c>Volume</c> dimension carries ml/g/L/kg.
/// </para>
/// <para>
/// A client hint only: the server never reads it when writing an item. Each item's pair is
/// still validated and normalised on its own by <c>ItemContent</c>.
/// </para>
/// </summary>
public bool IsMeasurable { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
-27
View File
@@ -15,38 +15,11 @@ 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; }
/// <summary>Quantity ordered, in <see cref="UomId"/>.</summary>
public decimal Qty { get; set; }
public decimal UnitPrice { get; set; }//
public decimal Tax { get; set; }
/// <summary>
/// <see cref="Qty"/> restated in the item's base UOM, resolved once at line creation.
/// This — not <see cref="Qty"/> — is what open-quantity and close checks compare against,
/// because a GRN may legitimately receive against this line in a different UOM.
/// </summary>
public decimal QtyBase { get; set; }
/// <summary>The factor used to derive <see cref="QtyBase"/>; 1 when the line is in base UOM.</summary>
public decimal ConversionFactor { get; set; } = 1m;
/// <summary>
/// Accrues as GRNs confirm (FR-PROC-07), in <see cref="UomId"/>. <b>Denormalized for
/// display only</b> — it is derived by dividing <see cref="QtyReceivedBase"/> by the
/// factor, so it can drift. Never branch on it; use <see cref="QtyReceivedBase"/>.
/// </summary>
public decimal QtyReceived { get; set; }
/// <summary>
/// Authoritative received-to-date in the item's base UOM. GRN confirm accrues here and
/// the PO close condition compares this against <see cref="QtyBase"/>, so receipts in a
/// UOM other than the PO's still add up correctly.
/// </summary>
public decimal QtyReceivedBase { 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>
@@ -14,24 +14,8 @@ public class SalesInvoiceLine
public string Description { get; set; } = string.Empty;
/// <summary>Quantity sold, in <see cref="UomId"/> — what the user entered and what prints.</summary>
public decimal Qty { get; set; }
public decimal FreeQty { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>
/// <see cref="Qty"/> restated in the item's base UOM, resolved once at save. Posting
/// consumes stock against this snapshot — the FIFO engine is base-UOM only, so passing
/// the entered quantity would deplete the wrong amount whenever the line is not in base UOM.
/// </summary>
public decimal QtyBase { get; set; }
/// <summary><see cref="FreeQty"/> restated in the item's base UOM.</summary>
public decimal FreeQtyBase { get; set; }
/// <summary>The factor used to derive the base quantities; 1 when the line is in base UOM.</summary>
public decimal ConversionFactor { get; set; } = 1m;
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
@@ -0,0 +1,32 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Sales return header — a customer returns previously sold goods, generating an
/// inbound stock movement. Auto-posts with a mandatory reason code, mirroring
/// <see cref="PurchaseReturn"/> with the direction reversed.
/// </summary>
public class SalesReturn
{
public int ReturnId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int CustomerId { get; set; }
public Customer? Customer { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public int ReasonCodeId { get; set; }
public ReasonCode? ReasonCode { get; set; }
public ReturnStatus Status { get; set; } = ReturnStatus.Posted;
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public ICollection<SalesReturnLine> Lines { get; set; } = new List<SalesReturnLine>();
}
@@ -0,0 +1,21 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Sales-return line referencing the original sales invoice line for traceability.
/// <see cref="Qty"/> is in base UOM.
/// </summary>
public class SalesReturnLine
{
public int ReturnLineId { get; set; }
public int ReturnId { get; set; }
public SalesReturn? Return { get; set; }
public int? SalesInvoiceLineId { get; set; }
public SalesInvoiceLine? SalesInvoiceLine { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public decimal Qty { get; set; }
}
@@ -14,24 +14,8 @@ public class SalesSlipLine
public string Description { get; set; } = string.Empty;
/// <summary>Quantity sold, in <see cref="UomId"/> — what the user entered and what prints.</summary>
public decimal Qty { get; set; }
public decimal FreeQty { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>
/// <see cref="Qty"/> restated in the item's base UOM, resolved once at save. Posting
/// consumes stock against this snapshot — the FIFO engine is base-UOM only, so passing
/// the entered quantity would deplete the wrong amount whenever the line is not in base UOM.
/// </summary>
public decimal QtyBase { get; set; }
/// <summary><see cref="FreeQty"/> restated in the item's base UOM.</summary>
public decimal FreeQtyBase { get; set; }
/// <summary>The factor used to derive the base quantities; 1 when the line is in base UOM.</summary>
public decimal ConversionFactor { get; set; } = 1m;
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
}
+17
View File
@@ -0,0 +1,17 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Whether an item is sold under warranty (FR-MD-01). Stored as a string, same
/// convention as <see cref="StockNature"/> and <see cref="TrackingMode"/>.
/// </summary>
public enum Warranty
{
NonWarranty,
Warranty
}
/// <summary>Allowed warranty coverage lengths, in months — set once on the item (FR-MD-01).</summary>
public static class WarrantyPeriods
{
public static readonly int[] AllowedMonths = { 3, 6, 12, 18 };
}
+9 -3
View File
@@ -5,12 +5,14 @@ namespace ERPCore.Dtos.Grn;
// Responses (docs/11 §4) --------------------------------------------------------
public sealed record GrnLineWarrantyNumberDto(string WarrantyNo, int WarrantyPeriodMonths);
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,
HoldStatus HoldStatus, int? BatchId);
HoldStatus HoldStatus, int? BatchId, IReadOnlyList<GrnLineWarrantyNumberDto> WarrantyNumbers);
public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
@@ -44,7 +46,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>
@@ -59,6 +60,11 @@ public sealed class CreateGrnLineInput
[Range(0, 100)] public decimal VatPct { get; set; }
[EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
public BatchInput? Batch { get; set; }
/// <summary>
/// Required, one per unit (count must equal <see cref="Qty"/>), when the item is
/// warranty-tracked (<c>Item.Warranty == Warranty.Warranty</c>). Ignored otherwise.
/// </summary>
public List<string>? WarrantyNumbers { get; set; }
}
public sealed class CreateGrnRequest
+17 -3
View File
@@ -5,12 +5,16 @@ namespace ERPCore.Dtos.ItemTypes;
/// <summary>
/// Item type resource (docs/11-BACKEND-PHASE1.md §2.7) — a dimension name such as Color
/// or Size. Carries no values and no item linkage: <c>GET /item-types</c> exists to
/// populate the frontend builder's dropdown, and the chosen values are encoded into the
/// or Size. Carries no values and no item linkage: the chosen values are encoded into the
/// client-generated SKU rather than stored (docs/10 Part C.9).
/// <para>
/// <c>IsMeasurable</c> marks a dimension whose values are content measurements (500 ml, 1 L)
/// rather than plain labels; the builder captures a number + unit per value and writes it to
/// each generated item's content size.
/// </para>
/// </summary>
public sealed record ItemTypeDto(
int ItemTypeId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
int ItemTypeId, string Name, bool IsMeasurable, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
@@ -18,11 +22,21 @@ public sealed record ItemTypeDto(
public sealed class CreateItemTypeRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
/// <summary>Omitted ⇒ false, i.e. plain-text values. See <see cref="ItemTypeDto"/>.</summary>
public bool IsMeasurable { get; set; }
}
public sealed class UpdateItemTypeRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
/// <summary>
/// Nullable on purpose: a plain <c>bool</c> binds an absent property as <c>false</c>, so any
/// client that PUT only a name — as the item-types screen used to — would silently clear the
/// flag on every rename. Omitting this field <b>preserves</b> the stored value.
/// </summary>
public bool? IsMeasurable { get; set; }
}
public sealed class UpdateItemTypeStatusRequest
+35 -25
View File
@@ -9,7 +9,11 @@ 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);
Warranty Warranty, int? WarrantyPeriodMonths,
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 +21,22 @@ 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,
Warranty Warranty, int? WarrantyPeriodMonths,
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);
@@ -61,9 +61,20 @@ public sealed class CreateItemRequest
public int? DefaultVendorId { get; set; }
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
[EnumDataType(typeof(Warranty))] public Warranty Warranty { get; set; } = Warranty.NonWarranty;
/// <summary>Required (one of <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; ignored otherwise.</summary>
public int? WarrantyPeriodMonths { get; set; }
[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
@@ -80,9 +91,20 @@ public sealed class UpdateItemRequest
public int? DefaultVendorId { get; set; }
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
[EnumDataType(typeof(Warranty))] public Warranty Warranty { get; set; } = Warranty.NonWarranty;
/// <summary>Required (one of <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; ignored otherwise.</summary>
public int? WarrantyPeriodMonths { get; set; }
[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 +123,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();
}
@@ -5,18 +5,9 @@ namespace ERPCore.Dtos.Procurement;
// Responses (docs/11 §3.3) ------------------------------------------------------
/// <summary>
/// A PO line. <paramref name="Qty"/> and <paramref name="QtyReceived"/> are in
/// <paramref name="UomId"/> and are what the user sees; <paramref name="QtyBase"/> and
/// <paramref name="QtyReceivedBase"/> are the item's base UOM and are what the server
/// actually enforces — GRN over-receipt and the PO close condition both run on the base
/// pair, because goods may legitimately be received in a different UOM from the one
/// ordered. A client showing remaining/outstanding quantity should read the base pair.
/// </summary>
public sealed record PoLineDto(
int PoLineId, int ItemId, int UomId, int WarehouseId,
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived,
decimal QtyBase = 0m, decimal QtyReceivedBase = 0m, decimal ConversionFactor = 1m);
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);
@@ -34,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; }
+3 -5
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(
@@ -31,10 +31,9 @@ public sealed record BundleSaleTemplateSummaryDto(
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description,
EntityStatus Status, int LineCount, DateTime CreatedAt, DateTime? UpdatedAt);
/// <summary>Quantities are in the item's base UOM — see <c>SalesInvoicePostingIssueDto</c>.</summary>
public sealed record BundleSalePostingIssueDto(
int BundleSaleLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, string BaseUomName = "");
decimal RequestedQty, decimal AvailableQty, decimal ShortQty);
public sealed record BundleSalePostingCheckDto(
int BundleSaleId, string BundleNo, BundleSaleStatus Status, bool CanPost,
@@ -43,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; }
+2 -10
View File
@@ -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);
@@ -24,16 +24,9 @@ public sealed record SalesInvoiceSummaryDto(
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt);
/// <summary>
/// A line that cannot be posted for lack of stock. The three quantities are in the item's
/// <b>base</b> UOM (on-hand only exists in base), which may differ from the UOM shown on the
/// line — hence <paramref name="BaseUomName"/>: without it a line reading "2 BOX" produces
/// an unexplained "requested 24, available 10".
/// </summary>
public sealed record SalesInvoicePostingIssueDto(
int SalesInvoiceLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue,
string BaseUomName = "");
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue);
public sealed record SalesInvoicePostingCheckDto(
int SalesInvoiceId, string InvoiceNo, SalesInvoiceStatus Status, bool CanPost,
@@ -42,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; }
@@ -0,0 +1,43 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Sales;
// Responses -----------------------------------------------------------------
public sealed record SalesReturnLineDto(int ReturnLineId, int? SalesInvoiceLineId, int ItemId, decimal Qty);
public sealed record SalesReturnDto(
int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
int CreatedBy, DateTime CreatedAt, IReadOnlyList<SalesReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
/// <summary>Row shape for <c>GET /sales-returns</c> — no lines/ledgerRefs (those need a per-row query).</summary>
public sealed record SalesReturnSummaryDto(
int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount, decimal TotalQty);
/// <summary>
/// Remaining returnable qty for one sales invoice line — the invoiced qty minus
/// whatever has already been returned against it. The invoice line's own <c>Qty</c>
/// is never mutated by a return, so this is computed on read from return history.
/// </summary>
public sealed record SalesInvoiceLineRemainingDto(int SalesInvoiceLineId, decimal RemainingQty);
// Requests --------------------------------------------------------------------
public sealed class CreateSalesReturnLineInput
{
/// <summary>Original sales invoice line, for traceability against the sale.</summary>
public int? SalesInvoiceLineId { get; set; }
[Required] public int ItemId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
}
public sealed class CreateSalesReturnRequest
{
[Required] public int CustomerId { get; set; }
[Required] public int WarehouseId { get; set; }
/// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error.</summary>
public int? ReasonCodeId { get; set; }
[Required, MinLength(1)] public List<CreateSalesReturnLineInput> Lines { get; set; } = new();
}
+3 -6
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);
@@ -23,11 +23,9 @@ public sealed record SalesSlipSummaryDto(
string CustomerSnapshotName, int WarehouseId, SalesSlipStatus Status,
SalesSlipTotalsDto Totals, DateTime CreatedAt);
/// <summary>Quantities are in the item's base UOM — see <c>SalesInvoicePostingIssueDto</c>.</summary>
public sealed record SalesSlipPostingIssueDto(
int SalesSlipLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue,
string BaseUomName = "");
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue);
public sealed record SalesSlipPostingCheckDto(
int SalesSlipId, string SlipNo, SalesSlipStatus Status, bool CanPost,
@@ -36,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,
@@ -47,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; }
+3 -11
View File
@@ -2,23 +2,16 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Stock;
// Every quantity a stock endpoint returns is in the item's base UOM — stock, layers and the
// ledger are base-only by construction. The BaseUomId/BaseUomName pair on each of these DTOs
// exists so a client can *label* those figures; without it every stock screen renders a bare
// number the user has to guess the unit of. They are never a conversion instruction.
/// <summary>Stock enquiry (docs/11 §5.1). available = onHand onHold reserved inTransit(out).</summary>
public sealed record StockOnHandDto(
int ItemId, int WarehouseId, decimal OnHand, decimal Available,
decimal OnHold, decimal InTransit, decimal Reserved, DateTime AsOf,
int BaseUomId = 0, string BaseUomName = "");
decimal OnHold, decimal InTransit, decimal Reserved, DateTime AsOf);
/// <summary>A stock-ledger row (docs/11 §5.2).</summary>
public sealed record StockLedgerRowDto(
int LedgerId, int ItemId, int WarehouseId, int? BinId, int? BatchId, int? SerialId,
Direction Direction, decimal QtyBase, decimal UnitCost, decimal Value, decimal RunningBalance,
string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt,
int BaseUomId = 0, string BaseUomName = "");
string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt);
/// <summary>An open FIFO layer in a valuation (docs/11 §5.3).</summary>
public sealed record StockValuationLayerDto(int LayerId, decimal QtyRemaining, decimal UnitCost, decimal Value, DateTime ReceiptDate);
@@ -26,5 +19,4 @@ public sealed record StockValuationLayerDto(int LayerId, decimal QtyRemaining, d
/// <summary>Valuation of on-hand stock from open FIFO layers (docs/11 §5.3).</summary>
public sealed record StockValuationDto(
int ItemId, int WarehouseId, IReadOnlyList<StockValuationLayerDto> Layers,
decimal TotalQty, decimal TotalValue, string Currency, string CostingMethod,
int BaseUomId = 0, string BaseUomName = "");
decimal TotalQty, decimal TotalValue, string Currency, string CostingMethod);
-9
View File
@@ -9,12 +9,3 @@ public sealed class CreateUomRequest
{
[Required, StringLength(50)] public string Name { get; set; } = string.Empty;
}
/// <summary>
/// A UOM an item may actually be transacted in: its base UOM (<paramref name="Factor"/> 1,
/// <paramref name="IsBase"/> true) plus every UOM it has a conversion from. Backs both
/// entry-time validation and <c>GET /items/{itemId}/uoms</c>, so the client can offer only
/// units that will survive posting instead of the whole global list.
/// </summary>
/// <param name="Factor">Multiply a quantity in this UOM by <paramref name="Factor"/> to get base UOM.</param>
public sealed record AllowedUomDto(int UomId, string Name, decimal Factor, bool IsBase);
@@ -12,15 +12,12 @@ public sealed class BundleSaleLineConfiguration : IEntityTypeConfiguration<Bundl
builder.HasKey(x => x.BundleSaleLineId);
builder.Property(x => x.Description).IsRequired().HasMaxLength(200);
builder.Property(x => x.Qty).HasPrecision(18, 4);
builder.Property(x => x.QtyBase).HasPrecision(18, 4);
builder.Property(x => x.ConversionFactor).HasPrecision(18, 6).HasDefaultValue(1m);
builder.Property(x => x.UnitPrice).HasPrecision(18, 4);
builder.Property(x => x.LineTotal).HasPrecision(18, 4);
builder.Property(x => x.IncludeInBundle).HasDefaultValue(true);
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);
}
}
@@ -36,8 +36,6 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
builder.HasKey(l => l.GrnLineId);
builder.Property(l => l.Qty).HasPrecision(18, 4);
builder.Property(l => l.QtyBase).HasPrecision(18, 4);
builder.Property(l => l.ConversionFactor).HasPrecision(18, 6).HasDefaultValue(1m);
builder.Property(l => l.UnitCost).HasPrecision(18, 6);
builder.Property(l => l.PoUnitPrice).HasPrecision(18, 6);
builder.Property(l => l.DiscountPct).HasPrecision(9, 4);
@@ -51,8 +49,25 @@ 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);
}
}
public sealed class GrnLineWarrantyNumberConfiguration : IEntityTypeConfiguration<GrnLineWarrantyNumber>
{
public void Configure(EntityTypeBuilder<GrnLineWarrantyNumber> builder)
{
builder.ToTable("grn_line_warranty_numbers");
builder.HasKey(w => w.GrnLineWarrantyNumberId);
builder.Property(w => w.WarrantyNo).IsRequired().HasMaxLength(100);
builder.HasOne(w => w.GrnLine).WithMany(l => l.WarrantyNumbers)
.HasForeignKey(w => w.GrnLineId).OnDelete(DeleteBehavior.Cascade);
// A warranty number entered twice on the same line is almost certainly a typo —
// catch it at the DB, not just client-side.
builder.HasIndex(w => new { w.GrnLineId, w.WarrantyNo }).IsUnique();
}
}
@@ -22,10 +22,23 @@ 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)
.HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.Warranty)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(Warranty.NonWarranty);
builder.Property(i => i.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
@@ -19,6 +19,11 @@ public sealed class ItemTypeConfiguration : IEntityTypeConfiguration<ItemType>
builder.Property(t => t.Name).IsRequired().HasMaxLength(200);
builder.HasIndex(t => t.Name).IsUnique();
// false is the only safe default here: EF uses the CLR default as its "unset" sentinel,
// so if the store default were true, inserting an explicit false would be mistaken for
// "not set" and silently written as true. Sentinel and store default must agree.
builder.Property(t => t.IsMeasurable).IsRequired().HasDefaultValue(false);
builder.Property(t => t.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
@@ -91,13 +91,13 @@ public sealed class StageInputConfiguration : IEntityTypeConfiguration<StageInpu
builder.HasKey(i => i.InputId);
builder.Property(i => i.Source).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.QtyUnit).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.QtyPerBatch).HasPrecision(18, 4);
builder.HasOne(i => i.Stage).WithMany(s => s.Inputs)
.HasForeignKey(i => i.StageId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.FromOutput).WithMany()
.HasForeignKey(i => i.FromOutputId).OnDelete(DeleteBehavior.Restrict);
}
@@ -206,6 +206,7 @@ public sealed class RunStageInputConfiguration : IEntityTypeConfiguration<RunSta
builder.HasKey(i => i.RunInputId);
builder.Property(i => i.Source).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.QtyUnit).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.PlannedQty).HasPrecision(18, 4);
builder.Property(i => i.ConsumedQty).HasPrecision(18, 4);
builder.Property(i => i.ConsumedValue).HasPrecision(18, 4);
@@ -217,7 +218,6 @@ public sealed class RunStageInputConfiguration : IEntityTypeConfiguration<RunSta
.HasForeignKey(i => i.RunStageId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.FromRunOutput).WithMany()
.HasForeignKey(i => i.FromRunOutputId).OnDelete(DeleteBehavior.Restrict);
@@ -49,9 +49,6 @@ public sealed class PoLineConfiguration : IEntityTypeConfiguration<PoLine>
builder.HasKey(l => l.PoLineId);
builder.Property(l => l.Qty).HasPrecision(18, 4);
builder.Property(l => l.QtyBase).HasPrecision(18, 4);
builder.Property(l => l.QtyReceivedBase).HasPrecision(18, 4);
builder.Property(l => l.ConversionFactor).HasPrecision(18, 6).HasDefaultValue(1m);
builder.Property(l => l.UnitPrice).HasPrecision(18, 4);
builder.Property(l => l.Tax).HasPrecision(9, 4);
builder.Property(l => l.QtyReceived).HasPrecision(18, 4);
@@ -66,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)
@@ -70,9 +70,6 @@ public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration<Sal
builder.Property(x => x.Qty).HasPrecision(18, 4);
builder.Property(x => x.FreeQty).HasPrecision(18, 4);
builder.Property(x => x.QtyBase).HasPrecision(18, 4);
builder.Property(x => x.FreeQtyBase).HasPrecision(18, 4);
builder.Property(x => x.ConversionFactor).HasPrecision(18, 6).HasDefaultValue(1m);
builder.Property(x => x.UnitPrice).HasPrecision(18, 4);
builder.Property(x => x.BaseCost).HasPrecision(18, 4);
builder.Property(x => x.DiscountPct).HasPrecision(9, 4);
@@ -82,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)
@@ -0,0 +1,40 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class SalesReturnConfiguration : IEntityTypeConfiguration<SalesReturn>
{
public void Configure(EntityTypeBuilder<SalesReturn> builder)
{
builder.ToTable("sales_returns");
builder.HasKey(r => r.ReturnId);
builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(r => r.DocNo).IsUnique();
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(r => r.CreatedAt).IsRequired();
builder.HasOne(r => r.Customer).WithMany().HasForeignKey(r => r.CustomerId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.ReasonCode).WithMany().HasForeignKey(r => r.ReasonCodeId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.Creator).WithMany().HasForeignKey(r => r.CreatedBy).OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class SalesReturnLineConfiguration : IEntityTypeConfiguration<SalesReturnLine>
{
public void Configure(EntityTypeBuilder<SalesReturnLine> builder)
{
builder.ToTable("sales_return_lines");
builder.HasKey(l => l.ReturnLineId);
builder.Property(l => l.Qty).HasPrecision(18, 4);
builder.HasOne(l => l.Return).WithMany(r => r.Lines).HasForeignKey(l => l.ReturnId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.SalesInvoiceLine).WithMany().HasForeignKey(l => l.SalesInvoiceLineId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -70,9 +70,6 @@ public sealed class SalesSlipLineConfiguration : IEntityTypeConfiguration<SalesS
builder.Property(x => x.Qty).HasPrecision(18, 4);
builder.Property(x => x.FreeQty).HasPrecision(18, 4);
builder.Property(x => x.QtyBase).HasPrecision(18, 4);
builder.Property(x => x.FreeQtyBase).HasPrecision(18, 4);
builder.Property(x => x.ConversionFactor).HasPrecision(18, 6).HasDefaultValue(1m);
builder.Property(x => x.UnitPrice).HasPrecision(18, 4);
builder.Property(x => x.BaseCost).HasPrecision(18, 4);
builder.Property(x => x.DiscountPct).HasPrecision(9, 4);
@@ -82,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,38 +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();
// The API guards this too, but UomConverter divides unit cost by the factor — a zero
// reaching the table from a seeder or direct SQL would be a divide-by-zero at post time.
builder.ToTable(t => t.HasCheckConstraint("ck_uom_conversions_factor_positive", "\"Factor\" > 0"));
}
}
@@ -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>();
@@ -94,6 +93,8 @@ public class ErpDbContext : DbContext
public DbSet<BundleSaleTemplateLine> BundleSaleTemplateLines => Set<BundleSaleTemplateLine>();
public DbSet<BundleSale> BundleSales => Set<BundleSale>();
public DbSet<BundleSaleLine> BundleSaleLines => Set<BundleSaleLine>();
public DbSet<SalesReturn> SalesReturns => Set<SalesReturn>();
public DbSet<SalesReturnLine> SalesReturnLines => Set<SalesReturnLine>();
// --- Reference data (docs/10 Part C.7) ---
public DbSet<ReasonCode> ReasonCodes => Set<ReasonCode>();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddItemTypeIsMeasurable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsMeasurable",
table: "item_types",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsMeasurable",
table: "item_types");
}
}
}
File diff suppressed because it is too large Load Diff
+2 -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>();
@@ -115,6 +115,7 @@ builder.Services.AddScoped<ISalesSlipService, SalesSlipService>();
builder.Services.AddScoped<IBundleSaleService, BundleSaleService>();
builder.Services.AddScoped<ISalesPromotionSuggestionService, SalesPromotionSuggestionService>();
builder.Services.AddScoped<ISalesReportService, SalesReportService>();
builder.Services.AddScoped<ISalesReturnService, SalesReturnService>();
// Stock transactions + reference data (docs/11 §56)
builder.Services.AddScoped<IStockMutator, StockMutator>();
+3 -19
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,22 +207,13 @@ 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);
// Keep the entered UOM on the line and snapshot the base quantity beside it, the
// same shape as invoices and slips. Qty and UnitPrice stay a matching pair in the
// entered UOM so LineTotal — which Recalculate rolls into the header — is the
// value the user priced; only QtyBase crosses into the base-UOM stock engine.
var factor = await _uomConverter.ResolveFactorAsync(item, r.UomId, ct);
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 = r.Qty,
UomId = r.UomId,
QtyBase = UomConverter.ApplyFactor(r.Qty, factor),
ConversionFactor = factor,
WarehouseId = lineWarehouseId,
UnitPrice = r.UnitPrice,
LineTotal = calc.LineTotal,
@@ -258,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());
}
+44 -43
View File
@@ -7,7 +7,6 @@ using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
@@ -29,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;
@@ -44,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;
@@ -96,7 +91,7 @@ public sealed class GrnService : IGrnService
public async Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default)
{
var grn = await _grns.Query().AsNoTracking()
.Include(g => g.Lines)
.Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers)
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
return grn is null ? null : Map(grn);
}
@@ -134,17 +129,9 @@ 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);
// Resolve the base quantity at entry. This rejects a UOM the item cannot be
// received in while the GRN is still a draft, and snapshots the factor so confirm
// (and any later reversal) reproduces exactly this quantity.
var conversionFactor = await _uomConverter.ResolveFactorAsync(item, input.UomId, ct);
var qtyBaseEntered = UomConverter.ApplyFactor(input.Qty, conversionFactor);
// Cost: for a PO line, the PO price is used unless an override is entered (then it
// wins and a variance is recorded against the PO snapshot — docs/02-SECURITY C.3,
// revised). Direct receipts always use the entered cost.
@@ -157,13 +144,12 @@ 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);
// Compare in base UOM: a GRN may legitimately receive in a different UOM from
// the one the PO was raised in (10 BOX ordered, 120 PCS delivered), and
// comparing the two raw numbers would reject that valid receipt.
var openQtyBase = poLine.QtyBase - poLine.QtyReceivedBase;
if (qtyBaseEntered > openQtyBase * (1 + OverReceiptTolerance))
// 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,
$"Receiving {input.Qty} ({qtyBaseEntered} base) exceeds the open quantity {openQtyBase} base on PO line {input.PoLineId}.", 422);
$"Receiving {input.Qty} exceeds the open quantity {openQty} on PO line {input.PoLineId}.", 422);
poUnitPrice = poLine.UnitPrice;
unitCost = input.UnitCost > 0 ? input.UnitCost : poLine.UnitPrice;
@@ -179,17 +165,15 @@ public sealed class GrnService : IGrnService
var vatAmount = Math.Round(receivedValue * input.VatPct / 100m, 4, MidpointRounding.AwayFromZero);
var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
var warrantyNumbers = ResolveWarrantyNumbers(item, input.WarrantyNumbers, input.Qty);
lines.Add(new GrnLine
{
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,
QtyBase = qtyBaseEntered,
ConversionFactor = conversionFactor,
UnitCost = unitCost,
PoUnitPrice = poUnitPrice,
DiscountPct = input.DiscountPct,
@@ -198,7 +182,8 @@ public sealed class GrnService : IGrnService
VatAmount = vatAmount,
ReceivedValue = receivedValue,
LineTotal = receivedValue + vatAmount,
HoldStatus = input.HoldStatus
HoldStatus = input.HoldStatus,
WarrantyNumbers = warrantyNumbers
});
}
@@ -247,12 +232,10 @@ public sealed class GrnService : IGrnService
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
{
// FIFO layer costs at the after-discount net price; VAT is recoverable and never
// enters stock value (docs/10 FR-GRN-06, revised). Quantity and cost come from
// the factor snapshotted at line creation, not a fresh lookup — see GrnLine.QtyBase.
var qtyBase = line.QtyBase;
var unitCostBase = line.ConversionFactor == 1m
? line.NetUnitCost
: Math.Round(line.NetUnitCost / line.ConversionFactor, 6, MidpointRounding.AwayFromZero);
// 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,
@@ -273,13 +256,7 @@ public sealed class GrnService : IGrnService
if (line.PoLineId is not null)
{
var poLine = await _poLines.GetByIdAsync(line.PoLineId.Value, token);
if (poLine is not null)
{
// Accrue in base so receipts in a UOM other than the PO's still add up.
// QtyReceived is kept in the PO's own UOM for display only.
poLine.QtyReceivedBase += qtyBase;
poLine.QtyReceived = UomConverter.FromBase(poLine.QtyReceivedBase, poLine.ConversionFactor);
}
if (poLine is not null) poLine.QtyReceived += line.Qty;
}
}
@@ -366,14 +343,37 @@ public sealed class GrnService : IGrnService
return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges
}
/// <summary>
/// A warranty-tracked item requires exactly one warranty number per received unit —
/// same shape of rule as <see cref="ResolveBatchAsync"/> for batch tracking. The coverage
/// period is not entered at receipt; it is snapshotted from <see cref="Item.WarrantyPeriodMonths"/>,
/// which the item must have been given at creation (<see cref="ItemService"/> enforces that).
/// </summary>
private static List<GrnLineWarrantyNumber> ResolveWarrantyNumbers(Item item, List<string>? numbers, decimal qty)
{
if (item.Warranty != Warranty.Warranty) return new List<GrnLineWarrantyNumber>();
if (item.WarrantyPeriodMonths is null)
throw new DomainException(
ErrorCodes.Validation, $"Item {item.Sku} is under warranty but has no warranty period configured.", 422);
var trimmed = (numbers ?? new List<string>()).Select(n => n.Trim()).Where(n => n.Length > 0).ToList();
if (trimmed.Count != qty)
throw new DomainException(
ErrorCodes.Validation,
$"Item {item.Sku} is under warranty; provide exactly {qty} warranty number(s), got {trimmed.Count}.", 422);
if (trimmed.Distinct(StringComparer.OrdinalIgnoreCase).Count() != trimmed.Count)
throw new DomainException(ErrorCodes.Validation, $"Warranty numbers for item {item.Sku} must be unique.", 422);
return trimmed.Select(n => new GrnLineWarrantyNumber { WarrantyNo = n, WarrantyPeriodMonths = item.WarrantyPeriodMonths.Value }).ToList();
}
private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
{
if (poId is null) return;
var po = await _pos.Query().Include(p => p.Lines).FirstOrDefaultAsync(p => p.PoId == poId, ct);
if (po is null) return;
// Base-vs-base: QtyReceived is a denormalized display figure and must not gate closing.
po.Status = po.Lines.All(l => l.QtyReceivedBase >= l.QtyBase)
po.Status = po.Lines.All(l => l.QtyReceived >= l.Qty)
? PurchaseOrderStatus.FullyReceived
: PurchaseOrderStatus.PartiallyReceived;
po.UpdatedAt = DateTime.UtcNow;
@@ -405,8 +405,9 @@ 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());
l.HoldStatus, l.BatchId,
l.WarrantyNumbers.Select(w => new GrnLineWarrantyNumberDto(w.WarrantyNo, w.WarrantyPeriodMonths)).ToList())).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,
@@ -0,0 +1,18 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
namespace ERPCore.Services.Interfaces;
/// <summary>Sales-return business logic — customer returns, mirroring purchase-return logic reversed.</summary>
public interface ISalesReturnService
{
Task<PagedResponse<SalesReturnSummaryDto>> ListAsync(
PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default);
Task<SalesReturnDto?> GetAsync(int returnId, CancellationToken ct = default);
Task<SalesReturnDto> CreateAsync(CreateSalesReturnRequest request, CancellationToken ct = default);
/// <summary>Remaining returnable qty per line of one sales invoice (invoiced qty minus already-returned).</summary>
Task<IReadOnlyList<SalesInvoiceLineRemainingDto>> GetRemainingByInvoiceAsync(int salesInvoiceId, CancellationToken ct = default);
}
@@ -1,61 +0,0 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Uoms;
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);
/// <summary>
/// The single factor lookup every other method is built on: <c>1</c> when
/// <paramref name="uomId"/> is already the base UOM, otherwise the item's conversion
/// factor from that UOM to base. Throws 422 when none is defined.
/// </summary>
/// <remarks>
/// Callers that persist a line should store this alongside the quantity so posting
/// reads a snapshot instead of re-resolving — a factor edited between save and post
/// must never change what an already-saved document posts.
/// </remarks>
Task<decimal> ResolveFactorAsync(Item item, int uomId, CancellationToken ct = default);
/// <summary>
/// Every UOM <paramref name="itemId"/> may be transacted in — base UOM first, then each
/// conversion source, ordered by name.
/// </summary>
Task<IReadOnlyList<AllowedUomDto>> GetAllowedUomsAsync(int itemId, CancellationToken ct = default);
/// <summary>
/// Entry-time guard: throws 422 naming the allowed units when <paramref name="uomId"/>
/// is neither the item's base UOM nor a UOM it has a conversion from. Call this when a
/// document line is created so the user is told at entry, not by a cryptic failure at post.
/// </summary>
Task ValidateUomAsync(Item item, int uomId, 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);
}
}
+65 -82
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.Warranty, i.WarrantyPeriodMonths, 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,10 @@ 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 warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
var item = new Item
{
Sku = request.Sku.Trim(),
@@ -116,8 +127,14 @@ public sealed class ItemService : IItemService
DefaultVendorId = request.DefaultVendorId,
StockNature = request.StockNature,
TrackingMode = request.TrackingMode,
Warranty = request.Warranty,
WarrantyPeriodMonths = warrantyPeriodMonths,
TaxClass = request.TaxClass,
SalePrice = request.SalePrice,
ContentQty = request.ContentQty,
ContentUnit = request.ContentUnit,
ContentBaseQty = contentBaseQty,
ContentBaseUnit = contentBaseUnit,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
@@ -133,7 +150,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,14 +164,18 @@ public sealed class ItemService : IItemService
request.CategoryId, request.SubCategoryId, request.BrandId,
request.BaseUomId, request.DefaultVendorId, ct);
// Conversions are stored as <other> → base. Repointing the base UOM would leave every
// existing row aimed at a UOM that is no longer the base: invisible to IUomConverter,
// filtered out of the allowed-UOM list, and unfixable through the conversions editor
// (which would 422 on re-save). Make the user clear them deliberately instead.
if (request.BaseUomId != item.BaseUomId && item.UomConversions.Count > 0)
throw new DomainException(ErrorCodes.Validation,
$"Item {item.Sku} has {item.UomConversions.Count} UOM conversion(s) defined against base UOM {item.BaseUomId}. " +
"Remove them before changing the base UOM, then re-enter them against the new base.", 422);
// 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);
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
item.Sku = request.Sku.Trim();
item.Name = request.Name.Trim();
@@ -167,8 +187,14 @@ public sealed class ItemService : IItemService
item.DefaultVendorId = request.DefaultVendorId;
item.StockNature = request.StockNature;
item.TrackingMode = request.TrackingMode;
item.Warranty = request.Warranty;
item.WarrantyPeriodMonths = warrantyPeriodMonths;
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);
@@ -233,72 +259,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);
// Conversions are one-directional: always <other> → base. IUomConverter looks up
// exactly that shape and never inverts a factor, so a row saved the other way round
// would persist happily, render in the UI, and then be invisible at post time. Reject
// it here instead of letting it fail later as an unexplained 422.
foreach (var c in request.Conversions)
{
if (c.ToUom != item.BaseUomId)
throw new DomainException(ErrorCodes.Validation,
$"Conversion {c.FromUom} → {c.ToUom} is invalid: conversions must convert to the item's base UOM ({item.BaseUomId}).", 422);
if (c.FromUom == c.ToUom)
throw new DomainException(ErrorCodes.Validation,
$"Conversion {c.FromUom} → {c.ToUom} is invalid: a UOM cannot convert to itself.", 422);
if (c.FromUom == item.BaseUomId)
throw new DomainException(ErrorCodes.Validation,
"The base UOM converts to itself implicitly (factor 1) and must not be listed.", 422);
if (c.Factor <= 0m)
throw new DomainException(ErrorCodes.Validation,
$"Conversion {c.FromUom} → {c.ToUom} must have a factor greater than zero.", 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
@@ -364,6 +332,23 @@ public sealed class ItemService : IItemService
}
}
/// <summary>
/// A warranty-tracked item must declare a coverage period (one of
/// <see cref="WarrantyPeriods.AllowedMonths"/>); a non-warranty item carries none —
/// any value sent for one is silently dropped rather than trusted from the client.
/// </summary>
private static int? ValidateWarrantyPeriod(Warranty warranty, int? warrantyPeriodMonths)
{
if (warranty != Warranty.Warranty) return null;
if (warrantyPeriodMonths is null || !WarrantyPeriods.AllowedMonths.Contains(warrantyPeriodMonths.Value))
throw new DomainException(
ErrorCodes.Validation,
$"Warranty period must be one of {string.Join(", ", WarrantyPeriods.AllowedMonths)} months.", 422);
return warrantyPeriodMonths;
}
private async Task SaveGuardingConcurrencyAsync(CancellationToken ct)
{
try
@@ -379,14 +364,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.Warranty, i.WarrantyPeriodMonths, i.TaxClass, i.SalePrice,
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
i.Status,
i.ReorderSettings
.OrderBy(r => r.WarehouseId)
.Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty))
.ToList(),
i.UomConversions
.OrderBy(c => c.ConversionId)
.Select(c => new UomConversionDto(c.ConversionId, c.FromUomId, c.ToUomId, c.Factor))
.ToList(),
i.CreatedAt, i.UpdatedAt);
}
+6 -2
View File
@@ -41,7 +41,7 @@ public sealed class ItemTypeService : IItemTypeService
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(t => t.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(t => new ItemTypeDto(t.ItemTypeId, t.Name, t.Status, t.CreatedAt, t.UpdatedAt))
.Select(t => new ItemTypeDto(t.ItemTypeId, t.Name, t.IsMeasurable, t.Status, t.CreatedAt, t.UpdatedAt))
.ToListAsync(ct);
return PagedResponse<ItemTypeDto>.Create(rows, query.Page, query.PageSize, total);
@@ -63,6 +63,7 @@ public sealed class ItemTypeService : IItemTypeService
var itemType = new ItemType
{
Name = name,
IsMeasurable = request.IsMeasurable,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
@@ -90,6 +91,9 @@ public sealed class ItemTypeService : IItemTypeService
// Renaming does not touch existing items: their SKUs already encode the values that
// were chosen, and nothing joins back to this row (docs/10 Part C.9).
itemType.Name = name;
// Omitted ⇒ keep what is stored. A plain bool would bind an absent property as false and
// so let a name-only PUT silently clear the flag on every rename.
itemType.IsMeasurable = request.IsMeasurable ?? itemType.IsMeasurable;
itemType.UpdatedAt = DateTime.UtcNow;
try
@@ -114,5 +118,5 @@ public sealed class ItemTypeService : IItemTypeService
await _uow.SaveChangesAsync(ct);
}
private static ItemTypeDto Map(ItemType t) => new(t.ItemTypeId, t.Name, t.Status, t.CreatedAt, t.UpdatedAt);
private static ItemTypeDto Map(ItemType t) => new(t.ItemTypeId, t.Name, t.IsMeasurable, t.Status, t.CreatedAt, t.UpdatedAt);
}
@@ -21,9 +21,10 @@ namespace ERPCore.Services.Production;
/// </remarks>
public static class ProductionGraphValidator
{
public sealed record InputDraft(int Index, StageInputSource Source, int? ItemId, string? FromOutputKey);
public sealed record InputDraft(
int Index, StageInputSource Source, int? ItemId, string? FromOutputKey, StageQtyUnit QtyUnit);
public sealed record OutputDraft(string Key, string Name, int? ItemId);
public sealed record OutputDraft(string Key, string Name, int? ItemId, int? UomId);
public sealed record StageDraft(
string Key, string Name, IReadOnlyList<InputDraft> Inputs, IReadOnlyList<OutputDraft> Outputs);
@@ -171,6 +172,12 @@ public static class ProductionGraphValidator
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
$"Input {input.Index + 1} of stage '{s.Name}' is a Stock input and cannot reference an upstream output.", 422);
}
// WIP has no content size — it is counted in whatever unit its source output
// declares — so only a Stock input may be expressed in ml/g.
if (input.Source == StageInputSource.Upstream && input.QtyUnit != StageQtyUnit.Pack)
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
$"Input {input.Index + 1} of stage '{s.Name}' is Upstream, so its quantity must be in whole units of its source output, not content units.", 422);
}
}
@@ -189,6 +196,20 @@ public static class ProductionGraphValidator
foreach (var o in s.Outputs.Where(o => o.ItemId is not null))
throw Invalid(
$"Output '{o.Name}' of stage '{s.Name}' is intermediate work-in-progress and cannot reference an item — only the final stage produces a stocked item.");
// 7 — output units. An item-bearing output already has a unit (the item's base UOM),
// so carrying a second one could only contradict it. WIP has no item to ask, so it
// must name its own — the label the run board and any downstream input display.
foreach (var s in stages)
foreach (var o in s.Outputs)
{
if (o.ItemId is null && o.UomId is null)
throw new DomainException(ErrorCodes.WipUnitRequired,
$"Output '{o.Name}' of stage '{s.Name}' is work-in-progress and must declare a unit.", 422);
if (o.ItemId is not null && o.UomId is not null)
throw Invalid(
$"Output '{o.Name}' of stage '{s.Name}' references an item, so its unit comes from that item and must not be set.");
}
}
/// <summary>Set of keys reachable from <paramref name="roots"/> following <paramref name="next"/>.</summary>
@@ -26,7 +26,7 @@ public sealed class ProductionRunService : IProductionRunService
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IFifoCostingService _fifo;
private readonly IUomConverter _uomConverter;
private readonly IItemMeasure _measure;
private readonly INumberSequenceService _numbers;
private readonly IUnitOfWork _uow;
private readonly ICurrentUser _currentUser;
@@ -35,7 +35,7 @@ public sealed class ProductionRunService : IProductionRunService
IRepository<ProductionRun> runs, IRepository<ProductionTemplate> templates,
IRepository<Warehouse> warehouses, IRepository<Bin> bins,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes,
IFifoCostingService fifo, IUomConverter uomConverter,
IFifoCostingService fifo, IItemMeasure measure,
INumberSequenceService numbers, IUnitOfWork uow, ICurrentUser currentUser)
{
_runs = runs;
@@ -45,7 +45,7 @@ public sealed class ProductionRunService : IProductionRunService
_items = items;
_reasonCodes = reasonCodes;
_fifo = fifo;
_uomConverter = uomConverter;
_measure = measure;
_numbers = numbers;
_uow = uow;
_currentUser = currentUser;
@@ -245,7 +245,7 @@ public sealed class ProductionRunService : IProductionRunService
Source = i.Source,
ItemId = i.ItemId,
FromRunOutput = i.FromOutputId is null ? null : outputByTemplateOutputId[i.FromOutputId.Value],
UomId = i.UomId,
QtyUnit = i.QtyUnit,
PlannedQty = Scale(i.QtyPerBatch, ratio)
});
}
@@ -367,9 +367,11 @@ public sealed class ProductionRunService : IProductionRunService
?? throw new DomainException(ErrorCodes.Validation,
$"Item {input.ItemId} on stage '{stage.Name}' no longer exists.", 422);
// PlannedQty is in the input's declared UOM; ConsumedQty is in the item's
// base UOM (the only unit FIFO and the ledger speak). Compare in base.
var plannedBase = await _uomConverter.ToBaseQtyAsync(item, input.UomId, input.PlannedQty, token);
// PlannedQty is in the input's declared unit — content (ml/g) for an item with
// a content size, otherwise packs. ConsumedQty is always packs, the only unit
// FIFO and the ledger speak, so resolve before comparing. 2000 ml of a 500 ml
// bottle is 4 bottles; 300 ml is 0.6 of one, which (18,4) stores exactly.
var plannedBase = _measure.ToPacks(item, input.PlannedQty, input.QtyUnit);
var delta = plannedBase - input.ConsumedQty;
if (delta <= 0) continue; // rework restart with no increase — nothing to draw
@@ -1057,7 +1059,9 @@ public sealed class ProductionRunService : IProductionRunService
$"Item {item.Sku} is {item.TrackingMode}-tracked; batch/serial-tracked finished goods "
+ "are not supported in this phase.", 422);
var qtyBase = await _uomConverter.ToBaseQtyAsync(item, output.UomId, good, ct);
// Output quantities are always a count of the finished item's base UOM — produced and
// scrapped are recorded in whole bottles, not millilitres — so nothing to resolve.
var qtyBase = good;
var consumedValue = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue);
var returnedValue = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue);
@@ -1223,7 +1227,7 @@ public sealed class ProductionRunService : IProductionRunService
ProductionJson.Deserialize<List<FieldDefDto>>(s.FieldDefs, []),
ParseJson(s.FieldValues),
s.Inputs.OrderBy(i => i.RunInputId).Select(i => new RunStageInputDto(
i.RunInputId, i.Source, i.ItemId, i.FromRunOutputId, i.UomId,
i.RunInputId, i.Source, i.ItemId, i.FromRunOutputId, i.QtyUnit,
i.PlannedQty, i.ConsumedQty, i.ConsumedValue,
i.DeliveredQty, i.ReturnedQty, i.ReturnedValue)).ToList(),
s.Outputs.OrderBy(o => o.RunOutputId).Select(o => new RunStageOutputDto(
@@ -27,7 +27,6 @@ public sealed class ProductionTemplateService : IProductionTemplateService
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<ProductionRun> _runs;
private readonly IUomConverter _uomConverter;
private readonly IUnitOfWork _uow;
private readonly ICurrentUser _currentUser;
@@ -35,9 +34,8 @@ public sealed class ProductionTemplateService : IProductionTemplateService
IRepository<ProductionTemplate> templates, IRepository<TemplateStage> stages,
IRepository<StageInput> inputs, IRepository<StageOutput> outputs, IRepository<StageEdge> edges,
IRepository<Item> items, IRepository<Uom> uoms, IRepository<ProductionRun> runs,
IUomConverter uomConverter, IUnitOfWork uow, ICurrentUser currentUser)
IUnitOfWork uow, ICurrentUser currentUser)
{
_uomConverter = uomConverter;
_templates = templates;
_stages = stages;
_inputs = inputs;
@@ -255,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();
@@ -279,32 +278,41 @@ 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);
var missingUoms = uomIds.Except(knownUoms).ToList();
if (missingUoms.Count > 0)
throw new DomainException(ErrorCodes.Validation,
$"UOM(s) {string.Join(", ", missingUoms)} do not exist.", 422);
// Existence is not enough: a stage line's UOM must be one its own item can convert to
// base, or the run would fail with a 422 at stage start — long after the template was
// authored. Check the (item, uom) pairing here, while the template is being saved.
var itemUomPairs = request.Stages
.SelectMany(s => s.Inputs.Select(i => (i.ItemId, i.UomId)).Concat(s.Outputs.Select(o => (o.ItemId, o.UomId))))
.Distinct().ToList();
foreach (var (itemId, uomId) in itemUomPairs)
if (uomIds.Count > 0)
{
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
if (item is not null)
await _uomConverter.ValidateUomAsync(item, uomId, ct);
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);
}
// Annotations go into jsonb unvalidated by anything else, so pin the one field the
@@ -444,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
});
}
@@ -492,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();
@@ -8,7 +8,6 @@ using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
@@ -29,7 +28,6 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Warehouse> _warehouses;
private readonly IUomConverter _uomConverter;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
@@ -37,9 +35,8 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
public PurchaseOrderService(
IRepository<PurchaseOrder> pos, IRepository<Vendor> vendors, IRepository<Requisition> requisitions,
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
IUomConverter uomConverter, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_uomConverter = uomConverter;
_pos = pos;
_vendors = vendors;
_requisitions = requisitions;
@@ -86,7 +83,6 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
{
await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct);
var actor = _currentUser.AuditUserId;
var lines = await ToLinesAsync(request.Lines, ct);
var po = await _uow.ExecuteInTransactionAsync(async token =>
{
@@ -102,7 +98,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
Status = request.SaveAsDraft ? PurchaseOrderStatus.Draft : PurchaseOrderStatus.Approved,
CreatedBy = actor,
CreatedAt = DateTime.UtcNow,
Lines = lines
Lines = request.Lines.Select(ToLine).ToList()
};
await _pos.AddAsync(entity, token);
return entity;
@@ -133,8 +129,8 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
// Full line replacement (Phase 1: no receipts yet, so qtyReceived is 0 on every line).
po.Lines.Clear();
foreach (var line in await ToLinesAsync(request.Lines, ct))
po.Lines.Add(line);
foreach (var input in request.Lines)
po.Lines.Add(ToLine(input));
try
{
@@ -223,34 +219,15 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
// Supersedes Phase-1 Option B "freely editable while open" — see docs/10 FR-PROC-05.
private static bool IsEditable(PurchaseOrderStatus status) => status is PurchaseOrderStatus.Draft;
/// <summary>
/// Materialises request lines, resolving each one's base quantity up front. Resolving here
/// rejects a UOM the item cannot be ordered in at entry time, and gives GRN receipt matching
/// a stable base figure to compare against regardless of the UOM the goods arrive in.
/// </summary>
private async Task<List<PoLine>> ToLinesAsync(IReadOnlyCollection<CreatePoLineInput> inputs, CancellationToken ct)
private static PoLine ToLine(CreatePoLineInput l) => new()
{
var lines = new List<PoLine>(inputs.Count);
foreach (var l in inputs)
{
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == l.ItemId, ct);
var factor = await _uomConverter.ResolveFactorAsync(item, l.UomId, ct);
lines.Add(new PoLine
{
ItemId = l.ItemId,
UomId = l.UomId,
WarehouseId = l.WarehouseId,
Qty = l.Qty,
QtyBase = UomConverter.ApplyFactor(l.Qty, factor),
ConversionFactor = factor,
UnitPrice = l.UnitPrice,
Tax = l.Tax,
QtyReceived = 0,
QtyReceivedBase = 0
});
}
return lines;
}
ItemId = l.ItemId,
WarehouseId = l.WarehouseId,
Qty = l.Qty,
UnitPrice = l.UnitPrice,
Tax = l.Tax,
QtyReceived = 0
};
private static PoTotalsDto ComputeTotals(IEnumerable<PoLine> lines)
{
@@ -280,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);
}
@@ -298,6 +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,
l.QtyBase, l.QtyReceivedBase, l.ConversionFactor)).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.");
}
@@ -25,7 +25,6 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
private readonly ISalesPostingService _posting;
private readonly ISalesMappingService _mapping;
private readonly ISalesDocumentWorkflowService _workflow;
private readonly IUomConverter _uomConverter;
private readonly ICurrentUser _currentUser;
private readonly INumberSequenceService _numbers;
private readonly IUnitOfWork _uow;
@@ -33,10 +32,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
public SalesInvoiceService(
IRepository<SalesInvoice> invoices, IRepository<Customer> customers, IRepository<Item> items,
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
ISalesDocumentWorkflowService workflow, IUomConverter uomConverter,
ISalesDocumentWorkflowService workflow,
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
{
_uomConverter = uomConverter;
_invoices = invoices;
_customers = customers;
_items = items;
@@ -147,29 +145,19 @@ 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;
var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, r.TaxPct, r.IsFreeIssue);
// Resolve the base quantity now and store it on the line. This doubles as the
// entry-time UOM check (an item that cannot be sold in this UOM throws 422 here,
// while the user is still editing) and as the snapshot posting consumes — a
// conversion factor edited later must not change what this document posts.
var factor = await _uomConverter.ResolveFactorAsync(item, r.UomId, ct);
lines.Add(new SalesInvoiceLine
{
ItemId = r.ItemId,
Description = item.Name,
Qty = r.Qty,
FreeQty = r.FreeQty,
UomId = r.UomId,
QtyBase = UomConverter.ApplyFactor(r.Qty, factor),
FreeQtyBase = UomConverter.ApplyFactor(r.FreeQty, factor),
ConversionFactor = factor,
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());
}
+14 -26
View File
@@ -23,8 +23,6 @@ public sealed class SalesPostingService : ISalesPostingService
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
// No IUomConverter here by design: every line arrives with its base quantity already
// snapshotted by the service that saved it, so posting has nothing left to convert.
public SalesPostingService(
IRepository<SalesInvoice> invoices,
IRepository<SalesSlip> slips,
@@ -59,20 +57,18 @@ public sealed class SalesPostingService : ISalesPostingService
{
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
continue;
// Compare base against base: on-hand is base UOM, so the entered quantity would
// under-report the requirement on any line not in the item's base UOM.
var requestedQty = line.QtyBase + line.FreeQtyBase;
var requestedQty = line.Qty + line.FreeQty;
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
if (available >= requestedQty) continue;
var item = await _items.Query().AsNoTracking()
.Where(x => x.ItemId == line.ItemId)
.Select(x => new { x.Sku, x.Name, BaseUomName = x.BaseUom!.Name })
.Select(x => new { x.Sku, x.Name })
.FirstAsync(ct);
issues.Add(new SalesInvoicePostingIssueDto(
line.SalesInvoiceLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId,
requestedQty, available, requestedQty - available, line.IsFreeIssue, item.BaseUomName));
requestedQty, available, requestedQty - available, line.IsFreeIssue));
}
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, issues.Count == 0, issues);
@@ -92,19 +88,18 @@ public sealed class SalesPostingService : ISalesPostingService
{
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
continue;
// Base against base — see the matching comment in CheckInvoiceAsync.
var requestedQty = line.QtyBase + line.FreeQtyBase;
var requestedQty = line.Qty + line.FreeQty;
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
if (available >= requestedQty) continue;
var item = await _items.Query().AsNoTracking()
.Where(x => x.ItemId == line.ItemId)
.Select(x => new { x.Sku, x.Name, BaseUomName = x.BaseUom!.Name })
.Select(x => new { x.Sku, x.Name })
.FirstAsync(ct);
issues.Add(new SalesSlipPostingIssueDto(
line.SalesSlipLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId,
requestedQty, available, requestedQty - available, line.IsFreeIssue, item.BaseUomName));
requestedQty, available, requestedQty - available, line.IsFreeIssue));
}
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, issues.Count == 0, issues);
@@ -126,15 +121,12 @@ public sealed class SalesPostingService : ISalesPostingService
continue;
var item = await _items.Query().AsNoTracking()
.Where(x => x.ItemId == line.ItemId)
.Select(x => new { x.Sku, x.Name, BaseUomName = x.BaseUom!.Name })
.FirstAsync(ct);
.FirstAsync(x => x.ItemId == line.ItemId, ct);
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
if (available >= line.QtyBase) continue;
if (available >= line.Qty) continue;
issues.Add(new BundleSalePostingIssueDto(
line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId,
line.QtyBase, available, line.QtyBase - available, item.BaseUomName));
line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
}
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
@@ -146,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.QtyBase + l.FreeQtyBase, l.QtyBase, l.FreeQtyBase)),
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,
@@ -159,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.QtyBase + l.FreeQtyBase, l.QtyBase, l.FreeQtyBase)),
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,
@@ -172,7 +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.",
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.QtyBase, l.QtyBase, 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,
@@ -206,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,
@@ -217,11 +211,5 @@ public sealed class SalesPostingService : ISalesPostingService
}, ct);
}
/// <summary>
/// A line reduced to what posting needs. Every quantity here is in the item's <b>base</b>
/// UOM, taken from the snapshot the document service resolved at save — the FIFO engine
/// accepts nothing else, and re-resolving at post time would let a factor edited in the
/// meantime change what a saved document consumes.
/// </summary>
private sealed record PostingLine(int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty);
}
@@ -0,0 +1,200 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Sales-return service. Auto-posts with a mandatory Return reason code and
/// generates an inbound stock movement via the shared <see cref="IStockMutator"/>
/// (positive delta — creates an inbound FIFO layer at last cost). Single UoW
/// transaction, mirroring <see cref="PurchaseReturnService"/> with the direction
/// reversed.
/// </summary>
public sealed class SalesReturnService : ISalesReturnService
{
private readonly IRepository<SalesReturn> _returns;
private readonly IRepository<Customer> _customers;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IRepository<SalesInvoiceLine> _salesInvoiceLines;
private readonly IRepository<SalesReturnLine> _returnLines;
private readonly IRepository<StockLedger> _ledger;
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public SalesReturnService(
IRepository<SalesReturn> returns, IRepository<Customer> customers, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<SalesInvoiceLine> salesInvoiceLines,
IRepository<SalesReturnLine> returnLines, IRepository<StockLedger> ledger, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_returns = returns;
_customers = customers;
_warehouses = warehouses;
_items = items;
_reasonCodes = reasonCodes;
_salesInvoiceLines = salesInvoiceLines;
_returnLines = returnLines;
_ledger = ledger;
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<PagedResponse<SalesReturnSummaryDto>> ListAsync(
PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default)
{
var q = _returns.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%"));
}
if (customerId is not null) q = q.Where(r => r.CustomerId == customerId);
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(r => r.ReturnId)
.Skip(query.Skip).Take(query.PageSize)
.Select(r => new SalesReturnSummaryDto(
r.ReturnId, r.DocNo, r.CustomerId, r.WarehouseId, r.ReasonCodeId, r.Status,
r.CreatedBy, r.CreatedAt, r.Lines.Count, r.Lines.Sum(l => l.Qty)))
.ToListAsync(ct);
return PagedResponse<SalesReturnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<SalesReturnDto?> GetAsync(int returnId, CancellationToken ct = default)
{
var ret = await _returns.Query().AsNoTracking()
.Include(r => r.Lines)
.FirstOrDefaultAsync(r => r.ReturnId == returnId, ct);
if (ret is null) return null;
// Polymorphic ledger reference — recovered by source-doc lookup.
var ledgerRefs = await _ledger.Query().AsNoTracking()
.Where(l => l.SourceDocType == DocumentTypes.SalesReturn && l.SourceDocId == returnId)
.OrderBy(l => l.LedgerId)
.Select(l => l.LedgerId)
.ToListAsync(ct);
return ToDto(ret, ledgerRefs);
}
public async Task<SalesReturnDto> CreateAsync(CreateSalesReturnRequest request, CancellationToken ct = default)
{
if (request.ReasonCodeId is null)
throw new DomainException(ErrorCodes.ReasonCodeRequired, "A reason code is required for a sales return.", 400);
if (!await _customers.Query().AnyAsync(c => c.CustomerId == request.CustomerId, ct))
throw new DomainException(ErrorCodes.Validation, $"Customer {request.CustomerId} does not exist.", 422);
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
var reason = await _reasonCodes.Query().AsNoTracking().FirstOrDefaultAsync(r => r.ReasonCodeId == request.ReasonCodeId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} does not exist.", 422);
if (reason.Context != ReasonContext.Return)
throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not a Return reason.", 422);
// Original sold qty is never mutated — "remaining returnable" is computed from
// return history instead, so the invoice keeps recording what was actually sold.
var pendingByInvoiceLine = new Dictionary<int, decimal>();
foreach (var line in request.Lines)
{
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct))
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
if (line.SalesInvoiceLineId is not null)
{
var invoiceLineId = line.SalesInvoiceLineId.Value;
var invoiceLine = await _salesInvoiceLines.Query().AsNoTracking().FirstOrDefaultAsync(l => l.SalesInvoiceLineId == invoiceLineId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Sales invoice line {invoiceLineId} does not exist.", 422);
if (invoiceLine.ItemId != line.ItemId)
throw new DomainException(ErrorCodes.Validation, $"Sales invoice line {invoiceLineId} is for a different item.", 422);
var alreadyReturned = await _returnLines.Query().AsNoTracking()
.Where(l => l.SalesInvoiceLineId == invoiceLineId)
.SumAsync(l => (decimal?)l.Qty, ct) ?? 0m;
pendingByInvoiceLine.TryGetValue(invoiceLineId, out var pending);
var remaining = invoiceLine.Qty - alreadyReturned - pending;
if (line.Qty > remaining)
throw new DomainException(ErrorCodes.Validation, $"Insufficient quantity — only {remaining} remain returnable on sales invoice line {invoiceLineId} (requested {line.Qty}).", 422);
pendingByInvoiceLine[invoiceLineId] = pending + line.Qty;
}
}
var now = DateTime.UtcNow;
var deltas = request.Lines.Select(l => new StockDelta(l.ItemId, null, null, l.Qty)).ToList();
var (entity, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
{
var docNo = await _numbers.NextAsync(DocumentTypes.SalesReturn, token);
var ret = new SalesReturn
{
DocNo = docNo,
CustomerId = request.CustomerId,
WarehouseId = request.WarehouseId,
ReasonCodeId = request.ReasonCodeId.Value,
Status = ReturnStatus.Posted,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = now,
Lines = request.Lines.Select(l => new SalesReturnLine
{
SalesInvoiceLineId = l.SalesInvoiceLineId,
ItemId = l.ItemId,
Qty = l.Qty
}).ToList()
};
await _returns.AddAsync(ret, token);
await _uow.SaveChangesAsync(token); // flush for a valid ledger sourceDocId
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.SalesReturn, ret.ReturnId, now, deltas, token);
return (ret, refs);
}, ct);
// Map ledger ids after commit so they are populated.
return ToDto(entity, ledgerEntries.Select(r => r.LedgerId).ToList());
}
public async Task<IReadOnlyList<SalesInvoiceLineRemainingDto>> GetRemainingByInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
{
var lines = await _salesInvoiceLines.Query().AsNoTracking()
.Where(l => l.SalesInvoiceId == salesInvoiceId)
.Select(l => new { l.SalesInvoiceLineId, l.Qty })
.ToListAsync(ct);
var lineIds = lines.Select(l => l.SalesInvoiceLineId).ToList();
var returnedByLine = await _returnLines.Query().AsNoTracking()
.Where(l => l.SalesInvoiceLineId != null && lineIds.Contains(l.SalesInvoiceLineId.Value))
.GroupBy(l => l.SalesInvoiceLineId!.Value)
.Select(g => new { SalesInvoiceLineId = g.Key, Returned = g.Sum(x => x.Qty) })
.ToDictionaryAsync(x => x.SalesInvoiceLineId, x => x.Returned, ct);
return lines
.Select(l => new SalesInvoiceLineRemainingDto(
l.SalesInvoiceLineId,
l.Qty - (returnedByLine.TryGetValue(l.SalesInvoiceLineId, out var returned) ? returned : 0m)))
.ToList();
}
private static SalesReturnDto ToDto(SalesReturn r, IReadOnlyList<int> ledgerRefs) => new(
r.ReturnId, r.DocNo, r.CustomerId, r.WarehouseId, r.ReasonCodeId, r.Status, r.CreatedBy, r.CreatedAt,
r.Lines.OrderBy(l => l.ReturnLineId)
.Select(l => new SalesReturnLineDto(l.ReturnLineId, l.SalesInvoiceLineId, l.ItemId, l.Qty)).ToList(),
ledgerRefs);
}
+8 -17
View File
@@ -26,7 +26,6 @@ public sealed class SalesSlipService : ISalesSlipService
private readonly ISalesPostingService _posting;
private readonly ISalesMappingService _mapping;
private readonly ISalesDocumentWorkflowService _workflow;
private readonly IUomConverter _uomConverter;
private readonly ICurrentUser _currentUser;
private readonly INumberSequenceService _numbers;
private readonly IUnitOfWork _uow;
@@ -34,10 +33,9 @@ public sealed class SalesSlipService : ISalesSlipService
public SalesSlipService(
IRepository<SalesSlip> slips, IRepository<Customer> customers, IRepository<Item> items,
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, IRepository<User> users, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
ISalesDocumentWorkflowService workflow, IUomConverter uomConverter,
ISalesDocumentWorkflowService workflow,
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
{
_uomConverter = uomConverter;
_slips = slips;
_customers = customers;
_items = items;
@@ -171,27 +169,19 @@ 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;
var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, r.TaxPct, r.IsFreeIssue);
// Resolve the base quantity now and store it on the line — see the matching
// comment in SalesInvoiceService.BuildLinesAsync.
var factor = await _uomConverter.ResolveFactorAsync(item, r.UomId, ct);
lines.Add(new SalesSlipLine
{
ItemId = r.ItemId,
Description = item.Name,
Qty = r.Qty,
FreeQty = r.FreeQty,
UomId = r.UomId,
QtyBase = UomConverter.ApplyFactor(r.Qty, factor),
FreeQtyBase = UomConverter.ApplyFactor(r.FreeQty, factor),
ConversionFactor = factor,
WarehouseId = r.WarehouseId,
UnitPrice = unitPrice,
BaseCost = unitPrice,
@@ -231,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()
@@ -249,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}");
@@ -272,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);
}
+5 -38
View File
@@ -14,32 +14,17 @@ public sealed class StockService : IStockService
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<StockTransferLine> _transferLines;
private readonly IRepository<Item> _items;
public StockService(
IFifoCostingService fifo, IRepository<StockLayer> layers,
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines,
IRepository<Item> items)
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines)
{
_fifo = fifo;
_layers = layers;
_ledger = ledger;
_transferLines = transferLines;
_items = items;
}
/// <summary>
/// Base UOM (id + name) for a set of items, as one query. Stock reads are already
/// set-based to avoid N+1; this keeps the UOM label on the same footing.
/// </summary>
private async Task<Dictionary<int, (int Id, string Name)>> BaseUomsAsync(
IReadOnlyCollection<int> itemIds, CancellationToken ct)
=> (await _items.Query().AsNoTracking()
.Where(i => itemIds.Contains(i.ItemId))
.Select(i => new { i.ItemId, i.BaseUomId, Name = i.BaseUom!.Name })
.ToListAsync(ct))
.ToDictionary(x => x.ItemId, x => (x.BaseUomId, x.Name));
public async Task<StockOnHandDto> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default)
{
var onHand = await _fifo.GetOnHandAsync(itemId, warehouseId, ct);
@@ -62,10 +47,7 @@ public sealed class StockService : IStockService
const decimal reserved = 0m;
var available = onHand - onHold - reserved;
var uom = (await BaseUomsAsync([itemId], ct)).GetValueOrDefault(itemId);
return new StockOnHandDto(
itemId, warehouseId, onHand, available, onHold, inTransit, reserved, DateTime.UtcNow,
uom.Id, uom.Name ?? string.Empty);
return new StockOnHandDto(itemId, warehouseId, onHand, available, onHold, inTransit, reserved, DateTime.UtcNow);
}
/// <summary>
@@ -118,20 +100,16 @@ public sealed class StockService : IStockService
.ToListAsync(ct))
.ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty);
var baseUoms = await BaseUomsAsync(itemIds, ct);
var asOf = DateTime.UtcNow;
var rows = page.Select(p =>
{
var key = (p.ItemId, p.WarehouseId);
var hold = onHold.GetValueOrDefault(key);
var transit = inTransit.GetValueOrDefault(key);
var uom = baseUoms.GetValueOrDefault(p.ItemId);
const decimal reserved = 0m;
// Same formula as GetOnHandAsync: in-transit is reported, not re-subtracted.
return new StockOnHandDto(
p.ItemId, p.WarehouseId, p.OnHand, p.OnHand - hold - reserved, hold, transit, reserved, asOf,
uom.Id, uom.Name ?? string.Empty);
p.ItemId, p.WarehouseId, p.OnHand, p.OnHand - hold - reserved, hold, transit, reserved, asOf);
}).ToList();
return PagedResponse<StockOnHandDto>.Create(rows, query.Page, query.PageSize, total);
@@ -161,20 +139,9 @@ public sealed class StockService : IStockService
l.SourceDocType, l.SourceDocId, l.UserId, l.CreatedAt))
.ToListAsync(ct);
var baseUoms = await BaseUomsAsync(rows.Select(r => r.ItemId).Distinct().ToList(), ct);
rows = rows.Select(r =>
{
var uom = baseUoms.GetValueOrDefault(r.ItemId);
return r with { BaseUomId = uom.Id, BaseUomName = uom.Name ?? string.Empty };
}).ToList();
return PagedResponse<StockLedgerRowDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
{
var valuation = await _fifo.GetValuationAsync(itemId, warehouseId, ct);
var uom = (await BaseUomsAsync([itemId], ct)).GetValueOrDefault(itemId);
return valuation with { BaseUomId = uom.Id, BaseUomName = uom.Name ?? string.Empty };
}
public Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
=> _fifo.GetValuationAsync(itemId, warehouseId, ct);
}
@@ -1,139 +0,0 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Uoms;
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>
/// <remarks>
/// Conversions are one-directional by design: a row always reads
/// <c>FromUom → ToUom = item.BaseUomId</c>, enforced on write by
/// <c>ItemService.UpdateUomConversionsAsync</c>. Nothing here inverts a factor, so a row
/// stored in the opposite direction would be invisible to every caller — which is exactly
/// why the write side rejects it rather than this side guessing.
/// </remarks>
public sealed class UomConverter : IUomConverter
{
/// <summary>Quantity columns are <c>(18,4)</c> across the model.</summary>
private const int QtyScale = 4;
/// <summary>Unit-cost columns and <c>uom_conversions.Factor</c> are <c>(18,6)</c>.</summary>
private const int CostScale = 6;
private readonly IRepository<UomConversion> _conversions;
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
public UomConverter(IRepository<UomConversion> conversions, IRepository<Item> items, IRepository<Uom> uoms)
{
_conversions = conversions;
_items = items;
_uoms = uoms;
}
public async Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default)
{
var factor = await ResolveFactorAsync(item, uomId, ct);
if (factor == 1m)
return (qty, unitCostPerUom);
// Quantity scales up by the factor, so the per-unit cost scales down by it —
// total value is preserved. Round to each column's own scale here rather than
// letting the provider truncate on write, so what posts is what was computed.
return (
Math.Round(qty * factor, QtyScale, MidpointRounding.AwayFromZero),
Math.Round(unitCostPerUom / factor, CostScale, MidpointRounding.AwayFromZero));
}
public async Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default)
=> (await ToBaseAsync(item, uomId, qty, 0m, ct)).QtyBase;
public async Task<decimal> ResolveFactorAsync(Item item, int uomId, CancellationToken ct = default)
{
if (uomId == item.BaseUomId)
return 1m;
var conv = await _conversions.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct);
if (conv is null)
throw await NoConversionAsync(item, uomId, ct);
return conv.Factor;
}
public async Task<IReadOnlyList<AllowedUomDto>> GetAllowedUomsAsync(int itemId, CancellationToken ct = default)
{
var item = await _items.Query().AsNoTracking()
.Where(i => i.ItemId == itemId)
.Select(i => new { i.ItemId, i.BaseUomId, BaseUomName = i.BaseUom!.Name })
.FirstOrDefaultAsync(ct)
?? throw new NotFoundException($"Item {itemId} was not found.");
var converted = await _conversions.Query().AsNoTracking()
.Where(c => c.ItemId == itemId && c.ToUomId == item.BaseUomId && c.FromUomId != item.BaseUomId)
.Select(c => new AllowedUomDto(c.FromUomId, c.FromUom!.Name, c.Factor, false))
.ToListAsync(ct);
// Base first — it is what every form defaults to — then the alternates by name.
return converted
.OrderBy(u => u.Name)
.Prepend(new AllowedUomDto(item.BaseUomId, item.BaseUomName, 1m, true))
.ToList();
}
public async Task ValidateUomAsync(Item item, int uomId, CancellationToken ct = default)
{
if (uomId == item.BaseUomId)
return;
var exists = await _conversions.Query().AsNoTracking()
.AnyAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct);
if (!exists)
throw await NoConversionAsync(item, uomId, ct);
}
/// <summary>
/// Applies an already-resolved factor to a quantity, rounded to the quantity scale.
/// Callers that snapshot a line use this so every base quantity in the system is
/// derived and rounded identically.
/// </summary>
public static decimal ApplyFactor(decimal qty, decimal factor)
=> Math.Round(qty * factor, QtyScale, MidpointRounding.AwayFromZero);
/// <summary>
/// Restates a base quantity in <paramref name="factor"/>'s UOM. <b>Display only</b> —
/// this divides, so it can drift, and its result must never reach a stock or ledger
/// write. Conversion toward base is the authoritative direction.
/// </summary>
public static decimal FromBase(decimal qtyBase, decimal factor)
=> factor == 0m ? 0m : Math.Round(qtyBase / factor, QtyScale, MidpointRounding.AwayFromZero);
/// <summary>
/// Builds the 422 for an unconvertible UOM. Names the units the item actually accepts,
/// because the bare id in the old message told the user nothing about how to recover.
/// </summary>
private async Task<DomainException> NoConversionAsync(Item item, int uomId, CancellationToken ct)
{
var attempted = await _uoms.Query().AsNoTracking()
.Where(u => u.UomId == uomId)
.Select(u => u.Name)
.FirstOrDefaultAsync(ct) ?? $"#{uomId}";
var allowed = await GetAllowedUomsAsync(item.ItemId, ct);
return new DomainException(ErrorCodes.Validation,
$"Item {item.Sku} cannot be transacted in {attempted}. Allowed units: " +
$"{string.Join(", ", allowed.Select(u => u.Name))}. " +
"Add a UOM conversion on the item to use another unit.", 422);
}
}
@@ -56,6 +56,7 @@ public static class ErrorCodes
public const string GraphDisconnected = "GRAPH_DISCONNECTED";
public const string GraphInputSourceInvalid = "GRAPH_INPUT_SOURCE_INVALID";
public const string TerminalOutputItemRequired = "TERMINAL_OUTPUT_ITEM_REQUIRED";
public const string WipUnitRequired = "WIP_UNIT_REQUIRED";
public const string StageNotReady = "STAGE_NOT_READY";
public const string StageNotInProgress = "STAGE_NOT_IN_PROGRESS";
public const string StageNotDone = "STAGE_NOT_DONE";
+68 -1
View File
@@ -4,6 +4,73 @@ Legend: `[ ]` not started · `[~]` in progress · `[x]` done
Spec: `docs/10-BACKEND-PHASE1.md` (model + rules) · `docs/11-BACKEND-PHASE1.md` (API)
Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** as the code. When ticking `[x]`, append a short note + any deviation.
## Per-variant content size (2026-08-11) — follow-up to the UOM re-model below
The UOM re-model put `ContentQty`/`ContentUnit` on `Item` correctly, but the item **create page
is a variant builder** and collected **one** form-level pair, copying it into every generated
variant. Building "Coca-Cola in 500 ml / 1 L / 250 ml" produced three items all recorded as the
same size — the exact case the builder exists for. The item contract already accepted a
per-item pair, so the whole fix is in how values are captured.
- **`ItemType.IsMeasurable`** (bool, default `false`) — set on Products → Item Types. A flagged
dimension's values are entered as a number + unit; the chip label, the SKU segment, the item
name and the stored content size all derive from that one pair. Unflagged dimensions are
unchanged free text, which is what an apparel `Size` (S/M/L) needs.
- **`UpdateItemTypeRequest.IsMeasurable` is `bool?` and preserved when omitted.** A plain `bool`
binds an absent property as `false`, so the admin screen's name-only PUT would have cleared the
flag on every rename — the same bug class already recorded for `product-config` further down.
- **The `BUILDER_ITEM_TYPES = ["color","size"]` hardcode is gone.** It had one consumer and had
become a live bug: a user-created "Pack Size" would be flagged measurable and then never
appear. Removal is behaviour-preserving on any current database (the seeder seeds exactly those
two names, and the fetch was already `status: Active`) and restores the documented contract
that users add their own types. Every Active item type is now offered; deactivation is the
intended remedy and the admin page already says so.
- **SKU collision fixed before it could bite.** `skuSegment` strips the decimal point and
truncates to 3, so derived labels collided — `1.5L`/`15L``15L`, `500ml`/`500g``500`,
`2.5ml`/`25ml``25M`. Since the create loop is sequential and non-transactional, that would
have failed partway with `SKU_DUPLICATE` after creating some rows. Measurement segments now use
`measureKey`, which mirrors `ItemContent.Normalize` (L/Kg ×1000) and renders the point as `P`.
- **Values dedupe on the normalised size, not the label** — `500 ml` and `0.5 L` read differently
but store identically, and `ItemContent.Normalize` is the server's notion of equality.
- **At most one measurable dimension** per product: unchecked measurable types are disabled once
one is checked, re-checked at submit.
- **The form-level pair survives as a fallback** — correct when the varying dimension isn't size —
and is hidden *and cleared* whenever a measurable dimension is active, so the two can never
disagree. Its validation is skipped in that mode, since its error message would otherwise be
invisible inside the hidden block.
- The item **edit** page is untouched: one item, one size.
## UOM re-model (2026-08-11) — supersedes every "UOM conversion" note below
Per-item UOM conversion is **gone**. Entries further down this file that describe
`IUomConverter`, `PUT /items/{id}/uom-conversions`, `uom_conversions`, or a line-level
`uomId` are historical and no longer describe the code.
What replaced it:
- **One unit per item.** `Item.BaseUomId` is the pack an item is stocked and counted in, and
every quantity in the system — stock layers, ledger rows, GRN/PO/sales/bundle/transfer lines
— is a plain count of it. A differently sized pack is a different item. `UomId` was dropped
from all six document-line entities; `Uom` itself survives as the lookup.
- **Optional content size on `Item`**: `ContentQty` + `ContentUnit` as entered (`Ml|L|G|Kg`),
normalised on write into `ContentBaseQty` + `ContentBaseUnit` (only ever `Ml` or `G`, L/Kg
×1000). All four null ⇒ nothing measurable to hold. `ItemContent` is the pure normaliser.
- **Production is the only content consumer.** `StageInput.QtyUnit` (`Pack|Content`) says what
`QtyPerBatch` means; `IItemMeasure` divides a `Content` quantity by the item's content size
to get packs. **Fractional packs are legal** — 300 ml of a 500 ml bottle consumes 0.6000.
Outputs are always pack counts, so scrap stays in whole broken bottles.
- **WIP keeps a label.** `StageOutput.UomId`/`RunStageOutput.UomId` are now nullable and
required *only* when `ItemId` is null (`422 WIP_UNIT_REQUIRED`), since an item-bearing output
takes its unit from the item. WIP never touches stock, so the label is never converted.
- **Two live defects fixed as a consequence.** `SalesPostingService` injected `IUomConverter`
and never called it, so a sales line in a non-base UOM consumed the wrong quantity outright;
`GrnService` accrued `poLine.QtyReceived += line.Qty` and range-checked over-receipt across
the same unit boundary. Both are now like-for-like by construction.
- **`BaseUomId` is frozen once an item has stock history** (`409 MASTER_IN_USE`) — it is the
sole meaning of every recorded quantity, so changing it would silently reinterpret all of it.
- Smoke: `m4b_uom_conversion.py` deleted; `m4c_content_units.py` added (whole packs, fractional
packs, and the contentless-item guard).
## 8. Sales
- [x] Sales bootstrap data seeded locally for development: warehouses, UOMs, categories, items, customers, current-year `SI`/`SSL` sequences, plus sample invoice/slip headers and lines. Existing data is preserved.
- [x] Sales report API consolidated into `GET /api/v1/reports/sales` (catalog), `GET /api/v1/reports/sales/{reportId}` (report metadata), and `POST /api/v1/reports/sales/query` (filtered data). Legacy per-report GET routes removed; invalid report/filter combinations now fail validation.
@@ -29,7 +96,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
- [x] Warehouse + Bin (`/warehouses`, nested `/warehouses/{id}/bins`, bin code unique per warehouse)
- [x] Item reorder settings (`PUT /items/{id}/reorder` full-replace upsert, warehouse-exists validation)
- [x] Brand master (FR-MD-09) — CRUD + status + ETag; `Item.brandId` nullable FK
- [x] Item Type master (FR-MD-10) — CRUD + status + ETag; **unreferenced by design**, feeds the builder dropdown only
- [x] Item Type master (FR-MD-10) — CRUD + status + ETag; **unreferenced by design**. Feeds the builder's dimension list, and since 2026-08-11 carries `isMeasurable`, which decides whether its values are captured as free text or as a number + unit that becomes each item's content size (see the entry at the top of this file)
- [x] SubCategory (FR-MD-04) — nested list/create under a category, `PUT`/`PATCH status` by id; `Item.subCategoryId` nullable FK, validated to belong to `categoryId`
- [x] Product Configuration (FR-MD-11) — singleton `GET`/`PUT /product-config`; `CONFIG_DISABLED` gating on item writes
- [x] Item **sale price** (FR-MD-01, 2026-07-22) — nullable `Item.SalePrice` (`numeric(18,4)`); on all Item DTOs (list/detail/create/update), validated `>= 0`. **Sales-only** — never enters GRN/FIFO/ledger. `null` ⇒ sell at stock value. Migration `AddItemSalePrice`. See the 2026-07-22 Done entry.
Binary file not shown.
+11 -8
View File
@@ -44,25 +44,26 @@ def diamond(raw_item, finished_item, uom):
"estimatedMinutes": 60, "posX": 80, "posY": 120,
"fieldDefs": [{"key": "moisture_ok", "label": "Moisture check",
"type": "Checkbox", "required": True}],
"inputs": [{"source": "Stock", "itemId": raw_item, "uomId": uom, "qtyPerBatch": 8}],
"inputs": [{"source": "Stock", "itemId": raw_item, "qtyPerBatch": 8}],
"outputs": [{"key": "tmp-frame", "name": "Frame set", "uomId": uom, "qtyPerBatch": 1}],
},
{
"key": "tmp-prep", "name": "Prep cushions", "roleLabel": "Upholstery",
"estimatedMinutes": 30, "posX": 80, "posY": 320, "fieldDefs": [],
"inputs": [{"source": "Stock", "itemId": raw_item, "uomId": uom, "qtyPerBatch": 2}],
"inputs": [{"source": "Stock", "itemId": raw_item, "qtyPerBatch": 2}],
"outputs": [{"key": "tmp-cushion", "name": "Cushion set", "uomId": uom, "qtyPerBatch": 1}],
},
{
"key": "tmp-asm", "name": "Assemble & QA", "roleLabel": "QA",
"estimatedMinutes": 45, "posX": 560, "posY": 200, "fieldDefs": [],
"inputs": [
{"source": "Upstream", "fromOutputKey": "tmp-frame", "uomId": uom, "qtyPerBatch": 1},
{"source": "Upstream", "fromOutputKey": "tmp-cushion", "uomId": uom, "qtyPerBatch": 1},
{"source": "Upstream", "fromOutputKey": "tmp-frame", "qtyPerBatch": 1},
{"source": "Upstream", "fromOutputKey": "tmp-cushion", "qtyPerBatch": 1},
],
# Terminal output must name the finished item (FR-MFG-05).
# Terminal output must name the finished item (FR-MFG-05), and takes its unit
# from that item — sending a uomId as well is rejected.
"outputs": [{"key": "tmp-chair", "name": "Chair", "itemId": finished_item,
"uomId": uom, "qtyPerBatch": 1}],
"qtyPerBatch": 1}],
},
],
"edges": [
@@ -243,7 +244,7 @@ def main():
grandparent["stages"].append({
"key": "tmp-mid", "name": "Middle", "estimatedMinutes": 5, "posX": 320, "posY": 120,
"fieldDefs": [],
"inputs": [{"source": "Upstream", "fromOutputKey": cut_output_key, "uomId": uom, "qtyPerBatch": 1}],
"inputs": [{"source": "Upstream", "fromOutputKey": cut_output_key, "qtyPerBatch": 1}],
"outputs": [{"key": "tmp-mid-out", "name": "Mid part", "uomId": uom, "qtyPerBatch": 1}],
})
grandparent["edges"] = [e for e in grandparent["edges"] if e["parentKey"] != cut_key]
@@ -320,9 +321,11 @@ def rebuild_from_get(g: dict) -> dict:
"inputs": [
{"source": i["source"], "itemId": i.get("itemId"),
"fromOutputKey": i.get("fromOutputKey"),
"uomId": i["uomId"], "qtyPerBatch": i["qtyPerBatch"]}
"qtyUnit": i["qtyUnit"], "qtyPerBatch": i["qtyPerBatch"]}
for i in s["inputs"]
],
# uomId round-trips as null on an item-bearing output and as the WIP label
# otherwise, so echoing it back verbatim is correct either way.
"outputs": [
{"key": o["key"], "itemId": o.get("itemId"), "name": o["name"],
"uomId": o["uomId"], "qtyPerBatch": o["qtyPerBatch"]}
+1 -1
View File
@@ -147,7 +147,7 @@ def main():
"estimatedMinutes": s["estimatedMinutes"], "posX": s["posX"], "posY": s["posY"],
"fieldDefs": s["fieldDefs"],
"inputs": [{"source": i["source"], "itemId": i.get("itemId"),
"fromOutputKey": i.get("fromOutputKey"), "uomId": i["uomId"],
"fromOutputKey": i.get("fromOutputKey"), "qtyUnit": i["qtyUnit"],
"qtyPerBatch": i["qtyPerBatch"]} for i in s["inputs"]],
"outputs": [{"key": o["key"], "itemId": o.get("itemId"), "name": o["name"],
"uomId": o["uomId"], "qtyPerBatch": o["qtyPerBatch"]} for o in s["outputs"]]}
+9 -9
View File
@@ -65,16 +65,16 @@ def ledger_rows(c, run_id, source):
return rows
def seed_stock(c, wh, raw, pack, uom):
def seed_stock(c, wh, raw, pack):
"""
Seed on-hand at explicit unit costs. Two raw layers at different costs mean the FIFO
consumption at start has to weight them, so `consumedValue` is a real number the
assertions can check rather than the 0.00 a positive adjustment would produce.
"""
seed_costed_stock(c, wh, [
(raw, uom, SEED_RAW * 0.4, RAW_COST_1),
(raw, uom, SEED_RAW * 0.6, RAW_COST_2),
(pack, uom, SEED_PACK, PACK_COST),
(raw, SEED_RAW * 0.4, RAW_COST_1),
(raw, SEED_RAW * 0.6, RAW_COST_2),
(pack, SEED_PACK, PACK_COST),
])
@@ -90,7 +90,7 @@ def build_template(c, raw, pack, finished, uom):
"estimatedMinutes": 60, "posX": 80, "posY": 100,
"fieldDefs": [{"key": "moisture_ok", "label": "Moisture check",
"type": "Checkbox", "required": True}],
"inputs": [{"source": "Stock", "itemId": raw, "uomId": uom, "qtyPerBatch": 8}],
"inputs": [{"source": "Stock", "itemId": raw, "qtyPerBatch": 8}],
"outputs": [{"key": "tmp-frame", "name": "Frame", "uomId": uom, "qtyPerBatch": 1}],
},
{
@@ -99,11 +99,11 @@ def build_template(c, raw, pack, finished, uom):
"key": "tmp-asm", "name": "Assemble", "roleLabel": "QA",
"estimatedMinutes": 45, "posX": 520, "posY": 100, "fieldDefs": [],
"inputs": [
{"source": "Upstream", "fromOutputKey": "tmp-frame", "uomId": uom, "qtyPerBatch": 1},
{"source": "Stock", "itemId": pack, "uomId": uom, "qtyPerBatch": 2},
{"source": "Upstream", "fromOutputKey": "tmp-frame", "qtyPerBatch": 1},
{"source": "Stock", "itemId": pack, "qtyPerBatch": 2},
],
"outputs": [{"key": "tmp-chair", "name": "Chair", "itemId": finished,
"uomId": uom, "qtyPerBatch": 1}],
"qtyPerBatch": 1}],
},
],
"edges": [{"parentKey": "tmp-cut", "childKey": "tmp-asm"}],
@@ -141,7 +141,7 @@ def main():
drained = drain_stock(c, wh)
if drained:
print(f"drained {len(drained)} leftover item(s) from a previous execution")
seed_stock(c, wh, raw, pack, uom)
seed_stock(c, wh, raw, pack)
raw_before = on_hand(c, raw, wh)
pack_before = on_hand(c, pack, wh)
print(f"warehouse={wh} raw={raw}(on-hand {raw_before}) pack={pack}(on-hand {pack_before}) finished={finished}")
+1 -1
View File
@@ -1 +1 @@
{"runId": 64, "templateId": 2, "warehouseId": 4, "assembleStageId": 118, "finishedItemId": 13, "rawItemId": 18, "packItemId": 14}
{"runId": 20, "templateId": 6, "warehouseId": 4, "assembleStageId": 30, "finishedItemId": 1, "rawItemId": 4, "packItemId": 3}
-170
View File
@@ -1,170 +0,0 @@
"""M4b smoke test — UOM conversion on production stock inputs.
This covers the single highest-risk correctness gap in the manufacturing phase.
`IFifoCostingService.ConsumeAsync` works exclusively in an item's BASE UOM, while
`STAGE_INPUT.uom_id` is a free FK — docs/30 never mentions conversion at all. Without the
shared `IUomConverter` (extracted from `GrnService.ToBaseAsync`), a stage input declared in
"box of 12" would consume 1 base unit instead of 12 and silently mis-cost the whole run.
The dev database has no `uom_conversions` rows at all, so the non-base path was previously
unexercised by any data. This script creates a real conversion and proves:
* a stage input in a non-base UOM consumes qtyPerBatch x scaleFactor x factor base units
* the ledger records the BASE quantity, not the declared one
* an input in a UOM with no conversion defined is refused with 422 rather than mis-consumed
python Backend/smoke/m4b_uom_conversion.py
"""
from __future__ import annotations
import sys
from smoke_common import bootstrap
WAREHOUSE_CODE = "SMOKE-PRD"
TEMPLATE_CODE = "SMOKE-PT-M4B"
FACTOR = 12 # 1 case = 12 base units
QTY_PER_BATCH = 3 # cases per batch
TARGET_QTY = 10 # -> scale 10 -> 30 cases -> 360 base units
SEED = 5000
def main():
c, chk, args = bootstrap(__doc__)
print(f"API {args.api}")
# --- fixtures ---------------------------------------------------------
wh = next((w["warehouseId"] for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"]
if w["code"] == WAREHOUSE_CODE), None)
if wh is None:
sys.exit("FATAL: run m4_stage_actions.py first (it creates the SMOKE-PRD warehouse).")
items = c.get("/items?pageSize=5&status=Active").body["items"]
raw, finished = items[0], items[1]
base_uom = raw["baseUomId"]
uoms = c.get("/uoms?pageSize=50").body["items"]
case_uom = next((u["uomId"] for u in uoms if u["uomId"] != base_uom), None)
if case_uom is None:
sys.exit("FATAL: need at least 2 UOMs to test conversion.")
print(f"item={raw['itemId']} baseUom={base_uom} caseUom={case_uom} factor={FACTOR}")
# --- define the conversion -------------------------------------------
chk.section("1. Define a non-base UOM conversion for the item")
conv = c.put(f"/items/{raw['itemId']}/uom-conversions",
{"conversions": [{"fromUom": case_uom, "toUom": base_uom, "factor": FACTOR}]})
chk.status("PUT /items/{id}/uom-conversions", conv, 200)
if conv.status != 200:
return chk.finish("M4b")
chk.check("conversion stored", any(float(x["factor"]) == FACTOR for x in conv.body["conversions"]), True)
reason = c.get("/reason-codes?context=Adjustment&pageSize=5").body["items"][0]["reasonCodeId"]
c.post("/stock-adjustments", {"warehouseId": wh, "reasonCodeId": reason,
"lines": [{"itemId": raw["itemId"], "qtyDelta": SEED}]})
before = float(c.get(f"/stock/on-hand?itemId={raw['itemId']}&warehouseId={wh}").body["onHand"])
print(f"on-hand before: {before}")
# --- template whose stock input is declared in CASES ------------------
chk.section("2. A stage input declared in the non-base UOM")
payload = {
"code": TEMPLATE_CODE, "name": "Smoke M4b conversion line",
"stages": [{
"key": "tmp-only", "name": "Pack", "estimatedMinutes": 10,
"posX": 0, "posY": 0, "fieldDefs": [],
# Declared in cases, not base units.
"inputs": [{"source": "Stock", "itemId": raw["itemId"],
"uomId": case_uom, "qtyPerBatch": QTY_PER_BATCH}],
"outputs": [{"key": "tmp-out", "name": "Packed", "itemId": finished["itemId"],
"uomId": base_uom, "qtyPerBatch": 1}],
}],
"edges": [],
}
existing = next((t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"]
if t["code"] == TEMPLATE_CODE), None)
if existing:
head = c.get(f"/production-templates/{existing['templateId']}")
res = c.put(f"/production-templates/{existing['templateId']}", payload, if_match=head.etag)
tid = existing["templateId"] if res.status in (200, 409) else None
chk.check("template ready", tid is not None, True)
else:
res = c.post("/production-templates", payload)
chk.status("create single-stage template", res, 201)
tid = res.body["templateId"] if res.status == 201 else None
if tid is None:
return chk.finish("M4b")
# A lone stage is both the entry and the terminal — worth asserting explicitly.
run = c.post("/production-runs", {"templateId": tid, "targetQty": TARGET_QTY, "warehouseId": wh})
chk.status("create the run", run, 201)
if run.status != 201:
return chk.finish("M4b")
stage = run.body["stages"][0]
chk.check("single stage is both entry and terminal",
(stage["isEntry"], stage["isTerminal"]), (True, True))
chk.check("single stage starts Ready", stage["status"], "Ready")
chk.check("plannedQty stays in the DECLARED uom (3 x 10 cases)",
float(stage["inputs"][0]["plannedQty"]), float(QTY_PER_BATCH * TARGET_QTY))
# --- the actual conversion assertion ---------------------------------
chk.section("3. Consumption converts cases to base units")
expected_base = QTY_PER_BATCH * TARGET_QTY * FACTOR # 3 x 10 x 12 = 360
started = c.post(f"/production-runs/{run.body['runId']}/stages/{stage['runStageId']}/start")
chk.status("start the stage", started, 200)
if started.status != 200:
return chk.finish("M4b")
con = started.body["consumed"][0]
chk.check(f"consumed {expected_base} BASE units, not {QTY_PER_BATCH * TARGET_QTY}",
float(con["qty"]), float(expected_base))
chk.check("on-hand fell by the base quantity",
float(c.get(f"/stock/on-hand?itemId={raw['itemId']}&warehouseId={wh}").body["onHand"]),
before - expected_base)
rows = c.get(f"/stock/ledger?sourceDocType=PRDI&sourceDocId={run.body['runId']}&pageSize=50").body["items"]
chk.check("one PRDI row", len(rows), 1)
if rows:
chk.check("ledger qtyBase is the converted quantity", float(rows[0]["qtyBase"]), float(expected_base))
detail = c.get(f"/production-runs/{run.body['runId']}").body
chk.check("consumedQty stored in base units",
float(detail["stages"][0]["inputs"][0]["consumedQty"]), float(expected_base))
# --- missing conversion is refused, not silently mis-consumed --------
chk.section("4. An undefined conversion is refused (422), never assumed 1:1")
third_uom = next((u["uomId"] for u in c.get("/uoms?pageSize=50").body["items"]
if u["uomId"] not in (base_uom, case_uom)), None)
if third_uom is None:
chk.check("skipped: need a third UOM", True, True)
else:
bad = dict(payload)
bad["code"] = TEMPLATE_CODE + "-BAD"
bad["stages"] = [dict(payload["stages"][0])]
bad["stages"][0] = {**payload["stages"][0],
"inputs": [{"source": "Stock", "itemId": raw["itemId"],
"uomId": third_uom, "qtyPerBatch": 1}]}
made = c.post("/production-templates", bad)
if made.status != 201:
head = c.get(f"/production-templates?q={TEMPLATE_CODE}-BAD")
tid2 = next((t["templateId"] for t in head.body["items"]
if t["code"] == TEMPLATE_CODE + "-BAD"), None)
else:
tid2 = made.body["templateId"]
if tid2:
run2 = c.post("/production-runs", {"templateId": tid2, "targetQty": 1, "warehouseId": wh})
if run2.status == 201:
s2 = run2.body["stages"][0]["runStageId"]
chk.status("start a stage whose input UOM has no conversion",
c.post(f"/production-runs/{run2.body['runId']}/stages/{s2}/start"), 422)
else:
chk.check("could create the second run", run2.status, 201)
return chk.finish("M4b")
if __name__ == "__main__":
sys.exit(main())
+226
View File
@@ -0,0 +1,226 @@
"""M4c smoke test — production stage inputs expressed in content units (FR-MFG-04).
Replaces m4b_uom_conversion.py, which tested the per-item UOM conversion table that no longer
exists. Stock is now always a count of the item's base UOM (a bottle, a packet), and the only
place another unit appears is a production formula: a stage input may be written as an amount
of the item's *content* (ml or g), which the server divides by the item's content size to get
the pack count FIFO actually consumes.
What this pins down, none of which any other script covers:
* 2000 ml against a 500 ml bottle consumes exactly 4.0000 bottles, and the ledger agrees;
* 300 ml against the same item consumes 0.6000 — **fractional packs are legal**, which is
the whole reason the quantity columns are (18,4);
* a Content input on an item with no content size is refused at template save (422), not
silently treated as packs at stage start.
Self-contained: creates its own item, warehouse, template and runs.
python Backend/smoke/m4c_content_units.py
"""
from __future__ import annotations
import sys
from smoke_common import bootstrap, drain_stock, seed_costed_stock
WAREHOUSE_CODE = "SMOKE-CONTENT"
TEMPLATE_CODE = "SMOKE-PT-M4C"
SKU = "SMOKE-BOTTLE-500ML"
PLAIN_SKU = "SMOKE-NO-CONTENT"
CONTENT_QTY = 500 # ml held by one bottle
SEED_BOTTLES = 100
UNIT_COST = 3.0
def production_reason(c, code):
for r in c.get("/reason-codes?context=Production&pageSize=50").body["items"]:
if r["code"] == code:
return r["reasonCodeId"]
sys.exit(f"FATAL: Production reason {code} not seeded.")
def ensure_warehouse(c):
for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"]:
if w["code"] == WAREHOUSE_CODE:
return w["warehouseId"]
created = c.post("/warehouses", {"code": WAREHOUSE_CODE, "name": "Content-unit smoke warehouse"})
if created.status != 201:
sys.exit(f"FATAL: could not create the smoke warehouse: {created.status} {created.body}")
return created.body["warehouseId"]
def ensure_item(c, sku, name, content_qty, content_unit):
"""Find-or-create; content fields are only sent when the item is meant to have them."""
for i in c.get(f"/items?q={sku}&pageSize=50").body["items"]:
if i["sku"] == sku:
return i
category = c.get("/categories?pageSize=1").body["items"]
uoms = c.get("/uoms?pageSize=1").body["items"]
if not category or not uoms:
sys.exit("FATAL: need at least one category and one UOM seeded.")
body = {
"sku": sku, "name": name,
"categoryId": category[0]["categoryId"],
"baseUomId": uoms[0]["uomId"],
"stockNature": "Stocked", "trackingMode": "None",
}
if content_qty is not None:
body["contentQty"] = content_qty
body["contentUnit"] = content_unit
created = c.post("/items", body)
if created.status != 201:
sys.exit(f"FATAL: could not create item {sku}: {created.status} {created.body}")
return created.body
def save_template(c, raw_item, finished_item, qty_per_batch, qty_unit, suffix):
"""
One template per section, never a shared one.
Each section starts a run and leaves it InProgress, and a template with a live run is
edit-locked (FR-MFG-06, 409 TEMPLATE_IN_USE) — so re-saving a single shared code would
fail from the second section onward for reasons that have nothing to do with content units.
"""
code = f"{TEMPLATE_CODE}-{suffix}"
payload = {
"code": code, "name": f"Content-unit smoke line ({suffix})",
"stages": [{
"key": "tmp-mix", "name": "Mix", "estimatedMinutes": 5, "posX": 0, "posY": 0,
"fieldDefs": [],
"inputs": [{"source": "Stock", "itemId": raw_item,
"qtyUnit": qty_unit, "qtyPerBatch": qty_per_batch}],
"outputs": [{"key": "tmp-out", "name": "Mixed", "itemId": finished_item,
"qtyPerBatch": 1}],
}],
"edges": [],
}
existing = next((t for t in c.get(f"/production-templates?q={code}").body["items"]
if t["code"] == code), None)
if existing:
head = c.get(f"/production-templates/{existing['templateId']}")
return c.put(f"/production-templates/{existing['templateId']}", payload, if_match=head.etag)
return c.post("/production-templates", payload)
def saved_ok(chk, label, response):
"""Template save is find-or-create, so a POST gives 201 and a PUT gives 200."""
ok = chk.check(f"{label} -> 200/201", response.status in (200, 201), True)
if not ok:
print(f" server said: {response.body}")
return ok
def consume_once(c, chk, tid, wh, target_qty, label, expected_packs):
"""
Create a run, start its only stage, and assert what FIFO actually took.
`expected_packs` is compared against the RAW formula quantity resolved to packs, which is
only valid while the run scale factor is 1. Run creation computes
`ratio = targetQty / terminalOutput.qtyPerBatch` and pre-scales every planned quantity, so
this holds because `save_template` pins the terminal output to `qtyPerBatch: 1` and every
caller here passes `target_qty=1`. Change either and these numbers move by that ratio.
"""
created = c.post("/production-runs", {"templateId": tid, "targetQty": target_qty, "warehouseId": wh})
if not chk.status(f"{label}: create the run", created, 201):
return
run = created.body
stage_id = run["stages"][0]["runStageId"]
started = c.post(f"/production-runs/{run['runId']}/stages/{stage_id}/start",
idempotency_key=f"m4c-{run['runId']}")
if not chk.status(f"{label}: POST .../start", started, 200):
return
consumed = started.body["consumed"]
if not chk.check(f"{label}: exactly one input consumed", len(consumed), 1):
return
chk.check(f"{label}: consumed {expected_packs} base units",
round(float(consumed[0]["qty"]), 4), expected_packs)
rows = c.get(f"/stock/ledger?sourceDocType=PRDI&sourceDocId={run['runId']}&pageSize=50").body["items"]
chk.check(f"{label}: one PRDI ledger row", len(rows), 1)
if rows:
chk.check(f"{label}: ledger qtyBase matches the consumption",
round(abs(float(rows[0]["qtyBase"])), 4), expected_packs)
# Cancel so the script is re-runnable. A run left InProgress edit-locks its template
# (FR-MFG-06), so the next execution could not re-save it and would fail with a 409 that
# says nothing about content units. Cancelling also returns the consumed stock (FR-MFG-17),
# which keeps the seeded on-hand stable across runs.
c.post(f"/production-runs/{run['runId']}/cancel",
{"reasonCodeId": production_reason(c, "PRD-CANCEL"), "note": "m4c cleanup"})
def cancel_stale_runs(c):
"""
Cancel any InProgress run this script left behind previously.
Self-healing rather than merely tidy: a live run edit-locks its template, so without this
a re-run (or an earlier interrupted run) fails at template save with 409 TEMPLATE_IN_USE —
a failure that looks like a content-unit bug and is not one.
"""
ours = {t["templateId"] for t in c.get(f"/production-templates?q={TEMPLATE_CODE}&pageSize=50").body["items"]
if t["code"].startswith(TEMPLATE_CODE)}
if not ours:
return
stale = [r for r in c.get("/production-runs?status=InProgress&pageSize=200").body["items"]
if r["templateId"] in ours]
for r in stale:
c.post(f"/production-runs/{r['runId']}/cancel",
{"reasonCodeId": production_reason(c, "PRD-CANCEL"), "note": "m4c stale cleanup"})
if stale:
print(f"cancelled {len(stale)} stale run(s) from a previous execution")
def main():
c, chk, args = bootstrap(__doc__)
print(f"API {args.api}")
cancel_stale_runs(c)
bottle = ensure_item(c, SKU, "Smoke syrup 500ml bottle", CONTENT_QTY, "Ml")
plain = ensure_item(c, PLAIN_SKU, "Smoke item with no content", None, None)
finished = c.get("/items?pageSize=5&status=Active").body["items"][0]
print(f"bottle={bottle['itemId']} contentBaseQty={bottle.get('contentBaseQty')} "
f"plain={plain['itemId']} finished={finished['itemId']}")
chk.section("0. Content size normalises to a base unit on the item")
chk.check("contentBaseQty is the entered ml", float(bottle["contentBaseQty"]), float(CONTENT_QTY))
chk.check("contentBaseUnit is Ml", bottle["contentBaseUnit"], "Ml")
wh = ensure_warehouse(c)
drain_stock(c, wh)
seed_costed_stock(c, wh, [(bottle["itemId"], SEED_BOTTLES, UNIT_COST)])
# ------------------------------------------------- whole packs out of content units
chk.section("1. A content quantity resolves to whole packs (2000 ml / 500 ml = 4)")
saved = save_template(c, bottle["itemId"], finished["itemId"], 2000, "Content", "whole")
if saved_ok(chk, "save the Content template", saved):
consume_once(c, chk, saved.body["templateId"], wh, 1, "2000 ml", 4.0)
# --------------------------------------------------------------- fractional packs
chk.section("2. A content quantity below one pack consumes a FRACTION of one (300 ml = 0.6)")
saved = save_template(c, bottle["itemId"], finished["itemId"], 300, "Content", "frac")
if saved_ok(chk, "save the fractional Content template", saved):
consume_once(c, chk, saved.body["templateId"], wh, 1, "300 ml", 0.6)
# ------------------------------------------------------------------ the guard rail
chk.section("3. Content units are refused on an item that has no content size")
refused = save_template(c, plain["itemId"], finished["itemId"], 100, "Content", "nocontent")
chk.status("Content input on a contentless item", refused, 422)
chk.section("4. The same item still works when the formula is written in packs")
saved = save_template(c, bottle["itemId"], finished["itemId"], 3, "Pack", "pack")
if saved_ok(chk, "save the Pack template", saved):
consume_once(c, chk, saved.body["templateId"], wh, 1, "3 bottles", 3.0)
return chk.finish("M4c")
if __name__ == "__main__":
sys.exit(main())
+6 -6
View File
@@ -157,18 +157,18 @@ def main():
if i["itemId"] == raw)
finished_b = state["finishedItemId"]
# A fresh single-stage template: base-UOM input so no conversion muddies the arithmetic,
# qtyPerBatch 3.5 so the consumed quantity is NOT a multiple of the target (which is what
# A fresh single-stage template: a pack-counted input, so no content division muddies
# the arithmetic; qtyPerBatch 3.5 so consumed is NOT a multiple of the target (which is what
# forces pool / goodQty to repeat).
payload_b = {
"code": BIG_TEMPLATE_CODE, "name": "Smoke M5 rounding line",
"stages": [{
"key": "tmp-b", "name": "Mix", "estimatedMinutes": 5, "posX": 0, "posY": 0,
"fieldDefs": [],
"inputs": [{"source": "Stock", "itemId": raw, "uomId": raw_uom,
"inputs": [{"source": "Stock", "itemId": raw,
"qtyPerBatch": BIG_QTY_PER_BATCH}],
"outputs": [{"key": "tmp-bo", "name": "Mixed", "itemId": finished_b,
"uomId": raw_uom, "qtyPerBatch": 1}],
"qtyPerBatch": 1}],
}],
"edges": [],
}
@@ -190,7 +190,7 @@ def main():
# and the pool would not match the figures this section reasons about — the assertions
# would still "pass" while testing something else entirely.
drain_stock(c, wh)
seed_costed_stock(c, wh, [(raw, raw_uom, 5000, BIG_UNIT_COST)])
seed_costed_stock(c, wh, [(raw, 5000, BIG_UNIT_COST)])
big = c.post("/production-runs", {"templateId": tid, "targetQty": BIG_TARGET, "warehouseId": wh})
if big.status != 201:
@@ -243,7 +243,7 @@ def main():
"key": "tmp-one", "name": "Make", "estimatedMinutes": 1, "posX": 0, "posY": 0,
"fieldDefs": [], "inputs": [],
"outputs": [{"key": "tmp-o", "name": "Tracked", "itemId": tracked["itemId"],
"uomId": tracked["baseUomId"], "qtyPerBatch": 1}],
"qtyPerBatch": 1}],
}],
"edges": [],
}
@@ -54,13 +54,13 @@ def ensure_template(c, raw, finished, uom):
"stages": [
{"key": "tmp-cut", "name": "Cut", "estimatedMinutes": 10, "posX": 0, "posY": 0,
"fieldDefs": [],
"inputs": [{"source": "Stock", "itemId": raw, "uomId": uom, "qtyPerBatch": RAW_QPB}],
"inputs": [{"source": "Stock", "itemId": raw, "qtyPerBatch": RAW_QPB}],
"outputs": [{"key": "tmp-f", "name": "Frame", "uomId": uom, "qtyPerBatch": 1}]},
{"key": "tmp-asm", "name": "Assemble", "estimatedMinutes": 10, "posX": 400, "posY": 0,
"fieldDefs": [],
"inputs": [{"source": "Upstream", "fromOutputKey": "tmp-f", "uomId": uom, "qtyPerBatch": 1}],
"inputs": [{"source": "Upstream", "fromOutputKey": "tmp-f", "qtyPerBatch": 1}],
"outputs": [{"key": "tmp-c", "name": "Chair", "itemId": finished,
"uomId": uom, "qtyPerBatch": 1}]},
"qtyPerBatch": 1}]},
],
"edges": [{"parentKey": "tmp-cut", "childKey": "tmp-asm"}],
}
@@ -101,7 +101,7 @@ def main():
uom = items[0]["baseUomId"]
drain_stock(c, wh)
seed_costed_stock(c, wh, [(raw, uom, SEED, UNIT_COST)])
seed_costed_stock(c, wh, [(raw, SEED, UNIT_COST)])
tid = ensure_template(c, raw, finished, uom)
consumed_units = RAW_QPB * TARGET # 50
consumed_value = consumed_units * UNIT_COST # 150.00
+1 -6
View File
@@ -22,14 +22,9 @@ 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"),
# UOM engine. These run after the manufacturing scripts because they reuse the
# SMOKE-PRD warehouse m4 creates, and they drain it before seeding their own stock.
("UOM conversion direction is enforced", "uom_direction.py"),
("UOM non-base sales consume converted qty", "uom_sales_nonbase.py"),
("UOM cross-unit GRN against a PO", "uom_grn_po_cross.py"),
]
SUMMARY = re.compile(r"^(\S+): (\d+)/(\d+) assertions passed")
+4 -3
View File
@@ -240,15 +240,16 @@ def seed_costed_stock(c, warehouse_id: int, lines, vendor_id: int | None = None)
"""
Create on-hand at explicit unit costs via a direct GRN + confirm.
`lines` is an iterable of (item_id, uom_id, qty, unit_cost).
`lines` is an iterable of (item_id, qty, unit_cost). Quantities are counts of the item's
base UOM — document lines carry no unit of their own.
"""
vendor_id = vendor_id or ensure_vendor(c)
grn = c.post("/grns", {
"vendorId": vendor_id,
"warehouseId": warehouse_id,
"lines": [
{"itemId": i, "uomId": u, "qty": q, "unitCost": cost, "discountPct": 0, "vatPct": 0}
for (i, u, q, cost) in lines
{"itemId": i, "qty": q, "unitCost": cost, "discountPct": 0, "vatPct": 0}
for (i, q, cost) in lines
],
})
if grn.status != 201:
-81
View File
@@ -1,81 +0,0 @@
"""Smoke test — UOM conversions are one-directional, and the API says so.
`UomConverter` looks up exactly one shape, `FromUom -> ToUom = item.BaseUomId`, and never
inverts a factor. `UpdateUomConversionsAsync` used to accept *any* pair, so saving the more
natural-reading `base -> BOX` produced a row that returned 200, appeared in the item detail
response, and was then silently invisible to every consumer — surfacing much later as
"no UOM conversion" 422 at GRN confirm or stage start, on an item that visibly had one.
* base -> other is rejected with 422 (the direction that used to save and then not work)
* other -> base is accepted
* a self-conversion and a base-as-source row are rejected
* a zero/negative factor is rejected (UomConverter divides unit cost by it)
* changing an item's base UOM while conversions exist is refused rather than orphaning them
python Backend/smoke/uom_direction.py
"""
from __future__ import annotations
import sys
from smoke_common import bootstrap
def main():
c, chk, args = bootstrap(__doc__)
print(f"API {args.api}")
item = c.get("/items?pageSize=1&status=Active").body["items"]
if not item:
sys.exit("FATAL: no active items.")
item = item[0]
item_id, base_uom = item["itemId"], item["baseUomId"]
uoms = c.get("/uoms?pageSize=50").body["items"]
other = next((u["uomId"] for u in uoms if u["uomId"] != base_uom), None)
if other is None:
sys.exit("FATAL: need at least 2 UOMs.")
print(f"item={item_id} baseUom={base_uom} otherUom={other}")
def put(conversions):
return c.put(f"/items/{item_id}/uom-conversions", {"conversions": conversions})
chk.section("1. The correct direction is accepted")
ok = put([{"fromUom": other, "toUom": base_uom, "factor": 12}])
chk.status("other -> base", ok, 200)
if ok.status == 200:
chk.check("stored with the base UOM as target", ok.body["conversions"][0]["toUom"], base_uom)
chk.section("2. The reverse direction is rejected, not silently stored")
chk.status("base -> other", put([{"fromUom": base_uom, "toUom": other, "factor": 12}]), 422)
chk.section("3. Degenerate rows are rejected")
chk.status("self-conversion (other -> other)", put([{"fromUom": other, "toUom": other, "factor": 2}]), 422)
chk.status("zero factor", put([{"fromUom": other, "toUom": base_uom, "factor": 0}]), 422)
chk.status("negative factor", put([{"fromUom": other, "toUom": base_uom, "factor": -3}]), 422)
chk.section("4. Base UOM cannot be repointed while conversions exist")
# Restore a valid conversion first, so the guard has something to protect.
put([{"fromUom": other, "toUom": base_uom, "factor": 12}])
head = c.get(f"/items/{item_id}")
if head.status == 200:
body = head.body
moved = c.put(f"/items/{item_id}", {
"sku": body["sku"], "name": body["name"], "description": body.get("description"),
"categoryId": body["categoryId"], "subCategoryId": body.get("subCategoryId"),
"brandId": body.get("brandId"),
"baseUomId": other, # <- the repoint being guarded
"defaultVendorId": body.get("defaultVendorId"),
"stockNature": body["stockNature"], "trackingMode": body["trackingMode"],
"taxClass": body.get("taxClass"), "salePrice": body.get("salePrice"),
}, if_match=head.etag)
chk.status("change base UOM with conversions defined", moved, 422)
else:
chk.check("could read the item for the repoint test", head.status, 200)
return chk.finish("UOM-DIRECTION")
if __name__ == "__main__":
sys.exit(main())
-127
View File
@@ -1,127 +0,0 @@
"""Smoke test — receiving in a different UOM from the one ordered.
`GrnService` compared the GRN line's entered quantity against `poLine.Qty - poLine.QtyReceived`
with no conversion, so a PO for 10 BOX receiving a legitimate 120 base units was rejected
outright with OVER_RECEIPT_TOLERANCE — a user-visible false failure. It then accrued the GRN's
quantity into `poLine.QtyReceived` (a PO-UOM field), and the close condition consumed that
mixed-unit value, so a PO could close early or never close.
Both sides now run on the base pair (`QtyBase` / `QtyReceivedBase`), with `QtyReceived` kept
as a denormalized display figure only.
* a receipt in base UOM against a PO raised in BOX is ACCEPTED
* the FIFO layer and ledger record the base quantity
* `qtyReceivedBase` accrues correctly and the PO reaches FullyReceived
* over-receipt beyond tolerance is still rejected, now measured in base units
python Backend/smoke/uom_grn_po_cross.py
"""
from __future__ import annotations
import sys
from smoke_common import bootstrap, drain_stock, ensure_vendor
WAREHOUSE_CODE = "SMOKE-PRD"
FACTOR = 12
ORDER_BOXES = 10 # -> 120 base units
RECEIVE_BASE = 120 # the whole order, expressed in base units
def main():
c, chk, args = bootstrap(__doc__)
print(f"API {args.api}")
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).")
item = next((i for i in c.get("/items?pageSize=20&status=Active").body["items"]
if i["stockNature"] == "Stocked" and i["trackingMode"] == "None"), None)
if item is None:
sys.exit("FATAL: need a Stocked, untracked item.")
base_uom = item["baseUomId"]
uoms = c.get("/uoms?pageSize=50").body["items"]
box_uom = next((u["uomId"] for u in uoms if u["uomId"] != base_uom), None)
if box_uom is None:
sys.exit("FATAL: need at least 2 UOMs.")
vendor = ensure_vendor(c)
print(f"item={item['itemId']} baseUom={base_uom} boxUom={box_uom} factor={FACTOR}")
chk.section("1. Conversion + a PO raised in BOX")
conv = c.put(f"/items/{item['itemId']}/uom-conversions",
{"conversions": [{"fromUom": box_uom, "toUom": base_uom, "factor": FACTOR}]})
chk.status("define BOX -> base conversion", conv, 200)
if conv.status != 200:
return chk.finish("UOM-GRN-PO")
po = c.post("/purchase-orders", {
"vendorId": vendor,
"lines": [{"itemId": item["itemId"], "uomId": box_uom, "warehouseId": wh,
"qty": ORDER_BOXES, "unitPrice": 60, "tax": 0}],
})
chk.status("create the PO in BOX", po, 201)
if po.status != 201:
return chk.finish("UOM-GRN-PO")
po_line = po.body["lines"][0]
chk.check("PO line keeps the ordered qty in BOX", float(po_line["qty"]), float(ORDER_BOXES))
chk.check("PO line snapshots the base quantity", float(po_line["qtyBase"]), float(ORDER_BOXES * FACTOR))
chk.check("PO line snapshots the factor", float(po_line["conversionFactor"]), float(FACTOR))
drain_stock(c, wh)
before = float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"])
chk.section("2. Receiving the order in BASE units is accepted")
grn = c.post("/grns", {
"vendorId": vendor, "warehouseId": wh, "poId": po.body["poId"],
"lines": [{"poLineId": po_line["poLineId"], "itemId": item["itemId"],
"uomId": base_uom, # <- different UOM from the PO
"qty": RECEIVE_BASE, "unitCost": 5, "discountPct": 0, "vatPct": 0}],
})
# This is the assertion that fails on the old code: it returned 422 OVER_RECEIPT_TOLERANCE.
chk.status("GRN in base UOM against a BOX purchase order", grn, 201)
if grn.status != 201:
return chk.finish("UOM-GRN-PO")
confirmed = c.post(f"/grns/{grn.body['grnId']}/confirm")
chk.status("confirm the GRN", confirmed, 200)
if confirmed.status != 200:
return chk.finish("UOM-GRN-PO")
chk.check("on-hand rose by the base quantity",
float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"]),
before + RECEIVE_BASE)
rows = c.get(f"/stock/ledger?sourceDocType=GRN&sourceDocId={grn.body['grnId']}&pageSize=50").body["items"]
chk.check("one GRN ledger row", len(rows), 1)
if rows:
chk.check("ledger qtyBase is the received base quantity", float(rows[0]["qtyBase"]), float(RECEIVE_BASE))
chk.section("3. The PO closes on the base pair")
reread = c.get(f"/purchase-orders/{po.body['poId']}")
chk.status("re-read the PO", reread, 200)
if reread.status == 200:
rl = reread.body["lines"][0]
chk.check("qtyReceivedBase accrued in base units", float(rl["qtyReceivedBase"]), float(RECEIVE_BASE))
chk.check("qtyReceived shown back in the PO's own UOM", float(rl["qtyReceived"]), float(ORDER_BOXES))
chk.check("PO is FullyReceived", reread.body["status"], "FullyReceived")
chk.section("4. Over-receipt is still rejected, measured in base")
over = c.post("/grns", {
"vendorId": vendor, "warehouseId": wh, "poId": po.body["poId"],
"lines": [{"poLineId": po_line["poLineId"], "itemId": item["itemId"],
"uomId": base_uom, "qty": RECEIVE_BASE, "unitCost": 5,
"discountPct": 0, "vatPct": 0}],
})
chk.status("receiving the whole order again", over, 422, "OVER_RECEIPT_TOLERANCE")
return chk.finish("UOM-GRN-PO")
if __name__ == "__main__":
sys.exit(main())
-200
View File
@@ -1,200 +0,0 @@
"""Smoke test — selling in a non-base UOM consumes the converted quantity.
This is the regression test for the UOM engine's worst defect. `SalesPostingService`
injected `IUomConverter` and never called it: `PostAsync` fed the *entered* line quantity
straight into `IFifoCostingService.ConsumeAsync`, whose contract is base UOM only. Selling
2 BOX of a 12-per-box item therefore removed 2 base units instead of 24 and wrote
`StockLedger.QtyBase = 2` into a column defined as base — overstating stock, understating
COGS, and drifting the ledger's running balance away from the layer sum.
`m4b_uom_conversion.py` covered exactly the same hazard on the *production* path, which is
why that path was correct and this one was not. This script closes the gap:
* an invoice line in a non-base UOM consumes qty x factor base units
* the ledger records the BASE quantity
* the line still reports the ENTERED qty and UOM, so the printed document says "2 BOX"
* the pre-post check reports the shortfall in base units (it compared entered vs base
on-hand before, and answered "can post" when it could not)
* the same holds for a sales slip, which shares PostAsync
* a UOM the item has no conversion for is refused at line creation, not at post
python Backend/smoke/uom_sales_nonbase.py
"""
from __future__ import annotations
import sys
from smoke_common import bootstrap, drain_stock, seed_costed_stock
WAREHOUSE_CODE = "SMOKE-PRD"
FACTOR = 12 # 1 BOX = 12 base units
SELL_BOXES = 2 # -> 24 base units
SEED_BASE = 100 # base units on hand before selling
UNIT_COST = 5.0
def main():
c, chk, args = bootstrap(__doc__)
print(f"API {args.api}")
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).")
item = next((i for i in c.get("/items?pageSize=20&status=Active").body["items"]
if i["stockNature"] == "Stocked"), None)
if item is None:
sys.exit("FATAL: need a Stocked item.")
base_uom = item["baseUomId"]
uoms = c.get("/uoms?pageSize=50").body["items"]
box_uom = next((u["uomId"] for u in uoms if u["uomId"] != base_uom), None)
if box_uom is None:
sys.exit("FATAL: need at least 2 UOMs to test conversion.")
customer = c.get("/customers?pageSize=1").body["items"]
if not customer:
sys.exit("FATAL: no customers seeded.")
customer_id = customer[0]["customerId"]
print(f"item={item['itemId']} baseUom={base_uom} boxUom={box_uom} factor={FACTOR}")
# --- fixtures ---------------------------------------------------------
chk.section("1. Conversion + known on-hand")
conv = c.put(f"/items/{item['itemId']}/uom-conversions",
{"conversions": [{"fromUom": box_uom, "toUom": base_uom, "factor": FACTOR}]})
chk.status("define BOX -> base conversion", conv, 200)
if conv.status != 200:
return chk.finish("UOM-SALES")
allowed = c.get(f"/items/{item['itemId']}/uoms")
chk.status("GET /items/{id}/uoms", allowed, 200)
if allowed.status == 200:
ids = [u["uomId"] for u in allowed.body]
chk.check("allowed UOMs are base + the conversion source", sorted(ids), sorted([base_uom, box_uom]))
chk.check("base UOM is flagged and listed first", allowed.body[0]["isBase"], True)
drain_stock(c, wh)
seed_costed_stock(c, wh, [(item["itemId"], base_uom, SEED_BASE, UNIT_COST)])
before = float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"])
chk.check(f"on-hand seeded to {SEED_BASE} base units", before, float(SEED_BASE))
on_hand = c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body
chk.check("stock read is labelled with the base UOM", on_hand["baseUomId"], base_uom)
chk.check("stock read carries the base UOM name", bool(on_hand["baseUomName"]), True)
# --- invoice in BOX ---------------------------------------------------
chk.section("2. An invoice line entered in BOX")
expected_base = SELL_BOXES * FACTOR # 2 x 12 = 24
inv = c.post("/sales-invoices", {
"customerId": customer_id,
"warehouseId": wh,
"invoiceType": "B2C",
"lines": [{
"itemId": item["itemId"], "uomId": box_uom, "warehouseId": wh,
"qty": SELL_BOXES, "freeQty": 0, "unitPrice": 100, "allowManualPriceOverride": True,
"discountMode": "Percentage", "discountPct": 0, "discountAmount": 0,
"discountValue": 0, "taxPct": 0, "isFreeIssue": False,
}],
})
chk.status("create the invoice", inv, 201)
if inv.status != 201:
return chk.finish("UOM-SALES")
line = inv.body["lines"][0]
chk.check("line keeps the ENTERED qty (prints as 2 BOX)", float(line["qty"]), float(SELL_BOXES))
chk.check("line keeps the ENTERED uom", line["uomId"], box_uom)
check = c.get(f"/sales-invoices/{inv.body['salesInvoiceId']}/posting-check")
chk.status("posting check", check, 200)
if check.status == 200:
chk.check("posting check passes with enough stock", check.body["canPost"], True)
chk.section("3. Posting consumes the CONVERTED quantity")
posted = c.post(f"/sales-invoices/{inv.body['salesInvoiceId']}/post")
chk.status("post the invoice", posted, 200)
if posted.status != 200:
return chk.finish("UOM-SALES")
after = float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"])
chk.check(f"on-hand fell by {expected_base} base units, not {SELL_BOXES}", after, before - expected_base)
rows = c.get(f"/stock/ledger?sourceDocType=SINV&sourceDocId={inv.body['salesInvoiceId']}&pageSize=50").body["items"]
chk.check("one ledger row for the invoice", len(rows), 1)
if rows:
chk.check("ledger qtyBase is the CONVERTED quantity", float(rows[0]["qtyBase"]), float(expected_base))
chk.check("ledger row is labelled with the base UOM", rows[0]["baseUomId"], base_uom)
# --- the same on a slip ----------------------------------------------
chk.section("4. A sales slip behaves identically (shared PostAsync)")
before_slip = float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"])
slip = c.post("/sales-slips", {
"customerId": customer_id,
"warehouseId": wh,
"lines": [{
"itemId": item["itemId"], "uomId": box_uom, "warehouseId": wh,
"qty": SELL_BOXES, "freeQty": 0, "unitPrice": 100, "allowManualPriceOverride": True,
"discountMode": "Percentage", "discountPct": 0, "discountAmount": 0,
"discountValue": 0, "taxPct": 0, "isFreeIssue": False,
}],
})
chk.status("create the slip", slip, 201)
if slip.status == 201:
chk.status("post the slip", c.post(f"/sales-slips/{slip.body['salesSlipId']}/post"), 200)
chk.check("slip also consumed the converted quantity",
float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"]),
before_slip - expected_base)
# --- snapshot immutability -------------------------------------------
chk.section("5. A factor edited after save does not change what posts")
draft = c.post("/sales-invoices", {
"customerId": customer_id, "warehouseId": wh, "invoiceType": "B2C",
"lines": [{
"itemId": item["itemId"], "uomId": box_uom, "warehouseId": wh,
"qty": 1, "freeQty": 0, "unitPrice": 100, "allowManualPriceOverride": True,
"discountMode": "Percentage", "discountPct": 0, "discountAmount": 0,
"discountValue": 0, "taxPct": 0, "isFreeIssue": False,
}],
})
if draft.status == 201:
# Double the factor *after* the draft is saved.
c.put(f"/items/{item['itemId']}/uom-conversions",
{"conversions": [{"fromUom": box_uom, "toUom": base_uom, "factor": FACTOR * 2}]})
before_snap = float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"])
chk.status("post the pre-existing draft", c.post(f"/sales-invoices/{draft.body['salesInvoiceId']}/post"), 200)
chk.check(f"posted the snapshotted {FACTOR}, not the edited {FACTOR * 2}",
float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"]),
before_snap - FACTOR)
# Restore for re-runnability.
c.put(f"/items/{item['itemId']}/uom-conversions",
{"conversions": [{"fromUom": box_uom, "toUom": base_uom, "factor": FACTOR}]})
else:
chk.check("could create the snapshot-test draft", draft.status, 201)
# --- unusable UOM refused at entry ------------------------------------
chk.section("6. A UOM with no conversion is refused at line creation")
third = next((u["uomId"] for u in c.get("/uoms?pageSize=50").body["items"]
if u["uomId"] not in (base_uom, box_uom)), None)
if third is None:
chk.check("skipped: need a third UOM", True, True)
else:
bad = c.post("/sales-invoices", {
"customerId": customer_id, "warehouseId": wh, "invoiceType": "B2C",
"lines": [{
"itemId": item["itemId"], "uomId": third, "warehouseId": wh,
"qty": 1, "freeQty": 0, "unitPrice": 100, "allowManualPriceOverride": True,
"discountMode": "Percentage", "discountPct": 0, "discountAmount": 0,
"discountValue": 0, "taxPct": 0, "isFreeIssue": False,
}],
})
# The point is that this fails at CREATE (while the user is editing), not at post.
chk.status("invoice line in an unconvertible UOM", bad, 422)
return chk.finish("UOM-SALES")
if __name__ == "__main__":
sys.exit(main())
@@ -7,8 +7,8 @@ import { AlertTriangle, ArrowLeft, Ban, Check, Plus, Save, Trash2 } from "lucide
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { uomName } from "@/lib/uom"
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"
@@ -29,7 +29,6 @@ interface DraftLine {
key: string
poLineId: number | null
itemId: number | null
uomId: number | null
warehouseId: number | null
qty: string
unitPrice: string
@@ -73,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),
@@ -112,9 +110,6 @@ export default function PurchaseOrderDetailPage() {
function itemFor(itemId: number | null) {
return items.find((i) => i.itemId === itemId) ?? null
}
function uomLabel(uomId: number) {
return uomName(uomId, uoms)
}
function warehouseCode(warehouseId: number) {
return warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? `#${warehouseId}`
}
@@ -142,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,
@@ -158,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),
@@ -348,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>
@@ -377,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 ? uomLabel(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>
@@ -404,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 })}>

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