From 8d5a05a41939042753cff9a735da2e99e606b9e5 Mon Sep 17 00:00:00 2001 From: ImanThiyanga Date: Mon, 10 Aug 2026 15:12:51 +0530 Subject: [PATCH] Add smoke tests for UOM conversions and enhance frontend UOM management - Implemented smoke tests for UOM directionality, ensuring conversions are one-directional and correctly validated. - Added tests for receiving and selling items in different UOMs, verifying correct quantity handling and error responses. - Created a UOM conversions panel in the frontend to allow users to manage UOM conversions for items. - Introduced hooks for allowed UOMs to optimize fetching and caching of UOM data for document line forms. - Developed utility functions for consistent UOM formatting and conversion handling across the application. --- .../ERPCore/Controllers/ItemsController.cs | 19 +- .../ERPCore/Domain/Entities/BundleSaleLine.cs | 11 + Backend/ERPCore/Domain/Entities/GrnLine.cs | 12 ++ Backend/ERPCore/Domain/Entities/PoLine.cs | 24 +++ .../Domain/Entities/SalesInvoiceLine.cs | 14 ++ .../ERPCore/Domain/Entities/SalesSlipLine.cs | 14 ++ .../Dtos/Procurement/PurchaseOrderDtos.cs | 11 +- Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs | 3 +- .../ERPCore/Dtos/Sales/SalesInvoiceDtos.cs | 9 +- Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs | 4 +- Backend/ERPCore/Dtos/Stock/StockDtos.cs | 14 +- Backend/ERPCore/Dtos/Uoms/UomDtos.cs | 9 + .../BundleSaleLineConfiguration.cs | 2 + .../Configurations/GrnConfiguration.cs | 2 + .../PurchaseOrderConfiguration.cs | 3 + .../SalesInvoiceConfiguration.cs | 3 + .../Configurations/SalesSlipConfiguration.cs | 3 + .../UomConversionConfiguration.cs | 4 + Backend/ERPCore/Services/BundleSaleService.cs | 17 +- Backend/ERPCore/Services/GrnService.cs | 47 ++-- .../Services/Interfaces/IUomConverter.cs | 26 +++ Backend/ERPCore/Services/ItemService.cs | 29 +++ .../Production/ProductionTemplateService.cs | 18 +- .../ERPCore/Services/PurchaseOrderService.cs | 51 +++-- .../ERPCore/Services/SalesInvoiceService.cs | 13 +- .../ERPCore/Services/SalesPostingService.cs | 45 ++-- Backend/ERPCore/Services/SalesSlipService.cs | 11 +- .../ERPCore/Services/Stock/StockService.cs | 43 +++- .../ERPCore/Services/Stock/UomConverter.cs | 121 ++++++++++- .../smoke/__pycache__/run_all.cpython-313.pyc | Bin 0 -> 3873 bytes .../__pycache__/uom_direction.cpython-313.pyc | Bin 0 -> 5152 bytes .../uom_grn_po_cross.cpython-313.pyc | Bin 0 -> 8377 bytes .../uom_sales_nonbase.cpython-313.pyc | Bin 0 -> 12248 bytes Backend/smoke/run_all.py | 5 + Backend/smoke/uom_direction.py | 81 +++++++ Backend/smoke/uom_grn_po_cross.py | 127 +++++++++++ Backend/smoke/uom_sales_nonbase.py | 200 ++++++++++++++++++ .../procurement/purchase-orders/[id]/page.tsx | 7 +- .../procurement/purchase-orders/new/page.tsx | 20 +- .../templates/[id]/StageEditorPanel.tsx | 13 +- .../products/[id]/UomConversionsPanel.tsx | 193 +++++++++++++++++ .../app/dashboard/products/[id]/page.tsx | 23 +- .../app/dashboard/receiving/grn/[id]/page.tsx | 3 +- .../app/dashboard/receiving/grn/new/page.tsx | 34 ++- .../app/dashboard/sales/bundles/[id]/page.tsx | 23 +- .../app/dashboard/sales/bundles/new/page.tsx | 30 ++- .../dashboard/sales/free-issues/new/page.tsx | 11 +- .../dashboard/sales/invoices/[id]/page.tsx | 16 +- .../sales/invoices/[id]/print/page.tsx | 3 +- .../app/dashboard/sales/invoices/new/page.tsx | 14 +- .../app/dashboard/sales/slips/[id]/page.tsx | 19 +- .../app/dashboard/sales/slips/new/page.tsx | 13 +- .../app/dashboard/stock/enquiry/page.tsx | 12 +- .../app/dashboard/stock/ledger/page.tsx | 5 +- .../dashboard/stock/reorder-alerts/page.tsx | 20 +- .../app/print/sales/invoices/[id]/page.tsx | 3 +- .../app/print/sales/slips/[id]/page.tsx | 3 +- Frontend/erp-system/hooks/use-allowed-uoms.ts | 73 +++++++ Frontend/erp-system/lib/api/items.ts | 11 + Frontend/erp-system/lib/uom.ts | 94 ++++++++ Frontend/erp-system/types/master-data.ts | 19 ++ Frontend/erp-system/types/procurement.ts | 12 ++ Frontend/erp-system/types/stock.ts | 6 + 63 files changed, 1539 insertions(+), 136 deletions(-) create mode 100644 Backend/smoke/__pycache__/run_all.cpython-313.pyc create mode 100644 Backend/smoke/__pycache__/uom_direction.cpython-313.pyc create mode 100644 Backend/smoke/__pycache__/uom_grn_po_cross.cpython-313.pyc create mode 100644 Backend/smoke/__pycache__/uom_sales_nonbase.cpython-313.pyc create mode 100644 Backend/smoke/uom_direction.py create mode 100644 Backend/smoke/uom_grn_po_cross.py create mode 100644 Backend/smoke/uom_sales_nonbase.py create mode 100644 Frontend/erp-system/app/dashboard/products/[id]/UomConversionsPanel.tsx create mode 100644 Frontend/erp-system/hooks/use-allowed-uoms.ts create mode 100644 Frontend/erp-system/lib/uom.ts 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 0000000000000000000000000000000000000000..46b9491c3fefecbdc6ec3a22dadeff167bf77752 GIT binary patch literal 3873 zcmb^!O;8ls`SncqOw%(1vx17q>J!CfL}Y{D53X7$2#O;(&8P{)u4cNKX`SgFUw4B{ zwk(oMhV=ke?beRjgKTXo%O;1A+EncxbIcK5?rR9i^$3k| ziG~V>w`xLTp(fO|zV&Eel=i3nF~RQjA(XInY-LnDl`*m^&dItxA*U>xXnGo(Ipd~^ zOdTJUQL{Z5hLBR0kr>%^k<1=#VCS13>NbSVdB{`ctl%E!aAqotn zElVY4H_mn96j5bMHC@JI$NTqPi&3orpf0eu5g&eoKc0uj?xJPSm{ zau)PbrrYFvo*0v$(i?*OZRSHzS+<0MX*wnJBZUH0NG*x0z7kJcN z7ZHOx#R6f#^ut6Y>MdI{!8>5pzQpiZgQyrh(cRV2(dnA5$F?#9>h1F3k#{?A3y4LC zrhvbxu>eVO%`Wu}pU3UOhJA8m0Jm=dNQ!-le>)iN5yh)RiQd>D;Ta9E7~_OwT{l2H z4SaM7_Uy21Wnxt^UjfHEFjym(r>-_}D^3$RpTU!IR#RN~LQSqidJOUXZa_#t*E$fzMX{uQea)%{whgn8yk9XpAzk{WS1+Xat_^J@Y?RecYu(F7( zn-eN2)aE)RH%=sodn$REPE)*d94Ck3G~mEyCS?!5;H%V42%EfZnT4&n&T+{$a#97R zjEv)5RNeI`Bx*{f@zx5oAW-s#ku5}WUDEowhjOGfeiTA^!lFI`35hyokegI~Sx>3i z!tUG=DR)%zAOM}-OXb@#k$rCntZ2Z4Dn(;W)h7&+Qk6oKiv0SjjM+jlbwh6(mm%#; zISXD|PqlRm&=kF8O&97YwhJHuJOwXP*zNKrhH19hIs|in;?nErmGSUUp_U@1i7w?0 zDMjG`uh4wGWlU_1b>o&Xr8U;>j$ZhKFL0iwf|BwI8l9p3B0oaR%TB1gAED9p%81Y? zl(X{ZRl3A_ZT8OTtV6Zc8IjR_qXF-{7WFd&cM!6HLcVjf$~zZ*qQ4r6z9b#lBnu-) zPY&~9Knxy->OShe3lu&P_OL2*6B@1dc8RFBs?15Y-M0Z^liDxxB4W{HCJAo<{8Wj8 z7&;!%xeZu#6?)z(vE5r$<|LQc1k$q1X2aBZw;92df{KxqT<{YusqxK-KliuaXQ{`CZ5t$glj7{QK8_(G~%l z3)jAK=TI^Xe6B%f0yYz)LyY*A9@#*9OELnPRGajRbu~!bnhd~hTQW%Vsv>{9<2y2K zPYNz%2*&!uT+-($NPQ#j06qeZ8<#j zRfYmjz7v2K+8`zYozOFP0B@l}jUXn^kMv zvMt(7Ijl_5lTN_26j0gWrU7Oj( z{1yUX2Cao^KIHG7dJ@7bAw1K&S{;Irr@jT-rVxDROIkWZpM_Z2my$Ovx^# z7sSPFi~N#*X<(^)x%$-alt1u;Yc)IO?tE}(EfSr(K6|}%WjTEK=c&&#pJosR>tQr&}{_jf+xn^!|Mb0=m`JP9?f zgc_fOcCCbVt%YmmF3etd5^h`xHJfjMigkMSbV+_3+P%7U z$K7);_M(P8ul6HOD4r?aF5XzKk1e(=HY^LBkJzr&Jx$AO)6B)E`&w6cT;%5MRlaV8 z-&G8+^0g~`V==Va(73>S^3J`$!erUR{`tQ7qot-&a^Y}kaN(9~WM-*(Y5Q{Z@yC4k ziz?K7@RdN>4VJ?5cNQ)${AeM!bYfXJ{pbA$S0cNXJ9=OEnb0}r6=Jx4=I_yM#qTY% zZ-`Qjkajq?GMe0Ey7lc6`vv)3=b&f8m^9 z9=c}uufxqhvdulBEi6PdX`)axx@cbNa*pODtKQbEFAzCK?~b^4@8t}|&Z?)#S=iEA zWL|;cxu0Q}7rqd~{<8rwd%rh;4BB{tNHO&n*A| literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c70b2948c3e01a3f0ddc944ccd5f93712aae2951 GIT binary patch literal 5152 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 N&Td(9BDT)d{2xR<^0NQ{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d5409bfa89d13cc47e1d45b4ed566a314ac9e840 GIT binary patch literal 8377 zcmcgxTWs5Cb|xk2e(^<4oH$NGJHA92TfQfgi=Fr)$Crui5v|0DGm&YDwiQdHOv-l5 zo$biehs4{5D43ll11wq(T68^&1=ax;sJG~Y3k*;%un*KqZ3H4Hg6;r|yd_PG?!NW> zlqks=c_)LSC18^JFXun!JLmlT&*wKbn;C=a_s*5OYlkuHcj$+D5t$eK&8P74IYwbT zM&XoZ6d%#>8WO#0N42~bp2R3IqT_WVdR{+b;0@5H>unol zzA%Mh^briZt@Tu^3+11+*llYq)|GAO!ZR-bIa*pT1WORWcITrr-0Q3Z0X7kKdo~0OmCFo~n zNIw^j(9o`oyXqMuLqV2l6iASPhLcOnG#d>@*GPJv4zjT7OoaOYhPXb2wq`Q_!=q@z z1ZK>u!RP|Xav+*rVR#|Pu}!4Ffp!z)#Q$D%i3IjqR1qvmk%9usIrMv*IYXqm}l;^&q_0Wve2 zq504n_=;p1z%J7WF6-j}QXv>%1ORrG6?V*!i%cYH281DU7?$J1bSNl-!hxU=3DIlhTyP!$gQY@ipae;tfq_cIMWQI) zl(i8OYHTPj=INjSGN2!MIVh05{r&EVN%w#v0EOe=@ZdNJ^1(oaU5E+H!z&1e7|k1{ z`a+pDv-FldS?39WG7mGOfST$D2s56c{R;rr;BqLG!$A?m0j$lc4YSM|c#Dj3A%>?} zKZCRtqQOuo7jjJs-eHCo00_k>#b_lq5h5fRS&J@!FZyWzBEtq+1eBjG%Uswu&$GS= z=kxQNAh;rH=9eg&ow#hFS(b~^C`5!Yr&c!2a$Ho1@^nPD1OPnNCqy~_qHJRze0avU z0%AE{G*Q>aN8Qa6o`IKE5cCplTrcmz%@2RM%MNc^48z~tvtZbr^3Sp9>?VR?(;9V@ zy0I+7$Oz7Argdr$g?lv~z4|Nvv|}9_bry+Do75gkQ-ygk53Kd}MQNvzn#yIzD568F z&i5c4-?}IrQo)hi`Y5a`t1YYbYW=V^PnW7wJmu_3Pkjh4 z@lN`fRQV+4B`7npFI;{ARu*;a!Md=zJnG)^PMoqXuyxgbh%sDjnF=cw9~e^L%rI*h8odCgQE8mpp^@5x)L_7!Mf zTyA}SHpp%GF1h*0YKEcjyh`J~RZbtUN-k=@EqB_fT0f;#{RSD5$E5g<=TxHrvf_+a z3)w)ZvWt4+y+X}c3;3c~zBH*alzb@`f6Y^Zx45reXazvJMR{iP7jo&0Y9G}KF?yV8 za~U8?+g*feEo(g>N;|yPeNk%tUq@;2oa*#ii(=M=ECGDa=D#Pb?EqcRd2Rdh-&R!D z@00(kG21cpRSp4(#6PTg=)I*c&cPBPEo}Ygc2Fx>%r<{dBgU zZc%pC`s~kw2X(0cGkaz?I&eS8MXS16!CJ+0rdu7U;CNzb3kn8MQzTXH~jUG|pj zi>{Ibq6-y#KLQ*|ipF!{Zd`f*;#Uf=Dy4c+2B`Msd-5@&_U%Kdm=F5$v%RHM{{eDy z-?Mj?J8*!lZnOe5xKFQQJKj}wCB=LHUprpe0e&9xmQlmhNLQ8)0G+<`oZuWC6N%o5 zVbYHm#5_8PbfBa82)vxaNZ=H_aO52V2w2>UQy7A0IyV6^1pzn&UxVW=^x~*@1^Zk_ zV%Ol%WP$nfnseGY94ZLH&jlFSvI=$l0tbcfa9|AT4Ev2q{1VVQdozDKSMLoMRE6yp3)J@>eB9d$5WKsosmi(#)Yyrku~ z&c)5%bh#rw_{jN_KTDK1ztLi)nU8cx8yVxE-!V9 z)`kc@&rnc#c6YVPx?q$E3*yLN?_}=>7nD*r+zBNS4Dr!^rD}p|3QAKR$cWQWP5YtP z13pA49CI}i@CI2hoUYSKJ(&PZ@{5Z@dlxI zLnm95@@_C( z-T^ms5E0J9EsJ#-h5xpiiZ7)r4#mTXin=XRqM{AZE_bBQp4+h}&R*R+n>hR7f1{R1 zWia%Q0;^J%f$Yq1|4pdn71jY zD;beR8IEfT%D95EM%KZ45a)aq2y|+*C@#xJq>>Nmg3^s7V{ZZ43atVOw?806CCAmD z5u0t>jZe)x3yIdj&qm_C@%g_T`5BQ3**~|Q#6N>Sv3egOQJ^enh`x5jvI#Cx3E6I0 zk5Bm<@j!~w#`NM=cqCAJwSK@7>h#IL^Nc4)FPH5T$}Su znjl*Wen9RU5XlEC$hs(p9{S8O;_(182Q;0+!8G?|sG9uDmwNz#Xa@D0f%9vg7>9)- z%e;R9&}&whLgD2l7{a5y!l~!cCd{8eH}DK5Yk`fCO^5`U;DJSywNd&W>@UU)RWTfp zb%;e2#EMLADya!JW+H`zR>f)m_*vO@MAO;nuh=*I0q;28Y<^`aUE@wR1E!rA#`^azrJj}5} z&@HyMyGT!tc~uxz#I7m|_fqaGbqQn=FpR*kF#%E60(6id6A=#3F=klP;lN9@tvC&Q zJ3=InF)~;-B6#~bAquQePymjQjZXNZvVMg|Pba~@hbAF+@NMX(0NR8Y+~Uj}BI8sS zXUJetuPD_b>p(Y%TVo600{d!MEXlqo5Q9juCJPJ+WEMltu6-MOu_+5-hRwSya1c(+ z0Oy}vi!j|FqaoJC5(hKHrffsjlZcT+_$rLu3hX=z{iafbVoisO zoP=(0x5A$a-=alfXHu=s26l^R=NX^?=?vEhv0o31W!%XGQ26ACVHHV*?vqR28YZ48 zXdodbR}m%ZUqlW-I!}xPkB%B4&16f#SkVFcf?@mENm-xSgk{^lX0fBwMUEGemPI(q zxA{r&WCkzve*vV)>H;vF8K-Bs+Th;-*z)1x47#(DC%_f5!RHHbexFa)MR?%DWi1^E z$^@Ja;B}sf@`%TiiCHeNCToQ?2yW(HFv=tLP}UFj_D_y`WP#dT7vktBk8(q{D*XO+x33TKO4f!L*6EOOzpV8y zEXoAU&kM3<6@;OLl?m7@1=#>wF8TsMswm7UIv%krvL?dG2H<1Sek1E=8Td~GY+Mle zvMC$w@RcTbZdH)=JpAi}4#*llBol}*=1I^+Kp$iveBZjRC|?+vK&K^s77amTSdc`X@J%j{5ZvV!aOro)e}A1M!N-M;;xCnHTZ>7jCI`AX)8xRy~ra9@#qwCjk%uo$1LbkYK%q#|B;)EbAZVM4V3< zFQiKx@zF#H85??Gu|DkD=#t9nl9u{smNN;G-)PotwrldZel|(wPfV*~N7E$*uWaV`}nNqIO!U zxeZn$OMJ)@m_M>UB9*wdiEV$1xR5qj?*Cx@2XTMWa5P;~{!rWypEM;)8YN@n3zPZb z;f=#mX>GcqDt)|W^Yj;Yw&$N$)o=IhEWOs^jy@dDwB`NyYaLOqe}fTbL#*$W8Pgdv zOVn@jTOBE)C2cU>Ut3>`_a_aNZ!cjleK@}{AD>UyPdr&l*lPeI&~SKtSh9EilG(Ys z>;BbP^6cnuyeaBV@|rJ2%;fqze(Bvgw|hQu@mjKLEH&XxU8hrlyNT;}lM{<6B9t4~ z^mAtWs&r{A(K()UO{6?GQd4)59wh1;q9l%|h{Mlq-^>bmDu4cooDv_wt5)@dBq0Xt3k4Zu!!46xH#46*)K_8fNI zJHl>%iWo{8%=g80F-|27N8ZM+wCu6|k^afyP3=#OUmCY66J(oovVHrsRCzI3dMP%H z5Kw$gZaTN96w#J8ls+?5CJdENI+BJHZ;MlLZ+mWc#CspRAGxLK_U+CcGW! zn(*vJrK-tfh4)#-twhBwY1*Ht2qf)H+FtSFj}!J&|EbgH>R%Z#t4+xW$K_q^KO6ta zxL29D@}YFuBYEyfWxk|k=9z^_SQu$8oUpL5ft;gi|F(aN_?zLaB~bO>43;#ctZBm} zm0$U~evjDu@Ye=u;$~v#RM{BS3szBrfv3z-VLvGs9}@;>FSrO zTPt6FA~l?o9PcM>=VOC8L{4rV-g2af=Cr|f|A*^8e4&c5ZQaA6Ue2dAOp_DP48`AT&Zg+O?cw+EI^2$`|mM=B4 zn7p-bMB~=sUmW}D*!EPy*|S@cGEU@Fcc+L!l+0&0&c+-6bQJyk_U=-;_SDuA3Zixt zLmd!8)hL9lhSYc7OZx9fJaQnxonTz_G)KfJJUAs$LvPD#Y6H?Zx(smbR<8?n#l zEa7`lgW2bsTSldr&&Q*ZfFD4uR^{JxKKSQ%m}8Z%o$|R+K3>XqjUPl~N6~Ez-O$F4 zgtK%QNphn$RC@4!bVEg(Y`hfa0?Q$$hhKo7=rAKp!0nY5$MM&iGMxCY1`I#-JIwrh ztS5o>yeO-V+0!OVOrN%w#w-xHG3$Tmr*XXQ)yEo)us#rz+M~#~L!S<9O#a37e<3Pf LX)&ToS@C}WSI(oO literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d9d230dc1789a8a3187f13aafdc50680dbd25f47 GIT binary patch literal 12248 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 literal 0 HcmV?d00001 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 ------------------------------------------------------------------