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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user