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
+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);
}
}