Add smoke tests for UOM conversions and enhance frontend UOM management
- Implemented smoke tests for UOM directionality, ensuring conversions are one-directional and correctly validated. - Added tests for receiving and selling items in different UOMs, verifying correct quantity handling and error responses. - Created a UOM conversions panel in the frontend to allow users to manage UOM conversions for items. - Introduced hooks for allowed UOMs to optimize fetching and caching of UOM data for document line forms. - Developed utility functions for consistent UOM formatting and conversion handling across the application.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Items;
|
using ERPCore.Dtos.Items;
|
||||||
|
using ERPCore.Dtos.Uoms;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@@ -11,8 +12,13 @@ namespace ERPCore.Controllers;
|
|||||||
public sealed class ItemsController : ApiControllerBase
|
public sealed class ItemsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IItemService _items;
|
private readonly IItemService _items;
|
||||||
|
private readonly IUomConverter _uomConverter;
|
||||||
|
|
||||||
public ItemsController(IItemService items) => _items = items;
|
public ItemsController(IItemService items, IUomConverter uomConverter)
|
||||||
|
{
|
||||||
|
_items = items;
|
||||||
|
_uomConverter = uomConverter;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>List items with optional filters and paging.</summary>
|
/// <summary>List items with optional filters and paging.</summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -88,4 +94,15 @@ public sealed class ItemsController : ApiControllerBase
|
|||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<ItemUomConversionsDto>> UpdateUomConversions(int itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct)
|
public async Task<ActionResult<ItemUomConversionsDto>> UpdateUomConversions(int itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct)
|
||||||
=> Ok(await _items.UpdateUomConversionsAsync(itemId, request, ct));
|
=> Ok(await _items.UpdateUomConversionsAsync(itemId, request, ct));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The UOMs this item may be transacted in — its base UOM plus every UOM it has a
|
||||||
|
/// conversion from, each with the factor to base. Document line forms use this to offer
|
||||||
|
/// only units that will survive posting, instead of the whole global UOM list (FR-MD-02/03).
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{itemId:int}/uoms")]
|
||||||
|
[ProducesResponseType(typeof(IReadOnlyList<AllowedUomDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<AllowedUomDto>>> GetAllowedUoms(int itemId, CancellationToken ct)
|
||||||
|
=> Ok(await _uomConverter.GetAllowedUomsAsync(itemId, ct));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,20 @@ public class BundleSaleLine
|
|||||||
public int ItemId { get; set; }
|
public int ItemId { get; set; }
|
||||||
public Item? Item { get; set; }
|
public Item? Item { get; set; }
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
|
/// <summary>Component quantity, in <see cref="UomId"/> — what the user entered.</summary>
|
||||||
public decimal Qty { get; set; }
|
public decimal Qty { get; set; }
|
||||||
public int UomId { get; set; }
|
public int UomId { get; set; }
|
||||||
public Uom? Uom { get; set; }
|
public Uom? Uom { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="Qty"/> restated in the item's base UOM, resolved once at save. Posting
|
||||||
|
/// consumes this. <see cref="Qty"/> and <see cref="UnitPrice"/> are always a matching
|
||||||
|
/// pair in <see cref="UomId"/>, so <see cref="LineTotal"/> stays value-correct.
|
||||||
|
/// </summary>
|
||||||
|
public decimal QtyBase { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The factor used to derive <see cref="QtyBase"/>; 1 when the line is in base UOM.</summary>
|
||||||
|
public decimal ConversionFactor { get; set; } = 1m;
|
||||||
public int WarehouseId { get; set; }
|
public int WarehouseId { get; set; }
|
||||||
public Warehouse? Warehouse { get; set; }
|
public Warehouse? Warehouse { get; set; }
|
||||||
public decimal UnitPrice { get; set; }
|
public decimal UnitPrice { get; set; }
|
||||||
|
|||||||
@@ -34,8 +34,20 @@ public class GrnLine
|
|||||||
public int? BatchId { get; set; }
|
public int? BatchId { get; set; }
|
||||||
public Batch? Batch { get; set; }
|
public Batch? Batch { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Quantity received, in <see cref="UomId"/> — what the user entered.</summary>
|
||||||
public decimal Qty { get; set; }
|
public decimal Qty { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="Qty"/> restated in the item's base UOM, resolved once at line creation.
|
||||||
|
/// Confirm reads this snapshot rather than re-converting, so a conversion factor edited
|
||||||
|
/// between create and confirm cannot change what a saved GRN posts, and a later reversal
|
||||||
|
/// reproduces the original layer exactly.
|
||||||
|
/// </summary>
|
||||||
|
public decimal QtyBase { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The factor used to derive <see cref="QtyBase"/>; 1 when the line is in base UOM.</summary>
|
||||||
|
public decimal ConversionFactor { get; set; } = 1m;
|
||||||
|
|
||||||
/// <summary>Gross unit cost received at (entered, or PO price when omitted).</summary>
|
/// <summary>Gross unit cost received at (entered, or PO price when omitted).</summary>
|
||||||
public decimal UnitCost { get; set; }
|
public decimal UnitCost { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,32 @@ public class PoLine
|
|||||||
public int WarehouseId { get; set; }
|
public int WarehouseId { get; set; }
|
||||||
public Warehouse? Warehouse { get; set; }
|
public Warehouse? Warehouse { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Quantity ordered, in <see cref="UomId"/>.</summary>
|
||||||
public decimal Qty { get; set; }
|
public decimal Qty { get; set; }
|
||||||
public decimal UnitPrice { get; set; }//
|
public decimal UnitPrice { get; set; }//
|
||||||
public decimal Tax { get; set; }
|
public decimal Tax { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="Qty"/> restated in the item's base UOM, resolved once at line creation.
|
||||||
|
/// This — not <see cref="Qty"/> — is what open-quantity and close checks compare against,
|
||||||
|
/// because a GRN may legitimately receive against this line in a different UOM.
|
||||||
|
/// </summary>
|
||||||
|
public decimal QtyBase { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The factor used to derive <see cref="QtyBase"/>; 1 when the line is in base UOM.</summary>
|
||||||
|
public decimal ConversionFactor { get; set; } = 1m;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Accrues as GRNs confirm (FR-PROC-07), in <see cref="UomId"/>. <b>Denormalized for
|
||||||
|
/// display only</b> — it is derived by dividing <see cref="QtyReceivedBase"/> by the
|
||||||
|
/// factor, so it can drift. Never branch on it; use <see cref="QtyReceivedBase"/>.
|
||||||
|
/// </summary>
|
||||||
public decimal QtyReceived { get; set; }
|
public decimal QtyReceived { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authoritative received-to-date in the item's base UOM. GRN confirm accrues here and
|
||||||
|
/// the PO close condition compares this against <see cref="QtyBase"/>, so receipts in a
|
||||||
|
/// UOM other than the PO's still add up correctly.
|
||||||
|
/// </summary>
|
||||||
|
public decimal QtyReceivedBase { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,24 @@ public class SalesInvoiceLine
|
|||||||
|
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Quantity sold, in <see cref="UomId"/> — what the user entered and what prints.</summary>
|
||||||
public decimal Qty { get; set; }
|
public decimal Qty { get; set; }
|
||||||
public decimal FreeQty { get; set; }
|
public decimal FreeQty { get; set; }
|
||||||
public int UomId { get; set; }
|
public int UomId { get; set; }
|
||||||
public Uom? Uom { get; set; }
|
public Uom? Uom { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="Qty"/> restated in the item's base UOM, resolved once at save. Posting
|
||||||
|
/// consumes stock against this snapshot — the FIFO engine is base-UOM only, so passing
|
||||||
|
/// the entered quantity would deplete the wrong amount whenever the line is not in base UOM.
|
||||||
|
/// </summary>
|
||||||
|
public decimal QtyBase { get; set; }
|
||||||
|
|
||||||
|
/// <summary><see cref="FreeQty"/> restated in the item's base UOM.</summary>
|
||||||
|
public decimal FreeQtyBase { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The factor used to derive the base quantities; 1 when the line is in base UOM.</summary>
|
||||||
|
public decimal ConversionFactor { get; set; } = 1m;
|
||||||
public int WarehouseId { get; set; }
|
public int WarehouseId { get; set; }
|
||||||
public Warehouse? Warehouse { get; set; }
|
public Warehouse? Warehouse { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,24 @@ public class SalesSlipLine
|
|||||||
|
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Quantity sold, in <see cref="UomId"/> — what the user entered and what prints.</summary>
|
||||||
public decimal Qty { get; set; }
|
public decimal Qty { get; set; }
|
||||||
public decimal FreeQty { get; set; }
|
public decimal FreeQty { get; set; }
|
||||||
public int UomId { get; set; }
|
public int UomId { get; set; }
|
||||||
public Uom? Uom { get; set; }
|
public Uom? Uom { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="Qty"/> restated in the item's base UOM, resolved once at save. Posting
|
||||||
|
/// consumes stock against this snapshot — the FIFO engine is base-UOM only, so passing
|
||||||
|
/// the entered quantity would deplete the wrong amount whenever the line is not in base UOM.
|
||||||
|
/// </summary>
|
||||||
|
public decimal QtyBase { get; set; }
|
||||||
|
|
||||||
|
/// <summary><see cref="FreeQty"/> restated in the item's base UOM.</summary>
|
||||||
|
public decimal FreeQtyBase { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The factor used to derive the base quantities; 1 when the line is in base UOM.</summary>
|
||||||
|
public decimal ConversionFactor { get; set; } = 1m;
|
||||||
public int WarehouseId { get; set; }
|
public int WarehouseId { get; set; }
|
||||||
public Warehouse? Warehouse { get; set; }
|
public Warehouse? Warehouse { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,18 @@ namespace ERPCore.Dtos.Procurement;
|
|||||||
|
|
||||||
// Responses (docs/11 §3.3) ------------------------------------------------------
|
// Responses (docs/11 §3.3) ------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A PO line. <paramref name="Qty"/> and <paramref name="QtyReceived"/> are in
|
||||||
|
/// <paramref name="UomId"/> and are what the user sees; <paramref name="QtyBase"/> and
|
||||||
|
/// <paramref name="QtyReceivedBase"/> are the item's base UOM and are what the server
|
||||||
|
/// actually enforces — GRN over-receipt and the PO close condition both run on the base
|
||||||
|
/// pair, because goods may legitimately be received in a different UOM from the one
|
||||||
|
/// ordered. A client showing remaining/outstanding quantity should read the base pair.
|
||||||
|
/// </summary>
|
||||||
public sealed record PoLineDto(
|
public sealed record PoLineDto(
|
||||||
int PoLineId, int ItemId, int UomId, int WarehouseId,
|
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);
|
public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency);
|
||||||
|
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ public sealed record BundleSaleTemplateSummaryDto(
|
|||||||
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description,
|
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description,
|
||||||
EntityStatus Status, int LineCount, DateTime CreatedAt, DateTime? UpdatedAt);
|
EntityStatus Status, int LineCount, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||||
|
|
||||||
|
/// <summary>Quantities are in the item's base UOM — see <c>SalesInvoicePostingIssueDto</c>.</summary>
|
||||||
public sealed record BundleSalePostingIssueDto(
|
public sealed record BundleSalePostingIssueDto(
|
||||||
int BundleSaleLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
|
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(
|
public sealed record BundleSalePostingCheckDto(
|
||||||
int BundleSaleId, string BundleNo, BundleSaleStatus Status, bool CanPost,
|
int BundleSaleId, string BundleNo, BundleSaleStatus Status, bool CanPost,
|
||||||
|
|||||||
@@ -24,9 +24,16 @@ public sealed record SalesInvoiceSummaryDto(
|
|||||||
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
|
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
|
||||||
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt);
|
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A line that cannot be posted for lack of stock. The three quantities are in the item's
|
||||||
|
/// <b>base</b> UOM (on-hand only exists in base), which may differ from the UOM shown on the
|
||||||
|
/// line — hence <paramref name="BaseUomName"/>: without it a line reading "2 BOX" produces
|
||||||
|
/// an unexplained "requested 24, available 10".
|
||||||
|
/// </summary>
|
||||||
public sealed record SalesInvoicePostingIssueDto(
|
public sealed record SalesInvoicePostingIssueDto(
|
||||||
int SalesInvoiceLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
|
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(
|
public sealed record SalesInvoicePostingCheckDto(
|
||||||
int SalesInvoiceId, string InvoiceNo, SalesInvoiceStatus Status, bool CanPost,
|
int SalesInvoiceId, string InvoiceNo, SalesInvoiceStatus Status, bool CanPost,
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ public sealed record SalesSlipSummaryDto(
|
|||||||
string CustomerSnapshotName, int WarehouseId, SalesSlipStatus Status,
|
string CustomerSnapshotName, int WarehouseId, SalesSlipStatus Status,
|
||||||
SalesSlipTotalsDto Totals, DateTime CreatedAt);
|
SalesSlipTotalsDto Totals, DateTime CreatedAt);
|
||||||
|
|
||||||
|
/// <summary>Quantities are in the item's base UOM — see <c>SalesInvoicePostingIssueDto</c>.</summary>
|
||||||
public sealed record SalesSlipPostingIssueDto(
|
public sealed record SalesSlipPostingIssueDto(
|
||||||
int SalesSlipLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
|
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(
|
public sealed record SalesSlipPostingCheckDto(
|
||||||
int SalesSlipId, string SlipNo, SalesSlipStatus Status, bool CanPost,
|
int SalesSlipId, string SlipNo, SalesSlipStatus Status, bool CanPost,
|
||||||
|
|||||||
@@ -2,16 +2,23 @@ using ERPCore.Domain.Enums;
|
|||||||
|
|
||||||
namespace ERPCore.Dtos.Stock;
|
namespace ERPCore.Dtos.Stock;
|
||||||
|
|
||||||
|
// Every quantity a stock endpoint returns is in the item's base UOM — stock, layers and the
|
||||||
|
// ledger are base-only by construction. The BaseUomId/BaseUomName pair on each of these DTOs
|
||||||
|
// exists so a client can *label* those figures; without it every stock screen renders a bare
|
||||||
|
// number the user has to guess the unit of. They are never a conversion instruction.
|
||||||
|
|
||||||
/// <summary>Stock enquiry (docs/11 §5.1). available = onHand − onHold − reserved − inTransit(out).</summary>
|
/// <summary>Stock enquiry (docs/11 §5.1). available = onHand − onHold − reserved − inTransit(out).</summary>
|
||||||
public sealed record StockOnHandDto(
|
public sealed record StockOnHandDto(
|
||||||
int ItemId, int WarehouseId, decimal OnHand, decimal Available,
|
int ItemId, int WarehouseId, decimal OnHand, decimal Available,
|
||||||
decimal OnHold, decimal InTransit, decimal Reserved, DateTime AsOf);
|
decimal OnHold, decimal InTransit, decimal Reserved, DateTime AsOf,
|
||||||
|
int BaseUomId = 0, string BaseUomName = "");
|
||||||
|
|
||||||
/// <summary>A stock-ledger row (docs/11 §5.2).</summary>
|
/// <summary>A stock-ledger row (docs/11 §5.2).</summary>
|
||||||
public sealed record StockLedgerRowDto(
|
public sealed record StockLedgerRowDto(
|
||||||
int LedgerId, int ItemId, int WarehouseId, int? BinId, int? BatchId, int? SerialId,
|
int LedgerId, int ItemId, int WarehouseId, int? BinId, int? BatchId, int? SerialId,
|
||||||
Direction Direction, decimal QtyBase, decimal UnitCost, decimal Value, decimal RunningBalance,
|
Direction Direction, decimal QtyBase, decimal UnitCost, decimal Value, decimal RunningBalance,
|
||||||
string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt);
|
string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt,
|
||||||
|
int BaseUomId = 0, string BaseUomName = "");
|
||||||
|
|
||||||
/// <summary>An open FIFO layer in a valuation (docs/11 §5.3).</summary>
|
/// <summary>An open FIFO layer in a valuation (docs/11 §5.3).</summary>
|
||||||
public sealed record StockValuationLayerDto(int LayerId, decimal QtyRemaining, decimal UnitCost, decimal Value, DateTime ReceiptDate);
|
public sealed record StockValuationLayerDto(int LayerId, decimal QtyRemaining, decimal UnitCost, decimal Value, DateTime ReceiptDate);
|
||||||
@@ -19,4 +26,5 @@ public sealed record StockValuationLayerDto(int LayerId, decimal QtyRemaining, d
|
|||||||
/// <summary>Valuation of on-hand stock from open FIFO layers (docs/11 §5.3).</summary>
|
/// <summary>Valuation of on-hand stock from open FIFO layers (docs/11 §5.3).</summary>
|
||||||
public sealed record StockValuationDto(
|
public sealed record StockValuationDto(
|
||||||
int ItemId, int WarehouseId, IReadOnlyList<StockValuationLayerDto> Layers,
|
int ItemId, int WarehouseId, IReadOnlyList<StockValuationLayerDto> Layers,
|
||||||
decimal TotalQty, decimal TotalValue, string Currency, string CostingMethod);
|
decimal TotalQty, decimal TotalValue, string Currency, string CostingMethod,
|
||||||
|
int BaseUomId = 0, string BaseUomName = "");
|
||||||
|
|||||||
@@ -9,3 +9,12 @@ public sealed class CreateUomRequest
|
|||||||
{
|
{
|
||||||
[Required, StringLength(50)] public string Name { get; set; } = string.Empty;
|
[Required, StringLength(50)] public string Name { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A UOM an item may actually be transacted in: its base UOM (<paramref name="Factor"/> 1,
|
||||||
|
/// <paramref name="IsBase"/> true) plus every UOM it has a conversion from. Backs both
|
||||||
|
/// entry-time validation and <c>GET /items/{itemId}/uoms</c>, so the client can offer only
|
||||||
|
/// units that will survive posting instead of the whole global list.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Factor">Multiply a quantity in this UOM by <paramref name="Factor"/> to get base UOM.</param>
|
||||||
|
public sealed record AllowedUomDto(int UomId, string Name, decimal Factor, bool IsBase);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ public sealed class BundleSaleLineConfiguration : IEntityTypeConfiguration<Bundl
|
|||||||
builder.HasKey(x => x.BundleSaleLineId);
|
builder.HasKey(x => x.BundleSaleLineId);
|
||||||
builder.Property(x => x.Description).IsRequired().HasMaxLength(200);
|
builder.Property(x => x.Description).IsRequired().HasMaxLength(200);
|
||||||
builder.Property(x => x.Qty).HasPrecision(18, 4);
|
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.UnitPrice).HasPrecision(18, 4);
|
||||||
builder.Property(x => x.LineTotal).HasPrecision(18, 4);
|
builder.Property(x => x.LineTotal).HasPrecision(18, 4);
|
||||||
builder.Property(x => x.IncludeInBundle).HasDefaultValue(true);
|
builder.Property(x => x.IncludeInBundle).HasDefaultValue(true);
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
|
|||||||
builder.HasKey(l => l.GrnLineId);
|
builder.HasKey(l => l.GrnLineId);
|
||||||
|
|
||||||
builder.Property(l => l.Qty).HasPrecision(18, 4);
|
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.UnitCost).HasPrecision(18, 6);
|
||||||
builder.Property(l => l.PoUnitPrice).HasPrecision(18, 6);
|
builder.Property(l => l.PoUnitPrice).HasPrecision(18, 6);
|
||||||
builder.Property(l => l.DiscountPct).HasPrecision(9, 4);
|
builder.Property(l => l.DiscountPct).HasPrecision(9, 4);
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ public sealed class PoLineConfiguration : IEntityTypeConfiguration<PoLine>
|
|||||||
builder.HasKey(l => l.PoLineId);
|
builder.HasKey(l => l.PoLineId);
|
||||||
|
|
||||||
builder.Property(l => l.Qty).HasPrecision(18, 4);
|
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.UnitPrice).HasPrecision(18, 4);
|
||||||
builder.Property(l => l.Tax).HasPrecision(9, 4);
|
builder.Property(l => l.Tax).HasPrecision(9, 4);
|
||||||
builder.Property(l => l.QtyReceived).HasPrecision(18, 4);
|
builder.Property(l => l.QtyReceived).HasPrecision(18, 4);
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration<Sal
|
|||||||
|
|
||||||
builder.Property(x => x.Qty).HasPrecision(18, 4);
|
builder.Property(x => x.Qty).HasPrecision(18, 4);
|
||||||
builder.Property(x => x.FreeQty).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.UnitPrice).HasPrecision(18, 4);
|
||||||
builder.Property(x => x.BaseCost).HasPrecision(18, 4);
|
builder.Property(x => x.BaseCost).HasPrecision(18, 4);
|
||||||
builder.Property(x => x.DiscountPct).HasPrecision(9, 4);
|
builder.Property(x => x.DiscountPct).HasPrecision(9, 4);
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ public sealed class SalesSlipLineConfiguration : IEntityTypeConfiguration<SalesS
|
|||||||
|
|
||||||
builder.Property(x => x.Qty).HasPrecision(18, 4);
|
builder.Property(x => x.Qty).HasPrecision(18, 4);
|
||||||
builder.Property(x => x.FreeQty).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.UnitPrice).HasPrecision(18, 4);
|
||||||
builder.Property(x => x.BaseCost).HasPrecision(18, 4);
|
builder.Property(x => x.BaseCost).HasPrecision(18, 4);
|
||||||
builder.Property(x => x.DiscountPct).HasPrecision(9, 4);
|
builder.Property(x => x.DiscountPct).HasPrecision(9, 4);
|
||||||
|
|||||||
@@ -30,5 +30,9 @@ public sealed class UomConversionConfiguration : IEntityTypeConfiguration<UomCon
|
|||||||
|
|
||||||
// One conversion per (item, from, to) triple.
|
// One conversion per (item, from, to) triple.
|
||||||
builder.HasIndex(c => new { c.ItemId, c.FromUomId, c.ToUomId }).IsUnique();
|
builder.HasIndex(c => new { c.ItemId, c.FromUomId, c.ToUomId }).IsUnique();
|
||||||
|
|
||||||
|
// The API guards this too, but UomConverter divides unit cost by the factor — a zero
|
||||||
|
// reaching the table from a seeder or direct SQL would be a divide-by-zero at post time.
|
||||||
|
builder.ToTable(t => t.HasCheckConstraint("ck_uom_conversions_factor_positive", "\"Factor\" > 0"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,16 +215,23 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
|
|
||||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
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);
|
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
|
lines.Add(new BundleSaleLine
|
||||||
{
|
{
|
||||||
ItemId = r.ItemId,
|
ItemId = r.ItemId,
|
||||||
Description = item.Name,
|
Description = item.Name,
|
||||||
Qty = qtyBase,
|
Qty = r.Qty,
|
||||||
UomId = item.BaseUomId,
|
UomId = r.UomId,
|
||||||
|
QtyBase = UomConverter.ApplyFactor(r.Qty, factor),
|
||||||
|
ConversionFactor = factor,
|
||||||
WarehouseId = lineWarehouseId,
|
WarehouseId = lineWarehouseId,
|
||||||
UnitPrice = unitCostBase,
|
UnitPrice = r.UnitPrice,
|
||||||
LineTotal = calc.LineTotal,
|
LineTotal = calc.LineTotal,
|
||||||
IncludeInBundle = r.IncludeInBundle,
|
IncludeInBundle = r.IncludeInBundle,
|
||||||
IsComponent = true,
|
IsComponent = true,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using ERPCore.Infra.Auth;
|
|||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
using ERPCore.Repositories.Interfaces;
|
using ERPCore.Repositories.Interfaces;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.Services.Stock;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
using Microsoft.EntityFrameworkCore;
|
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))
|
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);
|
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
|
// 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,
|
// wins and a variance is recorded against the PO snapshot — docs/02-SECURITY C.3,
|
||||||
// revised). Direct receipts always use the entered cost.
|
// revised). Direct receipts always use the entered cost.
|
||||||
@@ -150,10 +157,13 @@ public sealed class GrnService : IGrnService
|
|||||||
if (poLine.ItemId != input.ItemId)
|
if (poLine.ItemId != input.ItemId)
|
||||||
throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is for a different item.", 422);
|
throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is for a different item.", 422);
|
||||||
|
|
||||||
var openQty = poLine.Qty - poLine.QtyReceived;
|
// Compare in base UOM: a GRN may legitimately receive in a different UOM from
|
||||||
if (input.Qty > openQty * (1 + OverReceiptTolerance))
|
// 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,
|
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;
|
poUnitPrice = poLine.UnitPrice;
|
||||||
unitCost = input.UnitCost > 0 ? input.UnitCost : poLine.UnitPrice;
|
unitCost = input.UnitCost > 0 ? input.UnitCost : poLine.UnitPrice;
|
||||||
@@ -178,6 +188,8 @@ public sealed class GrnService : IGrnService
|
|||||||
BinId = input.BinId,
|
BinId = input.BinId,
|
||||||
Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted
|
Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted
|
||||||
Qty = input.Qty,
|
Qty = input.Qty,
|
||||||
|
QtyBase = qtyBaseEntered,
|
||||||
|
ConversionFactor = conversionFactor,
|
||||||
UnitCost = unitCost,
|
UnitCost = unitCost,
|
||||||
PoUnitPrice = poUnitPrice,
|
PoUnitPrice = poUnitPrice,
|
||||||
DiscountPct = input.DiscountPct,
|
DiscountPct = input.DiscountPct,
|
||||||
@@ -234,10 +246,13 @@ public sealed class GrnService : IGrnService
|
|||||||
{
|
{
|
||||||
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
|
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
|
// FIFO layer costs at the after-discount net price; VAT is recoverable and never
|
||||||
// enters stock value (docs/10 FR-GRN-06, revised).
|
// enters stock value (docs/10 FR-GRN-06, revised). Quantity and cost come from
|
||||||
var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.NetUnitCost, token);
|
// 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(
|
var layer = await _fifo.CreateInboundLayerAsync(
|
||||||
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId,
|
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId,
|
||||||
@@ -258,7 +273,13 @@ public sealed class GrnService : IGrnService
|
|||||||
if (line.PoLineId is not null)
|
if (line.PoLineId is not null)
|
||||||
{
|
{
|
||||||
var poLine = await _poLines.GetByIdAsync(line.PoLineId.Value, token);
|
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
|
return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Delegates to the shared <see cref="IUomConverter"/>. This was a private method here
|
|
||||||
/// until manufacturing needed the same conversion for stage stock inputs; behaviour is
|
|
||||||
/// identical, so receive costing is unchanged.
|
|
||||||
/// </summary>
|
|
||||||
private Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
|
|
||||||
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct)
|
|
||||||
=> _uomConverter.ToBaseAsync(item, uomId, qty, unitCostPerUom, ct);
|
|
||||||
|
|
||||||
private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
|
private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (poId is null) return;
|
if (poId is null) return;
|
||||||
var po = await _pos.Query().Include(p => p.Lines).FirstOrDefaultAsync(p => p.PoId == poId, ct);
|
var po = await _pos.Query().Include(p => p.Lines).FirstOrDefaultAsync(p => p.PoId == poId, ct);
|
||||||
if (po is null) return;
|
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.FullyReceived
|
||||||
: PurchaseOrderStatus.PartiallyReceived;
|
: PurchaseOrderStatus.PartiallyReceived;
|
||||||
po.UpdatedAt = DateTime.UtcNow;
|
po.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Dtos.Uoms;
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
@@ -32,4 +33,29 @@ public interface IUomConverter
|
|||||||
/// consumes, not from the document).
|
/// consumes, not from the document).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default);
|
Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The single factor lookup every other method is built on: <c>1</c> when
|
||||||
|
/// <paramref name="uomId"/> is already the base UOM, otherwise the item's conversion
|
||||||
|
/// factor from that UOM to base. Throws 422 when none is defined.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Callers that persist a line should store this alongside the quantity so posting
|
||||||
|
/// reads a snapshot instead of re-resolving — a factor edited between save and post
|
||||||
|
/// must never change what an already-saved document posts.
|
||||||
|
/// </remarks>
|
||||||
|
Task<decimal> ResolveFactorAsync(Item item, int uomId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every UOM <paramref name="itemId"/> may be transacted in — base UOM first, then each
|
||||||
|
/// conversion source, ordered by name.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<AllowedUomDto>> GetAllowedUomsAsync(int itemId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entry-time guard: throws 422 naming the allowed units when <paramref name="uomId"/>
|
||||||
|
/// is neither the item's base UOM nor a UOM it has a conversion from. Call this when a
|
||||||
|
/// document line is created so the user is told at entry, not by a cryptic failure at post.
|
||||||
|
/// </summary>
|
||||||
|
Task ValidateUomAsync(Item item, int uomId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,15 @@ public sealed class ItemService : IItemService
|
|||||||
request.CategoryId, request.SubCategoryId, request.BrandId,
|
request.CategoryId, request.SubCategoryId, request.BrandId,
|
||||||
request.BaseUomId, request.DefaultVendorId, ct);
|
request.BaseUomId, request.DefaultVendorId, ct);
|
||||||
|
|
||||||
|
// Conversions are stored as <other> → base. Repointing the base UOM would leave every
|
||||||
|
// existing row aimed at a UOM that is no longer the base: invisible to IUomConverter,
|
||||||
|
// filtered out of the allowed-UOM list, and unfixable through the conversions editor
|
||||||
|
// (which would 422 on re-save). Make the user clear them deliberately instead.
|
||||||
|
if (request.BaseUomId != item.BaseUomId && item.UomConversions.Count > 0)
|
||||||
|
throw new DomainException(ErrorCodes.Validation,
|
||||||
|
$"Item {item.Sku} has {item.UomConversions.Count} UOM conversion(s) defined against base UOM {item.BaseUomId}. " +
|
||||||
|
"Remove them before changing the base UOM, then re-enter them against the new base.", 422);
|
||||||
|
|
||||||
item.Sku = request.Sku.Trim();
|
item.Sku = request.Sku.Trim();
|
||||||
item.Name = request.Name.Trim();
|
item.Name = request.Name.Trim();
|
||||||
item.Description = request.Description;
|
item.Description = request.Description;
|
||||||
@@ -240,6 +249,26 @@ public sealed class ItemService : IItemService
|
|||||||
if (!await _uoms.Query().AnyAsync(u => u.UomId == uomId, ct))
|
if (!await _uoms.Query().AnyAsync(u => u.UomId == uomId, ct))
|
||||||
throw new DomainException(ErrorCodes.Validation, $"UOM {uomId} does not exist.", 422);
|
throw new DomainException(ErrorCodes.Validation, $"UOM {uomId} does not exist.", 422);
|
||||||
|
|
||||||
|
// Conversions are one-directional: always <other> → base. IUomConverter looks up
|
||||||
|
// exactly that shape and never inverts a factor, so a row saved the other way round
|
||||||
|
// would persist happily, render in the UI, and then be invisible at post time. Reject
|
||||||
|
// it here instead of letting it fail later as an unexplained 422.
|
||||||
|
foreach (var c in request.Conversions)
|
||||||
|
{
|
||||||
|
if (c.ToUom != item.BaseUomId)
|
||||||
|
throw new DomainException(ErrorCodes.Validation,
|
||||||
|
$"Conversion {c.FromUom} → {c.ToUom} is invalid: conversions must convert to the item's base UOM ({item.BaseUomId}).", 422);
|
||||||
|
if (c.FromUom == c.ToUom)
|
||||||
|
throw new DomainException(ErrorCodes.Validation,
|
||||||
|
$"Conversion {c.FromUom} → {c.ToUom} is invalid: a UOM cannot convert to itself.", 422);
|
||||||
|
if (c.FromUom == item.BaseUomId)
|
||||||
|
throw new DomainException(ErrorCodes.Validation,
|
||||||
|
"The base UOM converts to itself implicitly (factor 1) and must not be listed.", 422);
|
||||||
|
if (c.Factor <= 0m)
|
||||||
|
throw new DomainException(ErrorCodes.Validation,
|
||||||
|
$"Conversion {c.FromUom} → {c.ToUom} must have a factor greater than zero.", 422);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var stale in item.UomConversions.Where(c => request.Conversions.All(r => r.FromUom != c.FromUomId || r.ToUom != c.ToUomId)).ToList())
|
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);
|
item.UomConversions.Remove(stale);
|
||||||
foreach (var input in request.Conversions)
|
foreach (var input in request.Conversions)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public sealed class ProductionTemplateService : IProductionTemplateService
|
|||||||
private readonly IRepository<Item> _items;
|
private readonly IRepository<Item> _items;
|
||||||
private readonly IRepository<Uom> _uoms;
|
private readonly IRepository<Uom> _uoms;
|
||||||
private readonly IRepository<ProductionRun> _runs;
|
private readonly IRepository<ProductionRun> _runs;
|
||||||
|
private readonly IUomConverter _uomConverter;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
|
|
||||||
@@ -34,8 +35,9 @@ public sealed class ProductionTemplateService : IProductionTemplateService
|
|||||||
IRepository<ProductionTemplate> templates, IRepository<TemplateStage> stages,
|
IRepository<ProductionTemplate> templates, IRepository<TemplateStage> stages,
|
||||||
IRepository<StageInput> inputs, IRepository<StageOutput> outputs, IRepository<StageEdge> edges,
|
IRepository<StageInput> inputs, IRepository<StageOutput> outputs, IRepository<StageEdge> edges,
|
||||||
IRepository<Item> items, IRepository<Uom> uoms, IRepository<ProductionRun> runs,
|
IRepository<Item> items, IRepository<Uom> uoms, IRepository<ProductionRun> runs,
|
||||||
IUnitOfWork uow, ICurrentUser currentUser)
|
IUomConverter uomConverter, IUnitOfWork uow, ICurrentUser currentUser)
|
||||||
{
|
{
|
||||||
|
_uomConverter = uomConverter;
|
||||||
_templates = templates;
|
_templates = templates;
|
||||||
_stages = stages;
|
_stages = stages;
|
||||||
_inputs = inputs;
|
_inputs = inputs;
|
||||||
@@ -291,6 +293,20 @@ public sealed class ProductionTemplateService : IProductionTemplateService
|
|||||||
throw new DomainException(ErrorCodes.Validation,
|
throw new DomainException(ErrorCodes.Validation,
|
||||||
$"UOM(s) {string.Join(", ", missingUoms)} do not exist.", 422);
|
$"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
|
// 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.
|
// client renders off. Unknown kinds would round-trip fine but draw nothing.
|
||||||
var badKinds = request.Annotations
|
var badKinds = request.Annotations
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using ERPCore.Infra.Auth;
|
|||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
using ERPCore.Repositories.Interfaces;
|
using ERPCore.Repositories.Interfaces;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.Services.Stock;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
|||||||
private readonly IRepository<Item> _items;
|
private readonly IRepository<Item> _items;
|
||||||
private readonly IRepository<Uom> _uoms;
|
private readonly IRepository<Uom> _uoms;
|
||||||
private readonly IRepository<Warehouse> _warehouses;
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
|
private readonly IUomConverter _uomConverter;
|
||||||
private readonly INumberSequenceService _numbers;
|
private readonly INumberSequenceService _numbers;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
@@ -35,8 +37,9 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
|||||||
public PurchaseOrderService(
|
public PurchaseOrderService(
|
||||||
IRepository<PurchaseOrder> pos, IRepository<Vendor> vendors, IRepository<Requisition> requisitions,
|
IRepository<PurchaseOrder> pos, IRepository<Vendor> vendors, IRepository<Requisition> requisitions,
|
||||||
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
|
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
|
||||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
IUomConverter uomConverter, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
|
_uomConverter = uomConverter;
|
||||||
_pos = pos;
|
_pos = pos;
|
||||||
_vendors = vendors;
|
_vendors = vendors;
|
||||||
_requisitions = requisitions;
|
_requisitions = requisitions;
|
||||||
@@ -83,6 +86,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
|||||||
{
|
{
|
||||||
await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct);
|
await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct);
|
||||||
var actor = _currentUser.AuditUserId;
|
var actor = _currentUser.AuditUserId;
|
||||||
|
var lines = await ToLinesAsync(request.Lines, ct);
|
||||||
|
|
||||||
var po = await _uow.ExecuteInTransactionAsync(async token =>
|
var po = await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
{
|
{
|
||||||
@@ -98,7 +102,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
|||||||
Status = request.SaveAsDraft ? PurchaseOrderStatus.Draft : PurchaseOrderStatus.Approved,
|
Status = request.SaveAsDraft ? PurchaseOrderStatus.Draft : PurchaseOrderStatus.Approved,
|
||||||
CreatedBy = actor,
|
CreatedBy = actor,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
Lines = request.Lines.Select(ToLine).ToList()
|
Lines = lines
|
||||||
};
|
};
|
||||||
await _pos.AddAsync(entity, token);
|
await _pos.AddAsync(entity, token);
|
||||||
return entity;
|
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).
|
// Full line replacement (Phase 1: no receipts yet, so qtyReceived is 0 on every line).
|
||||||
po.Lines.Clear();
|
po.Lines.Clear();
|
||||||
foreach (var input in request.Lines)
|
foreach (var line in await ToLinesAsync(request.Lines, ct))
|
||||||
po.Lines.Add(ToLine(input));
|
po.Lines.Add(line);
|
||||||
|
|
||||||
try
|
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.
|
// 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 bool IsEditable(PurchaseOrderStatus status) => status is PurchaseOrderStatus.Draft;
|
||||||
|
|
||||||
private static PoLine ToLine(CreatePoLineInput l) => new()
|
/// <summary>
|
||||||
|
/// Materialises request lines, resolving each one's base quantity up front. Resolving here
|
||||||
|
/// rejects a UOM the item cannot be ordered in at entry time, and gives GRN receipt matching
|
||||||
|
/// a stable base figure to compare against regardless of the UOM the goods arrive in.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<List<PoLine>> ToLinesAsync(IReadOnlyCollection<CreatePoLineInput> inputs, CancellationToken ct)
|
||||||
{
|
{
|
||||||
ItemId = l.ItemId,
|
var lines = new List<PoLine>(inputs.Count);
|
||||||
UomId = l.UomId,
|
foreach (var l in inputs)
|
||||||
WarehouseId = l.WarehouseId,
|
{
|
||||||
Qty = l.Qty,
|
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == l.ItemId, ct);
|
||||||
UnitPrice = l.UnitPrice,
|
var factor = await _uomConverter.ResolveFactorAsync(item, l.UomId, ct);
|
||||||
Tax = l.Tax,
|
lines.Add(new PoLine
|
||||||
QtyReceived = 0
|
{
|
||||||
};
|
ItemId = l.ItemId,
|
||||||
|
UomId = l.UomId,
|
||||||
|
WarehouseId = l.WarehouseId,
|
||||||
|
Qty = l.Qty,
|
||||||
|
QtyBase = UomConverter.ApplyFactor(l.Qty, factor),
|
||||||
|
ConversionFactor = factor,
|
||||||
|
UnitPrice = l.UnitPrice,
|
||||||
|
Tax = l.Tax,
|
||||||
|
QtyReceived = 0,
|
||||||
|
QtyReceivedBase = 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
private static PoTotalsDto ComputeTotals(IEnumerable<PoLine> lines)
|
private static PoTotalsDto ComputeTotals(IEnumerable<PoLine> lines)
|
||||||
{
|
{
|
||||||
@@ -276,5 +298,6 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
|||||||
p.PoId, p.DocNo, p.VendorId, p.RequisitionId, p.Status, p.ApprovalRequired,
|
p.PoId, p.DocNo, p.VendorId, p.RequisitionId, p.Status, p.ApprovalRequired,
|
||||||
p.CreatedBy, p.CreatedAt, p.UpdatedAt, ComputeTotals(p.Lines),
|
p.CreatedBy, p.CreatedAt, p.UpdatedAt, ComputeTotals(p.Lines),
|
||||||
p.Lines.OrderBy(l => l.PoLineId).Select(l => new PoLineDto(
|
p.Lines.OrderBy(l => l.PoLineId).Select(l => new PoLineDto(
|
||||||
l.PoLineId, l.ItemId, l.UomId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived)).ToList());
|
l.PoLineId, l.ItemId, l.UomId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived,
|
||||||
|
l.QtyBase, l.QtyReceivedBase, l.ConversionFactor)).ToList());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
private readonly ISalesPostingService _posting;
|
private readonly ISalesPostingService _posting;
|
||||||
private readonly ISalesMappingService _mapping;
|
private readonly ISalesMappingService _mapping;
|
||||||
private readonly ISalesDocumentWorkflowService _workflow;
|
private readonly ISalesDocumentWorkflowService _workflow;
|
||||||
|
private readonly IUomConverter _uomConverter;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly INumberSequenceService _numbers;
|
private readonly INumberSequenceService _numbers;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
@@ -32,9 +33,10 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
public SalesInvoiceService(
|
public SalesInvoiceService(
|
||||||
IRepository<SalesInvoice> invoices, IRepository<Customer> customers, IRepository<Item> items,
|
IRepository<SalesInvoice> invoices, IRepository<Customer> customers, IRepository<Item> items,
|
||||||
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
|
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
|
||||||
ISalesDocumentWorkflowService workflow,
|
ISalesDocumentWorkflowService workflow, IUomConverter uomConverter,
|
||||||
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
|
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
|
_uomConverter = uomConverter;
|
||||||
_invoices = invoices;
|
_invoices = invoices;
|
||||||
_customers = customers;
|
_customers = customers;
|
||||||
_items = items;
|
_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);
|
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
|
lines.Add(new SalesInvoiceLine
|
||||||
{
|
{
|
||||||
ItemId = r.ItemId,
|
ItemId = r.ItemId,
|
||||||
@@ -159,6 +167,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
Qty = r.Qty,
|
Qty = r.Qty,
|
||||||
FreeQty = r.FreeQty,
|
FreeQty = r.FreeQty,
|
||||||
UomId = r.UomId,
|
UomId = r.UomId,
|
||||||
|
QtyBase = UomConverter.ApplyFactor(r.Qty, factor),
|
||||||
|
FreeQtyBase = UomConverter.ApplyFactor(r.FreeQty, factor),
|
||||||
|
ConversionFactor = factor,
|
||||||
WarehouseId = r.WarehouseId,
|
WarehouseId = r.WarehouseId,
|
||||||
UnitPrice = unitPrice,
|
UnitPrice = unitPrice,
|
||||||
BaseCost = unitPrice,
|
BaseCost = unitPrice,
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
private readonly IRepository<Item> _items;
|
private readonly IRepository<Item> _items;
|
||||||
private readonly IFifoCostingService _fifo;
|
private readonly IFifoCostingService _fifo;
|
||||||
private readonly ISalesDomainService _sales;
|
private readonly ISalesDomainService _sales;
|
||||||
private readonly IUomConverter _uomConverter;
|
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly IUnitOfWork _uow;
|
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(
|
public SalesPostingService(
|
||||||
IRepository<SalesInvoice> invoices,
|
IRepository<SalesInvoice> invoices,
|
||||||
IRepository<SalesSlip> slips,
|
IRepository<SalesSlip> slips,
|
||||||
@@ -31,7 +32,6 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
IRepository<Item> items,
|
IRepository<Item> items,
|
||||||
IFifoCostingService fifo,
|
IFifoCostingService fifo,
|
||||||
ISalesDomainService sales,
|
ISalesDomainService sales,
|
||||||
IUomConverter uomConverter,
|
|
||||||
ICurrentUser currentUser,
|
ICurrentUser currentUser,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
@@ -41,7 +41,6 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
_items = items;
|
_items = items;
|
||||||
_fifo = fifo;
|
_fifo = fifo;
|
||||||
_sales = sales;
|
_sales = sales;
|
||||||
_uomConverter = uomConverter;
|
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
}
|
}
|
||||||
@@ -60,18 +59,20 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
{
|
{
|
||||||
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
|
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
|
||||||
continue;
|
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);
|
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||||
if (available >= requestedQty) continue;
|
if (available >= requestedQty) continue;
|
||||||
|
|
||||||
var item = await _items.Query().AsNoTracking()
|
var item = await _items.Query().AsNoTracking()
|
||||||
.Where(x => x.ItemId == line.ItemId)
|
.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);
|
.FirstAsync(ct);
|
||||||
|
|
||||||
issues.Add(new SalesInvoicePostingIssueDto(
|
issues.Add(new SalesInvoicePostingIssueDto(
|
||||||
line.SalesInvoiceLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId,
|
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);
|
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))
|
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
|
||||||
continue;
|
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);
|
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||||
if (available >= requestedQty) continue;
|
if (available >= requestedQty) continue;
|
||||||
|
|
||||||
var item = await _items.Query().AsNoTracking()
|
var item = await _items.Query().AsNoTracking()
|
||||||
.Where(x => x.ItemId == line.ItemId)
|
.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);
|
.FirstAsync(ct);
|
||||||
|
|
||||||
issues.Add(new SalesSlipPostingIssueDto(
|
issues.Add(new SalesSlipPostingIssueDto(
|
||||||
line.SalesSlipLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId,
|
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);
|
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, issues.Count == 0, issues);
|
||||||
@@ -124,12 +126,15 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
var item = await _items.Query().AsNoTracking()
|
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);
|
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||||
if (available >= line.Qty) continue;
|
if (available >= line.QtyBase) continue;
|
||||||
|
|
||||||
issues.Add(new BundleSalePostingIssueDto(
|
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);
|
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.",
|
notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.",
|
||||||
statusSelector: x => x.Status,
|
statusSelector: x => x.Status,
|
||||||
ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.",
|
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,
|
setPosted: x => x.Status = SalesInvoiceStatus.Posted,
|
||||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||||
sourceDocType: DocumentTypes.SalesInvoice,
|
sourceDocType: DocumentTypes.SalesInvoice,
|
||||||
@@ -154,7 +159,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
notFoundMessage: $"Sales slip {salesSlipId} was not found.",
|
notFoundMessage: $"Sales slip {salesSlipId} was not found.",
|
||||||
statusSelector: x => x.Status,
|
statusSelector: x => x.Status,
|
||||||
ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.",
|
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,
|
setPosted: x => x.Status = SalesSlipStatus.Posted,
|
||||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||||
sourceDocType: DocumentTypes.SalesSlip,
|
sourceDocType: DocumentTypes.SalesSlip,
|
||||||
@@ -167,9 +172,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
notFoundMessage: $"Bundle sale {bundleSaleId} was not found.",
|
notFoundMessage: $"Bundle sale {bundleSaleId} was not found.",
|
||||||
statusSelector: x => x.Status,
|
statusSelector: x => x.Status,
|
||||||
ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.",
|
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
|
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.QtyBase, l.QtyBase, 0m)),
|
||||||
// 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)),
|
|
||||||
setPosted: x => x.Status = BundleSaleStatus.Posted,
|
setPosted: x => x.Status = BundleSaleStatus.Posted,
|
||||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||||
sourceDocType: DocumentTypes.BundleSale,
|
sourceDocType: DocumentTypes.BundleSale,
|
||||||
@@ -214,5 +217,11 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
}, ct);
|
}, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record PostingLine(int ItemId, int WarehouseId, int UomId, decimal Qty, decimal PaidQty, decimal FreeQty);
|
/// <summary>
|
||||||
|
/// A line reduced to what posting needs. Every quantity here is in the item's <b>base</b>
|
||||||
|
/// UOM, taken from the snapshot the document service resolved at save — the FIFO engine
|
||||||
|
/// accepts nothing else, and re-resolving at post time would let a factor edited in the
|
||||||
|
/// meantime change what a saved document consumes.
|
||||||
|
/// </summary>
|
||||||
|
private sealed record PostingLine(int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public sealed class SalesSlipService : ISalesSlipService
|
|||||||
private readonly ISalesPostingService _posting;
|
private readonly ISalesPostingService _posting;
|
||||||
private readonly ISalesMappingService _mapping;
|
private readonly ISalesMappingService _mapping;
|
||||||
private readonly ISalesDocumentWorkflowService _workflow;
|
private readonly ISalesDocumentWorkflowService _workflow;
|
||||||
|
private readonly IUomConverter _uomConverter;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly INumberSequenceService _numbers;
|
private readonly INumberSequenceService _numbers;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
@@ -33,9 +34,10 @@ public sealed class SalesSlipService : ISalesSlipService
|
|||||||
public SalesSlipService(
|
public SalesSlipService(
|
||||||
IRepository<SalesSlip> slips, IRepository<Customer> customers, IRepository<Item> items,
|
IRepository<SalesSlip> slips, IRepository<Customer> customers, IRepository<Item> items,
|
||||||
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, IRepository<User> users, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
|
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, IRepository<User> users, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
|
||||||
ISalesDocumentWorkflowService workflow,
|
ISalesDocumentWorkflowService workflow, IUomConverter uomConverter,
|
||||||
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
|
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
|
_uomConverter = uomConverter;
|
||||||
_slips = slips;
|
_slips = slips;
|
||||||
_customers = customers;
|
_customers = customers;
|
||||||
_items = items;
|
_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);
|
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
|
lines.Add(new SalesSlipLine
|
||||||
{
|
{
|
||||||
ItemId = r.ItemId,
|
ItemId = r.ItemId,
|
||||||
@@ -183,6 +189,9 @@ public sealed class SalesSlipService : ISalesSlipService
|
|||||||
Qty = r.Qty,
|
Qty = r.Qty,
|
||||||
FreeQty = r.FreeQty,
|
FreeQty = r.FreeQty,
|
||||||
UomId = r.UomId,
|
UomId = r.UomId,
|
||||||
|
QtyBase = UomConverter.ApplyFactor(r.Qty, factor),
|
||||||
|
FreeQtyBase = UomConverter.ApplyFactor(r.FreeQty, factor),
|
||||||
|
ConversionFactor = factor,
|
||||||
WarehouseId = r.WarehouseId,
|
WarehouseId = r.WarehouseId,
|
||||||
UnitPrice = unitPrice,
|
UnitPrice = unitPrice,
|
||||||
BaseCost = unitPrice,
|
BaseCost = unitPrice,
|
||||||
|
|||||||
@@ -14,17 +14,32 @@ public sealed class StockService : IStockService
|
|||||||
private readonly IRepository<StockLayer> _layers;
|
private readonly IRepository<StockLayer> _layers;
|
||||||
private readonly IRepository<StockLedger> _ledger;
|
private readonly IRepository<StockLedger> _ledger;
|
||||||
private readonly IRepository<StockTransferLine> _transferLines;
|
private readonly IRepository<StockTransferLine> _transferLines;
|
||||||
|
private readonly IRepository<Item> _items;
|
||||||
|
|
||||||
public StockService(
|
public StockService(
|
||||||
IFifoCostingService fifo, IRepository<StockLayer> layers,
|
IFifoCostingService fifo, IRepository<StockLayer> layers,
|
||||||
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines)
|
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines,
|
||||||
|
IRepository<Item> items)
|
||||||
{
|
{
|
||||||
_fifo = fifo;
|
_fifo = fifo;
|
||||||
_layers = layers;
|
_layers = layers;
|
||||||
_ledger = ledger;
|
_ledger = ledger;
|
||||||
_transferLines = transferLines;
|
_transferLines = transferLines;
|
||||||
|
_items = items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base UOM (id + name) for a set of items, as one query. Stock reads are already
|
||||||
|
/// set-based to avoid N+1; this keeps the UOM label on the same footing.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<Dictionary<int, (int Id, string Name)>> BaseUomsAsync(
|
||||||
|
IReadOnlyCollection<int> itemIds, CancellationToken ct)
|
||||||
|
=> (await _items.Query().AsNoTracking()
|
||||||
|
.Where(i => itemIds.Contains(i.ItemId))
|
||||||
|
.Select(i => new { i.ItemId, i.BaseUomId, Name = i.BaseUom!.Name })
|
||||||
|
.ToListAsync(ct))
|
||||||
|
.ToDictionary(x => x.ItemId, x => (x.BaseUomId, x.Name));
|
||||||
|
|
||||||
public async Task<StockOnHandDto> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
public async Task<StockOnHandDto> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var onHand = await _fifo.GetOnHandAsync(itemId, warehouseId, ct);
|
var onHand = await _fifo.GetOnHandAsync(itemId, warehouseId, ct);
|
||||||
@@ -47,7 +62,10 @@ public sealed class StockService : IStockService
|
|||||||
const decimal reserved = 0m;
|
const decimal reserved = 0m;
|
||||||
var available = onHand - onHold - reserved;
|
var available = onHand - onHold - reserved;
|
||||||
|
|
||||||
return new StockOnHandDto(itemId, warehouseId, onHand, available, onHold, inTransit, reserved, DateTime.UtcNow);
|
var uom = (await BaseUomsAsync([itemId], ct)).GetValueOrDefault(itemId);
|
||||||
|
return new StockOnHandDto(
|
||||||
|
itemId, warehouseId, onHand, available, onHold, inTransit, reserved, DateTime.UtcNow,
|
||||||
|
uom.Id, uom.Name ?? string.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -100,16 +118,20 @@ public sealed class StockService : IStockService
|
|||||||
.ToListAsync(ct))
|
.ToListAsync(ct))
|
||||||
.ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty);
|
.ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty);
|
||||||
|
|
||||||
|
var baseUoms = await BaseUomsAsync(itemIds, ct);
|
||||||
|
|
||||||
var asOf = DateTime.UtcNow;
|
var asOf = DateTime.UtcNow;
|
||||||
var rows = page.Select(p =>
|
var rows = page.Select(p =>
|
||||||
{
|
{
|
||||||
var key = (p.ItemId, p.WarehouseId);
|
var key = (p.ItemId, p.WarehouseId);
|
||||||
var hold = onHold.GetValueOrDefault(key);
|
var hold = onHold.GetValueOrDefault(key);
|
||||||
var transit = inTransit.GetValueOrDefault(key);
|
var transit = inTransit.GetValueOrDefault(key);
|
||||||
|
var uom = baseUoms.GetValueOrDefault(p.ItemId);
|
||||||
const decimal reserved = 0m;
|
const decimal reserved = 0m;
|
||||||
// Same formula as GetOnHandAsync: in-transit is reported, not re-subtracted.
|
// Same formula as GetOnHandAsync: in-transit is reported, not re-subtracted.
|
||||||
return new StockOnHandDto(
|
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();
|
}).ToList();
|
||||||
|
|
||||||
return PagedResponse<StockOnHandDto>.Create(rows, query.Page, query.PageSize, total);
|
return PagedResponse<StockOnHandDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
@@ -139,9 +161,20 @@ public sealed class StockService : IStockService
|
|||||||
l.SourceDocType, l.SourceDocId, l.UserId, l.CreatedAt))
|
l.SourceDocType, l.SourceDocId, l.UserId, l.CreatedAt))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var baseUoms = await BaseUomsAsync(rows.Select(r => r.ItemId).Distinct().ToList(), ct);
|
||||||
|
rows = rows.Select(r =>
|
||||||
|
{
|
||||||
|
var uom = baseUoms.GetValueOrDefault(r.ItemId);
|
||||||
|
return r with { BaseUomId = uom.Id, BaseUomName = uom.Name ?? string.Empty };
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
return PagedResponse<StockLedgerRowDto>.Create(rows, query.Page, query.PageSize, total);
|
return PagedResponse<StockLedgerRowDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
public async Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||||
=> _fifo.GetValuationAsync(itemId, warehouseId, ct);
|
{
|
||||||
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Dtos.Uoms;
|
||||||
using ERPCore.Repositories.Interfaces;
|
using ERPCore.Repositories.Interfaces;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
@@ -11,28 +12,128 @@ namespace ERPCore.Services.Stock;
|
|||||||
/// unchanged from the <c>GrnService.ToBaseAsync</c> it was extracted from, so the GRN
|
/// unchanged from the <c>GrnService.ToBaseAsync</c> it was extracted from, so the GRN
|
||||||
/// receive path keeps costing exactly as before.
|
/// receive path keeps costing exactly as before.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Conversions are one-directional by design: a row always reads
|
||||||
|
/// <c>FromUom → ToUom = item.BaseUomId</c>, enforced on write by
|
||||||
|
/// <c>ItemService.UpdateUomConversionsAsync</c>. Nothing here inverts a factor, so a row
|
||||||
|
/// stored in the opposite direction would be invisible to every caller — which is exactly
|
||||||
|
/// why the write side rejects it rather than this side guessing.
|
||||||
|
/// </remarks>
|
||||||
public sealed class UomConverter : IUomConverter
|
public sealed class UomConverter : IUomConverter
|
||||||
{
|
{
|
||||||
private readonly IRepository<UomConversion> _conversions;
|
/// <summary>Quantity columns are <c>(18,4)</c> across the model.</summary>
|
||||||
|
private const int QtyScale = 4;
|
||||||
|
|
||||||
public UomConverter(IRepository<UomConversion> conversions) => _conversions = conversions;
|
/// <summary>Unit-cost columns and <c>uom_conversions.Factor</c> are <c>(18,6)</c>.</summary>
|
||||||
|
private const int CostScale = 6;
|
||||||
|
|
||||||
|
private readonly IRepository<UomConversion> _conversions;
|
||||||
|
private readonly IRepository<Item> _items;
|
||||||
|
private readonly IRepository<Uom> _uoms;
|
||||||
|
|
||||||
|
public UomConverter(IRepository<UomConversion> conversions, IRepository<Item> items, IRepository<Uom> uoms)
|
||||||
|
{
|
||||||
|
_conversions = conversions;
|
||||||
|
_items = items;
|
||||||
|
_uoms = uoms;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
|
public async Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
|
||||||
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default)
|
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);
|
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 —
|
// Quantity scales up by the factor, so the per-unit cost scales down by it —
|
||||||
// total value is preserved.
|
// total value is preserved. Round to each column's own scale here rather than
|
||||||
return (qty * conv.Factor, unitCostPerUom / conv.Factor);
|
// letting the provider truncate on write, so what posts is what was computed.
|
||||||
|
return (
|
||||||
|
Math.Round(qty * factor, QtyScale, MidpointRounding.AwayFromZero),
|
||||||
|
Math.Round(unitCostPerUom / factor, CostScale, MidpointRounding.AwayFromZero));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default)
|
public async Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default)
|
||||||
=> (await ToBaseAsync(item, uomId, qty, 0m, ct)).QtyBase;
|
=> (await ToBaseAsync(item, uomId, qty, 0m, ct)).QtyBase;
|
||||||
|
|
||||||
|
public async Task<decimal> ResolveFactorAsync(Item item, int uomId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (uomId == item.BaseUomId)
|
||||||
|
return 1m;
|
||||||
|
|
||||||
|
var conv = await _conversions.Query().AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct);
|
||||||
|
|
||||||
|
if (conv is null)
|
||||||
|
throw await NoConversionAsync(item, uomId, ct);
|
||||||
|
|
||||||
|
return conv.Factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<AllowedUomDto>> GetAllowedUomsAsync(int itemId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var item = await _items.Query().AsNoTracking()
|
||||||
|
.Where(i => i.ItemId == itemId)
|
||||||
|
.Select(i => new { i.ItemId, i.BaseUomId, BaseUomName = i.BaseUom!.Name })
|
||||||
|
.FirstOrDefaultAsync(ct)
|
||||||
|
?? throw new NotFoundException($"Item {itemId} was not found.");
|
||||||
|
|
||||||
|
var converted = await _conversions.Query().AsNoTracking()
|
||||||
|
.Where(c => c.ItemId == itemId && c.ToUomId == item.BaseUomId && c.FromUomId != item.BaseUomId)
|
||||||
|
.Select(c => new AllowedUomDto(c.FromUomId, c.FromUom!.Name, c.Factor, false))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// Base first — it is what every form defaults to — then the alternates by name.
|
||||||
|
return converted
|
||||||
|
.OrderBy(u => u.Name)
|
||||||
|
.Prepend(new AllowedUomDto(item.BaseUomId, item.BaseUomName, 1m, true))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ValidateUomAsync(Item item, int uomId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (uomId == item.BaseUomId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var exists = await _conversions.Query().AsNoTracking()
|
||||||
|
.AnyAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct);
|
||||||
|
|
||||||
|
if (!exists)
|
||||||
|
throw await NoConversionAsync(item, uomId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies an already-resolved factor to a quantity, rounded to the quantity scale.
|
||||||
|
/// Callers that snapshot a line use this so every base quantity in the system is
|
||||||
|
/// derived and rounded identically.
|
||||||
|
/// </summary>
|
||||||
|
public static decimal ApplyFactor(decimal qty, decimal factor)
|
||||||
|
=> Math.Round(qty * factor, QtyScale, MidpointRounding.AwayFromZero);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restates a base quantity in <paramref name="factor"/>'s UOM. <b>Display only</b> —
|
||||||
|
/// this divides, so it can drift, and its result must never reach a stock or ledger
|
||||||
|
/// write. Conversion toward base is the authoritative direction.
|
||||||
|
/// </summary>
|
||||||
|
public static decimal FromBase(decimal qtyBase, decimal factor)
|
||||||
|
=> factor == 0m ? 0m : Math.Round(qtyBase / factor, QtyScale, MidpointRounding.AwayFromZero);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the 422 for an unconvertible UOM. Names the units the item actually accepts,
|
||||||
|
/// because the bare id in the old message told the user nothing about how to recover.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<DomainException> NoConversionAsync(Item item, int uomId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var attempted = await _uoms.Query().AsNoTracking()
|
||||||
|
.Where(u => u.UomId == uomId)
|
||||||
|
.Select(u => u.Name)
|
||||||
|
.FirstOrDefaultAsync(ct) ?? $"#{uomId}";
|
||||||
|
|
||||||
|
var allowed = await GetAllowedUomsAsync(item.ItemId, ct);
|
||||||
|
|
||||||
|
return new DomainException(ErrorCodes.Validation,
|
||||||
|
$"Item {item.Sku} cannot be transacted in {attempted}. Allowed units: " +
|
||||||
|
$"{string.Join(", ", allowed.Select(u => u.Name))}. " +
|
||||||
|
"Add a UOM conversion on the item to use another unit.", 422);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -25,6 +25,11 @@ SCRIPTS = [
|
|||||||
("M4b UOM conversion on stock inputs", "m4b_uom_conversion.py"),
|
("M4b UOM conversion on stock inputs", "m4b_uom_conversion.py"),
|
||||||
("M5 terminal receipt + cost pool", "m5_receipt.py"),
|
("M5 terminal receipt + cost pool", "m5_receipt.py"),
|
||||||
("M6+M7 leftover / rework / cancel", "m6_m7_leftover_rework_cancel.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")
|
SUMMARY = re.compile(r"^(\S+): (\d+)/(\d+) assertions passed")
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -7,6 +7,7 @@ import { AlertTriangle, ArrowLeft, Ban, Check, Plus, Save, Trash2 } from "lucide
|
|||||||
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
|
import { uomName } from "@/lib/uom"
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
import { vendorsApi } from "@/lib/api/vendors"
|
import { vendorsApi } from "@/lib/api/vendors"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -111,8 +112,8 @@ export default function PurchaseOrderDetailPage() {
|
|||||||
function itemFor(itemId: number | null) {
|
function itemFor(itemId: number | null) {
|
||||||
return items.find((i) => i.itemId === itemId) ?? null
|
return items.find((i) => i.itemId === itemId) ?? null
|
||||||
}
|
}
|
||||||
function uomName(uomId: number) {
|
function uomLabel(uomId: number) {
|
||||||
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
|
return uomName(uomId, uoms)
|
||||||
}
|
}
|
||||||
function warehouseCode(warehouseId: number) {
|
function warehouseCode(warehouseId: number) {
|
||||||
return warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? `#${warehouseId}`
|
return warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? `#${warehouseId}`
|
||||||
@@ -376,7 +377,7 @@ export default function PurchaseOrderDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<TableRow key={line.key}>
|
<TableRow key={line.key}>
|
||||||
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{line.uomId ? uomName(line.uomId) : "—"}</TableCell>
|
<TableCell className="px-3 py-3.5">{line.uomId ? uomLabel(line.uomId) : "—"}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{line.warehouseId ? warehouseCode(line.warehouseId) : "—"}</TableCell>
|
<TableCell className="px-3 py-3.5">{line.warehouseId ? warehouseCode(line.warehouseId) : "—"}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{line.qtyReceived}</TableCell>
|
<TableCell className="px-3 py-3.5">{line.qtyReceived}</TableCell>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { itemsApi } from "@/lib/api/items"
|
|||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { validatePoLine } from "@/lib/validations/procurement"
|
import { validatePoLine } from "@/lib/validations/procurement"
|
||||||
|
import { pickerOptions } from "@/lib/uom"
|
||||||
|
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { generateVendorCode } from "@/lib/vendor-code"
|
import { generateVendorCode } from "@/lib/vendor-code"
|
||||||
import { CreatePoLineInput } from "@/types/procurement"
|
import { CreatePoLineInput } from "@/types/procurement"
|
||||||
@@ -81,6 +83,7 @@ function NewPurchaseOrderContent() {
|
|||||||
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const allowedUoms = useAllowedUoms()
|
||||||
|
|
||||||
const [vendorDialogOpen, setVendorDialogOpen] = useState(false)
|
const [vendorDialogOpen, setVendorDialogOpen] = useState(false)
|
||||||
const [vName, setVName] = useState("")
|
const [vName, setVName] = useState("")
|
||||||
@@ -102,7 +105,8 @@ function NewPurchaseOrderContent() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
loadItems(),
|
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)),
|
warehousesApi.list().then((wh) => setWarehouses(wh.items)),
|
||||||
loadVendors(),
|
loadVendors(),
|
||||||
]).catch((err) => setLoadError(errorMessage(err)))
|
]).catch((err) => setLoadError(errorMessage(err)))
|
||||||
@@ -428,7 +432,17 @@ function NewPurchaseOrderContent() {
|
|||||||
<div className="flex h-11 items-center truncate text-base" title={item ? `${item.sku} — ${item.name}` : undefined}>{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</div>
|
<div className="flex h-11 items-center truncate text-base" title={item ? `${item.sku} — ${item.name}` : undefined}>{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
<Select<number | null>
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className="h-11! w-full text-base"
|
className="h-11! w-full text-base"
|
||||||
aria-invalid={!!errors.itemId}
|
aria-invalid={!!errors.itemId}
|
||||||
@@ -454,7 +468,7 @@ function NewPurchaseOrderContent() {
|
|||||||
<SelectValue placeholder="UOM" />
|
<SelectValue placeholder="UOM" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(uoms ?? []).map((u) => (
|
{pickerOptions(allowedUoms.get(line.itemId), uoms ?? []).map((u) => (
|
||||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||||
{u.name}
|
{u.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { Plus, Trash2 } from "lucide-react"
|
import { Plus, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { pickerOptions } from "@/lib/uom"
|
||||||
|
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
|
||||||
import { CustomFieldType, StageInputSource } from "@/types/production"
|
import { CustomFieldType, StageInputSource } from "@/types/production"
|
||||||
import { ItemListItem, Uom } from "@/types/master-data"
|
import { ItemListItem, Uom } from "@/types/master-data"
|
||||||
import {
|
import {
|
||||||
@@ -46,7 +48,8 @@ function QtyRow({
|
|||||||
}: {
|
}: {
|
||||||
qty: number
|
qty: number
|
||||||
uomId: number | null
|
uomId: number | null
|
||||||
uoms: Uom[]
|
/** Already narrowed to the row's item by the caller — see `pickerOptions`. */
|
||||||
|
uoms: { uomId: number; name: string }[]
|
||||||
readOnly: boolean
|
readOnly: boolean
|
||||||
onQtyChange: (qty: number) => void
|
onQtyChange: (qty: number) => void
|
||||||
onUomChange: (uomId: number) => void
|
onUomChange: (uomId: number) => void
|
||||||
@@ -125,9 +128,12 @@ export function StageEditorPanel({
|
|||||||
updateInput(localId, source === "Stock" ? { source, fromOutputKey: null } : { source, itemId: null })
|
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. */
|
/** Default the UOM to the item's base unit — right most of the time, still overridable. */
|
||||||
function pickInputItem(input: BuilderInput, itemId: number) {
|
function pickInputItem(input: BuilderInput, itemId: number) {
|
||||||
const item = items.find((i) => i.itemId === itemId)
|
const item = items.find((i) => i.itemId === itemId)
|
||||||
|
allowedUoms.load(itemId)
|
||||||
updateInput(input.localId, { itemId, uomId: input.uomId ?? item?.baseUomId ?? null })
|
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. */
|
/** The terminal output's name mirrors the finished item, so the two can't drift apart. */
|
||||||
function pickOutputItem(output: BuilderOutput, itemId: number) {
|
function pickOutputItem(output: BuilderOutput, itemId: number) {
|
||||||
const item = items.find((i) => i.itemId === itemId)
|
const item = items.find((i) => i.itemId === itemId)
|
||||||
|
allowedUoms.load(itemId)
|
||||||
updateOutput(output.key, {
|
updateOutput(output.key, {
|
||||||
itemId,
|
itemId,
|
||||||
name: item?.name ?? output.name,
|
name: item?.name ?? output.name,
|
||||||
@@ -288,7 +295,7 @@ export function StageEditorPanel({
|
|||||||
<QtyRow
|
<QtyRow
|
||||||
qty={input.qtyPerBatch}
|
qty={input.qtyPerBatch}
|
||||||
uomId={input.uomId}
|
uomId={input.uomId}
|
||||||
uoms={uoms}
|
uoms={pickerOptions(allowedUoms.get(input.itemId), uoms)}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
onQtyChange={(qtyPerBatch) => updateInput(input.localId, { qtyPerBatch })}
|
onQtyChange={(qtyPerBatch) => updateInput(input.localId, { qtyPerBatch })}
|
||||||
onUomChange={(uomId) => updateInput(input.localId, { uomId })}
|
onUomChange={(uomId) => updateInput(input.localId, { uomId })}
|
||||||
@@ -348,7 +355,7 @@ export function StageEditorPanel({
|
|||||||
<QtyRow
|
<QtyRow
|
||||||
qty={output.qtyPerBatch}
|
qty={output.qtyPerBatch}
|
||||||
uomId={output.uomId}
|
uomId={output.uomId}
|
||||||
uoms={uoms}
|
uoms={pickerOptions(allowedUoms.get(output.itemId), uoms)}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
onQtyChange={(qtyPerBatch) => updateOutput(output.key, { qtyPerBatch })}
|
onQtyChange={(qtyPerBatch) => updateOutput(output.key, { qtyPerBatch })}
|
||||||
onUomChange={(uomId) => updateOutput(output.key, { uomId })}
|
onUomChange={(uomId) => updateOutput(output.key, { uomId })}
|
||||||
|
|||||||
@@ -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 `<other> → 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<DraftRow[]>(() =>
|
||||||
|
conversions.map((c) => ({ key: nextKey(), fromUom: c.fromUom, factor: String(c.factor) })),
|
||||||
|
)
|
||||||
|
const [errors, setErrors] = useState<Record<string, Record<string, string>>>({})
|
||||||
|
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<DraftRow>) {
|
||||||
|
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<string, Record<string, string>> = {}
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-4 rounded-lg border border-border bg-card p-5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label className="text-base font-semibold">UOM conversions</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
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}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No conversions — this item can only be transacted in {baseName}.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-64">Unit</TableHead>
|
||||||
|
<TableHead className="w-40 text-right">Factor</TableHead>
|
||||||
|
<TableHead>Converts to</TableHead>
|
||||||
|
<TableHead className="w-16" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<TableRow key={row.key}>
|
||||||
|
<TableCell>
|
||||||
|
<Select<number | null>
|
||||||
|
value={row.fromUom}
|
||||||
|
onValueChange={(v) => updateRow(row.key, { fromUom: v })}
|
||||||
|
items={selectableUoms.map((u) => ({ label: u.name, value: u.uomId }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors[row.key]?.fromUom}>
|
||||||
|
<SelectValue placeholder="Select unit" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{selectableUoms.map((u) => (
|
||||||
|
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||||
|
{u.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldError errors={[errors[row.key]?.fromUom ? { message: errors[row.key].fromUom } : undefined]} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.000001"
|
||||||
|
value={row.factor}
|
||||||
|
onChange={(e) => updateRow(row.key, { factor: e.target.value })}
|
||||||
|
aria-invalid={!!errors[row.key]?.factor}
|
||||||
|
className="text-right"
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors[row.key]?.factor ? { message: errors[row.key].factor } : undefined]} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-base text-muted-foreground">
|
||||||
|
{row.factor && Number(row.factor) > 0
|
||||||
|
? `1 ${uoms.find((u) => u.uomId === row.fromUom)?.name ?? "unit"} = ${Number(row.factor)} ${baseName}`
|
||||||
|
: baseName}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => removeRow(row.key)} aria-label="Remove conversion">
|
||||||
|
<Trash2 className="size-5 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<Button variant="outline" onClick={addRow}>
|
||||||
|
<Plus className="size-5" />
|
||||||
|
Add conversion
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
<Save className="size-5" />
|
||||||
|
{saving ? "Saving…" : "Save conversions"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,9 +11,12 @@ import { uomsApi } from "@/lib/api/uoms"
|
|||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||||
import { validateItemForm } from "@/lib/validations/master-data"
|
import { validateItemForm } from "@/lib/validations/master-data"
|
||||||
|
import { uomName } from "@/lib/uom"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Item, StockNature, TrackingMode } from "@/types/master-data"
|
import { Item, StockNature, TrackingMode } from "@/types/master-data"
|
||||||
|
|
||||||
|
import { UomConversionsPanel } from "./UomConversionsPanel"
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -92,7 +95,9 @@ export default function ItemDetailPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Number.isFinite(itemId)) return
|
if (!Number.isFinite(itemId)) return
|
||||||
load()
|
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]) => {
|
.then(([cat, uo, wh]) => {
|
||||||
setCategories(cat.items)
|
setCategories(cat.items)
|
||||||
setUoms(uo.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) {
|
if (loadError && !item) {
|
||||||
return (
|
return (
|
||||||
@@ -321,8 +323,19 @@ export default function ItemDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{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).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<UomConversionsPanel
|
||||||
|
// Remount when the saved set changes so the editor's draft rows re-seed from the
|
||||||
|
// server's response rather than keeping stale local state after a save or reload.
|
||||||
|
key={item.conversions.map((c) => `${c.conversionId}:${c.factor}`).join("|")}
|
||||||
|
itemId={item.itemId}
|
||||||
|
baseUomId={item.baseUomId}
|
||||||
|
conversions={item.conversions}
|
||||||
|
uoms={uoms}
|
||||||
|
onSaved={load}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { CheckCircle2, PackageCheck, PackageX, ShieldAlert, XCircle } from "luci
|
|||||||
import { grnsApi } from "@/lib/api/grns"
|
import { grnsApi } from "@/lib/api/grns"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
|
import { uomName } from "@/lib/uom"
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -57,7 +58,7 @@ export default function GrnDetailPage() {
|
|||||||
return items.find((i) => i.itemId === itemId)
|
return items.find((i) => i.itemId === itemId)
|
||||||
}
|
}
|
||||||
function uomFor(uomId: number) {
|
function uomFor(uomId: number) {
|
||||||
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
|
return uomName(uomId, uoms)
|
||||||
}
|
}
|
||||||
function binFor(binId: number | null) {
|
function binFor(binId: number | null) {
|
||||||
if (!binId) return "—"
|
if (!binId) return "—"
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { itemsApi } from "@/lib/api/items"
|
|||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { validateLine, splitSerials, grnHeaderSchema } from "@/lib/validations/grn"
|
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 { cn } from "@/lib/utils"
|
||||||
import { CreateGrnLineInput, HoldStatus } from "@/types/grn"
|
import { CreateGrnLineInput, HoldStatus } from "@/types/grn"
|
||||||
import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement"
|
import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement"
|
||||||
@@ -108,12 +110,14 @@ export default function NewGrnPage() {
|
|||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [refreshingItems, setRefreshingItems] = useState(false)
|
const [refreshingItems, setRefreshingItems] = useState(false)
|
||||||
|
const allowedUoms = useAllowedUoms()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
warehousesApi.list(),
|
warehousesApi.list(),
|
||||||
itemsApi.list({ pageSize: 200, status: "Active" }),
|
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" }),
|
vendorsApi.list({ pageSize: 200, status: "Active" }),
|
||||||
purchaseOrdersApi.list({ pageSize: 200 }),
|
purchaseOrdersApi.list({ pageSize: 200 }),
|
||||||
])
|
])
|
||||||
@@ -155,13 +159,18 @@ export default function NewGrnPage() {
|
|||||||
setHeaderError(null)
|
setHeaderError(null)
|
||||||
try {
|
try {
|
||||||
const po: PurchaseOrder = await purchaseOrdersApi.get(nextPoId)
|
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) {
|
if (openLines.length === 0) {
|
||||||
setHeaderError("This purchase order has no open (unreceived) lines.")
|
setHeaderError("This purchase order has no open (unreceived) lines.")
|
||||||
setLines([])
|
setLines([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setWarehouseId((prev) => prev ?? openLines[0].warehouseId)
|
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(
|
setLines(
|
||||||
openLines.map(
|
openLines.map(
|
||||||
(l): DraftLine => ({
|
(l): DraftLine => ({
|
||||||
@@ -469,7 +478,16 @@ export default function NewGrnPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
<Select<number | null>
|
||||||
|
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 })
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||||
<SelectValue placeholder="Select item" />
|
<SelectValue placeholder="Select item" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -488,7 +506,7 @@ export default function NewGrnPage() {
|
|||||||
<TableCell className="px-3 py-3 align-top">
|
<TableCell className="px-3 py-3 align-top">
|
||||||
{line.poLineId ? (
|
{line.poLineId ? (
|
||||||
<div className="flex h-11 items-center text-base">
|
<div className="flex h-11 items-center text-base">
|
||||||
{uoms?.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}
|
{uomName(line.uomId, uoms ?? [])}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -497,7 +515,7 @@ export default function NewGrnPage() {
|
|||||||
<SelectValue placeholder="UOM" />
|
<SelectValue placeholder="UOM" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(uoms ?? []).map((u) => (
|
{pickerOptions(allowedUoms.get(line.itemId), uoms ?? []).map((u) => (
|
||||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||||
{u.name}
|
{u.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -532,6 +550,12 @@ export default function NewGrnPage() {
|
|||||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
||||||
className="h-11 text-base"
|
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) ?? []) && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{basePreview(Number(line.qty), line.uomId, allowedUoms.get(line.itemId) ?? [])}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<TableCell className="px-3 py-3 align-top">
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { bundleApi } from "@/lib/api/bundles"
|
|||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
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 { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -55,6 +57,7 @@ export default function BundleSaleDetailPage() {
|
|||||||
const bundleSaleId = Number(params.id)
|
const bundleSaleId = Number(params.id)
|
||||||
const [bundle, setBundle] = useState<BundleSale | null>(null)
|
const [bundle, setBundle] = useState<BundleSale | null>(null)
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
|
const allowedUoms = useAllowedUoms()
|
||||||
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
|
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
|
||||||
const [template, setTemplate] = useState<BundleSaleTemplate | null>(null)
|
const [template, setTemplate] = useState<BundleSaleTemplate | null>(null)
|
||||||
const [customers, setCustomers] = useState<Customer[]>([])
|
const [customers, setCustomers] = useState<Customer[]>([])
|
||||||
@@ -102,12 +105,16 @@ export default function BundleSaleDetailPage() {
|
|||||||
setTemplateId(data.bundleSaleTemplateId)
|
setTemplateId(data.bundleSaleTemplateId)
|
||||||
setBundleName(data.bundleName)
|
setBundleName(data.bundleName)
|
||||||
setBundlePrice(data.bundlePrice)
|
setBundlePrice(data.bundlePrice)
|
||||||
|
data.lines.forEach((line) => allowedUoms.load(line.itemId))
|
||||||
setLines(
|
setLines(
|
||||||
data.lines.map((line) => ({
|
data.lines.map((line) => ({
|
||||||
key: `${line.bundleSaleLineId}`,
|
key: `${line.bundleSaleLineId}`,
|
||||||
bundleSaleTemplateLineId: line.bundleSaleLineId,
|
bundleSaleTemplateLineId: line.bundleSaleLineId,
|
||||||
itemId: line.itemId,
|
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,
|
warehouseId: line.warehouseId,
|
||||||
qty: line.qty,
|
qty: line.qty,
|
||||||
unitPrice: line.unitPrice,
|
unitPrice: line.unitPrice,
|
||||||
@@ -377,6 +384,7 @@ export default function BundleSaleDetailPage() {
|
|||||||
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
|
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
|
||||||
const itemId = Number(v)
|
const itemId = Number(v)
|
||||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||||
|
allowedUoms.load(itemId)
|
||||||
updateLine(line.key, { itemId, uomId: item?.baseUomId ?? line.uomId, unitPrice: item?.salePrice ?? line.unitPrice })
|
updateLine(line.key, { itemId, uomId: item?.baseUomId ?? line.uomId, unitPrice: item?.salePrice ?? line.unitPrice })
|
||||||
}} disabled={!editing || !isDraft}>
|
}} disabled={!editing || !isDraft}>
|
||||||
<SelectTrigger className="h-9 text-sm">
|
<SelectTrigger className="h-9 text-sm">
|
||||||
@@ -392,12 +400,14 @@ export default function BundleSaleDetailPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="min-w-40">
|
<TableCell className="min-w-40">
|
||||||
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled>
|
{/* Enabled now that bundle lines keep the entered UOM (the server
|
||||||
|
snapshots QtyBase beside it instead of overwriting Qty). */}
|
||||||
|
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled={!editing || !isDraft || !line.itemId}>
|
||||||
<SelectTrigger className="h-9 text-sm">
|
<SelectTrigger className="h-9 text-sm">
|
||||||
<SelectValue placeholder="Select UOM" />
|
<SelectValue placeholder="Select UOM" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{uoms.map((uom) => (
|
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((uom) => (
|
||||||
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
|
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
|
||||||
{uom.name}
|
{uom.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -405,7 +415,12 @@ export default function BundleSaleDetailPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
|
<TableCell className="text-right w-28">
|
||||||
|
<Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" />
|
||||||
|
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? []) && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])}</p>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
|
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{editing && isDraft ? (
|
{editing && isDraft ? (
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { uomsApi } from "@/lib/api/uoms"
|
|||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { usersApi } from "@/lib/api/users"
|
import { usersApi } from "@/lib/api/users"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
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 { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
@@ -61,6 +63,7 @@ function NewBundleSaleContent() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
|
const allowedUoms = useAllowedUoms()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -94,11 +97,12 @@ function NewBundleSaleContent() {
|
|||||||
setLines(
|
setLines(
|
||||||
res.lines.length > 0
|
res.lines.length > 0
|
||||||
? res.lines.map((line) => {
|
? res.lines.map((line) => {
|
||||||
const item = items.find((candidate) => candidate.itemId === line.itemId)
|
// Load each component's allowed units so the picker narrows for template-seeded rows too.
|
||||||
return createBlankLine({
|
allowedUoms.load(line.itemId)
|
||||||
...line,
|
// Keep the template's own UOM. Forcing it to the item's base while keeping the
|
||||||
uomId: item?.baseUomId ?? line.uomId,
|
// template's unitPrice would leave qty and price in different units, and
|
||||||
})
|
// componentSubtotal below multiplies the two.
|
||||||
|
return createBlankLine({ ...line, uomId: line.uomId })
|
||||||
})
|
})
|
||||||
: [createBlankLine()]
|
: [createBlankLine()]
|
||||||
)
|
)
|
||||||
@@ -259,6 +263,7 @@ function NewBundleSaleContent() {
|
|||||||
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
|
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
|
||||||
const itemId = Number(v)
|
const itemId = Number(v)
|
||||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||||
|
allowedUoms.load(itemId)
|
||||||
updateLine(line.key, {
|
updateLine(line.key, {
|
||||||
itemId,
|
itemId,
|
||||||
uomId: item?.baseUomId ?? line.uomId,
|
uomId: item?.baseUomId ?? line.uomId,
|
||||||
@@ -278,12 +283,14 @@ function NewBundleSaleContent() {
|
|||||||
</Select>
|
</Select>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="min-w-40">
|
<TableCell className="min-w-40">
|
||||||
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled>
|
{/* Enabled now that bundle lines keep the entered UOM: the server
|
||||||
|
snapshots the base quantity beside it instead of overwriting qty. */}
|
||||||
|
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled={!line.itemId}>
|
||||||
<SelectTrigger className="h-9 text-sm">
|
<SelectTrigger className="h-9 text-sm">
|
||||||
<SelectValue placeholder="Select UOM" />
|
<SelectValue placeholder="Select UOM" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{uoms.map((uom) => (
|
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((uom) => (
|
||||||
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
|
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
|
||||||
{uom.name}
|
{uom.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -291,7 +298,14 @@ function NewBundleSaleContent() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="text-right" /></TableCell>
|
<TableCell className="text-right w-28">
|
||||||
|
<Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="text-right" />
|
||||||
|
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? []) && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} className="text-right" /></TableCell>
|
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} className="text-right" /></TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { pickerOptions, uomName as resolveUomName } from "@/lib/uom"
|
||||||
|
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
|
||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
@@ -70,6 +72,7 @@ export default function NewFreeIssuePage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const allowedUoms = useAllowedUoms()
|
||||||
|
|
||||||
async function refreshRows() {
|
async function refreshRows() {
|
||||||
const list = await salesApi.listFreeIssues({ pageSize: 50 })
|
const list = await salesApi.listFreeIssues({ pageSize: 50 })
|
||||||
@@ -88,7 +91,7 @@ export default function NewFreeIssuePage() {
|
|||||||
warehouseName: warehouse?.name ?? `Warehouse ${detail.data.warehouseId}`,
|
warehouseName: warehouse?.name ?? `Warehouse ${detail.data.warehouseId}`,
|
||||||
itemName: item?.name ?? firstLine?.description ?? "—",
|
itemName: item?.name ?? firstLine?.description ?? "—",
|
||||||
itemSku: item?.sku ?? `SKU-${firstLine?.itemId ?? 0}`,
|
itemSku: item?.sku ?? `SKU-${firstLine?.itemId ?? 0}`,
|
||||||
uomName: uom?.name ?? `UOM ${firstLine?.uomId ?? 0}`,
|
uomName: uom?.name ?? resolveUomName(firstLine?.uomId, uoms),
|
||||||
qty: firstLine?.qty ?? 0,
|
qty: firstLine?.qty ?? 0,
|
||||||
freeQty: firstLine?.freeQty ?? 0,
|
freeQty: firstLine?.freeQty ?? 0,
|
||||||
} satisfies FreeIssueRow
|
} satisfies FreeIssueRow
|
||||||
@@ -146,11 +149,13 @@ export default function NewFreeIssuePage() {
|
|||||||
|
|
||||||
function selectItem(key: string, itemId: number) {
|
function selectItem(key: string, itemId: number) {
|
||||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||||
|
allowedUoms.load(itemId)
|
||||||
updateLine(key, { itemId, uomId: item?.baseUomId ?? 0 })
|
updateLine(key, { itemId, uomId: item?.baseUomId ?? 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectEditingItem(key: string, itemId: number) {
|
function selectEditingItem(key: string, itemId: number) {
|
||||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||||
|
allowedUoms.load(itemId)
|
||||||
updateEditingLine(key, { itemId, uomId: item?.baseUomId ?? 0 })
|
updateEditingLine(key, { itemId, uomId: item?.baseUomId ?? 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,7 +330,7 @@ export default function NewFreeIssuePage() {
|
|||||||
<SelectValue placeholder="UOM" />
|
<SelectValue placeholder="UOM" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{uoms.map((u) => (
|
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => (
|
||||||
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
||||||
{u.name}
|
{u.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -390,7 +395,7 @@ export default function NewFreeIssuePage() {
|
|||||||
<SelectValue placeholder="UOM" />
|
<SelectValue placeholder="UOM" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{uoms.map((u) => (
|
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => (
|
||||||
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
||||||
{u.name}
|
{u.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { itemsApi } from "@/lib/api/items"
|
|||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { basePreview, pickerOptions, uomName } from "@/lib/uom"
|
||||||
|
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||||
import { Customer } from "@/types/customers"
|
import { Customer } from "@/types/customers"
|
||||||
@@ -75,6 +77,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [busy, setBusy] = useState<"post" | "cancel" | null>(null)
|
const [busy, setBusy] = useState<"post" | "cancel" | null>(null)
|
||||||
|
const allowedUoms = useAllowedUoms()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Number.isFinite(invoiceId)) {
|
if (!Number.isFinite(invoiceId)) {
|
||||||
@@ -99,6 +102,9 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
setCustomerId(doc.data.customerId)
|
setCustomerId(doc.data.customerId)
|
||||||
setWarehouseId(doc.data.warehouseId)
|
setWarehouseId(doc.data.warehouseId)
|
||||||
setInvoiceType(doc.data.invoiceType)
|
setInvoiceType(doc.data.invoiceType)
|
||||||
|
// Preload allowed units for the items already on the document, so editing an
|
||||||
|
// existing line offers the narrowed list immediately.
|
||||||
|
doc.data.lines.forEach((line) => allowedUoms.load(line.itemId))
|
||||||
setLines(
|
setLines(
|
||||||
doc.data.lines.map((line) => ({
|
doc.data.lines.map((line) => ({
|
||||||
key: String(line.salesInvoiceLineId),
|
key: String(line.salesInvoiceLineId),
|
||||||
@@ -150,6 +156,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
|
|
||||||
function selectItem(key: string, itemId: number) {
|
function selectItem(key: string, itemId: number) {
|
||||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||||
|
allowedUoms.load(itemId)
|
||||||
updateLine(key, {
|
updateLine(key, {
|
||||||
itemId,
|
itemId,
|
||||||
uomId: item?.baseUomId ?? 0,
|
uomId: item?.baseUomId ?? 0,
|
||||||
@@ -353,7 +360,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
<div className="font-medium text-foreground">{line.description}</div>
|
<div className="font-medium text-foreground">{line.description}</div>
|
||||||
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</td>
|
<td className="px-4 py-3">{uomName(line.uomId, uoms)}</td>
|
||||||
<td className="px-4 py-3 text-right">{line.qty.toFixed(0)}</td>
|
<td className="px-4 py-3 text-right">{line.qty.toFixed(0)}</td>
|
||||||
<td className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</td>
|
<td className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</td>
|
||||||
<td className="px-4 py-3 text-right">{money.format(line.unitPrice)}</td>
|
<td className="px-4 py-3 text-right">{money.format(line.unitPrice)}</td>
|
||||||
@@ -503,7 +510,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
||||||
>
|
>
|
||||||
<option value="">UOM</option>
|
<option value="">UOM</option>
|
||||||
{uoms.map((u) => (
|
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => (
|
||||||
<option key={u.uomId} value={String(u.uomId)}>
|
<option key={u.uomId} value={String(u.uomId)}>
|
||||||
{u.name}
|
{u.name}
|
||||||
</option>
|
</option>
|
||||||
@@ -519,6 +526,11 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) || 0 })}
|
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) || 0 })}
|
||||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
||||||
/>
|
/>
|
||||||
|
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? []) && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 w-28">
|
<td className="px-4 py-3 w-28">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ArrowLeft, Printer } from "lucide-react"
|
|||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
|
import { uomName } from "@/lib/uom"
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -143,7 +144,7 @@ export default function SalesInvoicePrintPage({ params }: { params: { id: string
|
|||||||
<div className="font-medium text-foreground">{line.description}</div>
|
<div className="font-medium text-foreground">{line.description}</div>
|
||||||
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
|
<TableCell>{uomName(line.uomId, uoms)}</TableCell>
|
||||||
<TableCell className="text-right">{line.qty.toFixed(2)}</TableCell>
|
<TableCell className="text-right">{line.qty.toFixed(2)}</TableCell>
|
||||||
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"}</TableCell>
|
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"}</TableCell>
|
||||||
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
|
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { cn } from "@/lib/utils"
|
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 { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
@@ -71,6 +73,7 @@ export default function NewSalesInvoicePage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const allowedUoms = useAllowedUoms()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -129,6 +132,9 @@ export default function NewSalesInvoicePage() {
|
|||||||
|
|
||||||
function selectItem(key: string, itemId: number) {
|
function selectItem(key: string, itemId: number) {
|
||||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||||
|
// Load the units this item can actually be sold in, so the UOM picker below narrows from
|
||||||
|
// the global list to base + defined conversions.
|
||||||
|
allowedUoms.load(itemId)
|
||||||
updateLine(key, {
|
updateLine(key, {
|
||||||
itemId,
|
itemId,
|
||||||
uomId: item?.baseUomId ?? 0,
|
uomId: item?.baseUomId ?? 0,
|
||||||
@@ -363,7 +369,7 @@ export default function NewSalesInvoicePage() {
|
|||||||
<SelectValue placeholder="UOM" />
|
<SelectValue placeholder="UOM" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{uoms.map((u) => (
|
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => (
|
||||||
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
||||||
{u.name}
|
{u.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -380,6 +386,12 @@ export default function NewSalesInvoicePage() {
|
|||||||
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })}
|
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })}
|
||||||
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
|
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) ?? []) && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-2">
|
<TableCell className="px-4 py-2">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, X } from "lu
|
|||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
|
import { basePreview, pickerOptions, uomName } from "@/lib/uom"
|
||||||
|
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { usersApi } from "@/lib/api/users"
|
import { usersApi } from "@/lib/api/users"
|
||||||
@@ -71,6 +73,7 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [actionBusy, setActionBusy] = useState<"post" | "cancel" | null>(null)
|
const [actionBusy, setActionBusy] = useState<"post" | "cancel" | null>(null)
|
||||||
|
const allowedUoms = useAllowedUoms()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Number.isFinite(slipId)) {
|
if (!Number.isFinite(slipId)) {
|
||||||
@@ -97,6 +100,7 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
|||||||
setWarehouseId(doc.data.warehouseId)
|
setWarehouseId(doc.data.warehouseId)
|
||||||
setCashierUserId(doc.data.cashierUserId)
|
setCashierUserId(doc.data.cashierUserId)
|
||||||
setPromotionSuggestion(null)
|
setPromotionSuggestion(null)
|
||||||
|
doc.data.lines.forEach((line) => allowedUoms.load(line.itemId))
|
||||||
setLines(
|
setLines(
|
||||||
doc.data.lines.map((line) => ({
|
doc.data.lines.map((line) => ({
|
||||||
key: String(line.salesSlipLineId),
|
key: String(line.salesSlipLineId),
|
||||||
@@ -149,8 +153,12 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectItem(key: string, itemId: number) {
|
function selectItem(key: string, itemId: number) {
|
||||||
|
allowedUoms.load(itemId)
|
||||||
updateLine(key, {
|
updateLine(key, {
|
||||||
itemId,
|
itemId,
|
||||||
|
// Reset to the new item's base unit: a UOM carried over from the previous item
|
||||||
|
// usually has no conversion for this one, and would be refused on save.
|
||||||
|
uomId: items.find((i) => i.itemId === itemId)?.baseUomId ?? 0,
|
||||||
unitPrice: getSuggestedUnitPrice(items, itemId),
|
unitPrice: getSuggestedUnitPrice(items, itemId),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -346,7 +354,7 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
|||||||
: "No price suggestion available"}
|
: "No price suggestion available"}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-3">{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
|
<TableCell className="px-4 py-3">{uomName(line.uomId, uoms)}</TableCell>
|
||||||
<TableCell className="px-4 py-3 text-right">{line.qty.toFixed(0)}</TableCell>
|
<TableCell className="px-4 py-3 text-right">{line.qty.toFixed(0)}</TableCell>
|
||||||
<TableCell className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "-"}</TableCell>
|
<TableCell className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "-"}</TableCell>
|
||||||
<TableCell className="px-4 py-3 text-right">{money.format(line.unitPrice)}</TableCell>
|
<TableCell className="px-4 py-3 text-right">{money.format(line.unitPrice)}</TableCell>
|
||||||
@@ -457,10 +465,15 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateLine(line.key, { uomId: Number(v) })} disabled={locked}>
|
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateLine(line.key, { uomId: Number(v) })} disabled={locked}>
|
||||||
<SelectTrigger className="h-11!"><SelectValue placeholder="UOM" /></SelectTrigger>
|
<SelectTrigger className="h-11!"><SelectValue placeholder="UOM" /></SelectTrigger>
|
||||||
<SelectContent>{uoms.map((u) => <SelectItem key={u.uomId} value={String(u.uomId)}>{u.name}</SelectItem>)}</SelectContent>
|
<SelectContent>{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => <SelectItem key={u.uomId} value={String(u.uomId)}>{u.name}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={locked} /></TableCell>
|
<TableCell>
|
||||||
|
<Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={locked} />
|
||||||
|
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? []) && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])}</p>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
<TableCell><Input type="number" min="0" step="0.01" value={line.freeQty} onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })} disabled={locked} /></TableCell>
|
<TableCell><Input type="number" min="0" step="0.01" value={line.freeQty} onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })} disabled={locked} /></TableCell>
|
||||||
<TableCell><Input type="number" min="0" step="0.01" value={line.unitPrice ?? ""} onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })} disabled={locked} /></TableCell>
|
<TableCell><Input type="number" min="0" step="0.01" value={line.unitPrice ?? ""} onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })} disabled={locked} /></TableCell>
|
||||||
<TableCell><Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} disabled={locked}><Minus className="size-4" /></Button></TableCell>
|
<TableCell><Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} disabled={locked}><Minus className="size-4" /></Button></TableCell>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { cn } from "@/lib/utils"
|
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 { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
@@ -65,6 +67,7 @@ export default function NewSalesSlipPage() {
|
|||||||
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
|
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
|
const allowedUoms = useAllowedUoms()
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -104,6 +107,8 @@ export default function NewSalesSlipPage() {
|
|||||||
|
|
||||||
function selectItem(key: string, itemId: number) {
|
function selectItem(key: string, itemId: number) {
|
||||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
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, {
|
updateLine(key, {
|
||||||
itemId,
|
itemId,
|
||||||
uomId: item?.baseUomId ?? 0,
|
uomId: item?.baseUomId ?? 0,
|
||||||
@@ -334,7 +339,7 @@ export default function NewSalesSlipPage() {
|
|||||||
<SelectValue placeholder="UOM" />
|
<SelectValue placeholder="UOM" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{uoms.map((u) => (
|
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => (
|
||||||
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
||||||
{u.name}
|
{u.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -351,6 +356,12 @@ export default function NewSalesSlipPage() {
|
|||||||
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })}
|
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })}
|
||||||
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
|
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) ?? []) && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-2">
|
<TableCell className="px-4 py-2">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { PackageSearch, Search } from "lucide-react"
|
|||||||
import { stockApi } from "@/lib/api/stock"
|
import { stockApi } from "@/lib/api/stock"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
|
import { formatQtyValue, formatQtyWithName } from "@/lib/uom"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { OnHand } from "@/types/stock"
|
import { OnHand } from "@/types/stock"
|
||||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||||
@@ -135,11 +136,12 @@ export default function StockEnquiryPage() {
|
|||||||
<div className="text-sm text-muted-foreground">{item?.name}</div>
|
<div className="text-sm text-muted-foreground">{item?.name}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{wh?.code ?? `#${row.warehouseId}`}</TableCell>
|
<TableCell className="px-3 py-3.5">{wh?.code ?? `#${row.warehouseId}`}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{row.onHand}</TableCell>
|
{/* Every figure here is base UOM; the label is what tells the user which. */}
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{row.available}</TableCell>
|
<TableCell className="px-3 py-3.5">{formatQtyWithName(row.onHand, row.baseUomName)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{row.onHold}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{formatQtyWithName(row.available, row.baseUomName)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{row.inTransit}</TableCell>
|
<TableCell className="px-3 py-3.5">{formatQtyValue(row.onHold)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{row.reserved}</TableCell>
|
<TableCell className="px-3 py-3.5">{formatQtyValue(row.inTransit)}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">{formatQtyValue(row.reserved)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/stock/valuation?itemId=${row.itemId}&warehouseId=${row.warehouseId}`}
|
href={`/dashboard/stock/valuation?itemId=${row.itemId}&warehouseId=${row.warehouseId}`}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ChevronLeft, ChevronRight, ScrollText } from "lucide-react"
|
|||||||
import { stockApi } from "@/lib/api/stock"
|
import { stockApi } from "@/lib/api/stock"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
|
import { formatQtyWithName } from "@/lib/uom"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { LedgerEntry } from "@/types/stock"
|
import { LedgerEntry } from "@/types/stock"
|
||||||
@@ -189,10 +190,10 @@ export default function StockLedgerPage() {
|
|||||||
{entry.direction}
|
{entry.direction}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{entry.qtyBase}</TableCell>
|
<TableCell className="px-3 py-3.5">{formatQtyWithName(entry.qtyBase, entry.baseUomName)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{entry.unitCost.toFixed(2)}</TableCell>
|
<TableCell className="px-3 py-3.5">{entry.unitCost.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{entry.value.toFixed(2)}</TableCell>
|
<TableCell className="px-3 py-3.5">{entry.value.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{entry.runningBalance}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{formatQtyWithName(entry.runningBalance, entry.baseUomName)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||||
{entry.sourceDocType} #{entry.sourceDocId}
|
{entry.sourceDocType} #{entry.sourceDocId}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import { AlertTriangle, CheckCircle2 } from "lucide-react"
|
|||||||
import { stockApi } from "@/lib/api/stock"
|
import { stockApi } from "@/lib/api/stock"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { formatQty } from "@/lib/uom"
|
||||||
import { ReorderAlert } from "@/types/stock"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
@@ -19,16 +21,23 @@ export default function ReorderAlertsPage() {
|
|||||||
const [alerts, setAlerts] = useState<ReorderAlert[] | null>(null)
|
const [alerts, setAlerts] = useState<ReorderAlert[] | null>(null)
|
||||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||||
|
const [uoms, setUoms] = useState<Uom[]>([])
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [requesting, setRequesting] = useState<string | null>(null)
|
const [requesting, setRequesting] = useState<string | null>(null)
|
||||||
const [requested, setRequested] = useState<Set<string>>(new Set())
|
const [requested, setRequested] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([stockApi.reorderAlerts(), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
|
Promise.all([
|
||||||
.then(([a, it, wh]) => {
|
stockApi.reorderAlerts(),
|
||||||
|
itemsApi.list({ pageSize: 200 }),
|
||||||
|
warehousesApi.list(),
|
||||||
|
uomsApi.list({ pageSize: 200 }),
|
||||||
|
])
|
||||||
|
.then(([a, it, wh, uo]) => {
|
||||||
setAlerts(a.items)
|
setAlerts(a.items)
|
||||||
setItems(it.items)
|
setItems(it.items)
|
||||||
setWarehouses(wh.items)
|
setWarehouses(wh.items)
|
||||||
|
setUoms(uo.items)
|
||||||
})
|
})
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}, [])
|
}, [])
|
||||||
@@ -44,7 +53,10 @@ export default function ReorderAlertsPage() {
|
|||||||
const res = await stockApi.createReorderRequisition(alert.itemId, alert.warehouseId)
|
const res = await stockApi.createReorderRequisition(alert.itemId, alert.warehouseId)
|
||||||
setRequested((prev) => new Set(prev).add(key))
|
setRequested((prev) => new Set(prev).add(key))
|
||||||
const qty = res.lines[0]?.qty ?? alert.suggestedRequisitionQty
|
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) {
|
} catch (err) {
|
||||||
toast.error("Could not create requisition", errorMessage(err))
|
toast.error("Could not create requisition", errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Printer } from "lucide-react"
|
|||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
|
import { uomName } from "@/lib/uom"
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -131,7 +132,7 @@ export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id
|
|||||||
<div className="font-medium text-foreground">{line.description}</div>
|
<div className="font-medium text-foreground">{line.description}</div>
|
||||||
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
|
<TableCell>{uomName(line.uomId, uoms)}</TableCell>
|
||||||
<TableCell className="text-right">{line.qty.toFixed(2)}</TableCell>
|
<TableCell className="text-right">{line.qty.toFixed(2)}</TableCell>
|
||||||
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"}</TableCell>
|
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"}</TableCell>
|
||||||
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
|
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Printer } from "lucide-react"
|
|||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
|
import { uomName } from "@/lib/uom"
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { usersApi } from "@/lib/api/users"
|
import { usersApi } from "@/lib/api/users"
|
||||||
@@ -123,7 +124,7 @@ export default function SalesSlipPrintPage({ params }: { params: Promise<{ id: s
|
|||||||
<div className="font-medium text-foreground">{line.description}</div>
|
<div className="font-medium text-foreground">{line.description}</div>
|
||||||
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
|
<TableCell>{uomName(line.uomId, uoms)}</TableCell>
|
||||||
<TableCell className="text-right">{line.qty.toFixed(0)}</TableCell>
|
<TableCell className="text-right">{line.qty.toFixed(0)}</TableCell>
|
||||||
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</TableCell>
|
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</TableCell>
|
||||||
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
|
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
|
||||||
|
|||||||
@@ -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<Record<number, AllowedUom[]>>({})
|
||||||
|
// Tracks in-flight and failed ids so a repeated render never re-issues the same request.
|
||||||
|
const pending = useRef<Set<number>>(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<AllowedUom[]>([])
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||||
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
|
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
|
||||||
import {
|
import {
|
||||||
|
AllowedUom,
|
||||||
CreateItemRequest,
|
CreateItemRequest,
|
||||||
Item,
|
Item,
|
||||||
ItemListItem,
|
ItemListItem,
|
||||||
@@ -60,10 +61,20 @@ export const itemsApi = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the item's conversions. Every row must be `<other> → 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 `<other> → base` and never inverts).
|
||||||
|
*/
|
||||||
updateUomConversions(itemId: number, request: UpdateUomConversionsRequest): Promise<UpdateUomConversionsResponse> {
|
updateUomConversions(itemId: number, request: UpdateUomConversionsRequest): Promise<UpdateUomConversionsResponse> {
|
||||||
return apiRequest<UpdateUomConversionsResponse>(`/items/${itemId}/uom-conversions`, {
|
return apiRequest<UpdateUomConversionsResponse>(`/items/${itemId}/uom-conversions`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: request,
|
body: request,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** The UOMs this item can be transacted in — base UOM first, then each conversion source. */
|
||||||
|
allowedUoms(itemId: number): Promise<AllowedUom[]> {
|
||||||
|
return apiRequest<AllowedUom[]>(`/items/${itemId}/uoms`)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
}
|
||||||
@@ -33,6 +33,11 @@ export interface ItemReorderSetting {
|
|||||||
reorderQty: number
|
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 {
|
export interface UomConversion {
|
||||||
conversionId: number
|
conversionId: number
|
||||||
fromUom: number
|
fromUom: number
|
||||||
@@ -40,6 +45,20 @@ export interface UomConversion {
|
|||||||
factor: number
|
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}`).
|
* Full Item resource (docs/11 §2.1 `GET /items/{itemId}`).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -138,11 +138,23 @@ export interface PoLine {
|
|||||||
itemId: number
|
itemId: number
|
||||||
uomId: number
|
uomId: number
|
||||||
warehouseId: number
|
warehouseId: number
|
||||||
|
/** Ordered quantity, in `uomId`. */
|
||||||
qty: number
|
qty: number
|
||||||
unitPrice: number
|
unitPrice: number
|
||||||
/** Rate, 0..1 (e.g. 0.18), not an amount. */
|
/** Rate, 0..1 (e.g. 0.18), not an amount. */
|
||||||
tax: number
|
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
|
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 {
|
export interface PoTotals {
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export interface OnHand {
|
|||||||
/** Always 0 in Phase 1 (sales reservation stub, FR-STK-11). */
|
/** Always 0 in Phase 1 (sales reservation stub, FR-STK-11). */
|
||||||
reserved: number
|
reserved: number
|
||||||
asOf: string
|
asOf: string
|
||||||
|
/** The item's base UOM — every quantity above is in it. For labelling, not conversion. */
|
||||||
|
baseUomId: number
|
||||||
|
baseUomName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 5.2 Ledger -------------------------------------------------------------------
|
// --- 5.2 Ledger -------------------------------------------------------------------
|
||||||
@@ -41,6 +44,9 @@ export interface LedgerEntry {
|
|||||||
sourceDocId: number
|
sourceDocId: number
|
||||||
userId: number
|
userId: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
/** The item's base UOM — `qtyBase` and `runningBalance` are in it. */
|
||||||
|
baseUomId: number
|
||||||
|
baseUomName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 5.3 Valuation ------------------------------------------------------------------
|
// --- 5.3 Valuation ------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user