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() {
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
) : ( <> - value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> + + value={line.itemId} + onValueChange={(v) => { + // Reset to the item's base unit and load its allowed units. + allowedUoms.load(v) + updateLine(line.key, { + itemId: v, + uomId: (items ?? []).find((i) => i.itemId === v)?.baseUomId ?? null, + }) + }} + > - {(uoms ?? []).map((u) => ( + {pickerOptions(allowedUoms.get(line.itemId), uoms ?? []).map((u) => ( {u.name} diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx index 613f95b..ef1fa9b 100644 --- a/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx +++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx @@ -3,6 +3,8 @@ import { Plus, Trash2 } from "lucide-react" import { cn } from "@/lib/utils" +import { pickerOptions } from "@/lib/uom" +import { useAllowedUoms } from "@/hooks/use-allowed-uoms" import { CustomFieldType, StageInputSource } from "@/types/production" import { ItemListItem, Uom } from "@/types/master-data" import { @@ -46,7 +48,8 @@ function QtyRow({ }: { qty: number uomId: number | null - uoms: Uom[] + /** Already narrowed to the row's item by the caller — see `pickerOptions`. */ + uoms: { uomId: number; name: string }[] readOnly: boolean onQtyChange: (qty: number) => void onUomChange: (uomId: number) => void @@ -125,9 +128,12 @@ export function StageEditorPanel({ updateInput(localId, source === "Stock" ? { source, fromOutputKey: null } : { source, itemId: null }) } + const allowedUoms = useAllowedUoms() + /** Default the UOM to the item's base unit — right most of the time, still overridable. */ function pickInputItem(input: BuilderInput, itemId: number) { const item = items.find((i) => i.itemId === itemId) + allowedUoms.load(itemId) updateInput(input.localId, { itemId, uomId: input.uomId ?? item?.baseUomId ?? null }) } @@ -146,6 +152,7 @@ export function StageEditorPanel({ /** The terminal output's name mirrors the finished item, so the two can't drift apart. */ function pickOutputItem(output: BuilderOutput, itemId: number) { const item = items.find((i) => i.itemId === itemId) + allowedUoms.load(itemId) updateOutput(output.key, { itemId, name: item?.name ?? output.name, @@ -288,7 +295,7 @@ export function StageEditorPanel({ updateInput(input.localId, { qtyPerBatch })} onUomChange={(uomId) => updateInput(input.localId, { uomId })} @@ -348,7 +355,7 @@ export function StageEditorPanel({ updateOutput(output.key, { qtyPerBatch })} onUomChange={(uomId) => updateOutput(output.key, { uomId })} diff --git a/Frontend/erp-system/app/dashboard/products/[id]/UomConversionsPanel.tsx b/Frontend/erp-system/app/dashboard/products/[id]/UomConversionsPanel.tsx new file mode 100644 index 0000000..ceab110 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/products/[id]/UomConversionsPanel.tsx @@ -0,0 +1,193 @@ +"use client" + +// Per-item UOM conversion editor (FR-MD-02). +// +// Until this panel existed, `PUT /items/{id}/uom-conversions` had no caller anywhere in the +// app: conversions were typed, and had an API client method, but no screen could create one. +// That made every non-base UOM unusable — a user could pick "Box-12" on an invoice line and +// only discover at post time that the item had no conversion for it. +// +// Direction is fixed and not user-editable: a row always converts **into** the item's base +// UOM. The server enforces that (`toUom` must equal `baseUomId`), because the conversion +// engine only ever looks up ` → base` and never inverts a factor. Rendering `toUom` +// as fixed text rather than a second picker is what keeps the two in step. +import { useState } from "react" +import { Plus, Save, Trash2 } from "lucide-react" + +import { itemsApi } from "@/lib/api/items" +import { errorMessage } from "@/lib/error-map" +import { validateConversionLine } from "@/lib/validations/master-data" +import { UomConversion } from "@/types/master-data" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { FieldError } from "@/components/ui/field" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +interface DraftRow { + key: string + fromUom: number | null + factor: string +} + +interface Props { + itemId: number + baseUomId: number + conversions: UomConversion[] + uoms: { uomId: number; name: string }[] + /** Lets the parent refresh the item so `conversions` stays in sync after a save. */ + onSaved: () => void +} + +let rowSeq = 0 +const nextKey = () => `conv-${rowSeq++}` + +export function UomConversionsPanel({ itemId, baseUomId, conversions, uoms, onSaved }: Props) { + const [rows, setRows] = useState(() => + conversions.map((c) => ({ key: nextKey(), fromUom: c.fromUom, factor: String(c.factor) })), + ) + const [errors, setErrors] = useState>>({}) + const [saving, setSaving] = useState(false) + + const baseName = uoms.find((u) => u.uomId === baseUomId)?.name ?? "base UOM" + // The base UOM converts to itself implicitly; offering it here would only produce a 422. + const selectableUoms = uoms.filter((u) => u.uomId !== baseUomId) + + function updateRow(key: string, patch: Partial) { + setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...patch } : r))) + } + + function addRow() { + setRows((prev) => [...prev, { key: nextKey(), fromUom: null, factor: "" }]) + } + + function removeRow(key: string) { + setRows((prev) => prev.filter((r) => r.key !== key)) + } + + async function handleSave() { + const nextErrors: Record> = {} + for (const row of rows) { + const rowErrors = validateConversionLine({ fromUom: row.fromUom, toUom: baseUomId, factor: row.factor }) + if (Object.keys(rowErrors).length > 0) nextErrors[row.key] = rowErrors + } + + // The unique index is on (item, from, to); catching it here beats a 400 from the server. + const chosen = rows.map((r) => r.fromUom).filter((u): u is number => u !== null) + const duplicates = chosen.filter((u, i) => chosen.indexOf(u) !== i) + for (const row of rows) { + if (row.fromUom !== null && duplicates.includes(row.fromUom)) { + nextErrors[row.key] = { ...nextErrors[row.key], fromUom: "One conversion per unit" } + } + } + + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSaving(true) + try { + // A full replace, matching the server's upsert semantics: an empty list clears them all, + // which is also how a user detaches conversions before changing the base UOM. + await itemsApi.updateUomConversions(itemId, { + conversions: rows.map((r) => ({ fromUom: r.fromUom as number, toUom: baseUomId, factor: Number(r.factor) })), + }) + toast.success("Conversions saved", `${rows.length} conversion(s) against ${baseName}`) + onSaved() + } catch (err) { + toast.error("Could not save conversions", errorMessage(err)) + } finally { + setSaving(false) + } + } + + return ( +
+
+ +

+ Units this item can be bought, sold, or produced in besides {baseName}. Each row says how many{" "} + {baseName} one of that unit is worth — a Box of 12 pieces is a factor of 12. Stock is always stored in{" "} + {baseName}. +

+
+ + {rows.length === 0 ? ( +

+ No conversions — this item can only be transacted in {baseName}. +

+ ) : ( + + + + Unit + Factor + Converts to + + + + + {rows.map((row) => ( + + + + value={row.fromUom} + onValueChange={(v) => updateRow(row.key, { fromUom: v })} + items={selectableUoms.map((u) => ({ label: u.name, value: u.uomId }))} + > + + + + + {selectableUoms.map((u) => ( + + {u.name} + + ))} + + + + + + updateRow(row.key, { factor: e.target.value })} + aria-invalid={!!errors[row.key]?.factor} + className="text-right" + /> + + + + {row.factor && Number(row.factor) > 0 + ? `1 ${uoms.find((u) => u.uomId === row.fromUom)?.name ?? "unit"} = ${Number(row.factor)} ${baseName}` + : baseName} + + + + + + ))} + +
+ )} + +
+ + +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx index cc34d0e..d4ddd82 100644 --- a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx @@ -11,9 +11,12 @@ import { uomsApi } from "@/lib/api/uoms" import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage, fieldErrors } from "@/lib/error-map" import { validateItemForm } from "@/lib/validations/master-data" +import { uomName } from "@/lib/uom" import { cn } from "@/lib/utils" import { Item, StockNature, TrackingMode } from "@/types/master-data" +import { UomConversionsPanel } from "./UomConversionsPanel" + import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -92,7 +95,9 @@ export default function ItemDetailPage() { useEffect(() => { if (!Number.isFinite(itemId)) return load() - Promise.all([categoriesApi.list(), uomsApi.list(), warehousesApi.list({ pageSize: 200 })]) + // pageSize matters here: the default page would silently truncate the UOM list as the + // master grows, hiding units from the base-UOM picker and the conversions editor. + Promise.all([categoriesApi.list(), uomsApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })]) .then(([cat, uo, wh]) => { setCategories(cat.items) setUoms(uo.items) @@ -155,9 +160,6 @@ export default function ItemDetailPage() { } } - function uomName(uomId: number) { - return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}` - } if (loadError && !item) { return ( @@ -321,8 +323,19 @@ export default function ItemDetailPage() {

- {uomName(item.baseUomId)} is the base UOM — every transaction converts to and stores quantities in base UOM (FR-MD-03). + {uomName(item.baseUomId, uoms)} is the base UOM — every transaction converts to and stores quantities in base UOM (FR-MD-03).

+ + `${c.conversionId}:${c.factor}`).join("|")} + itemId={item.itemId} + baseUomId={item.baseUomId} + conversions={item.conversions} + uoms={uoms} + onSaved={load} + /> ) } diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx index 4d66b46..c5853c9 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx @@ -8,6 +8,7 @@ import { CheckCircle2, PackageCheck, PackageX, ShieldAlert, XCircle } from "luci import { grnsApi } from "@/lib/api/grns" import { warehousesApi } from "@/lib/api/warehouses" import { itemsApi } from "@/lib/api/items" +import { uomName } from "@/lib/uom" import { uomsApi } from "@/lib/api/uoms" import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" @@ -57,7 +58,7 @@ export default function GrnDetailPage() { return items.find((i) => i.itemId === itemId) } function uomFor(uomId: number) { - return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}` + return uomName(uomId, uoms) } function binFor(binId: number | null) { if (!binId) return "—" diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx index fa9ff39..b88e840 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx @@ -13,6 +13,8 @@ import { itemsApi } from "@/lib/api/items" import { uomsApi } from "@/lib/api/uoms" import { errorMessage } from "@/lib/error-map" import { validateLine, splitSerials, grnHeaderSchema } from "@/lib/validations/grn" +import { basePreview, pickerOptions, uomName } from "@/lib/uom" +import { useAllowedUoms } from "@/hooks/use-allowed-uoms" import { cn } from "@/lib/utils" import { CreateGrnLineInput, HoldStatus } from "@/types/grn" import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement" @@ -108,12 +110,14 @@ export default function NewGrnPage() { const [submitError, setSubmitError] = useState(null) const [submitting, setSubmitting] = useState(false) const [refreshingItems, setRefreshingItems] = useState(false) + const allowedUoms = useAllowedUoms() useEffect(() => { Promise.all([ warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" }), - uomsApi.list(), + // pageSize: without it the default page silently truncates the unit list. + uomsApi.list({ pageSize: 200 }), vendorsApi.list({ pageSize: 200, status: "Active" }), purchaseOrdersApi.list({ pageSize: 200 }), ]) @@ -155,13 +159,18 @@ export default function NewGrnPage() { setHeaderError(null) try { const po: PurchaseOrder = await purchaseOrdersApi.get(nextPoId) - const openLines = po.lines.filter((l) => l.qtyReceived < l.qty) + // Open-ness is decided on the base pair, matching the server's close condition — + // qty/qtyReceived are PO-UOM display figures derived by division and can drift. + const openLines = po.lines.filter((l) => l.qtyReceivedBase < l.qtyBase) if (openLines.length === 0) { setHeaderError("This purchase order has no open (unreceived) lines.") setLines([]) return } setWarehouseId((prev) => prev ?? openLines[0].warehouseId) + // The receiving lines keep the PO's UOM, so preload each item's allowed units for the + // few lines a user may switch to a different pack size. + openLines.forEach((l) => allowedUoms.load(l.itemId)) setLines( openLines.map( (l): DraftLine => ({ @@ -469,7 +478,16 @@ export default function NewGrnPage() { ) : ( <> - value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> + + value={line.itemId} + onValueChange={(v) => { + // Reset the unit to the item's base and load its allowed + // units — a UOM carried over from the previous item would + // usually have no conversion for the new one. + allowedUoms.load(v) + updateLine(line.key, { itemId: v, uomId: itemFor(v)?.baseUomId ?? null }) + }} + > @@ -488,7 +506,7 @@ export default function NewGrnPage() { {line.poLineId ? (
- {uoms?.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`} + {uomName(line.uomId, uoms ?? [])}
) : ( <> @@ -497,7 +515,7 @@ export default function NewGrnPage() { - {(uoms ?? []).map((u) => ( + {pickerOptions(allowedUoms.get(line.itemId), uoms ?? []).map((u) => ( {u.name} @@ -532,6 +550,12 @@ export default function NewGrnPage() { onChange={(e) => updateLine(line.key, { qty: e.target.value })} className="h-11 text-base" /> + {/* Shows what will actually hit stock when receiving in a pack unit. */} + {basePreview(Number(line.qty), line.uomId, allowedUoms.get(line.itemId) ?? []) && ( +

+ {basePreview(Number(line.qty), line.uomId, allowedUoms.get(line.itemId) ?? [])} +

+ )}
diff --git a/Frontend/erp-system/app/dashboard/sales/bundles/[id]/page.tsx b/Frontend/erp-system/app/dashboard/sales/bundles/[id]/page.tsx index c22f692..14ecae5 100644 --- a/Frontend/erp-system/app/dashboard/sales/bundles/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/bundles/[id]/page.tsx @@ -9,6 +9,8 @@ import { bundleApi } from "@/lib/api/bundles" import { itemsApi } from "@/lib/api/items" import { uomsApi } from "@/lib/api/uoms" import { errorMessage } from "@/lib/error-map" +import { basePreview, pickerOptions } from "@/lib/uom" +import { useAllowedUoms } from "@/hooks/use-allowed-uoms" import { Button, buttonVariants } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { Input } from "@/components/ui/input" @@ -55,6 +57,7 @@ export default function BundleSaleDetailPage() { const bundleSaleId = Number(params.id) const [bundle, setBundle] = useState(null) const [editing, setEditing] = useState(false) + const allowedUoms = useAllowedUoms() const [templates, setTemplates] = useState([]) const [template, setTemplate] = useState(null) const [customers, setCustomers] = useState([]) @@ -102,12 +105,16 @@ export default function BundleSaleDetailPage() { setTemplateId(data.bundleSaleTemplateId) setBundleName(data.bundleName) setBundlePrice(data.bundlePrice) + data.lines.forEach((line) => allowedUoms.load(line.itemId)) setLines( data.lines.map((line) => ({ key: `${line.bundleSaleLineId}`, bundleSaleTemplateLineId: line.bundleSaleLineId, itemId: line.itemId, - uomId: items.find((candidate) => candidate.itemId === line.itemId)?.baseUomId ?? line.uomId, + // Keep the UOM the line was saved with. This used to be forced back to the + // item's base because the server rewrote it that way; it now preserves what + // was entered, so overriding here would discard the user's choice. + uomId: line.uomId, warehouseId: line.warehouseId, qty: line.qty, unitPrice: line.unitPrice, @@ -377,6 +384,7 @@ export default function BundleSaleDetailPage() { - updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled={!editing || !isDraft || !line.itemId}> - {uoms.map((uom) => ( + {pickerOptions(allowedUoms.get(line.itemId), uoms).map((uom) => ( {uom.name} @@ -405,7 +415,12 @@ export default function BundleSaleDetailPage() { - updateLine(line.key, { qty: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /> + + updateLine(line.key, { qty: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /> + {basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? []) && ( +

{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])}

+ )} +
updateLine(line.key, { unitPrice: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /> {editing && isDraft ? ( diff --git a/Frontend/erp-system/app/dashboard/sales/bundles/new/page.tsx b/Frontend/erp-system/app/dashboard/sales/bundles/new/page.tsx index ce49664..b2e2404 100644 --- a/Frontend/erp-system/app/dashboard/sales/bundles/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/bundles/new/page.tsx @@ -12,6 +12,8 @@ import { uomsApi } from "@/lib/api/uoms" import { warehousesApi } from "@/lib/api/warehouses" import { usersApi } from "@/lib/api/users" import { errorMessage } from "@/lib/error-map" +import { basePreview, pickerOptions } from "@/lib/uom" +import { useAllowedUoms } from "@/hooks/use-allowed-uoms" import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" @@ -61,6 +63,7 @@ function NewBundleSaleContent() { const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [submitError, setSubmitError] = useState(null) + const allowedUoms = useAllowedUoms() useEffect(() => { Promise.all([ @@ -94,11 +97,12 @@ function NewBundleSaleContent() { setLines( res.lines.length > 0 ? res.lines.map((line) => { - const item = items.find((candidate) => candidate.itemId === line.itemId) - return createBlankLine({ - ...line, - uomId: item?.baseUomId ?? line.uomId, - }) + // Load each component's allowed units so the picker narrows for template-seeded rows too. + allowedUoms.load(line.itemId) + // Keep the template's own UOM. Forcing it to the item's base while keeping the + // template's unitPrice would leave qty and price in different units, and + // componentSubtotal below multiplies the two. + return createBlankLine({ ...line, uomId: line.uomId }) }) : [createBlankLine()] ) @@ -259,6 +263,7 @@ function NewBundleSaleContent() { - updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled={!line.itemId}> - {uoms.map((uom) => ( + {pickerOptions(allowedUoms.get(line.itemId), uoms).map((uom) => ( {uom.name} @@ -291,7 +298,14 @@ function NewBundleSaleContent() { - updateLine(line.key, { qty: Number(e.target.value) })} className="text-right" /> + + updateLine(line.key, { qty: Number(e.target.value) })} className="text-right" /> + {basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? []) && ( +

+ {basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])} +

+ )} +
updateLine(line.key, { unitPrice: Number(e.target.value) })} className="text-right" /> diff --git a/Frontend/erp-system/app/dashboard/sales/slips/new/page.tsx b/Frontend/erp-system/app/dashboard/sales/slips/new/page.tsx index b7a2403..42efcb1 100644 --- a/Frontend/erp-system/app/dashboard/sales/slips/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/slips/new/page.tsx @@ -12,6 +12,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Badge } from "@/components/ui/badge" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { cn } from "@/lib/utils" +import { basePreview, pickerOptions } from "@/lib/uom" +import { useAllowedUoms } from "@/hooks/use-allowed-uoms" import { getSuggestedUnitPrice } from "@/lib/sales-line-utils" import { errorMessage } from "@/lib/error-map" import { salesApi } from "@/lib/api/sales" @@ -65,6 +67,7 @@ export default function NewSalesSlipPage() { const [lines, setLines] = useState([blankLine("line-1")]) const [loading, setLoading] = useState(true) const [submitError, setSubmitError] = useState(null) + const allowedUoms = useAllowedUoms() const [saving, setSaving] = useState(false) useEffect(() => { @@ -104,6 +107,8 @@ export default function NewSalesSlipPage() { function selectItem(key: string, itemId: number) { const item = items.find((candidate) => candidate.itemId === itemId) + // Narrow the UOM picker to units this item has a conversion for. + allowedUoms.load(itemId) updateLine(key, { itemId, uomId: item?.baseUomId ?? 0, @@ -334,7 +339,7 @@ export default function NewSalesSlipPage() { - {uoms.map((u) => ( + {pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => ( {u.name} @@ -351,6 +356,12 @@ export default function NewSalesSlipPage() { onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" /> + {/* Makes the conversion visible while editing instead of at post time. */} + {basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? []) && ( +

+ {basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])} +

+ )} {item?.name} {wh?.code ?? `#${row.warehouseId}`} - {row.onHand} - {row.available} - {row.onHold} - {row.inTransit} - {row.reserved} + {/* Every figure here is base UOM; the label is what tells the user which. */} + {formatQtyWithName(row.onHand, row.baseUomName)} + {formatQtyWithName(row.available, row.baseUomName)} + {formatQtyValue(row.onHold)} + {formatQtyValue(row.inTransit)} + {formatQtyValue(row.reserved)} - {entry.qtyBase} + {formatQtyWithName(entry.qtyBase, entry.baseUomName)} {entry.unitCost.toFixed(2)} {entry.value.toFixed(2)} - {entry.runningBalance} + {formatQtyWithName(entry.runningBalance, entry.baseUomName)} {entry.sourceDocType} #{entry.sourceDocId} diff --git a/Frontend/erp-system/app/dashboard/stock/reorder-alerts/page.tsx b/Frontend/erp-system/app/dashboard/stock/reorder-alerts/page.tsx index 0fd1441..179dc01 100644 --- a/Frontend/erp-system/app/dashboard/stock/reorder-alerts/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/reorder-alerts/page.tsx @@ -6,9 +6,11 @@ import { AlertTriangle, CheckCircle2 } from "lucide-react" import { stockApi } from "@/lib/api/stock" import { itemsApi } from "@/lib/api/items" import { warehousesApi } from "@/lib/api/warehouses" +import { uomsApi } from "@/lib/api/uoms" import { errorMessage } from "@/lib/error-map" +import { formatQty } from "@/lib/uom" import { ReorderAlert } from "@/types/stock" -import { ItemListItem, Warehouse } from "@/types/master-data" +import { ItemListItem, Uom, Warehouse } from "@/types/master-data" import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" @@ -19,16 +21,23 @@ export default function ReorderAlertsPage() { const [alerts, setAlerts] = useState(null) const [items, setItems] = useState(null) const [warehouses, setWarehouses] = useState(null) + const [uoms, setUoms] = useState([]) const [error, setError] = useState(null) const [requesting, setRequesting] = useState(null) const [requested, setRequested] = useState>(new Set()) useEffect(() => { - Promise.all([stockApi.reorderAlerts(), itemsApi.list({ pageSize: 200 }), warehousesApi.list()]) - .then(([a, it, wh]) => { + Promise.all([ + stockApi.reorderAlerts(), + itemsApi.list({ pageSize: 200 }), + warehousesApi.list(), + uomsApi.list({ pageSize: 200 }), + ]) + .then(([a, it, wh, uo]) => { setAlerts(a.items) setItems(it.items) setWarehouses(wh.items) + setUoms(uo.items) }) .catch((err) => setError(errorMessage(err))) }, []) @@ -44,7 +53,10 @@ export default function ReorderAlertsPage() { const res = await stockApi.createReorderRequisition(alert.itemId, alert.warehouseId) setRequested((prev) => new Set(prev).add(key)) const qty = res.lines[0]?.qty ?? alert.suggestedRequisitionQty - toast.success("Requisition created", `${res.docNo} for ${qty} units.`) + // Requisitions are base-UOM only, so name the item's actual base unit rather than + // the placeholder word "units". + const uom = itemsById.get(alert.itemId)?.baseUomId + toast.success("Requisition created", `${res.docNo} for ${formatQty(qty, uom ?? null, uoms)}.`) } catch (err) { toast.error("Could not create requisition", errorMessage(err)) } finally { diff --git a/Frontend/erp-system/app/print/sales/invoices/[id]/page.tsx b/Frontend/erp-system/app/print/sales/invoices/[id]/page.tsx index 629418d..d058a51 100644 --- a/Frontend/erp-system/app/print/sales/invoices/[id]/page.tsx +++ b/Frontend/erp-system/app/print/sales/invoices/[id]/page.tsx @@ -6,6 +6,7 @@ import { Printer } from "lucide-react" import { salesApi } from "@/lib/api/sales" import { customersApi } from "@/lib/api/customers" import { itemsApi } from "@/lib/api/items" +import { uomName } from "@/lib/uom" import { uomsApi } from "@/lib/api/uoms" import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage } from "@/lib/error-map" @@ -131,7 +132,7 @@ export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id
{line.description}
SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}
- {uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`} + {uomName(line.uomId, uoms)} {line.qty.toFixed(2)} {line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"} {line.unitPrice.toFixed(2)} diff --git a/Frontend/erp-system/app/print/sales/slips/[id]/page.tsx b/Frontend/erp-system/app/print/sales/slips/[id]/page.tsx index 24ca5db..d0a8292 100644 --- a/Frontend/erp-system/app/print/sales/slips/[id]/page.tsx +++ b/Frontend/erp-system/app/print/sales/slips/[id]/page.tsx @@ -6,6 +6,7 @@ import { Printer } from "lucide-react" import { salesApi } from "@/lib/api/sales" import { customersApi } from "@/lib/api/customers" import { itemsApi } from "@/lib/api/items" +import { uomName } from "@/lib/uom" import { uomsApi } from "@/lib/api/uoms" import { warehousesApi } from "@/lib/api/warehouses" import { usersApi } from "@/lib/api/users" @@ -123,7 +124,7 @@ export default function SalesSlipPrintPage({ params }: { params: Promise<{ id: s
{line.description}
SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}
- {uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`} + {uomName(line.uomId, uoms)} {line.qty.toFixed(0)} {line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"} {line.unitPrice.toFixed(2)} diff --git a/Frontend/erp-system/hooks/use-allowed-uoms.ts b/Frontend/erp-system/hooks/use-allowed-uoms.ts new file mode 100644 index 0000000..d778b1c --- /dev/null +++ b/Frontend/erp-system/hooks/use-allowed-uoms.ts @@ -0,0 +1,73 @@ +"use client" + +// Per-item allowed-UOM lookup for document line forms. +// +// Every line on a document can reference a different item, so this caches by itemId and +// fetches each one once. A naive refetch on every line edit would cost one request per line +// on a 20-line invoice; the cache keeps it to one request per distinct item. +import { useCallback, useEffect, useRef, useState } from "react" + +import { itemsApi } from "@/lib/api/items" +import { AllowedUom } from "@/types/master-data" + +export interface AllowedUomLookup { + /** Allowed units for an item, or `undefined` while it is still loading / unknown. */ + get: (itemId: number | null | undefined) => AllowedUom[] | undefined + /** Ensure an item's units are loaded. Safe to call repeatedly; in-flight requests are shared. */ + load: (itemId: number | null | undefined) => void +} + +export function useAllowedUoms(): AllowedUomLookup { + const [cache, setCache] = useState>({}) + // Tracks in-flight and failed ids so a repeated render never re-issues the same request. + const pending = useRef>(new Set()) + + const load = useCallback((itemId: number | null | undefined) => { + if (!itemId || pending.current.has(itemId)) return + pending.current.add(itemId) + itemsApi + .allowedUoms(itemId) + .then((uoms) => setCache((prev) => ({ ...prev, [itemId]: uoms }))) + .catch(() => { + // Leave the id marked so we don't hammer a failing endpoint. The picker falls back + // to the global UOM list (see `pickerOptions`), and the server still rejects an + // invalid unit on save — this is a degraded experience, not a correctness hole. + }) + }, []) + + const get = useCallback( + (itemId: number | null | undefined) => (itemId ? cache[itemId] : undefined), + [cache], + ) + + return { get, load } +} + +/** + * Single-item variant for screens with one item in scope (the conversion editor, a stock + * enquiry filtered to one item). + */ +export function useItemAllowedUoms(itemId: number | null | undefined): AllowedUom[] { + const [uoms, setUoms] = useState([]) + + useEffect(() => { + if (!itemId) { + setUoms([]) + return + } + let cancelled = false + itemsApi + .allowedUoms(itemId) + .then((res) => { + if (!cancelled) setUoms(res) + }) + .catch(() => { + if (!cancelled) setUoms([]) + }) + return () => { + cancelled = true + } + }, [itemId]) + + return uoms +} diff --git a/Frontend/erp-system/lib/api/items.ts b/Frontend/erp-system/lib/api/items.ts index a1471e4..8132772 100644 --- a/Frontend/erp-system/lib/api/items.ts +++ b/Frontend/erp-system/lib/api/items.ts @@ -3,6 +3,7 @@ import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client" import { ApiResult, EntityStatus, PagedResponse } from "@/types/common" import { + AllowedUom, CreateItemRequest, Item, ItemListItem, @@ -60,10 +61,20 @@ export const itemsApi = { }) }, + /** + * Replace the item's conversions. Every row must be ` → baseUomId` with a factor + * greater than zero; the reverse direction, a self-conversion, or a row from the base UOM + * is rejected with 422 (the engine only ever looks up ` → base` and never inverts). + */ updateUomConversions(itemId: number, request: UpdateUomConversionsRequest): Promise { return apiRequest(`/items/${itemId}/uom-conversions`, { method: "PUT", body: request, }) }, + + /** The UOMs this item can be transacted in — base UOM first, then each conversion source. */ + allowedUoms(itemId: number): Promise { + return apiRequest(`/items/${itemId}/uoms`) + }, } diff --git a/Frontend/erp-system/lib/uom.ts b/Frontend/erp-system/lib/uom.ts new file mode 100644 index 0000000..c8b71fe --- /dev/null +++ b/Frontend/erp-system/lib/uom.ts @@ -0,0 +1,94 @@ +// The single place the UI resolves and renders units of measure. +// +// Before this module, ~10 screens each re-declared their own inline `uoms.find(...)` under +// three different names, with two different unknown-UOM fallbacks (`#12` vs `UOM 12`), and +// there was no quantity formatter anywhere. Everything unit-shaped goes through here now. +// +// The one rule these helpers encode: **conversion toward base is authoritative.** The server +// stores stock, layers and the ledger exclusively in an item's base UOM, and resolves the +// base quantity when a document line is saved. `toBase` below exists to *preview* that for +// the user at entry time; it is never the source of what gets posted. +import { AllowedUom, Uom } from "@/types/master-data" + +/** Shown when a uomId has no matching row — one fallback across the whole app. */ +const UNKNOWN_UOM = "—" + +/** Quantities are `decimal(18,4)` server-side; trailing zeros are noise in a table. */ +const QTY_FORMATTER = new Intl.NumberFormat("en-US", { + minimumFractionDigits: 0, + maximumFractionDigits: 4, +}) + +/** `12` -> "PCS". Accepts any list with `uomId`/`name`, so `Uom[]` and `AllowedUom[]` both work. */ +export function uomName( + uomId: number | null | undefined, + uoms: readonly { uomId: number; name: string }[], +): string { + if (uomId === null || uomId === undefined || uomId === 0) return UNKNOWN_UOM + return uoms.find((u) => u.uomId === uomId)?.name ?? UNKNOWN_UOM +} + +/** `1234.5` -> "1,234.5". Quantity-specific: unlike `formatAmount` it does not force 2dp. */ +export function formatQtyValue(qty: number | null | undefined): string { + if (qty === null || qty === undefined || Number.isNaN(qty)) return "—" + return QTY_FORMATTER.format(qty) +} + +/** + * `(24, 12, uoms)` -> "24 PCS". The formatter every screen showing a quantity should use — + * a bare number leaves the user guessing which unit a figure is in. + */ +export function formatQty( + qty: number | null | undefined, + uomId: number | null | undefined, + uoms: readonly { uomId: number; name: string }[], +): string { + const value = formatQtyValue(qty) + const unit = uomName(uomId, uoms) + return unit === UNKNOWN_UOM ? value : `${value} ${unit}` +} + +/** Convenience for stock screens, whose DTOs carry `baseUomName` directly from the server. */ +export function formatQtyWithName(qty: number | null | undefined, uomName: string | null | undefined): string { + const value = formatQtyValue(qty) + return uomName ? `${value} ${uomName}` : value +} + +/** + * Converts an entered quantity to the item's base UOM, matching the server's arithmetic + * (multiply by the factor, round to 4dp). Display only — the authoritative base quantity is + * the one the server resolves and snapshots when the line is saved. + */ +export function toBase(qty: number, factor: number): number { + if (!Number.isFinite(qty) || !Number.isFinite(factor)) return 0 + return Math.round(qty * factor * 10_000) / 10_000 +} + +/** + * The hint rendered beside a quantity input: `"= 24 PCS"` when the chosen unit is not the + * item's base, and `null` when it is (a "= 24 PCS" next to "24 PCS" is just noise). + * + * Making the conversion visible at entry is the point — previously a user only discovered a + * unit mismatch as a 422 when they tried to post the finished document. + */ +export function basePreview( + qty: number | null | undefined, + uomId: number | null | undefined, + allowed: readonly AllowedUom[], +): string | null { + if (!qty || !uomId) return null + const selected = allowed.find((u) => u.uomId === uomId) + const base = allowed.find((u) => u.isBase) + if (!selected || !base || selected.isBase) return null + return `= ${formatQtyValue(toBase(qty, selected.factor))} ${base.name}` +} + +/** + * Falls back to the global UOM list while an item's allowed units are still loading (or when + * no item is chosen yet), so a picker never renders empty. Once `allowed` arrives it wins — + * that narrowing is the whole reason the endpoint exists. + */ +export function pickerOptions(allowed: readonly AllowedUom[] | undefined, all: readonly Uom[]): { uomId: number; name: string }[] { + if (allowed && allowed.length > 0) return allowed.map((u) => ({ uomId: u.uomId, name: u.name })) + return all.map((u) => ({ uomId: u.uomId, name: u.name })) +} diff --git a/Frontend/erp-system/types/master-data.ts b/Frontend/erp-system/types/master-data.ts index 98363da..5226f9f 100644 --- a/Frontend/erp-system/types/master-data.ts +++ b/Frontend/erp-system/types/master-data.ts @@ -33,6 +33,11 @@ export interface ItemReorderSetting { reorderQty: number } +/** + * A per-item conversion row. Always directional: `fromUom → toUom`, where `toUom` is + * required by the server to be the item's `baseUomId`. Quantity in `fromUom` × `factor` + * = quantity in base UOM. Saving one in the opposite direction is rejected with 422. + */ export interface UomConversion { conversionId: number fromUom: number @@ -40,6 +45,20 @@ export interface UomConversion { factor: number } +/** + * A UOM an item may actually be transacted in — from `GET /items/{itemId}/uoms`. + * + * Line forms must populate their UOM picker from this rather than the global `uoms` list: + * choosing a unit the item has no conversion for is rejected when the line is saved. + */ +export interface AllowedUom { + uomId: number + name: string + /** Multiply a quantity in this UOM by `factor` to get the item's base UOM. 1 for the base itself. */ + factor: number + isBase: boolean +} + /** * Full Item resource (docs/11 §2.1 `GET /items/{itemId}`). * diff --git a/Frontend/erp-system/types/procurement.ts b/Frontend/erp-system/types/procurement.ts index 5d95563..eacc22a 100644 --- a/Frontend/erp-system/types/procurement.ts +++ b/Frontend/erp-system/types/procurement.ts @@ -138,11 +138,23 @@ export interface PoLine { itemId: number uomId: number warehouseId: number + /** Ordered quantity, in `uomId`. */ qty: number unitPrice: number /** Rate, 0..1 (e.g. 0.18), not an amount. */ tax: number + /** + * Received to date, in `uomId`. **Display only** — the server derives it by dividing + * `qtyReceivedBase` by `conversionFactor`, so it can drift. Use the base pair below for + * any remaining/outstanding/closed logic. + */ qtyReceived: number + /** Ordered quantity in the item's base UOM — what over-receipt and PO closure compare on. */ + qtyBase: number + /** Received to date in the item's base UOM. Authoritative. */ + qtyReceivedBase: number + /** Factor used to derive the base pair from `uomId`; 1 when the line is in base UOM. */ + conversionFactor: number } export interface PoTotals { diff --git a/Frontend/erp-system/types/stock.ts b/Frontend/erp-system/types/stock.ts index 661d57d..4ed80c3 100644 --- a/Frontend/erp-system/types/stock.ts +++ b/Frontend/erp-system/types/stock.ts @@ -19,6 +19,9 @@ export interface OnHand { /** Always 0 in Phase 1 (sales reservation stub, FR-STK-11). */ reserved: number asOf: string + /** The item's base UOM — every quantity above is in it. For labelling, not conversion. */ + baseUomId: number + baseUomName: string } // --- 5.2 Ledger ------------------------------------------------------------------- @@ -41,6 +44,9 @@ export interface LedgerEntry { sourceDocId: number userId: number createdAt: string + /** The item's base UOM — `qtyBase` and `runningBalance` are in it. */ + baseUomId: number + baseUomName: string } // --- 5.3 Valuation ------------------------------------------------------------------