diff --git a/Backend/ERPCore/Controllers/ItemsController.cs b/Backend/ERPCore/Controllers/ItemsController.cs
index fe4ed11..ccd3b9f 100644
--- a/Backend/ERPCore/Controllers/ItemsController.cs
+++ b/Backend/ERPCore/Controllers/ItemsController.cs
@@ -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;
+ }
/// List items with optional filters and paging.
[HttpGet]
@@ -88,4 +94,15 @@ public sealed class ItemsController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> UpdateUomConversions(int itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct)
=> Ok(await _items.UpdateUomConversionsAsync(itemId, request, ct));
+
+ ///
+ /// 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).
+ ///
+ [HttpGet("{itemId:int}/uoms")]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>> GetAllowedUoms(int itemId, CancellationToken ct)
+ => Ok(await _uomConverter.GetAllowedUomsAsync(itemId, ct));
}
diff --git a/Backend/ERPCore/Domain/Entities/BundleSaleLine.cs b/Backend/ERPCore/Domain/Entities/BundleSaleLine.cs
index fecf7ef..c145248 100644
--- a/Backend/ERPCore/Domain/Entities/BundleSaleLine.cs
+++ b/Backend/ERPCore/Domain/Entities/BundleSaleLine.cs
@@ -11,9 +11,20 @@ public class BundleSaleLine
public int ItemId { get; set; }
public Item? Item { get; set; }
public string Description { get; set; } = string.Empty;
+ /// Component quantity, in — what the user entered.
public decimal Qty { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
+
+ ///
+ /// restated in the item's base UOM, resolved once at save. Posting
+ /// consumes this. and are always a matching
+ /// pair in , so stays value-correct.
+ ///
+ public decimal QtyBase { get; set; }
+
+ /// The factor used to derive ; 1 when the line is in base UOM.
+ public decimal ConversionFactor { get; set; } = 1m;
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public decimal UnitPrice { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/GrnLine.cs b/Backend/ERPCore/Domain/Entities/GrnLine.cs
index f6981d4..a55e134 100644
--- a/Backend/ERPCore/Domain/Entities/GrnLine.cs
+++ b/Backend/ERPCore/Domain/Entities/GrnLine.cs
@@ -34,8 +34,20 @@ public class GrnLine
public int? BatchId { get; set; }
public Batch? Batch { get; set; }
+ /// Quantity received, in — what the user entered.
public decimal Qty { get; set; }
+ ///
+ /// 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.
+ ///
+ public decimal QtyBase { get; set; }
+
+ /// The factor used to derive ; 1 when the line is in base UOM.
+ public decimal ConversionFactor { get; set; } = 1m;
+
/// Gross unit cost received at (entered, or PO price when omitted).
public decimal UnitCost { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/PoLine.cs b/Backend/ERPCore/Domain/Entities/PoLine.cs
index 0a5f087..1f46481 100644
--- a/Backend/ERPCore/Domain/Entities/PoLine.cs
+++ b/Backend/ERPCore/Domain/Entities/PoLine.cs
@@ -21,8 +21,32 @@ public class PoLine
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
+ /// Quantity ordered, in .
public decimal Qty { get; set; }
public decimal UnitPrice { get; set; }//
public decimal Tax { get; set; }
+
+ ///
+ /// restated in the item's base UOM, resolved once at line creation.
+ /// This — not — is what open-quantity and close checks compare against,
+ /// because a GRN may legitimately receive against this line in a different UOM.
+ ///
+ public decimal QtyBase { get; set; }
+
+ /// The factor used to derive ; 1 when the line is in base UOM.
+ public decimal ConversionFactor { get; set; } = 1m;
+
+ ///
+ /// Accrues as GRNs confirm (FR-PROC-07), in . Denormalized for
+ /// display only — it is derived by dividing by the
+ /// factor, so it can drift. Never branch on it; use .
+ ///
public decimal QtyReceived { get; set; }
+
+ ///
+ /// Authoritative received-to-date in the item's base UOM. GRN confirm accrues here and
+ /// the PO close condition compares this against , so receipts in a
+ /// UOM other than the PO's still add up correctly.
+ ///
+ public decimal QtyReceivedBase { get; set; }
}
diff --git a/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs b/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs
index e7e4beb..5bf321e 100644
--- a/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs
+++ b/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs
@@ -14,10 +14,24 @@ public class SalesInvoiceLine
public string Description { get; set; } = string.Empty;
+ /// Quantity sold, in — what the user entered and what prints.
public decimal Qty { get; set; }
public decimal FreeQty { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public decimal QtyBase { get; set; }
+
+ /// restated in the item's base UOM.
+ public decimal FreeQtyBase { get; set; }
+
+ /// The factor used to derive the base quantities; 1 when the line is in base UOM.
+ public decimal ConversionFactor { get; set; } = 1m;
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs b/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs
index 0eb3de2..25d16bb 100644
--- a/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs
+++ b/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs
@@ -14,10 +14,24 @@ public class SalesSlipLine
public string Description { get; set; } = string.Empty;
+ /// Quantity sold, in — what the user entered and what prints.
public decimal Qty { get; set; }
public decimal FreeQty { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public decimal QtyBase { get; set; }
+
+ /// restated in the item's base UOM.
+ public decimal FreeQtyBase { get; set; }
+
+ /// The factor used to derive the base quantities; 1 when the line is in base UOM.
+ public decimal ConversionFactor { get; set; } = 1m;
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs
index d062179..8b484ca 100644
--- a/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs
+++ b/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs
@@ -5,9 +5,18 @@ namespace ERPCore.Dtos.Procurement;
// Responses (docs/11 §3.3) ------------------------------------------------------
+///
+/// A PO line. and are in
+/// and are what the user sees; and
+/// 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.
+///
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);
diff --git a/Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs b/Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs
index 7d14af7..d29efdd 100644
--- a/Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs
+++ b/Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs
@@ -31,9 +31,10 @@ public sealed record BundleSaleTemplateSummaryDto(
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description,
EntityStatus Status, int LineCount, DateTime CreatedAt, DateTime? UpdatedAt);
+/// Quantities are in the item's base UOM — see SalesInvoicePostingIssueDto.
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,
diff --git a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs
index c541132..a3334cf 100644
--- a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs
+++ b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs
@@ -24,9 +24,16 @@ public sealed record SalesInvoiceSummaryDto(
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt);
+///
+/// A line that cannot be posted for lack of stock. The three quantities are in the item's
+/// base UOM (on-hand only exists in base), which may differ from the UOM shown on the
+/// line — hence : without it a line reading "2 BOX" produces
+/// an unexplained "requested 24, available 10".
+///
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,
diff --git a/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs
index 85ec177..80752f6 100644
--- a/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs
+++ b/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs
@@ -23,9 +23,11 @@ public sealed record SalesSlipSummaryDto(
string CustomerSnapshotName, int WarehouseId, SalesSlipStatus Status,
SalesSlipTotalsDto Totals, DateTime CreatedAt);
+/// Quantities are in the item's base UOM — see SalesInvoicePostingIssueDto.
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,
diff --git a/Backend/ERPCore/Dtos/Stock/StockDtos.cs b/Backend/ERPCore/Dtos/Stock/StockDtos.cs
index 9a1b3b2..e659515 100644
--- a/Backend/ERPCore/Dtos/Stock/StockDtos.cs
+++ b/Backend/ERPCore/Dtos/Stock/StockDtos.cs
@@ -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.
+
/// Stock enquiry (docs/11 §5.1). available = onHand − onHold − reserved − inTransit(out).
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 = "");
/// A stock-ledger row (docs/11 §5.2).
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 = "");
/// An open FIFO layer in a valuation (docs/11 §5.3).
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
/// Valuation of on-hand stock from open FIFO layers (docs/11 §5.3).
public sealed record StockValuationDto(
int ItemId, int WarehouseId, IReadOnlyList Layers,
- decimal TotalQty, decimal TotalValue, string Currency, string CostingMethod);
+ decimal TotalQty, decimal TotalValue, string Currency, string CostingMethod,
+ int BaseUomId = 0, string BaseUomName = "");
diff --git a/Backend/ERPCore/Dtos/Uoms/UomDtos.cs b/Backend/ERPCore/Dtos/Uoms/UomDtos.cs
index 655fd06..109da65 100644
--- a/Backend/ERPCore/Dtos/Uoms/UomDtos.cs
+++ b/Backend/ERPCore/Dtos/Uoms/UomDtos.cs
@@ -9,3 +9,12 @@ public sealed class CreateUomRequest
{
[Required, StringLength(50)] public string Name { get; set; } = string.Empty;
}
+
+///
+/// A UOM an item may actually be transacted in: its base UOM ( 1,
+/// true) plus every UOM it has a conversion from. Backs both
+/// entry-time validation and GET /items/{itemId}/uoms, so the client can offer only
+/// units that will survive posting instead of the whole global list.
+///
+/// Multiply a quantity in this UOM by to get base UOM.
+public sealed record AllowedUomDto(int UomId, string Name, decimal Factor, bool IsBase);
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleLineConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleLineConfiguration.cs
index fac3e88..fcb4b68 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleLineConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleLineConfiguration.cs
@@ -12,6 +12,8 @@ public sealed class BundleSaleLineConfiguration : IEntityTypeConfiguration 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);
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs
index 0c47198..f888651 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs
@@ -36,6 +36,8 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration
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);
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseOrderConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseOrderConfiguration.cs
index 8d452e0..ce87e2a 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseOrderConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseOrderConfiguration.cs
@@ -49,6 +49,9 @@ public sealed class PoLineConfiguration : IEntityTypeConfiguration
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);
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs
index e8f27ed..2b0ae6a 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs
@@ -70,6 +70,9 @@ public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration 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);
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SalesSlipConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SalesSlipConfiguration.cs
index deeeed0..96e2ae7 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/SalesSlipConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/SalesSlipConfiguration.cs
@@ -70,6 +70,9 @@ public sealed class SalesSlipLineConfiguration : IEntityTypeConfiguration 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);
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/UomConversionConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/UomConversionConfiguration.cs
index 15445d4..97d4dbd 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/UomConversionConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/UomConversionConfiguration.cs
@@ -30,5 +30,9 @@ public sealed class UomConversionConfiguration : IEntityTypeConfiguration 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"));
}
}
diff --git a/Backend/ERPCore/Services/BundleSaleService.cs b/Backend/ERPCore/Services/BundleSaleService.cs
index 1959a54..4937d3d 100644
--- a/Backend/ERPCore/Services/BundleSaleService.cs
+++ b/Backend/ERPCore/Services/BundleSaleService.cs
@@ -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,
diff --git a/Backend/ERPCore/Services/GrnService.cs b/Backend/ERPCore/Services/GrnService.cs
index 41b57ad..0bf44c0 100644
--- a/Backend/ERPCore/Services/GrnService.cs
+++ b/Backend/ERPCore/Services/GrnService.cs
@@ -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
}
- ///
- /// Delegates to the shared . This was a private method here
- /// until manufacturing needed the same conversion for stage stock inputs; behaviour is
- /// identical, so receive costing is unchanged.
- ///
- 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;
diff --git a/Backend/ERPCore/Services/Interfaces/IUomConverter.cs b/Backend/ERPCore/Services/Interfaces/IUomConverter.cs
index 14f1a1a..0944543 100644
--- a/Backend/ERPCore/Services/Interfaces/IUomConverter.cs
+++ b/Backend/ERPCore/Services/Interfaces/IUomConverter.cs
@@ -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).
///
Task ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default);
+
+ ///
+ /// The single factor lookup every other method is built on: 1 when
+ /// is already the base UOM, otherwise the item's conversion
+ /// factor from that UOM to base. Throws 422 when none is defined.
+ ///
+ ///
+ /// 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.
+ ///
+ Task ResolveFactorAsync(Item item, int uomId, CancellationToken ct = default);
+
+ ///
+ /// Every UOM may be transacted in — base UOM first, then each
+ /// conversion source, ordered by name.
+ ///
+ Task> GetAllowedUomsAsync(int itemId, CancellationToken ct = default);
+
+ ///
+ /// Entry-time guard: throws 422 naming the allowed units when
+ /// 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.
+ ///
+ Task ValidateUomAsync(Item item, int uomId, CancellationToken ct = default);
}
diff --git a/Backend/ERPCore/Services/ItemService.cs b/Backend/ERPCore/Services/ItemService.cs
index 79a24c5..68ab0ff 100644
--- a/Backend/ERPCore/Services/ItemService.cs
+++ b/Backend/ERPCore/Services/ItemService.cs
@@ -148,6 +148,15 @@ public sealed class ItemService : IItemService
request.CategoryId, request.SubCategoryId, request.BrandId,
request.BaseUomId, request.DefaultVendorId, ct);
+ // Conversions are stored as → 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 → 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)
diff --git a/Backend/ERPCore/Services/Production/ProductionTemplateService.cs b/Backend/ERPCore/Services/Production/ProductionTemplateService.cs
index d63aaf6..4621ea4 100644
--- a/Backend/ERPCore/Services/Production/ProductionTemplateService.cs
+++ b/Backend/ERPCore/Services/Production/ProductionTemplateService.cs
@@ -27,6 +27,7 @@ public sealed class ProductionTemplateService : IProductionTemplateService
private readonly IRepository _items;
private readonly IRepository _uoms;
private readonly IRepository _runs;
+ private readonly IUomConverter _uomConverter;
private readonly IUnitOfWork _uow;
private readonly ICurrentUser _currentUser;
@@ -34,8 +35,9 @@ public sealed class ProductionTemplateService : IProductionTemplateService
IRepository templates, IRepository stages,
IRepository inputs, IRepository outputs, IRepository edges,
IRepository items, IRepository uoms, IRepository 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
diff --git a/Backend/ERPCore/Services/PurchaseOrderService.cs b/Backend/ERPCore/Services/PurchaseOrderService.cs
index 25c114e..0f2d8aa 100644
--- a/Backend/ERPCore/Services/PurchaseOrderService.cs
+++ b/Backend/ERPCore/Services/PurchaseOrderService.cs
@@ -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 _items;
private readonly IRepository _uoms;
private readonly IRepository _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 pos, IRepository vendors, IRepository requisitions,
IRepository items, IRepository uoms, IRepository 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()
+ ///
+ /// 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.
+ ///
+ private async Task> ToLinesAsync(IReadOnlyCollection 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(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 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());
}
diff --git a/Backend/ERPCore/Services/SalesInvoiceService.cs b/Backend/ERPCore/Services/SalesInvoiceService.cs
index 61d9e0e..7a7ecfd 100644
--- a/Backend/ERPCore/Services/SalesInvoiceService.cs
+++ b/Backend/ERPCore/Services/SalesInvoiceService.cs
@@ -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 invoices, IRepository customers, IRepository items,
IRepository uoms, IRepository 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,
diff --git a/Backend/ERPCore/Services/SalesPostingService.cs b/Backend/ERPCore/Services/SalesPostingService.cs
index 1639b55..d7de06c 100644
--- a/Backend/ERPCore/Services/SalesPostingService.cs
+++ b/Backend/ERPCore/Services/SalesPostingService.cs
@@ -20,10 +20,11 @@ public sealed class SalesPostingService : ISalesPostingService
private readonly IRepository _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 invoices,
IRepository slips,
@@ -31,7 +32,6 @@ public sealed class SalesPostingService : ISalesPostingService
IRepository 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);
+ ///
+ /// A line reduced to what posting needs. Every quantity here is in the item's base
+ /// 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.
+ ///
+ private sealed record PostingLine(int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty);
}
diff --git a/Backend/ERPCore/Services/SalesSlipService.cs b/Backend/ERPCore/Services/SalesSlipService.cs
index 9e8074d..2fb96aa 100644
--- a/Backend/ERPCore/Services/SalesSlipService.cs
+++ b/Backend/ERPCore/Services/SalesSlipService.cs
@@ -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 slips, IRepository customers, IRepository items,
IRepository uoms, IRepository warehouses, IRepository 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,
diff --git a/Backend/ERPCore/Services/Stock/StockService.cs b/Backend/ERPCore/Services/Stock/StockService.cs
index 1d9e5fb..9e6b702 100644
--- a/Backend/ERPCore/Services/Stock/StockService.cs
+++ b/Backend/ERPCore/Services/Stock/StockService.cs
@@ -14,17 +14,32 @@ public sealed class StockService : IStockService
private readonly IRepository _layers;
private readonly IRepository _ledger;
private readonly IRepository _transferLines;
+ private readonly IRepository _items;
public StockService(
IFifoCostingService fifo, IRepository layers,
- IRepository ledger, IRepository transferLines)
+ IRepository ledger, IRepository transferLines,
+ IRepository items)
{
_fifo = fifo;
_layers = layers;
_ledger = ledger;
_transferLines = transferLines;
+ _items = items;
}
+ ///
+ /// 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.
+ ///
+ private async Task> BaseUomsAsync(
+ IReadOnlyCollection 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 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);
}
///
@@ -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.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.Create(rows, query.Page, query.PageSize, total);
}
- public Task GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
- => _fifo.GetValuationAsync(itemId, warehouseId, ct);
+ public async Task 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 };
+ }
}
diff --git a/Backend/ERPCore/Services/Stock/UomConverter.cs b/Backend/ERPCore/Services/Stock/UomConverter.cs
index c9fcacc..bcbb6c4 100644
--- a/Backend/ERPCore/Services/Stock/UomConverter.cs
+++ b/Backend/ERPCore/Services/Stock/UomConverter.cs
@@ -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 GrnService.ToBaseAsync it was extracted from, so the GRN
/// receive path keeps costing exactly as before.
///
+///
+/// Conversions are one-directional by design: a row always reads
+/// FromUom → ToUom = item.BaseUomId, enforced on write by
+/// ItemService.UpdateUomConversionsAsync. 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.
+///
public sealed class UomConverter : IUomConverter
{
- private readonly IRepository _conversions;
+ /// Quantity columns are (18,4) across the model.
+ private const int QtyScale = 4;
- public UomConverter(IRepository conversions) => _conversions = conversions;
+ /// Unit-cost columns and uom_conversions.Factor are (18,6).
+ private const int CostScale = 6;
+
+ private readonly IRepository _conversions;
+ private readonly IRepository _items;
+ private readonly IRepository _uoms;
+
+ public UomConverter(IRepository conversions, IRepository items, IRepository 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 ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default)
=> (await ToBaseAsync(item, uomId, qty, 0m, ct)).QtyBase;
+
+ public async Task 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> 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ public static decimal ApplyFactor(decimal qty, decimal factor)
+ => Math.Round(qty * factor, QtyScale, MidpointRounding.AwayFromZero);
+
+ ///
+ /// Restates a base quantity in 's UOM. Display only —
+ /// this divides, so it can drift, and its result must never reach a stock or ledger
+ /// write. Conversion toward base is the authoritative direction.
+ ///
+ public static decimal FromBase(decimal qtyBase, decimal factor)
+ => factor == 0m ? 0m : Math.Round(qtyBase / factor, QtyScale, MidpointRounding.AwayFromZero);
+
+ ///
+ /// 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.
+ ///
+ private async Task 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);
+ }
}
diff --git a/Backend/smoke/__pycache__/run_all.cpython-313.pyc b/Backend/smoke/__pycache__/run_all.cpython-313.pyc
new file mode 100644
index 0000000..46b9491
Binary files /dev/null and b/Backend/smoke/__pycache__/run_all.cpython-313.pyc differ
diff --git a/Backend/smoke/__pycache__/uom_direction.cpython-313.pyc b/Backend/smoke/__pycache__/uom_direction.cpython-313.pyc
new file mode 100644
index 0000000..c70b294
Binary files /dev/null and b/Backend/smoke/__pycache__/uom_direction.cpython-313.pyc differ
diff --git a/Backend/smoke/__pycache__/uom_grn_po_cross.cpython-313.pyc b/Backend/smoke/__pycache__/uom_grn_po_cross.cpython-313.pyc
new file mode 100644
index 0000000..d5409bf
Binary files /dev/null and b/Backend/smoke/__pycache__/uom_grn_po_cross.cpython-313.pyc differ
diff --git a/Backend/smoke/__pycache__/uom_sales_nonbase.cpython-313.pyc b/Backend/smoke/__pycache__/uom_sales_nonbase.cpython-313.pyc
new file mode 100644
index 0000000..d9d230d
Binary files /dev/null and b/Backend/smoke/__pycache__/uom_sales_nonbase.cpython-313.pyc differ
diff --git a/Backend/smoke/run_all.py b/Backend/smoke/run_all.py
index ea6cb51..b863a32 100644
--- a/Backend/smoke/run_all.py
+++ b/Backend/smoke/run_all.py
@@ -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")
diff --git a/Backend/smoke/uom_direction.py b/Backend/smoke/uom_direction.py
new file mode 100644
index 0000000..0b9ba90
--- /dev/null
+++ b/Backend/smoke/uom_direction.py
@@ -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())
diff --git a/Backend/smoke/uom_grn_po_cross.py b/Backend/smoke/uom_grn_po_cross.py
new file mode 100644
index 0000000..42ea566
--- /dev/null
+++ b/Backend/smoke/uom_grn_po_cross.py
@@ -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())
diff --git a/Backend/smoke/uom_sales_nonbase.py b/Backend/smoke/uom_sales_nonbase.py
new file mode 100644
index 0000000..cbf1c44
--- /dev/null
+++ b/Backend/smoke/uom_sales_nonbase.py
@@ -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())
diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx
index 3c928ab..677f7db 100644
--- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx
+++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx
@@ -7,6 +7,7 @@ import { AlertTriangle, ArrowLeft, Ban, Check, Plus, Save, Trash2 } from "lucide
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
+import { uomName } from "@/lib/uom"
import { uomsApi } from "@/lib/api/uoms"
import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage } from "@/lib/error-map"
@@ -111,8 +112,8 @@ export default function PurchaseOrderDetailPage() {
function itemFor(itemId: number | null) {
return items.find((i) => i.itemId === itemId) ?? null
}
- function uomName(uomId: number) {
- return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
+ function uomLabel(uomId: number) {
+ return uomName(uomId, uoms)
}
function warehouseCode(warehouseId: number) {
return warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? `#${warehouseId}`
@@ -376,7 +377,7 @@ export default function PurchaseOrderDetailPage() {
return (
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
- {line.uomId ? uomName(line.uomId) : "—"}
+ {line.uomId ? uomLabel(line.uomId) : "—"}{line.warehouseId ? warehouseCode(line.warehouseId) : "—"}{line.qty}{line.qtyReceived}
diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx
index 03bf970..3e9f9ab 100644
--- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx
+++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx
@@ -14,6 +14,8 @@ import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage } from "@/lib/error-map"
import { validatePoLine } from "@/lib/validations/procurement"
+import { pickerOptions } from "@/lib/uom"
+import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code"
import { CreatePoLineInput } from "@/types/procurement"
@@ -81,6 +83,7 @@ function NewPurchaseOrderContent() {
const [lineErrors, setLineErrors] = useState>>({})
const [submitError, setSubmitError] = useState(null)
const [submitting, setSubmitting] = useState(false)
+ const allowedUoms = useAllowedUoms()
const [vendorDialogOpen, setVendorDialogOpen] = useState(false)
const [vName, setVName] = useState("")
@@ -102,7 +105,8 @@ function NewPurchaseOrderContent() {
useEffect(() => {
Promise.all([
loadItems(),
- uomsApi.list().then((uo) => setUoms(uo.items)),
+ // pageSize: the default page would silently truncate the unit list as the master grows.
+ uomsApi.list({ pageSize: 200 }).then((uo) => setUoms(uo.items)),
warehousesApi.list().then((wh) => setWarehouses(wh.items)),
loadVendors(),
]).catch((err) => setLoadError(errorMessage(err)))
@@ -428,7 +432,17 @@ function NewPurchaseOrderContent() {