Add smoke tests for UOM conversions and enhance frontend UOM management

- Implemented smoke tests for UOM directionality, ensuring conversions are one-directional and correctly validated.
- Added tests for receiving and selling items in different UOMs, verifying correct quantity handling and error responses.
- Created a UOM conversions panel in the frontend to allow users to manage UOM conversions for items.
- Introduced hooks for allowed UOMs to optimize fetching and caching of UOM data for document line forms.
- Developed utility functions for consistent UOM formatting and conversion handling across the application.
This commit is contained in:
2026-08-10 15:12:51 +05:30
parent d37824cecc
commit 8d5a05a419
63 changed files with 1539 additions and 136 deletions
+18 -1
View File
@@ -1,6 +1,7 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Items;
using ERPCore.Dtos.Uoms;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -11,8 +12,13 @@ namespace ERPCore.Controllers;
public sealed class ItemsController : ApiControllerBase
{
private readonly IItemService _items;
private readonly IUomConverter _uomConverter;
public ItemsController(IItemService items) => _items = items;
public ItemsController(IItemService items, IUomConverter uomConverter)
{
_items = items;
_uomConverter = uomConverter;
}
/// <summary>List items with optional filters and paging.</summary>
[HttpGet]
@@ -88,4 +94,15 @@ public sealed class ItemsController : ApiControllerBase
[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));
}
@@ -11,9 +11,20 @@ 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; }
@@ -34,8 +34,20 @@ public class GrnLine
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; }
+24
View File
@@ -21,8 +21,32 @@ public class PoLine
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; }
}
@@ -14,10 +14,24 @@ 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; }
@@ -14,10 +14,24 @@ 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; }
@@ -5,9 +5,18 @@ 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 Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived,
decimal QtyBase = 0m, decimal QtyReceivedBase = 0m, decimal ConversionFactor = 1m);
public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency);
+2 -1
View File
@@ -31,9 +31,10 @@ 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);
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, string BaseUomName = "");
public sealed record BundleSalePostingCheckDto(
int BundleSaleId, string BundleNo, BundleSaleStatus Status, bool CanPost,
@@ -24,9 +24,16 @@ 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);
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue,
string BaseUomName = "");
public sealed record SalesInvoicePostingCheckDto(
int SalesInvoiceId, string InvoiceNo, SalesInvoiceStatus Status, bool CanPost,
+3 -1
View File
@@ -23,9 +23,11 @@ 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);
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue,
string BaseUomName = "");
public sealed record SalesSlipPostingCheckDto(
int SalesSlipId, string SlipNo, SalesSlipStatus Status, bool CanPost,
+11 -3
View File
@@ -2,16 +2,23 @@ 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);
decimal OnHold, decimal InTransit, decimal Reserved, DateTime AsOf,
int BaseUomId = 0, string BaseUomName = "");
/// <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);
string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt,
int BaseUomId = 0, string BaseUomName = "");
/// <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);
@@ -19,4 +26,5 @@ 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);
decimal TotalQty, decimal TotalValue, string Currency, string CostingMethod,
int BaseUomId = 0, string BaseUomName = "");
+9
View File
@@ -9,3 +9,12 @@ 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,6 +12,8 @@ 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);
@@ -36,6 +36,8 @@ 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);
@@ -49,6 +49,9 @@ 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);
@@ -70,6 +70,9 @@ 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);
@@ -70,6 +70,9 @@ 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);
@@ -30,5 +30,9 @@ public sealed class UomConversionConfiguration : IEntityTypeConfiguration<UomCon
// 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"));
}
}
+12 -5
View File
@@ -215,16 +215,23 @@ public sealed class BundleSaleService : IBundleSaleService
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, r.UomId, lineWarehouseId, r.Qty, 0m, null, ct);
var (qtyBase, unitCostBase) = await _uomConverter.ToBaseAsync(item, r.UomId, r.Qty, r.UnitPrice, ct);
var calc = _sales.ComputeLine(qtyBase, 0m, unitCostBase, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
// 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);
var calc = _sales.ComputeLine(r.Qty, 0m, r.UnitPrice, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
lines.Add(new BundleSaleLine
{
ItemId = r.ItemId,
Description = item.Name,
Qty = qtyBase,
UomId = item.BaseUomId,
Qty = r.Qty,
UomId = r.UomId,
QtyBase = UomConverter.ApplyFactor(r.Qty, factor),
ConversionFactor = factor,
WarehouseId = lineWarehouseId,
UnitPrice = unitCostBase,
UnitPrice = r.UnitPrice,
LineTotal = calc.LineTotal,
IncludeInBundle = r.IncludeInBundle,
IsComponent = true,
+30 -17
View File
@@ -7,6 +7,7 @@ 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;
@@ -138,6 +139,12 @@ public sealed class GrnService : IGrnService
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.
@@ -150,10 +157,13 @@ 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);
var openQty = poLine.Qty - poLine.QtyReceived;
if (input.Qty > openQty * (1 + OverReceiptTolerance))
// 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))
throw new DomainException(ErrorCodes.OverReceiptTolerance,
$"Receiving {input.Qty} exceeds the open quantity {openQty} on PO line {input.PoLineId}.", 422);
$"Receiving {input.Qty} ({qtyBaseEntered} base) exceeds the open quantity {openQtyBase} base on PO line {input.PoLineId}.", 422);
poUnitPrice = poLine.UnitPrice;
unitCost = input.UnitCost > 0 ? input.UnitCost : poLine.UnitPrice;
@@ -178,6 +188,8 @@ public sealed class GrnService : IGrnService
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,
@@ -234,10 +246,13 @@ public sealed class GrnService : IGrnService
{
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
{
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token);
// FIFO layer costs at the after-discount net price; VAT is recoverable and never
// enters stock value (docs/10 FR-GRN-06, revised).
var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.NetUnitCost, token);
// enters stock value (docs/10 FR-GRN-06, revised). 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);
var layer = await _fifo.CreateInboundLayerAsync(
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId,
@@ -258,7 +273,13 @@ 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) poLine.QtyReceived += line.Qty;
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);
}
}
}
@@ -345,22 +366,14 @@ public sealed class GrnService : IGrnService
return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges
}
/// <summary>
/// Delegates to the shared <see cref="IUomConverter"/>. This was a private method here
/// until manufacturing needed the same conversion for stage stock inputs; behaviour is
/// identical, so receive costing is unchanged.
/// </summary>
private Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct)
=> _uomConverter.ToBaseAsync(item, uomId, qty, unitCostPerUom, ct);
private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
{
if (poId is null) return;
var po = await _pos.Query().Include(p => p.Lines).FirstOrDefaultAsync(p => p.PoId == poId, ct);
if (po is null) return;
po.Status = po.Lines.All(l => l.QtyReceived >= l.Qty)
// Base-vs-base: QtyReceived is a denormalized display figure and must not gate closing.
po.Status = po.Lines.All(l => l.QtyReceivedBase >= l.QtyBase)
? PurchaseOrderStatus.FullyReceived
: PurchaseOrderStatus.PartiallyReceived;
po.UpdatedAt = DateTime.UtcNow;
@@ -1,4 +1,5 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Uoms;
namespace ERPCore.Services.Interfaces;
@@ -32,4 +33,29 @@ public interface IUomConverter
/// 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);
}
+29
View File
@@ -148,6 +148,15 @@ 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);
item.Sku = request.Sku.Trim();
item.Name = request.Name.Trim();
item.Description = request.Description;
@@ -240,6 +249,26 @@ public sealed class ItemService : IItemService
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)
@@ -27,6 +27,7 @@ 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;
@@ -34,8 +35,9 @@ 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,
IUnitOfWork uow, ICurrentUser currentUser)
IUomConverter uomConverter, IUnitOfWork uow, ICurrentUser currentUser)
{
_uomConverter = uomConverter;
_templates = templates;
_stages = stages;
_inputs = inputs;
@@ -291,6 +293,20 @@ public sealed class ProductionTemplateService : IProductionTemplateService
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)
{
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
if (item is not null)
await _uomConverter.ValidateUomAsync(item, uomId, ct);
}
// Annotations go into jsonb unvalidated by anything else, so pin the one field the
// client renders off. Unknown kinds would round-trip fine but draw nothing.
var badKinds = request.Annotations
@@ -8,6 +8,7 @@ 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;
@@ -28,6 +29,7 @@ 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;
@@ -35,8 +37,9 @@ 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,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
IUomConverter uomConverter, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_uomConverter = uomConverter;
_pos = pos;
_vendors = vendors;
_requisitions = requisitions;
@@ -83,6 +86,7 @@ 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 =>
{
@@ -98,7 +102,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
Status = request.SaveAsDraft ? PurchaseOrderStatus.Draft : PurchaseOrderStatus.Approved,
CreatedBy = actor,
CreatedAt = DateTime.UtcNow,
Lines = request.Lines.Select(ToLine).ToList()
Lines = lines
};
await _pos.AddAsync(entity, token);
return entity;
@@ -129,8 +133,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 input in request.Lines)
po.Lines.Add(ToLine(input));
foreach (var line in await ToLinesAsync(request.Lines, ct))
po.Lines.Add(line);
try
{
@@ -219,16 +223,34 @@ 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;
private static PoLine ToLine(CreatePoLineInput l) => new()
/// <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)
{
ItemId = l.ItemId,
UomId = l.UomId,
WarehouseId = l.WarehouseId,
Qty = l.Qty,
UnitPrice = l.UnitPrice,
Tax = l.Tax,
QtyReceived = 0
};
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;
}
private static PoTotalsDto ComputeTotals(IEnumerable<PoLine> lines)
{
@@ -276,5 +298,6 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
p.PoId, p.DocNo, p.VendorId, p.RequisitionId, p.Status, p.ApprovalRequired,
p.CreatedBy, p.CreatedAt, p.UpdatedAt, ComputeTotals(p.Lines),
p.Lines.OrderBy(l => l.PoLineId).Select(l => new PoLineDto(
l.PoLineId, l.ItemId, l.UomId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived)).ToList());
l.PoLineId, l.ItemId, l.UomId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived,
l.QtyBase, l.QtyReceivedBase, l.ConversionFactor)).ToList());
}
@@ -25,6 +25,7 @@ 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;
@@ -32,9 +33,10 @@ 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,
ISalesDocumentWorkflowService workflow, IUomConverter uomConverter,
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
{
_uomConverter = uomConverter;
_invoices = invoices;
_customers = customers;
_items = items;
@@ -152,6 +154,12 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
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,
@@ -159,6 +167,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
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,
+27 -18
View File
@@ -20,10 +20,11 @@ public sealed class SalesPostingService : ISalesPostingService
private readonly IRepository<Item> _items;
private readonly IFifoCostingService _fifo;
private readonly ISalesDomainService _sales;
private readonly IUomConverter _uomConverter;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
// 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,
@@ -31,7 +32,6 @@ public sealed class SalesPostingService : ISalesPostingService
IRepository<Item> items,
IFifoCostingService fifo,
ISalesDomainService sales,
IUomConverter uomConverter,
ICurrentUser currentUser,
IUnitOfWork uow)
{
@@ -41,7 +41,6 @@ public sealed class SalesPostingService : ISalesPostingService
_items = items;
_fifo = fifo;
_sales = sales;
_uomConverter = uomConverter;
_currentUser = currentUser;
_uow = uow;
}
@@ -60,18 +59,20 @@ public sealed class SalesPostingService : ISalesPostingService
{
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
continue;
var requestedQty = line.Qty + line.FreeQty;
// 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 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 })
.Select(x => new { x.Sku, x.Name, BaseUomName = x.BaseUom!.Name })
.FirstAsync(ct);
issues.Add(new SalesInvoicePostingIssueDto(
line.SalesInvoiceLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId,
requestedQty, available, requestedQty - available, line.IsFreeIssue));
requestedQty, available, requestedQty - available, line.IsFreeIssue, item.BaseUomName));
}
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, issues.Count == 0, issues);
@@ -91,18 +92,19 @@ public sealed class SalesPostingService : ISalesPostingService
{
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
continue;
var requestedQty = line.Qty + line.FreeQty;
// Base against base — see the matching comment in CheckInvoiceAsync.
var requestedQty = line.QtyBase + line.FreeQtyBase;
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 })
.Select(x => new { x.Sku, x.Name, BaseUomName = x.BaseUom!.Name })
.FirstAsync(ct);
issues.Add(new SalesSlipPostingIssueDto(
line.SalesSlipLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId,
requestedQty, available, requestedQty - available, line.IsFreeIssue));
requestedQty, available, requestedQty - available, line.IsFreeIssue, item.BaseUomName));
}
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, issues.Count == 0, issues);
@@ -124,12 +126,15 @@ public sealed class SalesPostingService : ISalesPostingService
continue;
var item = await _items.Query().AsNoTracking()
.FirstAsync(x => x.ItemId == line.ItemId, ct);
.Where(x => x.ItemId == line.ItemId)
.Select(x => new { x.Sku, x.Name, BaseUomName = x.BaseUom!.Name })
.FirstAsync(ct);
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
if (available >= line.Qty) continue;
if (available >= line.QtyBase) continue;
issues.Add(new BundleSalePostingIssueDto(
line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId,
line.QtyBase, available, line.QtyBase - available, item.BaseUomName));
}
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
@@ -141,7 +146,7 @@ public sealed class SalesPostingService : ISalesPostingService
notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.",
statusSelector: x => x.Status,
ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.",
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.QtyBase + l.FreeQtyBase, l.QtyBase, l.FreeQtyBase)),
setPosted: x => x.Status = SalesInvoiceStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: DocumentTypes.SalesInvoice,
@@ -154,7 +159,7 @@ public sealed class SalesPostingService : ISalesPostingService
notFoundMessage: $"Sales slip {salesSlipId} was not found.",
statusSelector: x => x.Status,
ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.",
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.QtyBase + l.FreeQtyBase, l.QtyBase, l.FreeQtyBase)),
setPosted: x => x.Status = SalesSlipStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: DocumentTypes.SalesSlip,
@@ -167,9 +172,7 @@ public sealed class SalesPostingService : ISalesPostingService
notFoundMessage: $"Bundle sale {bundleSaleId} was not found.",
statusSelector: x => x.Status,
ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.",
// Bundle lines are normalized to base UOM on save, so posting should consume the
// stored base quantity directly instead of converting again.
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty, l.Qty, 0m)),
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.QtyBase, l.QtyBase, 0m)),
setPosted: x => x.Status = BundleSaleStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: DocumentTypes.BundleSale,
@@ -214,5 +217,11 @@ public sealed class SalesPostingService : ISalesPostingService
}, ct);
}
private sealed record PostingLine(int ItemId, int WarehouseId, int UomId, decimal Qty, decimal PaidQty, decimal FreeQty);
/// <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);
}
+10 -1
View File
@@ -26,6 +26,7 @@ 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;
@@ -33,9 +34,10 @@ 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,
ISalesDocumentWorkflowService workflow, IUomConverter uomConverter,
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
{
_uomConverter = uomConverter;
_slips = slips;
_customers = customers;
_items = items;
@@ -176,6 +178,10 @@ public sealed class SalesSlipService : ISalesSlipService
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,
@@ -183,6 +189,9 @@ public sealed class SalesSlipService : ISalesSlipService
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,
+38 -5
View File
@@ -14,17 +14,32 @@ 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<StockLedger> ledger, IRepository<StockTransferLine> transferLines,
IRepository<Item> items)
{
_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);
@@ -47,7 +62,10 @@ public sealed class StockService : IStockService
const decimal reserved = 0m;
var available = onHand - onHold - reserved;
return new StockOnHandDto(itemId, warehouseId, onHand, available, onHold, inTransit, reserved, DateTime.UtcNow);
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);
}
/// <summary>
@@ -100,16 +118,20 @@ 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);
p.ItemId, p.WarehouseId, p.OnHand, p.OnHand - hold - reserved, hold, transit, reserved, asOf,
uom.Id, uom.Name ?? string.Empty);
}).ToList();
return PagedResponse<StockOnHandDto>.Create(rows, query.Page, query.PageSize, total);
@@ -139,9 +161,20 @@ 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 Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
=> _fifo.GetValuationAsync(itemId, warehouseId, ct);
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 };
}
}
+111 -10
View File
@@ -1,4 +1,5 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Uoms;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
@@ -11,28 +12,128 @@ namespace ERPCore.Services.Stock;
/// 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
{
private readonly IRepository<UomConversion> _conversions;
/// <summary>Quantity columns are <c>(18,4)</c> across the model.</summary>
private const int QtyScale = 4;
public UomConverter(IRepository<UomConversion> conversions) => _conversions = conversions;
/// <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)
{
if (uomId == item.BaseUomId)
var factor = await ResolveFactorAsync(item, uomId, ct);
if (factor == 1m)
return (qty, unitCostPerUom);
var conv = await _conversions.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct)
?? throw new DomainException(ErrorCodes.Validation,
$"No UOM conversion from {uomId} to base UOM {item.BaseUomId} for item {item.ItemId}.", 422);
// Quantity scales up by the factor, so the per-unit cost scales down by it —
// total value is preserved.
return (qty * conv.Factor, unitCostPerUom / conv.Factor);
// 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);
}
}