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
@@ -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);