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);
}
}
Binary file not shown.
+5
View File
@@ -25,6 +25,11 @@ SCRIPTS = [
("M4b UOM conversion on stock inputs", "m4b_uom_conversion.py"),
("M5 terminal receipt + cost pool", "m5_receipt.py"),
("M6+M7 leftover / rework / cancel", "m6_m7_leftover_rework_cancel.py"),
# UOM engine. These run after the manufacturing scripts because they reuse the
# SMOKE-PRD warehouse m4 creates, and they drain it before seeding their own stock.
("UOM conversion direction is enforced", "uom_direction.py"),
("UOM non-base sales consume converted qty", "uom_sales_nonbase.py"),
("UOM cross-unit GRN against a PO", "uom_grn_po_cross.py"),
]
SUMMARY = re.compile(r"^(\S+): (\d+)/(\d+) assertions passed")
+81
View File
@@ -0,0 +1,81 @@
"""Smoke test — UOM conversions are one-directional, and the API says so.
`UomConverter` looks up exactly one shape, `FromUom -> ToUom = item.BaseUomId`, and never
inverts a factor. `UpdateUomConversionsAsync` used to accept *any* pair, so saving the more
natural-reading `base -> BOX` produced a row that returned 200, appeared in the item detail
response, and was then silently invisible to every consumer — surfacing much later as
"no UOM conversion" 422 at GRN confirm or stage start, on an item that visibly had one.
* base -> other is rejected with 422 (the direction that used to save and then not work)
* other -> base is accepted
* a self-conversion and a base-as-source row are rejected
* a zero/negative factor is rejected (UomConverter divides unit cost by it)
* changing an item's base UOM while conversions exist is refused rather than orphaning them
python Backend/smoke/uom_direction.py
"""
from __future__ import annotations
import sys
from smoke_common import bootstrap
def main():
c, chk, args = bootstrap(__doc__)
print(f"API {args.api}")
item = c.get("/items?pageSize=1&status=Active").body["items"]
if not item:
sys.exit("FATAL: no active items.")
item = item[0]
item_id, base_uom = item["itemId"], item["baseUomId"]
uoms = c.get("/uoms?pageSize=50").body["items"]
other = next((u["uomId"] for u in uoms if u["uomId"] != base_uom), None)
if other is None:
sys.exit("FATAL: need at least 2 UOMs.")
print(f"item={item_id} baseUom={base_uom} otherUom={other}")
def put(conversions):
return c.put(f"/items/{item_id}/uom-conversions", {"conversions": conversions})
chk.section("1. The correct direction is accepted")
ok = put([{"fromUom": other, "toUom": base_uom, "factor": 12}])
chk.status("other -> base", ok, 200)
if ok.status == 200:
chk.check("stored with the base UOM as target", ok.body["conversions"][0]["toUom"], base_uom)
chk.section("2. The reverse direction is rejected, not silently stored")
chk.status("base -> other", put([{"fromUom": base_uom, "toUom": other, "factor": 12}]), 422)
chk.section("3. Degenerate rows are rejected")
chk.status("self-conversion (other -> other)", put([{"fromUom": other, "toUom": other, "factor": 2}]), 422)
chk.status("zero factor", put([{"fromUom": other, "toUom": base_uom, "factor": 0}]), 422)
chk.status("negative factor", put([{"fromUom": other, "toUom": base_uom, "factor": -3}]), 422)
chk.section("4. Base UOM cannot be repointed while conversions exist")
# Restore a valid conversion first, so the guard has something to protect.
put([{"fromUom": other, "toUom": base_uom, "factor": 12}])
head = c.get(f"/items/{item_id}")
if head.status == 200:
body = head.body
moved = c.put(f"/items/{item_id}", {
"sku": body["sku"], "name": body["name"], "description": body.get("description"),
"categoryId": body["categoryId"], "subCategoryId": body.get("subCategoryId"),
"brandId": body.get("brandId"),
"baseUomId": other, # <- the repoint being guarded
"defaultVendorId": body.get("defaultVendorId"),
"stockNature": body["stockNature"], "trackingMode": body["trackingMode"],
"taxClass": body.get("taxClass"), "salePrice": body.get("salePrice"),
}, if_match=head.etag)
chk.status("change base UOM with conversions defined", moved, 422)
else:
chk.check("could read the item for the repoint test", head.status, 200)
return chk.finish("UOM-DIRECTION")
if __name__ == "__main__":
sys.exit(main())
+127
View File
@@ -0,0 +1,127 @@
"""Smoke test — receiving in a different UOM from the one ordered.
`GrnService` compared the GRN line's entered quantity against `poLine.Qty - poLine.QtyReceived`
with no conversion, so a PO for 10 BOX receiving a legitimate 120 base units was rejected
outright with OVER_RECEIPT_TOLERANCE — a user-visible false failure. It then accrued the GRN's
quantity into `poLine.QtyReceived` (a PO-UOM field), and the close condition consumed that
mixed-unit value, so a PO could close early or never close.
Both sides now run on the base pair (`QtyBase` / `QtyReceivedBase`), with `QtyReceived` kept
as a denormalized display figure only.
* a receipt in base UOM against a PO raised in BOX is ACCEPTED
* the FIFO layer and ledger record the base quantity
* `qtyReceivedBase` accrues correctly and the PO reaches FullyReceived
* over-receipt beyond tolerance is still rejected, now measured in base units
python Backend/smoke/uom_grn_po_cross.py
"""
from __future__ import annotations
import sys
from smoke_common import bootstrap, drain_stock, ensure_vendor
WAREHOUSE_CODE = "SMOKE-PRD"
FACTOR = 12
ORDER_BOXES = 10 # -> 120 base units
RECEIVE_BASE = 120 # the whole order, expressed in base units
def main():
c, chk, args = bootstrap(__doc__)
print(f"API {args.api}")
wh = next((w["warehouseId"] for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"]
if w["code"] == WAREHOUSE_CODE), None)
if wh is None:
sys.exit("FATAL: run m4_stage_actions.py first (it creates the SMOKE-PRD warehouse).")
item = next((i for i in c.get("/items?pageSize=20&status=Active").body["items"]
if i["stockNature"] == "Stocked" and i["trackingMode"] == "None"), None)
if item is None:
sys.exit("FATAL: need a Stocked, untracked item.")
base_uom = item["baseUomId"]
uoms = c.get("/uoms?pageSize=50").body["items"]
box_uom = next((u["uomId"] for u in uoms if u["uomId"] != base_uom), None)
if box_uom is None:
sys.exit("FATAL: need at least 2 UOMs.")
vendor = ensure_vendor(c)
print(f"item={item['itemId']} baseUom={base_uom} boxUom={box_uom} factor={FACTOR}")
chk.section("1. Conversion + a PO raised in BOX")
conv = c.put(f"/items/{item['itemId']}/uom-conversions",
{"conversions": [{"fromUom": box_uom, "toUom": base_uom, "factor": FACTOR}]})
chk.status("define BOX -> base conversion", conv, 200)
if conv.status != 200:
return chk.finish("UOM-GRN-PO")
po = c.post("/purchase-orders", {
"vendorId": vendor,
"lines": [{"itemId": item["itemId"], "uomId": box_uom, "warehouseId": wh,
"qty": ORDER_BOXES, "unitPrice": 60, "tax": 0}],
})
chk.status("create the PO in BOX", po, 201)
if po.status != 201:
return chk.finish("UOM-GRN-PO")
po_line = po.body["lines"][0]
chk.check("PO line keeps the ordered qty in BOX", float(po_line["qty"]), float(ORDER_BOXES))
chk.check("PO line snapshots the base quantity", float(po_line["qtyBase"]), float(ORDER_BOXES * FACTOR))
chk.check("PO line snapshots the factor", float(po_line["conversionFactor"]), float(FACTOR))
drain_stock(c, wh)
before = float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"])
chk.section("2. Receiving the order in BASE units is accepted")
grn = c.post("/grns", {
"vendorId": vendor, "warehouseId": wh, "poId": po.body["poId"],
"lines": [{"poLineId": po_line["poLineId"], "itemId": item["itemId"],
"uomId": base_uom, # <- different UOM from the PO
"qty": RECEIVE_BASE, "unitCost": 5, "discountPct": 0, "vatPct": 0}],
})
# This is the assertion that fails on the old code: it returned 422 OVER_RECEIPT_TOLERANCE.
chk.status("GRN in base UOM against a BOX purchase order", grn, 201)
if grn.status != 201:
return chk.finish("UOM-GRN-PO")
confirmed = c.post(f"/grns/{grn.body['grnId']}/confirm")
chk.status("confirm the GRN", confirmed, 200)
if confirmed.status != 200:
return chk.finish("UOM-GRN-PO")
chk.check("on-hand rose by the base quantity",
float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"]),
before + RECEIVE_BASE)
rows = c.get(f"/stock/ledger?sourceDocType=GRN&sourceDocId={grn.body['grnId']}&pageSize=50").body["items"]
chk.check("one GRN ledger row", len(rows), 1)
if rows:
chk.check("ledger qtyBase is the received base quantity", float(rows[0]["qtyBase"]), float(RECEIVE_BASE))
chk.section("3. The PO closes on the base pair")
reread = c.get(f"/purchase-orders/{po.body['poId']}")
chk.status("re-read the PO", reread, 200)
if reread.status == 200:
rl = reread.body["lines"][0]
chk.check("qtyReceivedBase accrued in base units", float(rl["qtyReceivedBase"]), float(RECEIVE_BASE))
chk.check("qtyReceived shown back in the PO's own UOM", float(rl["qtyReceived"]), float(ORDER_BOXES))
chk.check("PO is FullyReceived", reread.body["status"], "FullyReceived")
chk.section("4. Over-receipt is still rejected, measured in base")
over = c.post("/grns", {
"vendorId": vendor, "warehouseId": wh, "poId": po.body["poId"],
"lines": [{"poLineId": po_line["poLineId"], "itemId": item["itemId"],
"uomId": base_uom, "qty": RECEIVE_BASE, "unitCost": 5,
"discountPct": 0, "vatPct": 0}],
})
chk.status("receiving the whole order again", over, 422, "OVER_RECEIPT_TOLERANCE")
return chk.finish("UOM-GRN-PO")
if __name__ == "__main__":
sys.exit(main())
+200
View File
@@ -0,0 +1,200 @@
"""Smoke test — selling in a non-base UOM consumes the converted quantity.
This is the regression test for the UOM engine's worst defect. `SalesPostingService`
injected `IUomConverter` and never called it: `PostAsync` fed the *entered* line quantity
straight into `IFifoCostingService.ConsumeAsync`, whose contract is base UOM only. Selling
2 BOX of a 12-per-box item therefore removed 2 base units instead of 24 and wrote
`StockLedger.QtyBase = 2` into a column defined as base — overstating stock, understating
COGS, and drifting the ledger's running balance away from the layer sum.
`m4b_uom_conversion.py` covered exactly the same hazard on the *production* path, which is
why that path was correct and this one was not. This script closes the gap:
* an invoice line in a non-base UOM consumes qty x factor base units
* the ledger records the BASE quantity
* the line still reports the ENTERED qty and UOM, so the printed document says "2 BOX"
* the pre-post check reports the shortfall in base units (it compared entered vs base
on-hand before, and answered "can post" when it could not)
* the same holds for a sales slip, which shares PostAsync
* a UOM the item has no conversion for is refused at line creation, not at post
python Backend/smoke/uom_sales_nonbase.py
"""
from __future__ import annotations
import sys
from smoke_common import bootstrap, drain_stock, seed_costed_stock
WAREHOUSE_CODE = "SMOKE-PRD"
FACTOR = 12 # 1 BOX = 12 base units
SELL_BOXES = 2 # -> 24 base units
SEED_BASE = 100 # base units on hand before selling
UNIT_COST = 5.0
def main():
c, chk, args = bootstrap(__doc__)
print(f"API {args.api}")
wh = next((w["warehouseId"] for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"]
if w["code"] == WAREHOUSE_CODE), None)
if wh is None:
sys.exit("FATAL: run m4_stage_actions.py first (it creates the SMOKE-PRD warehouse).")
item = next((i for i in c.get("/items?pageSize=20&status=Active").body["items"]
if i["stockNature"] == "Stocked"), None)
if item is None:
sys.exit("FATAL: need a Stocked item.")
base_uom = item["baseUomId"]
uoms = c.get("/uoms?pageSize=50").body["items"]
box_uom = next((u["uomId"] for u in uoms if u["uomId"] != base_uom), None)
if box_uom is None:
sys.exit("FATAL: need at least 2 UOMs to test conversion.")
customer = c.get("/customers?pageSize=1").body["items"]
if not customer:
sys.exit("FATAL: no customers seeded.")
customer_id = customer[0]["customerId"]
print(f"item={item['itemId']} baseUom={base_uom} boxUom={box_uom} factor={FACTOR}")
# --- fixtures ---------------------------------------------------------
chk.section("1. Conversion + known on-hand")
conv = c.put(f"/items/{item['itemId']}/uom-conversions",
{"conversions": [{"fromUom": box_uom, "toUom": base_uom, "factor": FACTOR}]})
chk.status("define BOX -> base conversion", conv, 200)
if conv.status != 200:
return chk.finish("UOM-SALES")
allowed = c.get(f"/items/{item['itemId']}/uoms")
chk.status("GET /items/{id}/uoms", allowed, 200)
if allowed.status == 200:
ids = [u["uomId"] for u in allowed.body]
chk.check("allowed UOMs are base + the conversion source", sorted(ids), sorted([base_uom, box_uom]))
chk.check("base UOM is flagged and listed first", allowed.body[0]["isBase"], True)
drain_stock(c, wh)
seed_costed_stock(c, wh, [(item["itemId"], base_uom, SEED_BASE, UNIT_COST)])
before = float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"])
chk.check(f"on-hand seeded to {SEED_BASE} base units", before, float(SEED_BASE))
on_hand = c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body
chk.check("stock read is labelled with the base UOM", on_hand["baseUomId"], base_uom)
chk.check("stock read carries the base UOM name", bool(on_hand["baseUomName"]), True)
# --- invoice in BOX ---------------------------------------------------
chk.section("2. An invoice line entered in BOX")
expected_base = SELL_BOXES * FACTOR # 2 x 12 = 24
inv = c.post("/sales-invoices", {
"customerId": customer_id,
"warehouseId": wh,
"invoiceType": "B2C",
"lines": [{
"itemId": item["itemId"], "uomId": box_uom, "warehouseId": wh,
"qty": SELL_BOXES, "freeQty": 0, "unitPrice": 100, "allowManualPriceOverride": True,
"discountMode": "Percentage", "discountPct": 0, "discountAmount": 0,
"discountValue": 0, "taxPct": 0, "isFreeIssue": False,
}],
})
chk.status("create the invoice", inv, 201)
if inv.status != 201:
return chk.finish("UOM-SALES")
line = inv.body["lines"][0]
chk.check("line keeps the ENTERED qty (prints as 2 BOX)", float(line["qty"]), float(SELL_BOXES))
chk.check("line keeps the ENTERED uom", line["uomId"], box_uom)
check = c.get(f"/sales-invoices/{inv.body['salesInvoiceId']}/posting-check")
chk.status("posting check", check, 200)
if check.status == 200:
chk.check("posting check passes with enough stock", check.body["canPost"], True)
chk.section("3. Posting consumes the CONVERTED quantity")
posted = c.post(f"/sales-invoices/{inv.body['salesInvoiceId']}/post")
chk.status("post the invoice", posted, 200)
if posted.status != 200:
return chk.finish("UOM-SALES")
after = float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"])
chk.check(f"on-hand fell by {expected_base} base units, not {SELL_BOXES}", after, before - expected_base)
rows = c.get(f"/stock/ledger?sourceDocType=SINV&sourceDocId={inv.body['salesInvoiceId']}&pageSize=50").body["items"]
chk.check("one ledger row for the invoice", len(rows), 1)
if rows:
chk.check("ledger qtyBase is the CONVERTED quantity", float(rows[0]["qtyBase"]), float(expected_base))
chk.check("ledger row is labelled with the base UOM", rows[0]["baseUomId"], base_uom)
# --- the same on a slip ----------------------------------------------
chk.section("4. A sales slip behaves identically (shared PostAsync)")
before_slip = float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"])
slip = c.post("/sales-slips", {
"customerId": customer_id,
"warehouseId": wh,
"lines": [{
"itemId": item["itemId"], "uomId": box_uom, "warehouseId": wh,
"qty": SELL_BOXES, "freeQty": 0, "unitPrice": 100, "allowManualPriceOverride": True,
"discountMode": "Percentage", "discountPct": 0, "discountAmount": 0,
"discountValue": 0, "taxPct": 0, "isFreeIssue": False,
}],
})
chk.status("create the slip", slip, 201)
if slip.status == 201:
chk.status("post the slip", c.post(f"/sales-slips/{slip.body['salesSlipId']}/post"), 200)
chk.check("slip also consumed the converted quantity",
float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"]),
before_slip - expected_base)
# --- snapshot immutability -------------------------------------------
chk.section("5. A factor edited after save does not change what posts")
draft = c.post("/sales-invoices", {
"customerId": customer_id, "warehouseId": wh, "invoiceType": "B2C",
"lines": [{
"itemId": item["itemId"], "uomId": box_uom, "warehouseId": wh,
"qty": 1, "freeQty": 0, "unitPrice": 100, "allowManualPriceOverride": True,
"discountMode": "Percentage", "discountPct": 0, "discountAmount": 0,
"discountValue": 0, "taxPct": 0, "isFreeIssue": False,
}],
})
if draft.status == 201:
# Double the factor *after* the draft is saved.
c.put(f"/items/{item['itemId']}/uom-conversions",
{"conversions": [{"fromUom": box_uom, "toUom": base_uom, "factor": FACTOR * 2}]})
before_snap = float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"])
chk.status("post the pre-existing draft", c.post(f"/sales-invoices/{draft.body['salesInvoiceId']}/post"), 200)
chk.check(f"posted the snapshotted {FACTOR}, not the edited {FACTOR * 2}",
float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"]),
before_snap - FACTOR)
# Restore for re-runnability.
c.put(f"/items/{item['itemId']}/uom-conversions",
{"conversions": [{"fromUom": box_uom, "toUom": base_uom, "factor": FACTOR}]})
else:
chk.check("could create the snapshot-test draft", draft.status, 201)
# --- unusable UOM refused at entry ------------------------------------
chk.section("6. A UOM with no conversion is refused at line creation")
third = next((u["uomId"] for u in c.get("/uoms?pageSize=50").body["items"]
if u["uomId"] not in (base_uom, box_uom)), None)
if third is None:
chk.check("skipped: need a third UOM", True, True)
else:
bad = c.post("/sales-invoices", {
"customerId": customer_id, "warehouseId": wh, "invoiceType": "B2C",
"lines": [{
"itemId": item["itemId"], "uomId": third, "warehouseId": wh,
"qty": 1, "freeQty": 0, "unitPrice": 100, "allowManualPriceOverride": True,
"discountMode": "Percentage", "discountPct": 0, "discountAmount": 0,
"discountValue": 0, "taxPct": 0, "isFreeIssue": False,
}],
})
# The point is that this fails at CREATE (while the user is editing), not at post.
chk.status("invoice line in an unconvertible UOM", bad, 422)
return chk.finish("UOM-SALES")
if __name__ == "__main__":
sys.exit(main())