Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d5a05a419 | |||
| d37824cecc | |||
| 271c940640 | |||
| f2825900aa | |||
| 6520930aeb | |||
| a8c6b4cb5e | |||
| 8e9974b735 | |||
| c4e016c460 | |||
| 7e8418685c |
+3
-8
@@ -37,11 +37,6 @@ Testing/e2e/.auth/
|
||||
Testing/e2e/blob-report/
|
||||
|
||||
# ── Migrations ─────────────────────────────────────────────────────────
|
||||
# Reverted 2026-07-31: excluding new EF Core migrations while
|
||||
# ErpDbContextModelSnapshot.cs stayed tracked meant every `dotnet ef
|
||||
# migrations add` after the initial 4 silently produced a migration git would
|
||||
# never see, while the (tracked) snapshot's changes committed normally —
|
||||
# so the snapshot kept claiming tables existed that no migration in git
|
||||
# history ever created them. Confirmed live: 25 HRM tables + 11 Manufacturing
|
||||
# tables were missing from the actual database for exactly this reason.
|
||||
# Migrations now stay tracked like any other source file — commit them.
|
||||
# Each dev keeps EF Core migrations local; DB schema changes are announced
|
||||
# to the team instead of committed, so migration files aren't shared here.
|
||||
Migrations/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Dtos.Uoms;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -11,8 +12,13 @@ namespace ERPCore.Controllers;
|
||||
public sealed class ItemsController : ApiControllerBase
|
||||
{
|
||||
private readonly IItemService _items;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
|
||||
public ItemsController(IItemService items) => _items = items;
|
||||
public ItemsController(IItemService items, IUomConverter uomConverter)
|
||||
{
|
||||
_items = items;
|
||||
_uomConverter = uomConverter;
|
||||
}
|
||||
|
||||
/// <summary>List items with optional filters and paging.</summary>
|
||||
[HttpGet]
|
||||
@@ -88,4 +94,15 @@ public sealed class ItemsController : ApiControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ItemUomConversionsDto>> UpdateUomConversions(int itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken 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 Item? Item { get; set; }
|
||||
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 int UomId { 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 Warehouse? Warehouse { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
@@ -34,8 +34,20 @@ public class GrnLine
|
||||
public int? BatchId { get; set; }
|
||||
public Batch? Batch { get; set; }
|
||||
|
||||
/// <summary>Quantity received, in <see cref="UomId"/> — what the user entered.</summary>
|
||||
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>
|
||||
public decimal UnitCost { get; set; }
|
||||
|
||||
|
||||
@@ -21,8 +21,32 @@ public class PoLine
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
/// <summary>Quantity ordered, in <see cref="UomId"/>.</summary>
|
||||
public decimal Qty { get; set; }
|
||||
public decimal UnitPrice { 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; }
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <summary>Quantity sold, in <see cref="UomId"/> — what the user entered and what prints.</summary>
|
||||
public decimal Qty { get; set; }
|
||||
public decimal FreeQty { get; set; }
|
||||
public int UomId { 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 Warehouse? Warehouse { get; set; }
|
||||
|
||||
|
||||
@@ -14,10 +14,24 @@ public class SalesSlipLine
|
||||
|
||||
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 FreeQty { get; set; }
|
||||
public int UomId { 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 Warehouse? Warehouse { get; set; }
|
||||
|
||||
|
||||
@@ -5,9 +5,18 @@ namespace ERPCore.Dtos.Procurement;
|
||||
|
||||
// Responses (docs/11 §3.3) ------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// A PO line. <paramref name="Qty"/> and <paramref name="QtyReceived"/> are in
|
||||
/// <paramref name="UomId"/> and are what the user sees; <paramref name="QtyBase"/> and
|
||||
/// <paramref name="QtyReceivedBase"/> are the item's base UOM and are what the server
|
||||
/// actually enforces — GRN over-receipt and the PO close condition both run on the base
|
||||
/// pair, because goods may legitimately be received in a different UOM from the one
|
||||
/// ordered. A client showing remaining/outstanding quantity should read the base pair.
|
||||
/// </summary>
|
||||
public sealed record PoLineDto(
|
||||
int PoLineId, int ItemId, int UomId, int WarehouseId,
|
||||
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived);
|
||||
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived,
|
||||
decimal QtyBase = 0m, decimal QtyReceivedBase = 0m, decimal ConversionFactor = 1m);
|
||||
|
||||
public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency);
|
||||
|
||||
|
||||
@@ -31,9 +31,10 @@ public sealed record BundleSaleTemplateSummaryDto(
|
||||
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description,
|
||||
EntityStatus Status, int LineCount, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
|
||||
/// <summary>Quantities are in the item's base UOM — see <c>SalesInvoicePostingIssueDto</c>.</summary>
|
||||
public sealed record BundleSalePostingIssueDto(
|
||||
int BundleSaleLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
|
||||
decimal RequestedQty, decimal AvailableQty, decimal ShortQty);
|
||||
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, string BaseUomName = "");
|
||||
|
||||
public sealed record BundleSalePostingCheckDto(
|
||||
int BundleSaleId, string BundleNo, BundleSaleStatus Status, bool CanPost,
|
||||
|
||||
@@ -24,9 +24,16 @@ public sealed record SalesInvoiceSummaryDto(
|
||||
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
|
||||
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// A line that cannot be posted for lack of stock. The three quantities are in the item's
|
||||
/// <b>base</b> UOM (on-hand only exists in base), which may differ from the UOM shown on the
|
||||
/// line — hence <paramref name="BaseUomName"/>: without it a line reading "2 BOX" produces
|
||||
/// an unexplained "requested 24, available 10".
|
||||
/// </summary>
|
||||
public sealed record SalesInvoicePostingIssueDto(
|
||||
int SalesInvoiceLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
|
||||
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue);
|
||||
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue,
|
||||
string BaseUomName = "");
|
||||
|
||||
public sealed record SalesInvoicePostingCheckDto(
|
||||
int SalesInvoiceId, string InvoiceNo, SalesInvoiceStatus Status, bool CanPost,
|
||||
|
||||
@@ -23,9 +23,11 @@ public sealed record SalesSlipSummaryDto(
|
||||
string CustomerSnapshotName, int WarehouseId, SalesSlipStatus Status,
|
||||
SalesSlipTotalsDto Totals, DateTime CreatedAt);
|
||||
|
||||
/// <summary>Quantities are in the item's base UOM — see <c>SalesInvoicePostingIssueDto</c>.</summary>
|
||||
public sealed record SalesSlipPostingIssueDto(
|
||||
int SalesSlipLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
|
||||
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue);
|
||||
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue,
|
||||
string BaseUomName = "");
|
||||
|
||||
public sealed record SalesSlipPostingCheckDto(
|
||||
int SalesSlipId, string SlipNo, SalesSlipStatus Status, bool CanPost,
|
||||
|
||||
@@ -2,16 +2,23 @@ using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Stock;
|
||||
|
||||
// Every quantity a stock endpoint returns is in the item's base UOM — stock, layers and the
|
||||
// ledger are base-only by construction. The BaseUomId/BaseUomName pair on each of these DTOs
|
||||
// exists so a client can *label* those figures; without it every stock screen renders a bare
|
||||
// number the user has to guess the unit of. They are never a conversion instruction.
|
||||
|
||||
/// <summary>Stock enquiry (docs/11 §5.1). available = onHand − onHold − reserved − inTransit(out).</summary>
|
||||
public sealed record StockOnHandDto(
|
||||
int ItemId, int WarehouseId, decimal OnHand, decimal Available,
|
||||
decimal OnHold, decimal InTransit, decimal Reserved, DateTime AsOf);
|
||||
decimal OnHold, decimal InTransit, decimal Reserved, DateTime AsOf,
|
||||
int BaseUomId = 0, string BaseUomName = "");
|
||||
|
||||
/// <summary>A stock-ledger row (docs/11 §5.2).</summary>
|
||||
public sealed record StockLedgerRowDto(
|
||||
int LedgerId, int ItemId, int WarehouseId, int? BinId, int? BatchId, int? SerialId,
|
||||
Direction Direction, decimal QtyBase, decimal UnitCost, decimal Value, decimal RunningBalance,
|
||||
string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt);
|
||||
string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt,
|
||||
int BaseUomId = 0, string BaseUomName = "");
|
||||
|
||||
/// <summary>An open FIFO layer in a valuation (docs/11 §5.3).</summary>
|
||||
public sealed record StockValuationLayerDto(int LayerId, decimal QtyRemaining, decimal UnitCost, decimal Value, DateTime ReceiptDate);
|
||||
@@ -19,4 +26,5 @@ public sealed record StockValuationLayerDto(int LayerId, decimal QtyRemaining, d
|
||||
/// <summary>Valuation of on-hand stock from open FIFO layers (docs/11 §5.3).</summary>
|
||||
public sealed record StockValuationDto(
|
||||
int ItemId, int WarehouseId, IReadOnlyList<StockValuationLayerDto> Layers,
|
||||
decimal TotalQty, decimal TotalValue, string Currency, string CostingMethod);
|
||||
decimal TotalQty, decimal TotalValue, string Currency, string CostingMethod,
|
||||
int BaseUomId = 0, string BaseUomName = "");
|
||||
|
||||
@@ -9,3 +9,12 @@ public sealed class CreateUomRequest
|
||||
{
|
||||
[Required, StringLength(50)] public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A UOM an item may actually be transacted in: its base UOM (<paramref name="Factor"/> 1,
|
||||
/// <paramref name="IsBase"/> true) plus every UOM it has a conversion from. Backs both
|
||||
/// entry-time validation and <c>GET /items/{itemId}/uoms</c>, so the client can offer only
|
||||
/// units that will survive posting instead of the whole global list.
|
||||
/// </summary>
|
||||
/// <param name="Factor">Multiply a quantity in this UOM by <paramref name="Factor"/> to get base UOM.</param>
|
||||
public sealed record AllowedUomDto(int UomId, string Name, decimal Factor, bool IsBase);
|
||||
|
||||
@@ -12,6 +12,8 @@ public sealed class BundleSaleLineConfiguration : IEntityTypeConfiguration<Bundl
|
||||
builder.HasKey(x => x.BundleSaleLineId);
|
||||
builder.Property(x => x.Description).IsRequired().HasMaxLength(200);
|
||||
builder.Property(x => x.Qty).HasPrecision(18, 4);
|
||||
builder.Property(x => x.QtyBase).HasPrecision(18, 4);
|
||||
builder.Property(x => x.ConversionFactor).HasPrecision(18, 6).HasDefaultValue(1m);
|
||||
builder.Property(x => x.UnitPrice).HasPrecision(18, 4);
|
||||
builder.Property(x => x.LineTotal).HasPrecision(18, 4);
|
||||
builder.Property(x => x.IncludeInBundle).HasDefaultValue(true);
|
||||
|
||||
@@ -36,6 +36,8 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
|
||||
builder.HasKey(l => l.GrnLineId);
|
||||
|
||||
builder.Property(l => l.Qty).HasPrecision(18, 4);
|
||||
builder.Property(l => l.QtyBase).HasPrecision(18, 4);
|
||||
builder.Property(l => l.ConversionFactor).HasPrecision(18, 6).HasDefaultValue(1m);
|
||||
builder.Property(l => l.UnitCost).HasPrecision(18, 6);
|
||||
builder.Property(l => l.PoUnitPrice).HasPrecision(18, 6);
|
||||
builder.Property(l => l.DiscountPct).HasPrecision(9, 4);
|
||||
|
||||
@@ -49,6 +49,9 @@ public sealed class PoLineConfiguration : IEntityTypeConfiguration<PoLine>
|
||||
builder.HasKey(l => l.PoLineId);
|
||||
|
||||
builder.Property(l => l.Qty).HasPrecision(18, 4);
|
||||
builder.Property(l => l.QtyBase).HasPrecision(18, 4);
|
||||
builder.Property(l => l.QtyReceivedBase).HasPrecision(18, 4);
|
||||
builder.Property(l => l.ConversionFactor).HasPrecision(18, 6).HasDefaultValue(1m);
|
||||
builder.Property(l => l.UnitPrice).HasPrecision(18, 4);
|
||||
builder.Property(l => l.Tax).HasPrecision(9, 4);
|
||||
builder.Property(l => l.QtyReceived).HasPrecision(18, 4);
|
||||
|
||||
@@ -70,6 +70,9 @@ public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration<Sal
|
||||
|
||||
builder.Property(x => x.Qty).HasPrecision(18, 4);
|
||||
builder.Property(x => x.FreeQty).HasPrecision(18, 4);
|
||||
builder.Property(x => x.QtyBase).HasPrecision(18, 4);
|
||||
builder.Property(x => x.FreeQtyBase).HasPrecision(18, 4);
|
||||
builder.Property(x => x.ConversionFactor).HasPrecision(18, 6).HasDefaultValue(1m);
|
||||
builder.Property(x => x.UnitPrice).HasPrecision(18, 4);
|
||||
builder.Property(x => x.BaseCost).HasPrecision(18, 4);
|
||||
builder.Property(x => x.DiscountPct).HasPrecision(9, 4);
|
||||
|
||||
@@ -70,6 +70,9 @@ public sealed class SalesSlipLineConfiguration : IEntityTypeConfiguration<SalesS
|
||||
|
||||
builder.Property(x => x.Qty).HasPrecision(18, 4);
|
||||
builder.Property(x => x.FreeQty).HasPrecision(18, 4);
|
||||
builder.Property(x => x.QtyBase).HasPrecision(18, 4);
|
||||
builder.Property(x => x.FreeQtyBase).HasPrecision(18, 4);
|
||||
builder.Property(x => x.ConversionFactor).HasPrecision(18, 6).HasDefaultValue(1m);
|
||||
builder.Property(x => x.UnitPrice).HasPrecision(18, 4);
|
||||
builder.Property(x => x.BaseCost).HasPrecision(18, 4);
|
||||
builder.Property(x => x.DiscountPct).HasPrecision(9, 4);
|
||||
|
||||
@@ -30,5 +30,9 @@ public sealed class UomConversionConfiguration : IEntityTypeConfiguration<UomCon
|
||||
|
||||
// One conversion per (item, from, to) triple.
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
-7093
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -215,16 +215,23 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||
await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, r.UomId, lineWarehouseId, r.Qty, 0m, null, ct);
|
||||
var (qtyBase, unitCostBase) = await _uomConverter.ToBaseAsync(item, r.UomId, r.Qty, r.UnitPrice, ct);
|
||||
var calc = _sales.ComputeLine(qtyBase, 0m, unitCostBase, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
|
||||
|
||||
// Keep the entered UOM on the line and snapshot the base quantity beside it, the
|
||||
// same shape as invoices and slips. Qty and UnitPrice stay a matching pair in the
|
||||
// entered UOM so LineTotal — which Recalculate rolls into the header — is the
|
||||
// value the user priced; only QtyBase crosses into the base-UOM stock engine.
|
||||
var factor = await _uomConverter.ResolveFactorAsync(item, r.UomId, ct);
|
||||
var calc = _sales.ComputeLine(r.Qty, 0m, r.UnitPrice, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
|
||||
lines.Add(new BundleSaleLine
|
||||
{
|
||||
ItemId = r.ItemId,
|
||||
Description = item.Name,
|
||||
Qty = qtyBase,
|
||||
UomId = item.BaseUomId,
|
||||
Qty = r.Qty,
|
||||
UomId = r.UomId,
|
||||
QtyBase = UomConverter.ApplyFactor(r.Qty, factor),
|
||||
ConversionFactor = factor,
|
||||
WarehouseId = lineWarehouseId,
|
||||
UnitPrice = unitCostBase,
|
||||
UnitPrice = r.UnitPrice,
|
||||
LineTotal = calc.LineTotal,
|
||||
IncludeInBundle = r.IncludeInBundle,
|
||||
IsComponent = true,
|
||||
|
||||
@@ -7,6 +7,7 @@ using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.Services.Stock;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -138,6 +139,12 @@ public sealed class GrnService : IGrnService
|
||||
if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422);
|
||||
|
||||
// Resolve the base quantity at entry. This rejects a UOM the item cannot be
|
||||
// received in while the GRN is still a draft, and snapshots the factor so confirm
|
||||
// (and any later reversal) reproduces exactly this quantity.
|
||||
var conversionFactor = await _uomConverter.ResolveFactorAsync(item, input.UomId, ct);
|
||||
var qtyBaseEntered = UomConverter.ApplyFactor(input.Qty, conversionFactor);
|
||||
|
||||
// Cost: for a PO line, the PO price is used unless an override is entered (then it
|
||||
// wins and a variance is recorded against the PO snapshot — docs/02-SECURITY C.3,
|
||||
// revised). Direct receipts always use the entered cost.
|
||||
@@ -150,10 +157,13 @@ public sealed class GrnService : IGrnService
|
||||
if (poLine.ItemId != input.ItemId)
|
||||
throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is for a different item.", 422);
|
||||
|
||||
var openQty = poLine.Qty - poLine.QtyReceived;
|
||||
if (input.Qty > openQty * (1 + OverReceiptTolerance))
|
||||
// Compare in base UOM: a GRN may legitimately receive in a different UOM from
|
||||
// the one the PO was raised in (10 BOX ordered, 120 PCS delivered), and
|
||||
// comparing the two raw numbers would reject that valid receipt.
|
||||
var openQtyBase = poLine.QtyBase - poLine.QtyReceivedBase;
|
||||
if (qtyBaseEntered > openQtyBase * (1 + OverReceiptTolerance))
|
||||
throw new DomainException(ErrorCodes.OverReceiptTolerance,
|
||||
$"Receiving {input.Qty} exceeds the open quantity {openQty} on PO line {input.PoLineId}.", 422);
|
||||
$"Receiving {input.Qty} ({qtyBaseEntered} base) exceeds the open quantity {openQtyBase} base on PO line {input.PoLineId}.", 422);
|
||||
|
||||
poUnitPrice = poLine.UnitPrice;
|
||||
unitCost = input.UnitCost > 0 ? input.UnitCost : poLine.UnitPrice;
|
||||
@@ -178,6 +188,8 @@ public sealed class GrnService : IGrnService
|
||||
BinId = input.BinId,
|
||||
Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted
|
||||
Qty = input.Qty,
|
||||
QtyBase = qtyBaseEntered,
|
||||
ConversionFactor = conversionFactor,
|
||||
UnitCost = unitCost,
|
||||
PoUnitPrice = poUnitPrice,
|
||||
DiscountPct = input.DiscountPct,
|
||||
@@ -234,10 +246,13 @@ public sealed class GrnService : IGrnService
|
||||
{
|
||||
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token);
|
||||
// FIFO layer costs at the after-discount net price; VAT is recoverable and never
|
||||
// enters stock value (docs/10 FR-GRN-06, revised).
|
||||
var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.NetUnitCost, token);
|
||||
// enters stock value (docs/10 FR-GRN-06, revised). Quantity and cost come from
|
||||
// the factor snapshotted at line creation, not a fresh lookup — see GrnLine.QtyBase.
|
||||
var qtyBase = line.QtyBase;
|
||||
var unitCostBase = line.ConversionFactor == 1m
|
||||
? line.NetUnitCost
|
||||
: Math.Round(line.NetUnitCost / line.ConversionFactor, 6, MidpointRounding.AwayFromZero);
|
||||
|
||||
var layer = await _fifo.CreateInboundLayerAsync(
|
||||
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId,
|
||||
@@ -258,7 +273,13 @@ public sealed class GrnService : IGrnService
|
||||
if (line.PoLineId is not null)
|
||||
{
|
||||
var poLine = await _poLines.GetByIdAsync(line.PoLineId.Value, token);
|
||||
if (poLine is not null) poLine.QtyReceived += line.Qty;
|
||||
if (poLine is not null)
|
||||
{
|
||||
// Accrue in base so receipts in a UOM other than the PO's still add up.
|
||||
// QtyReceived is kept in the PO's own UOM for display only.
|
||||
poLine.QtyReceivedBase += qtyBase;
|
||||
poLine.QtyReceived = UomConverter.FromBase(poLine.QtyReceivedBase, poLine.ConversionFactor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,22 +366,14 @@ public sealed class GrnService : IGrnService
|
||||
return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (poId is null) return;
|
||||
var po = await _pos.Query().Include(p => p.Lines).FirstOrDefaultAsync(p => p.PoId == poId, ct);
|
||||
if (po is null) return;
|
||||
|
||||
po.Status = po.Lines.All(l => l.QtyReceived >= l.Qty)
|
||||
// Base-vs-base: QtyReceived is a denormalized display figure and must not gate closing.
|
||||
po.Status = po.Lines.All(l => l.QtyReceivedBase >= l.QtyBase)
|
||||
? PurchaseOrderStatus.FullyReceived
|
||||
: PurchaseOrderStatus.PartiallyReceived;
|
||||
po.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
|
||||
@@ -8,7 +9,7 @@ public interface IBundleSaleService
|
||||
{
|
||||
Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default);
|
||||
Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default);
|
||||
Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||
Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||
Task<BundleSaleDto?> GetAsync(int bundleSaleId, CancellationToken ct = default);
|
||||
Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default);
|
||||
Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Uoms;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
@@ -32,4 +33,29 @@ public interface IUomConverter
|
||||
/// consumes, not from the document).
|
||||
/// </summary>
|
||||
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.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.Name = request.Name.Trim();
|
||||
item.Description = request.Description;
|
||||
@@ -240,6 +249,26 @@ public sealed class ItemService : IItemService
|
||||
if (!await _uoms.Query().AnyAsync(u => u.UomId == uomId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"UOM {uomId} does not exist.", 422);
|
||||
|
||||
// Conversions are one-directional: always <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())
|
||||
item.UomConversions.Remove(stale);
|
||||
foreach (var input in request.Conversions)
|
||||
|
||||
@@ -27,6 +27,7 @@ public sealed class ProductionTemplateService : IProductionTemplateService
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly IRepository<ProductionRun> _runs;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
@@ -34,8 +35,9 @@ public sealed class ProductionTemplateService : IProductionTemplateService
|
||||
IRepository<ProductionTemplate> templates, IRepository<TemplateStage> stages,
|
||||
IRepository<StageInput> inputs, IRepository<StageOutput> outputs, IRepository<StageEdge> edges,
|
||||
IRepository<Item> items, IRepository<Uom> uoms, IRepository<ProductionRun> runs,
|
||||
IUnitOfWork uow, ICurrentUser currentUser)
|
||||
IUomConverter uomConverter, IUnitOfWork uow, ICurrentUser currentUser)
|
||||
{
|
||||
_uomConverter = uomConverter;
|
||||
_templates = templates;
|
||||
_stages = stages;
|
||||
_inputs = inputs;
|
||||
@@ -291,6 +293,20 @@ public sealed class ProductionTemplateService : IProductionTemplateService
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"UOM(s) {string.Join(", ", missingUoms)} do not exist.", 422);
|
||||
|
||||
// Existence is not enough: a stage line's UOM must be one its own item can convert to
|
||||
// base, or the run would fail with a 422 at stage start — long after the template was
|
||||
// authored. Check the (item, uom) pairing here, while the template is being saved.
|
||||
var itemUomPairs = request.Stages
|
||||
.SelectMany(s => s.Inputs.Select(i => (i.ItemId, i.UomId)).Concat(s.Outputs.Select(o => (o.ItemId, o.UomId))))
|
||||
.Distinct().ToList();
|
||||
|
||||
foreach (var (itemId, uomId) in itemUomPairs)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
|
||||
if (item is not null)
|
||||
await _uomConverter.ValidateUomAsync(item, uomId, ct);
|
||||
}
|
||||
|
||||
// Annotations go into jsonb unvalidated by anything else, so pin the one field the
|
||||
// client renders off. Unknown kinds would round-trip fine but draw nothing.
|
||||
var badKinds = request.Annotations
|
||||
|
||||
@@ -8,6 +8,7 @@ using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.Services.Stock;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -28,6 +29,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
@@ -35,8 +37,9 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
public PurchaseOrderService(
|
||||
IRepository<PurchaseOrder> pos, IRepository<Vendor> vendors, IRepository<Requisition> requisitions,
|
||||
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;
|
||||
_vendors = vendors;
|
||||
_requisitions = requisitions;
|
||||
@@ -83,6 +86,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
{
|
||||
await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct);
|
||||
var actor = _currentUser.AuditUserId;
|
||||
var lines = await ToLinesAsync(request.Lines, ct);
|
||||
|
||||
var po = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
@@ -98,7 +102,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
Status = request.SaveAsDraft ? PurchaseOrderStatus.Draft : PurchaseOrderStatus.Approved,
|
||||
CreatedBy = actor,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = request.Lines.Select(ToLine).ToList()
|
||||
Lines = lines
|
||||
};
|
||||
await _pos.AddAsync(entity, token);
|
||||
return entity;
|
||||
@@ -129,8 +133,8 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
|
||||
// Full line replacement (Phase 1: no receipts yet, so qtyReceived is 0 on every line).
|
||||
po.Lines.Clear();
|
||||
foreach (var input in request.Lines)
|
||||
po.Lines.Add(ToLine(input));
|
||||
foreach (var line in await ToLinesAsync(request.Lines, ct))
|
||||
po.Lines.Add(line);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -219,16 +223,34 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
// Supersedes Phase-1 Option B "freely editable while open" — see docs/10 FR-PROC-05.
|
||||
private static bool IsEditable(PurchaseOrderStatus status) => status is PurchaseOrderStatus.Draft;
|
||||
|
||||
private static PoLine ToLine(CreatePoLineInput l) => new()
|
||||
/// <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,
|
||||
UomId = l.UomId,
|
||||
WarehouseId = l.WarehouseId,
|
||||
Qty = l.Qty,
|
||||
UnitPrice = l.UnitPrice,
|
||||
Tax = l.Tax,
|
||||
QtyReceived = 0
|
||||
};
|
||||
var lines = new List<PoLine>(inputs.Count);
|
||||
foreach (var l in inputs)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == l.ItemId, ct);
|
||||
var factor = await _uomConverter.ResolveFactorAsync(item, l.UomId, ct);
|
||||
lines.Add(new PoLine
|
||||
{
|
||||
ItemId = l.ItemId,
|
||||
UomId = l.UomId,
|
||||
WarehouseId = l.WarehouseId,
|
||||
Qty = l.Qty,
|
||||
QtyBase = UomConverter.ApplyFactor(l.Qty, factor),
|
||||
ConversionFactor = factor,
|
||||
UnitPrice = l.UnitPrice,
|
||||
Tax = l.Tax,
|
||||
QtyReceived = 0,
|
||||
QtyReceivedBase = 0
|
||||
});
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static PoTotalsDto ComputeTotals(IEnumerable<PoLine> lines)
|
||||
{
|
||||
@@ -276,5 +298,6 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
p.PoId, p.DocNo, p.VendorId, p.RequisitionId, p.Status, p.ApprovalRequired,
|
||||
p.CreatedBy, p.CreatedAt, p.UpdatedAt, ComputeTotals(p.Lines),
|
||||
p.Lines.OrderBy(l => l.PoLineId).Select(l => new PoLineDto(
|
||||
l.PoLineId, l.ItemId, l.UomId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived)).ToList());
|
||||
l.PoLineId, l.ItemId, l.UomId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived,
|
||||
l.QtyBase, l.QtyReceivedBase, l.ConversionFactor)).ToList());
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
private readonly ISalesPostingService _posting;
|
||||
private readonly ISalesMappingService _mapping;
|
||||
private readonly ISalesDocumentWorkflowService _workflow;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly IUnitOfWork _uow;
|
||||
@@ -32,9 +33,10 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
public SalesInvoiceService(
|
||||
IRepository<SalesInvoice> invoices, IRepository<Customer> customers, IRepository<Item> items,
|
||||
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
|
||||
ISalesDocumentWorkflowService workflow,
|
||||
ISalesDocumentWorkflowService workflow, IUomConverter uomConverter,
|
||||
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
|
||||
{
|
||||
_uomConverter = uomConverter;
|
||||
_invoices = invoices;
|
||||
_customers = customers;
|
||||
_items = items;
|
||||
@@ -152,6 +154,12 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
|
||||
var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, r.TaxPct, r.IsFreeIssue);
|
||||
|
||||
// Resolve the base quantity now and store it on the line. This doubles as the
|
||||
// entry-time UOM check (an item that cannot be sold in this UOM throws 422 here,
|
||||
// while the user is still editing) and as the snapshot posting consumes — a
|
||||
// conversion factor edited later must not change what this document posts.
|
||||
var factor = await _uomConverter.ResolveFactorAsync(item, r.UomId, ct);
|
||||
|
||||
lines.Add(new SalesInvoiceLine
|
||||
{
|
||||
ItemId = r.ItemId,
|
||||
@@ -159,6 +167,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
Qty = r.Qty,
|
||||
FreeQty = r.FreeQty,
|
||||
UomId = r.UomId,
|
||||
QtyBase = UomConverter.ApplyFactor(r.Qty, factor),
|
||||
FreeQtyBase = UomConverter.ApplyFactor(r.FreeQty, factor),
|
||||
ConversionFactor = factor,
|
||||
WarehouseId = r.WarehouseId,
|
||||
UnitPrice = unitPrice,
|
||||
BaseCost = unitPrice,
|
||||
|
||||
@@ -20,10 +20,11 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly ISalesDomainService _sales;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
// No IUomConverter here by design: every line arrives with its base quantity already
|
||||
// snapshotted by the service that saved it, so posting has nothing left to convert.
|
||||
public SalesPostingService(
|
||||
IRepository<SalesInvoice> invoices,
|
||||
IRepository<SalesSlip> slips,
|
||||
@@ -31,7 +32,6 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
IRepository<Item> items,
|
||||
IFifoCostingService fifo,
|
||||
ISalesDomainService sales,
|
||||
IUomConverter uomConverter,
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
@@ -41,7 +41,6 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
_items = items;
|
||||
_fifo = fifo;
|
||||
_sales = sales;
|
||||
_uomConverter = uomConverter;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
@@ -60,18 +59,20 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
{
|
||||
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
|
||||
continue;
|
||||
var requestedQty = line.Qty + line.FreeQty;
|
||||
// Compare base against base: on-hand is base UOM, so the entered quantity would
|
||||
// under-report the requirement on any line not in the item's base UOM.
|
||||
var requestedQty = line.QtyBase + line.FreeQtyBase;
|
||||
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||
if (available >= requestedQty) continue;
|
||||
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.Where(x => x.ItemId == line.ItemId)
|
||||
.Select(x => new { x.Sku, x.Name })
|
||||
.Select(x => new { x.Sku, x.Name, BaseUomName = x.BaseUom!.Name })
|
||||
.FirstAsync(ct);
|
||||
|
||||
issues.Add(new SalesInvoicePostingIssueDto(
|
||||
line.SalesInvoiceLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId,
|
||||
requestedQty, available, requestedQty - available, line.IsFreeIssue));
|
||||
requestedQty, available, requestedQty - available, line.IsFreeIssue, item.BaseUomName));
|
||||
}
|
||||
|
||||
return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, issues.Count == 0, issues);
|
||||
@@ -91,18 +92,19 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
{
|
||||
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
|
||||
continue;
|
||||
var requestedQty = line.Qty + line.FreeQty;
|
||||
// Base against base — see the matching comment in CheckInvoiceAsync.
|
||||
var requestedQty = line.QtyBase + line.FreeQtyBase;
|
||||
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||
if (available >= requestedQty) continue;
|
||||
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.Where(x => x.ItemId == line.ItemId)
|
||||
.Select(x => new { x.Sku, x.Name })
|
||||
.Select(x => new { x.Sku, x.Name, BaseUomName = x.BaseUom!.Name })
|
||||
.FirstAsync(ct);
|
||||
|
||||
issues.Add(new SalesSlipPostingIssueDto(
|
||||
line.SalesSlipLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId,
|
||||
requestedQty, available, requestedQty - available, line.IsFreeIssue));
|
||||
requestedQty, available, requestedQty - available, line.IsFreeIssue, item.BaseUomName));
|
||||
}
|
||||
|
||||
return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, issues.Count == 0, issues);
|
||||
@@ -124,12 +126,15 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
continue;
|
||||
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.FirstAsync(x => x.ItemId == line.ItemId, ct);
|
||||
.Where(x => x.ItemId == line.ItemId)
|
||||
.Select(x => new { x.Sku, x.Name, BaseUomName = x.BaseUom!.Name })
|
||||
.FirstAsync(ct);
|
||||
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||
if (available >= line.Qty) continue;
|
||||
if (available >= line.QtyBase) continue;
|
||||
|
||||
issues.Add(new BundleSalePostingIssueDto(
|
||||
line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
|
||||
line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId,
|
||||
line.QtyBase, available, line.QtyBase - available, item.BaseUomName));
|
||||
}
|
||||
|
||||
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
|
||||
@@ -141,7 +146,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.",
|
||||
statusSelector: x => x.Status,
|
||||
ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.",
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.QtyBase + l.FreeQtyBase, l.QtyBase, l.FreeQtyBase)),
|
||||
setPosted: x => x.Status = SalesInvoiceStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: DocumentTypes.SalesInvoice,
|
||||
@@ -154,7 +159,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
notFoundMessage: $"Sales slip {salesSlipId} was not found.",
|
||||
statusSelector: x => x.Status,
|
||||
ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.",
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.QtyBase + l.FreeQtyBase, l.QtyBase, l.FreeQtyBase)),
|
||||
setPosted: x => x.Status = SalesSlipStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: DocumentTypes.SalesSlip,
|
||||
@@ -167,9 +172,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
notFoundMessage: $"Bundle sale {bundleSaleId} was not found.",
|
||||
statusSelector: x => x.Status,
|
||||
ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.",
|
||||
// Bundle lines are normalized to base UOM on save, so posting should consume the
|
||||
// stored base quantity directly instead of converting again.
|
||||
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty, l.Qty, 0m)),
|
||||
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.QtyBase, l.QtyBase, 0m)),
|
||||
setPosted: x => x.Status = BundleSaleStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: DocumentTypes.BundleSale,
|
||||
@@ -214,5 +217,11 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
}, ct);
|
||||
}
|
||||
|
||||
private sealed record PostingLine(int ItemId, int WarehouseId, int UomId, decimal Qty, decimal PaidQty, decimal FreeQty);
|
||||
/// <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 ISalesMappingService _mapping;
|
||||
private readonly ISalesDocumentWorkflowService _workflow;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly IUnitOfWork _uow;
|
||||
@@ -33,9 +34,10 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
public SalesSlipService(
|
||||
IRepository<SalesSlip> slips, IRepository<Customer> customers, IRepository<Item> items,
|
||||
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)
|
||||
{
|
||||
_uomConverter = uomConverter;
|
||||
_slips = slips;
|
||||
_customers = customers;
|
||||
_items = items;
|
||||
@@ -176,6 +178,10 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
|
||||
var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, r.TaxPct, r.IsFreeIssue);
|
||||
|
||||
// Resolve the base quantity now and store it on the line — see the matching
|
||||
// comment in SalesInvoiceService.BuildLinesAsync.
|
||||
var factor = await _uomConverter.ResolveFactorAsync(item, r.UomId, ct);
|
||||
|
||||
lines.Add(new SalesSlipLine
|
||||
{
|
||||
ItemId = r.ItemId,
|
||||
@@ -183,6 +189,9 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
Qty = r.Qty,
|
||||
FreeQty = r.FreeQty,
|
||||
UomId = r.UomId,
|
||||
QtyBase = UomConverter.ApplyFactor(r.Qty, factor),
|
||||
FreeQtyBase = UomConverter.ApplyFactor(r.FreeQty, factor),
|
||||
ConversionFactor = factor,
|
||||
WarehouseId = r.WarehouseId,
|
||||
UnitPrice = unitPrice,
|
||||
BaseCost = unitPrice,
|
||||
|
||||
@@ -14,17 +14,32 @@ public sealed class StockService : IStockService
|
||||
private readonly IRepository<StockLayer> _layers;
|
||||
private readonly IRepository<StockLedger> _ledger;
|
||||
private readonly IRepository<StockTransferLine> _transferLines;
|
||||
private readonly IRepository<Item> _items;
|
||||
|
||||
public StockService(
|
||||
IFifoCostingService fifo, IRepository<StockLayer> layers,
|
||||
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines)
|
||||
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines,
|
||||
IRepository<Item> items)
|
||||
{
|
||||
_fifo = fifo;
|
||||
_layers = layers;
|
||||
_ledger = ledger;
|
||||
_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)
|
||||
{
|
||||
var onHand = await _fifo.GetOnHandAsync(itemId, warehouseId, ct);
|
||||
@@ -47,7 +62,10 @@ public sealed class StockService : IStockService
|
||||
const decimal reserved = 0m;
|
||||
var available = onHand - onHold - reserved;
|
||||
|
||||
return new StockOnHandDto(itemId, warehouseId, onHand, available, onHold, inTransit, reserved, DateTime.UtcNow);
|
||||
var uom = (await BaseUomsAsync([itemId], ct)).GetValueOrDefault(itemId);
|
||||
return new StockOnHandDto(
|
||||
itemId, warehouseId, onHand, available, onHold, inTransit, reserved, DateTime.UtcNow,
|
||||
uom.Id, uom.Name ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -100,16 +118,20 @@ public sealed class StockService : IStockService
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty);
|
||||
|
||||
var baseUoms = await BaseUomsAsync(itemIds, ct);
|
||||
|
||||
var asOf = DateTime.UtcNow;
|
||||
var rows = page.Select(p =>
|
||||
{
|
||||
var key = (p.ItemId, p.WarehouseId);
|
||||
var hold = onHold.GetValueOrDefault(key);
|
||||
var transit = inTransit.GetValueOrDefault(key);
|
||||
var uom = baseUoms.GetValueOrDefault(p.ItemId);
|
||||
const decimal reserved = 0m;
|
||||
// Same formula as GetOnHandAsync: in-transit is reported, not re-subtracted.
|
||||
return new StockOnHandDto(
|
||||
p.ItemId, p.WarehouseId, p.OnHand, p.OnHand - hold - reserved, hold, transit, reserved, asOf);
|
||||
p.ItemId, p.WarehouseId, p.OnHand, p.OnHand - hold - reserved, hold, transit, reserved, asOf,
|
||||
uom.Id, uom.Name ?? string.Empty);
|
||||
}).ToList();
|
||||
|
||||
return PagedResponse<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))
|
||||
.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);
|
||||
}
|
||||
|
||||
public Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||
=> _fifo.GetValuationAsync(itemId, warehouseId, ct);
|
||||
public async Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
var valuation = await _fifo.GetValuationAsync(itemId, warehouseId, ct);
|
||||
var uom = (await BaseUomsAsync([itemId], ct)).GetValueOrDefault(itemId);
|
||||
return valuation with { BaseUomId = uom.Id, BaseUomName = uom.Name ?? string.Empty };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Uoms;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
@@ -11,28 +12,128 @@ namespace ERPCore.Services.Stock;
|
||||
/// unchanged from the <c>GrnService.ToBaseAsync</c> it was extracted from, so the GRN
|
||||
/// receive path keeps costing exactly as before.
|
||||
/// </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
|
||||
{
|
||||
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(
|
||||
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default)
|
||||
{
|
||||
if (uomId == item.BaseUomId)
|
||||
var factor = await ResolveFactorAsync(item, uomId, ct);
|
||||
if (factor == 1m)
|
||||
return (qty, unitCostPerUom);
|
||||
|
||||
var conv = await _conversions.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation,
|
||||
$"No UOM conversion from {uomId} to base UOM {item.BaseUomId} for item {item.ItemId}.", 422);
|
||||
|
||||
// Quantity scales up by the factor, so the per-unit cost scales down by it —
|
||||
// total value is preserved.
|
||||
return (qty * conv.Factor, unitCostPerUom / conv.Factor);
|
||||
// total value is preserved. Round to each column's own scale here rather than
|
||||
// letting the provider truncate on write, so what posts is what was computed.
|
||||
return (
|
||||
Math.Round(qty * factor, QtyScale, MidpointRounding.AwayFromZero),
|
||||
Math.Round(unitCostPerUom / factor, CostScale, MidpointRounding.AwayFromZero));
|
||||
}
|
||||
|
||||
public async Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default)
|
||||
=> (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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=187.127.102.190;Port=5432;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
|
||||
"DefaultConnection": "Host=127.0.0.1;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
|
||||
},
|
||||
"AuthHex": {
|
||||
"BaseUrl": "http://localhost:5011"
|
||||
|
||||
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"),
|
||||
("M5 terminal receipt + cost pool", "m5_receipt.py"),
|
||||
("M6+M7 leftover / rework / cancel", "m6_m7_leftover_rework_cancel.py"),
|
||||
# UOM engine. These run after the manufacturing scripts because they reuse the
|
||||
# SMOKE-PRD warehouse m4 creates, and they drain it before seeding their own stock.
|
||||
("UOM conversion direction is enforced", "uom_direction.py"),
|
||||
("UOM non-base sales consume converted qty", "uom_sales_nonbase.py"),
|
||||
("UOM cross-unit GRN against a PO", "uom_grn_po_cross.py"),
|
||||
]
|
||||
|
||||
SUMMARY = re.compile(r"^(\S+): (\d+)/(\d+) assertions passed")
|
||||
|
||||
@@ -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 { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomName } from "@/lib/uom"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -111,8 +112,8 @@ export default function PurchaseOrderDetailPage() {
|
||||
function itemFor(itemId: number | null) {
|
||||
return items.find((i) => i.itemId === itemId) ?? null
|
||||
}
|
||||
function uomName(uomId: number) {
|
||||
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
|
||||
function uomLabel(uomId: number) {
|
||||
return uomName(uomId, uoms)
|
||||
}
|
||||
function warehouseCode(warehouseId: number) {
|
||||
return warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? `#${warehouseId}`
|
||||
@@ -195,11 +196,9 @@ export default function PurchaseOrderDetailPage() {
|
||||
setPo(updated)
|
||||
setLines(toDraftLines(updated))
|
||||
toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status} and locked for editing.`)
|
||||
toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status} and locked for editing.`)
|
||||
} catch (err) {
|
||||
setSaveError(errorMessage(err))
|
||||
toast.error("Could not approve purchase order", errorMessage(err))
|
||||
toast.error("Could not approve purchase order", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -286,7 +285,7 @@ export default function PurchaseOrderDetailPage() {
|
||||
<>
|
||||
<Button variant="success" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
|
||||
<Check className="size-5" />
|
||||
{submitting ? "Approving…" : "Approve"}
|
||||
{submitting ? "Approving�" : "Approve"}
|
||||
</Button>
|
||||
<Button variant="destructive" size="lg" onClick={handleDelete} disabled={deleting || submitting}>
|
||||
<Trash2 className="size-5" />
|
||||
@@ -378,7 +377,7 @@ export default function PurchaseOrderDetailPage() {
|
||||
return (
|
||||
<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">{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.qty}</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 { errorMessage } from "@/lib/error-map"
|
||||
import { validatePoLine } from "@/lib/validations/procurement"
|
||||
import { pickerOptions } from "@/lib/uom"
|
||||
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { generateVendorCode } from "@/lib/vendor-code"
|
||||
import { CreatePoLineInput } from "@/types/procurement"
|
||||
@@ -52,10 +54,10 @@ function newKey() {
|
||||
return `poline-${keySeq}`
|
||||
}
|
||||
|
||||
// Unit price and tax are no longer entered at PO creation — pricing is captured at GRN
|
||||
// receipt (with discount/VAT there). They default to 0 here and stay off the form, but
|
||||
// remain on the payload because the backend line DTO still requires them; a PO prefilled
|
||||
// from an RFQ keeps its negotiated price (below).
|
||||
// Tax is still not entered at PO creation — it's captured at GRN receipt (with discount/VAT
|
||||
// there) and stays off this form, though it remains on the payload since the backend line
|
||||
// DTO still requires it. Unit price *is* entered here; a PO prefilled from an RFQ starts
|
||||
// from its negotiated price (below) but stays editable.
|
||||
function emptyLine(): DraftLine {
|
||||
return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "0", tax: "0" }
|
||||
}
|
||||
@@ -81,6 +83,7 @@ function NewPurchaseOrderContent() {
|
||||
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const allowedUoms = useAllowedUoms()
|
||||
|
||||
const [vendorDialogOpen, setVendorDialogOpen] = useState(false)
|
||||
const [vName, setVName] = useState("")
|
||||
@@ -102,7 +105,8 @@ function NewPurchaseOrderContent() {
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
loadItems(),
|
||||
uomsApi.list().then((uo) => setUoms(uo.items)),
|
||||
// pageSize: the default page would silently truncate the unit list as the master grows.
|
||||
uomsApi.list({ pageSize: 200 }).then((uo) => setUoms(uo.items)),
|
||||
warehousesApi.list().then((wh) => setWarehouses(wh.items)),
|
||||
loadVendors(),
|
||||
]).catch((err) => setLoadError(errorMessage(err)))
|
||||
@@ -306,7 +310,7 @@ function NewPurchaseOrderContent() {
|
||||
{!loading && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<div className="flex flex-col gap-2 sm:col-span-1">
|
||||
<Label className="text-base">Vendor</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select<number | null>
|
||||
@@ -406,13 +410,14 @@ function NewPurchaseOrderContent() {
|
||||
|
||||
{lines.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-40 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-16 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-20 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -424,11 +429,25 @@ function NewPurchaseOrderContent() {
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{requisitionId || rfqId ? (
|
||||
<div className="flex h-11 items-center text-base">{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 })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||
<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
|
||||
className="h-11! w-full text-base"
|
||||
aria-invalid={!!errors.itemId}
|
||||
title={item ? `${item.sku} — ${item.name}` : undefined}
|
||||
>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -449,7 +468,7 @@ function NewPurchaseOrderContent() {
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(uoms ?? []).map((u) => (
|
||||
{pickerOptions(allowedUoms.get(line.itemId), uoms ?? []).map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
@@ -485,6 +504,18 @@ function NewPurchaseOrderContent() {
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.unitPrice}
|
||||
aria-invalid={!!errors.unitPrice}
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||
<Trash2 className="size-5" />
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { pickerOptions } from "@/lib/uom"
|
||||
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
|
||||
import { CustomFieldType, StageInputSource } from "@/types/production"
|
||||
import { ItemListItem, Uom } from "@/types/master-data"
|
||||
import {
|
||||
@@ -46,7 +48,8 @@ function QtyRow({
|
||||
}: {
|
||||
qty: number
|
||||
uomId: number | null
|
||||
uoms: Uom[]
|
||||
/** Already narrowed to the row's item by the caller — see `pickerOptions`. */
|
||||
uoms: { uomId: number; name: string }[]
|
||||
readOnly: boolean
|
||||
onQtyChange: (qty: number) => void
|
||||
onUomChange: (uomId: number) => void
|
||||
@@ -125,9 +128,12 @@ export function StageEditorPanel({
|
||||
updateInput(localId, source === "Stock" ? { source, fromOutputKey: null } : { source, itemId: null })
|
||||
}
|
||||
|
||||
const allowedUoms = useAllowedUoms()
|
||||
|
||||
/** Default the UOM to the item's base unit — right most of the time, still overridable. */
|
||||
function pickInputItem(input: BuilderInput, itemId: number) {
|
||||
const item = items.find((i) => i.itemId === itemId)
|
||||
allowedUoms.load(itemId)
|
||||
updateInput(input.localId, { itemId, uomId: input.uomId ?? item?.baseUomId ?? null })
|
||||
}
|
||||
|
||||
@@ -146,6 +152,7 @@ export function StageEditorPanel({
|
||||
/** The terminal output's name mirrors the finished item, so the two can't drift apart. */
|
||||
function pickOutputItem(output: BuilderOutput, itemId: number) {
|
||||
const item = items.find((i) => i.itemId === itemId)
|
||||
allowedUoms.load(itemId)
|
||||
updateOutput(output.key, {
|
||||
itemId,
|
||||
name: item?.name ?? output.name,
|
||||
@@ -288,7 +295,7 @@ export function StageEditorPanel({
|
||||
<QtyRow
|
||||
qty={input.qtyPerBatch}
|
||||
uomId={input.uomId}
|
||||
uoms={uoms}
|
||||
uoms={pickerOptions(allowedUoms.get(input.itemId), uoms)}
|
||||
readOnly={readOnly}
|
||||
onQtyChange={(qtyPerBatch) => updateInput(input.localId, { qtyPerBatch })}
|
||||
onUomChange={(uomId) => updateInput(input.localId, { uomId })}
|
||||
@@ -348,7 +355,7 @@ export function StageEditorPanel({
|
||||
<QtyRow
|
||||
qty={output.qtyPerBatch}
|
||||
uomId={output.uomId}
|
||||
uoms={uoms}
|
||||
uoms={pickerOptions(allowedUoms.get(output.itemId), uoms)}
|
||||
readOnly={readOnly}
|
||||
onQtyChange={(qtyPerBatch) => updateOutput(output.key, { qtyPerBatch })}
|
||||
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 { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { validateItemForm } from "@/lib/validations/master-data"
|
||||
import { uomName } from "@/lib/uom"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Item, StockNature, TrackingMode } from "@/types/master-data"
|
||||
|
||||
import { UomConversionsPanel } from "./UomConversionsPanel"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -92,7 +95,9 @@ export default function ItemDetailPage() {
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(itemId)) return
|
||||
load()
|
||||
Promise.all([categoriesApi.list(), uomsApi.list(), warehousesApi.list({ pageSize: 200 })])
|
||||
// pageSize matters here: the default page would silently truncate the UOM list as the
|
||||
// master grows, hiding units from the base-UOM picker and the conversions editor.
|
||||
Promise.all([categoriesApi.list(), uomsApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })])
|
||||
.then(([cat, uo, wh]) => {
|
||||
setCategories(cat.items)
|
||||
setUoms(uo.items)
|
||||
@@ -155,9 +160,6 @@ export default function ItemDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function uomName(uomId: number) {
|
||||
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
|
||||
}
|
||||
|
||||
if (loadError && !item) {
|
||||
return (
|
||||
@@ -188,7 +190,7 @@ export default function ItemDetailPage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">{item.sku}</h1>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}
|
||||
className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive")}
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
@@ -321,8 +323,19 @@ export default function ItemDetailPage() {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -257,7 +257,15 @@ export default function BrandsPage() {
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.brandId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{b.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant={b.status === "Active" ? "default" : "secondary"}>{b.status}</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-transparent",
|
||||
b.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{b.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(b.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Ban, CheckCircle2, Network, Pencil, Plus } from "lucide-react"
|
||||
import { categoriesApi, subCategoriesApi } from "@/lib/api/categories"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateCategoryName } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Category, SubCategory } from "@/types/master-data"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
@@ -181,7 +182,15 @@ export default function CategorySubCategoriesPage() {
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{s.subCategoryId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{s.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant={s.status === "Active" ? "default" : "secondary"}>{s.status}</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-transparent",
|
||||
s.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{s.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(s.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
|
||||
@@ -256,7 +256,15 @@ export default function CategoriesPage() {
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant={c.status === "Active" ? "default" : "secondary"}>{c.status}</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-transparent",
|
||||
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{c.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
|
||||
@@ -240,7 +240,7 @@ export default function ItemsPage() {
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||
item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{item.status}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CheckCircle2, PackageCheck, PackageX, ShieldAlert, XCircle } from "luci
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomName } from "@/lib/uom"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -57,7 +58,7 @@ export default function GrnDetailPage() {
|
||||
return items.find((i) => i.itemId === itemId)
|
||||
}
|
||||
function uomFor(uomId: number) {
|
||||
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
|
||||
return uomName(uomId, uoms)
|
||||
}
|
||||
function binFor(binId: number | null) {
|
||||
if (!binId) return "—"
|
||||
|
||||
@@ -13,6 +13,8 @@ import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateLine, splitSerials, grnHeaderSchema } from "@/lib/validations/grn"
|
||||
import { basePreview, pickerOptions, uomName } from "@/lib/uom"
|
||||
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CreateGrnLineInput, HoldStatus } from "@/types/grn"
|
||||
import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement"
|
||||
@@ -108,12 +110,14 @@ export default function NewGrnPage() {
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [refreshingItems, setRefreshingItems] = useState(false)
|
||||
const allowedUoms = useAllowedUoms()
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
warehousesApi.list(),
|
||||
itemsApi.list({ pageSize: 200, status: "Active" }),
|
||||
uomsApi.list(),
|
||||
// pageSize: without it the default page silently truncates the unit list.
|
||||
uomsApi.list({ pageSize: 200 }),
|
||||
vendorsApi.list({ pageSize: 200, status: "Active" }),
|
||||
purchaseOrdersApi.list({ pageSize: 200 }),
|
||||
])
|
||||
@@ -155,13 +159,18 @@ export default function NewGrnPage() {
|
||||
setHeaderError(null)
|
||||
try {
|
||||
const po: PurchaseOrder = await purchaseOrdersApi.get(nextPoId)
|
||||
const openLines = po.lines.filter((l) => l.qtyReceived < l.qty)
|
||||
// Open-ness is decided on the base pair, matching the server's close condition —
|
||||
// qty/qtyReceived are PO-UOM display figures derived by division and can drift.
|
||||
const openLines = po.lines.filter((l) => l.qtyReceivedBase < l.qtyBase)
|
||||
if (openLines.length === 0) {
|
||||
setHeaderError("This purchase order has no open (unreceived) lines.")
|
||||
setLines([])
|
||||
return
|
||||
}
|
||||
setWarehouseId((prev) => prev ?? openLines[0].warehouseId)
|
||||
// The receiving lines keep the PO's UOM, so preload each item's allowed units for the
|
||||
// few lines a user may switch to a different pack size.
|
||||
openLines.forEach((l) => allowedUoms.load(l.itemId))
|
||||
setLines(
|
||||
openLines.map(
|
||||
(l): DraftLine => ({
|
||||
@@ -440,220 +449,235 @@ export default function NewGrnPage() {
|
||||
|
||||
{!poLoading && lines.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-96 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
|
||||
<TableHead className="h-12 w-20 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Disc %</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">VAT %</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">Batch / Serial</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{line.poLineId ? (
|
||||
<div className="flex h-11 items-center text-base">
|
||||
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||
{i.sku} — {i.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{line.poLineId ? (
|
||||
<div className="flex h-11 items-center text-base">
|
||||
{uoms?.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(uoms ?? []).map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bins.map((b) => (
|
||||
<SelectItem key={b.binId} value={b.binId} className="text-base">
|
||||
{b.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.qty}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.unitCost}
|
||||
aria-invalid={!!errors.unitCost}
|
||||
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
|
||||
{line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && (
|
||||
<p className="mt-1 text-xs text-warning">
|
||||
PO price {line.poUnitPrice.toFixed(2)} — variance recorded
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.vatPct}
|
||||
aria-invalid={!!errors.vatPct}
|
||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
||||
{(() => {
|
||||
const c = computeLine(line)
|
||||
return (
|
||||
<div className="flex h-11 flex-col justify-center">
|
||||
<span>{c.lineTotal.toFixed(2)}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
net {c.receivedValue.toFixed(2)} + VAT {c.vatAmount.toFixed(2)}
|
||||
</span>
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Disc %</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">VAT %</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">Batch / Serial</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{line.poLineId ? (
|
||||
<div className="flex h-11 items-center text-base">
|
||||
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<HoldStatus>
|
||||
value={line.holdStatus}
|
||||
onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}
|
||||
>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Available" className="text-base">Available</SelectItem>
|
||||
<SelectItem value="OnHold" className="text-base">On hold (inspection)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{item?.trackingMode === "Batch" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Input
|
||||
placeholder="Batch no."
|
||||
value={line.batchNo}
|
||||
aria-invalid={!!errors.batchNo}
|
||||
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={line.expiryDate}
|
||||
onChange={(e) => updateLine(line.key, { expiryDate: e.target.value })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
|
||||
</div>
|
||||
)}
|
||||
{item?.trackingMode === "Serial" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<textarea
|
||||
placeholder="One serial per line"
|
||||
value={line.serialNumbersText}
|
||||
aria-invalid={!!errors.serialNumbers}
|
||||
onChange={(e) => updateLine(line.key, { serialNumbersText: e.target.value })}
|
||||
className="min-h-16 w-full rounded-lg border border-input bg-transparent p-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
/>
|
||||
<FieldError errors={[errors.serialNumbers ? { message: errors.serialNumbers } : undefined]} />
|
||||
</div>
|
||||
)}
|
||||
{(!item || item.trackingMode === "None") && (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<>
|
||||
<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}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||
{i.sku} — {i.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{line.poLineId ? (
|
||||
<div className="flex h-11 items-center text-base">
|
||||
{uomName(line.uomId, uoms ?? [])}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pickerOptions(allowedUoms.get(line.itemId), uoms ?? []).map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bins.map((b) => (
|
||||
<SelectItem key={b.binId} value={b.binId} className="text-base">
|
||||
{b.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.qty}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
{/* Shows what will actually hit stock when receiving in a pack unit. */}
|
||||
{basePreview(Number(line.qty), line.uomId, allowedUoms.get(line.itemId) ?? []) && (
|
||||
<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]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.unitCost}
|
||||
aria-invalid={!!errors.unitCost}
|
||||
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
|
||||
{line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && (
|
||||
<p className="mt-1 text-xs text-warning">
|
||||
PO price {line.poUnitPrice.toFixed(2)} — variance recorded
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.vatPct}
|
||||
aria-invalid={!!errors.vatPct}
|
||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
||||
{(() => {
|
||||
const c = computeLine(line)
|
||||
return (
|
||||
<div className="flex h-11 flex-col justify-center">
|
||||
<span>{c.lineTotal.toFixed(2)}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
net {c.receivedValue.toFixed(2)} + VAT {c.vatAmount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<HoldStatus>
|
||||
value={line.holdStatus}
|
||||
onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}
|
||||
>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Available" className="text-base">Available</SelectItem>
|
||||
<SelectItem value="OnHold" className="text-base">On hold (inspection)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{item?.trackingMode === "Batch" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Input
|
||||
placeholder="Batch no."
|
||||
value={line.batchNo}
|
||||
aria-invalid={!!errors.batchNo}
|
||||
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={line.expiryDate}
|
||||
onChange={(e) => updateLine(line.key, { expiryDate: e.target.value })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
|
||||
</div>
|
||||
)}
|
||||
{item?.trackingMode === "Serial" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<textarea
|
||||
placeholder="One serial per line"
|
||||
value={line.serialNumbersText}
|
||||
aria-invalid={!!errors.serialNumbers}
|
||||
onChange={(e) => updateLine(line.key, { serialNumbersText: e.target.value })}
|
||||
className="min-h-16 w-full rounded-lg border border-input bg-transparent p-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
/>
|
||||
<FieldError errors={[errors.serialNumbers ? { message: errors.serialNumbers } : undefined]} />
|
||||
</div>
|
||||
)}
|
||||
{(!item || item.trackingMode === "None") && (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!poLoading && lines.length > 0 && (
|
||||
<div className="flex justify-end gap-6 pr-12 text-base">
|
||||
<div className="flex justify-end gap-3 border-t border-border pt-4 text-base">
|
||||
<span className="text-muted-foreground">Document total (incl. VAT)</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { bundleApi } from "@/lib/api/bundles"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { basePreview, pickerOptions } from "@/lib/uom"
|
||||
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -16,7 +18,7 @@ import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BundleSale, BundleSaleTemplateSummary, BundleSaleTemplateLine, UpdateBundleSaleRequest } from "@/types/bundles"
|
||||
import { BundleSale, BundleSaleTemplate, BundleSaleTemplateSummary, BundleSaleTemplateLine, UpdateBundleSaleRequest } from "@/types/bundles"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
@@ -55,7 +57,9 @@ export default function BundleSaleDetailPage() {
|
||||
const bundleSaleId = Number(params.id)
|
||||
const [bundle, setBundle] = useState<BundleSale | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const allowedUoms = useAllowedUoms()
|
||||
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
|
||||
const [template, setTemplate] = useState<BundleSaleTemplate | null>(null)
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [items, setItems] = useState<ItemListItem[]>([])
|
||||
const [uoms, setUoms] = useState<Uom[]>([])
|
||||
@@ -101,12 +105,16 @@ export default function BundleSaleDetailPage() {
|
||||
setTemplateId(data.bundleSaleTemplateId)
|
||||
setBundleName(data.bundleName)
|
||||
setBundlePrice(data.bundlePrice)
|
||||
data.lines.forEach((line) => allowedUoms.load(line.itemId))
|
||||
setLines(
|
||||
data.lines.map((line) => ({
|
||||
key: `${line.bundleSaleLineId}`,
|
||||
bundleSaleTemplateLineId: line.bundleSaleLineId,
|
||||
itemId: line.itemId,
|
||||
uomId: items.find((candidate) => candidate.itemId === line.itemId)?.baseUomId ?? line.uomId,
|
||||
// Keep the UOM the line was saved with. This used to be forced back to the
|
||||
// item's base because the server rewrote it that way; it now preserves what
|
||||
// was entered, so overriding here would discard the user's choice.
|
||||
uomId: line.uomId,
|
||||
warehouseId: line.warehouseId,
|
||||
qty: line.qty,
|
||||
unitPrice: line.unitPrice,
|
||||
@@ -118,6 +126,11 @@ export default function BundleSaleDetailPage() {
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [bundleSaleId, params.id, items])
|
||||
|
||||
useEffect(() => {
|
||||
if (!templateId) return
|
||||
bundleApi.getTemplate(templateId).then(setTemplate).catch((err) => setError(errorMessage(err)))
|
||||
}, [templateId])
|
||||
|
||||
const templateLabel = useMemo(() => templates.find((t) => t.bundleSaleTemplateId === templateId)?.templateName ?? "Template", [templates, templateId])
|
||||
const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines])
|
||||
const isDraft = bundle?.status === "Draft"
|
||||
@@ -371,6 +384,7 @@ export default function BundleSaleDetailPage() {
|
||||
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
|
||||
const itemId = Number(v)
|
||||
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 })
|
||||
}} disabled={!editing || !isDraft}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
@@ -386,12 +400,14 @@ export default function BundleSaleDetailPage() {
|
||||
</Select>
|
||||
</TableCell>
|
||||
<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">
|
||||
<SelectValue placeholder="Select UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((uom) => (
|
||||
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((uom) => (
|
||||
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
|
||||
{uom.name}
|
||||
</SelectItem>
|
||||
@@ -399,7 +415,12 @@ export default function BundleSaleDetailPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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">
|
||||
{editing && isDraft ? (
|
||||
@@ -432,47 +453,6 @@ export default function BundleSaleDetailPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{bundle.status === "Draft" && shortageIssues.length > 0 ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="font-semibold">Stock shortage detected before posting</div>
|
||||
<div className="mt-1">This bundle cannot be posted until every included component has enough available stock in the selected warehouse.</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/receiving/grn/new"
|
||||
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Create GRN
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
|
||||
<tr>
|
||||
<th className="py-1 pr-3">Item</th>
|
||||
<th className="py-1 pr-3">Warehouse</th>
|
||||
<th className="py-1 pr-3 text-right">Requested</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shortageIssues.map((issue) => (
|
||||
<tr key={issue.bundleSaleLineId} className="border-t border-amber-200/60">
|
||||
<td className="py-2 pr-3">
|
||||
<div className="font-medium">{issue.itemSku}</div>
|
||||
<div className="text-xs text-amber-900/70">{issue.itemName}</div>
|
||||
</td>
|
||||
<td className="py-2 pr-3">{issue.warehouseId}</td>
|
||||
<td className="py-2 pr-3 text-right font-mono tabular-nums">{issue.requestedQty.toFixed(0)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-2xl border bg-card p-4 shadow-sm">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div><div className="text-xs text-muted-foreground">Component subtotal</div><div className="text-lg font-semibold">{componentSubtotal.toFixed(2)}</div></div>
|
||||
|
||||
@@ -12,6 +12,8 @@ import { uomsApi } from "@/lib/api/uoms"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { basePreview, pickerOptions } from "@/lib/uom"
|
||||
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
@@ -61,6 +63,7 @@ function NewBundleSaleContent() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const allowedUoms = useAllowedUoms()
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
@@ -94,11 +97,12 @@ function NewBundleSaleContent() {
|
||||
setLines(
|
||||
res.lines.length > 0
|
||||
? res.lines.map((line) => {
|
||||
const item = items.find((candidate) => candidate.itemId === line.itemId)
|
||||
return createBlankLine({
|
||||
...line,
|
||||
uomId: item?.baseUomId ?? line.uomId,
|
||||
})
|
||||
// Load each component's allowed units so the picker narrows for template-seeded rows too.
|
||||
allowedUoms.load(line.itemId)
|
||||
// Keep the template's own UOM. Forcing it to the item's base while keeping the
|
||||
// template's unitPrice would leave qty and price in different units, and
|
||||
// componentSubtotal below multiplies the two.
|
||||
return createBlankLine({ ...line, uomId: line.uomId })
|
||||
})
|
||||
: [createBlankLine()]
|
||||
)
|
||||
@@ -259,6 +263,7 @@ function NewBundleSaleContent() {
|
||||
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
|
||||
const itemId = Number(v)
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
allowedUoms.load(itemId)
|
||||
updateLine(line.key, {
|
||||
itemId,
|
||||
uomId: item?.baseUomId ?? line.uomId,
|
||||
@@ -278,12 +283,14 @@ function NewBundleSaleContent() {
|
||||
</Select>
|
||||
</TableCell>
|
||||
<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">
|
||||
<SelectValue placeholder="Select UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((uom) => (
|
||||
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((uom) => (
|
||||
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
|
||||
{uom.name}
|
||||
</SelectItem>
|
||||
@@ -291,7 +298,14 @@ function NewBundleSaleContent() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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">
|
||||
<button
|
||||
|
||||
@@ -10,6 +10,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { cn } from "@/lib/utils"
|
||||
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 { customersApi } from "@/lib/api/customers"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
@@ -70,6 +72,7 @@ export default function NewFreeIssuePage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const allowedUoms = useAllowedUoms()
|
||||
|
||||
async function refreshRows() {
|
||||
const list = await salesApi.listFreeIssues({ pageSize: 50 })
|
||||
@@ -88,7 +91,7 @@ export default function NewFreeIssuePage() {
|
||||
warehouseName: warehouse?.name ?? `Warehouse ${detail.data.warehouseId}`,
|
||||
itemName: item?.name ?? firstLine?.description ?? "—",
|
||||
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,
|
||||
freeQty: firstLine?.freeQty ?? 0,
|
||||
} satisfies FreeIssueRow
|
||||
@@ -146,11 +149,13 @@ export default function NewFreeIssuePage() {
|
||||
|
||||
function selectItem(key: string, itemId: number) {
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
allowedUoms.load(itemId)
|
||||
updateLine(key, { itemId, uomId: item?.baseUomId ?? 0 })
|
||||
}
|
||||
|
||||
function selectEditingItem(key: string, itemId: number) {
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
allowedUoms.load(itemId)
|
||||
updateEditingLine(key, { itemId, uomId: item?.baseUomId ?? 0 })
|
||||
}
|
||||
|
||||
@@ -325,7 +330,7 @@ export default function NewFreeIssuePage() {
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => (
|
||||
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
@@ -390,7 +395,7 @@ export default function NewFreeIssuePage() {
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => (
|
||||
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
|
||||
@@ -11,6 +11,8 @@ import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
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 { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||
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 [saving, setSaving] = useState(false)
|
||||
const [busy, setBusy] = useState<"post" | "cancel" | null>(null)
|
||||
const allowedUoms = useAllowedUoms()
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(invoiceId)) {
|
||||
@@ -99,6 +102,9 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
setCustomerId(doc.data.customerId)
|
||||
setWarehouseId(doc.data.warehouseId)
|
||||
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(
|
||||
doc.data.lines.map((line) => ({
|
||||
key: String(line.salesInvoiceLineId),
|
||||
@@ -150,6 +156,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
|
||||
function selectItem(key: string, itemId: number) {
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
allowedUoms.load(itemId)
|
||||
updateLine(key, {
|
||||
itemId,
|
||||
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="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
||||
</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.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</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"
|
||||
>
|
||||
<option value="">UOM</option>
|
||||
{uoms.map((u) => (
|
||||
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => (
|
||||
<option key={u.uomId} value={String(u.uomId)}>
|
||||
{u.name}
|
||||
</option>
|
||||
@@ -519,6 +526,11 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
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"
|
||||
/>
|
||||
{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 className="px-4 py-3 w-28">
|
||||
<input
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ArrowLeft, Printer } from "lucide-react"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomName } from "@/lib/uom"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -143,7 +144,7 @@ export default function SalesInvoicePrintPage({ params }: { params: { id: string
|
||||
<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>
|
||||
</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.freeQty > 0 ? line.freeQty.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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { basePreview, pickerOptions } from "@/lib/uom"
|
||||
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
|
||||
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
@@ -71,6 +73,7 @@ export default function NewSalesInvoicePage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const allowedUoms = useAllowedUoms()
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
@@ -129,6 +132,9 @@ export default function NewSalesInvoicePage() {
|
||||
|
||||
function selectItem(key: string, itemId: number) {
|
||||
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, {
|
||||
itemId,
|
||||
uomId: item?.baseUomId ?? 0,
|
||||
@@ -363,7 +369,7 @@ export default function NewSalesInvoicePage() {
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => (
|
||||
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
@@ -380,6 +386,12 @@ export default function NewSalesInvoicePage() {
|
||||
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })}
|
||||
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
|
||||
/>
|
||||
{/* Makes the conversion visible while editing instead of at post time. */}
|
||||
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? []) && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2">
|
||||
<Input
|
||||
|
||||
@@ -7,6 +7,8 @@ import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, X } from "lu
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
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 { warehousesApi } from "@/lib/api/warehouses"
|
||||
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 [saving, setSaving] = useState(false)
|
||||
const [actionBusy, setActionBusy] = useState<"post" | "cancel" | null>(null)
|
||||
const allowedUoms = useAllowedUoms()
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(slipId)) {
|
||||
@@ -97,6 +100,7 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
||||
setWarehouseId(doc.data.warehouseId)
|
||||
setCashierUserId(doc.data.cashierUserId)
|
||||
setPromotionSuggestion(null)
|
||||
doc.data.lines.forEach((line) => allowedUoms.load(line.itemId))
|
||||
setLines(
|
||||
doc.data.lines.map((line) => ({
|
||||
key: String(line.salesSlipLineId),
|
||||
@@ -149,8 +153,12 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
function selectItem(key: string, itemId: number) {
|
||||
allowedUoms.load(itemId)
|
||||
updateLine(key, {
|
||||
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),
|
||||
})
|
||||
}
|
||||
@@ -346,7 +354,7 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
||||
: "No price suggestion available"}
|
||||
</div>
|
||||
</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.freeQty > 0 ? line.freeQty.toFixed(0) : "-"}</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>
|
||||
<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>
|
||||
<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>
|
||||
</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.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>
|
||||
|
||||
@@ -12,6 +12,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { basePreview, pickerOptions } from "@/lib/uom"
|
||||
import { useAllowedUoms } from "@/hooks/use-allowed-uoms"
|
||||
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
@@ -65,6 +67,7 @@ export default function NewSalesSlipPage() {
|
||||
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const allowedUoms = useAllowedUoms()
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -104,6 +107,8 @@ export default function NewSalesSlipPage() {
|
||||
|
||||
function selectItem(key: string, itemId: number) {
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
// Narrow the UOM picker to units this item has a conversion for.
|
||||
allowedUoms.load(itemId)
|
||||
updateLine(key, {
|
||||
itemId,
|
||||
uomId: item?.baseUomId ?? 0,
|
||||
@@ -334,7 +339,7 @@ export default function NewSalesSlipPage() {
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
{pickerOptions(allowedUoms.get(line.itemId), uoms).map((u) => (
|
||||
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
@@ -351,6 +356,12 @@ export default function NewSalesSlipPage() {
|
||||
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })}
|
||||
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
|
||||
/>
|
||||
{/* Makes the conversion visible while editing instead of at post time. */}
|
||||
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? []) && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{basePreview(line.qty, line.uomId, allowedUoms.get(line.itemId) ?? [])}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2">
|
||||
<Input
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PackageSearch, Search } from "lucide-react"
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { formatQtyValue, formatQtyWithName } from "@/lib/uom"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { OnHand } from "@/types/stock"
|
||||
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>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{wh?.code ?? `#${row.warehouseId}`}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{row.onHand}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{row.available}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{row.onHold}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{row.inTransit}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{row.reserved}</TableCell>
|
||||
{/* Every figure here is base UOM; the label is what tells the user which. */}
|
||||
<TableCell className="px-3 py-3.5">{formatQtyWithName(row.onHand, row.baseUomName)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{formatQtyWithName(row.available, row.baseUomName)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{formatQtyValue(row.onHold)}</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">
|
||||
<Link
|
||||
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 { itemsApi } from "@/lib/api/items"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { formatQtyWithName } from "@/lib/uom"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LedgerEntry } from "@/types/stock"
|
||||
@@ -189,10 +190,10 @@ export default function StockLedgerPage() {
|
||||
{entry.direction}
|
||||
</Badge>
|
||||
</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.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">
|
||||
{entry.sourceDocType} #{entry.sourceDocId}
|
||||
</TableCell>
|
||||
|
||||
@@ -1,69 +1,3 @@
|
||||
import Link from "next/link"
|
||||
import {
|
||||
AlertOctagon,
|
||||
AlertTriangle,
|
||||
ArrowLeftRight,
|
||||
BadgeDollarSign,
|
||||
ClipboardList,
|
||||
PackageSearch,
|
||||
ScrollText,
|
||||
SlidersHorizontal,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
const areas: { title: string; description: string; href: string; icon: LucideIcon }[] = [
|
||||
{
|
||||
title: "Stock Enquiry",
|
||||
description: "On-hand, available, on-hold, and in-transit quantities by item and warehouse.",
|
||||
href: "/dashboard/stock/enquiry",
|
||||
icon: PackageSearch,
|
||||
},
|
||||
{
|
||||
title: "Stock Ledger",
|
||||
description: "The immutable, append-only movement journal — every in/out with running balance.",
|
||||
href: "/dashboard/stock/ledger",
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
title: "Valuation",
|
||||
description: "FIFO cost-layer breakdown and total stock value by item and warehouse.",
|
||||
href: "/dashboard/stock/valuation",
|
||||
icon: BadgeDollarSign,
|
||||
},
|
||||
{
|
||||
title: "Transfers",
|
||||
description: "Move stock between warehouses: create, dispatch, and receive (in-transit).",
|
||||
href: "/dashboard/stock/transfers",
|
||||
icon: ArrowLeftRight,
|
||||
},
|
||||
{
|
||||
title: "Adjustments",
|
||||
description: "Increase, decrease, or write off stock with a mandatory reason code.",
|
||||
href: "/dashboard/stock/adjustments",
|
||||
icon: SlidersHorizontal,
|
||||
},
|
||||
{
|
||||
title: "Counts",
|
||||
description: "Cycle or full physical counts — snapshot, enter counts, post variance.",
|
||||
href: "/dashboard/stock/counts",
|
||||
icon: ClipboardList,
|
||||
},
|
||||
{
|
||||
title: "Reorder Alerts",
|
||||
description: "Items at or below their reorder point, with a one-click requisition.",
|
||||
href: "/dashboard/stock/reorder-alerts",
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
{
|
||||
title: "Wastage",
|
||||
description: "Damage, theft/loss, and expiry write-offs — reason-coded adjustments with a totals report.",
|
||||
href: "/dashboard/stock/wastage",
|
||||
icon: AlertOctagon,
|
||||
},
|
||||
]
|
||||
|
||||
export default function StockHubPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -73,26 +7,6 @@ export default function StockHubPage() {
|
||||
FIFO-costed stock across multiple warehouses (FR-STK-01..14).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{areas.map((area) => (
|
||||
<Link key={area.href} href={area.href}>
|
||||
<Card className="h-full transition-shadow hover:shadow-md">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<area.icon className="size-5" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">{area.title}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-base text-muted-foreground">{area.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import { AlertTriangle, CheckCircle2 } from "lucide-react"
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatQty } from "@/lib/uom"
|
||||
import { ReorderAlert } from "@/types/stock"
|
||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -19,16 +21,23 @@ export default function ReorderAlertsPage() {
|
||||
const [alerts, setAlerts] = useState<ReorderAlert[] | null>(null)
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [uoms, setUoms] = useState<Uom[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [requesting, setRequesting] = useState<string | null>(null)
|
||||
const [requested, setRequested] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([stockApi.reorderAlerts(), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
|
||||
.then(([a, it, wh]) => {
|
||||
Promise.all([
|
||||
stockApi.reorderAlerts(),
|
||||
itemsApi.list({ pageSize: 200 }),
|
||||
warehousesApi.list(),
|
||||
uomsApi.list({ pageSize: 200 }),
|
||||
])
|
||||
.then(([a, it, wh, uo]) => {
|
||||
setAlerts(a.items)
|
||||
setItems(it.items)
|
||||
setWarehouses(wh.items)
|
||||
setUoms(uo.items)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
@@ -44,7 +53,10 @@ export default function ReorderAlertsPage() {
|
||||
const res = await stockApi.createReorderRequisition(alert.itemId, alert.warehouseId)
|
||||
setRequested((prev) => new Set(prev).add(key))
|
||||
const qty = res.lines[0]?.qty ?? alert.suggestedRequisitionQty
|
||||
toast.success("Requisition created", `${res.docNo} for ${qty} units.`)
|
||||
// Requisitions are base-UOM only, so name the item's actual base unit rather than
|
||||
// the placeholder word "units".
|
||||
const uom = itemsById.get(alert.itemId)?.baseUomId
|
||||
toast.success("Requisition created", `${res.docNo} for ${formatQty(qty, uom ?? null, uoms)}.`)
|
||||
} catch (err) {
|
||||
toast.error("Could not create requisition", errorMessage(err))
|
||||
} finally {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Printer } from "lucide-react"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomName } from "@/lib/uom"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -131,7 +132,7 @@ export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id
|
||||
<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>
|
||||
</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.freeQty > 0 ? line.freeQty.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 { customersApi } from "@/lib/api/customers"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomName } from "@/lib/uom"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
@@ -123,7 +124,7 @@ export default function SalesSlipPrintPage({ params }: { params: Promise<{ id: s
|
||||
<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>
|
||||
</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.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</TableCell>
|
||||
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
|
||||
|
||||
@@ -4,6 +4,9 @@ import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import {
|
||||
AlertOctagon,
|
||||
AlertTriangle,
|
||||
ArrowLeftRight,
|
||||
Banknote,
|
||||
BadgeDollarSign,
|
||||
BookOpen,
|
||||
@@ -29,6 +32,7 @@ import {
|
||||
Menu,
|
||||
Package,
|
||||
PackageCheck,
|
||||
PackageSearch,
|
||||
PackageX,
|
||||
PlayCircle,
|
||||
PieChart,
|
||||
@@ -36,6 +40,7 @@ import {
|
||||
ReceiptText,
|
||||
Ruler,
|
||||
Scale,
|
||||
ScrollText,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
ShoppingCart,
|
||||
@@ -116,7 +121,26 @@ const navItems: {
|
||||
],
|
||||
},
|
||||
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||
{ title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true },
|
||||
{
|
||||
title: "Stock",
|
||||
code: "stock",
|
||||
href: "/dashboard/stock",
|
||||
// Clicking "Stock" itself lands on Stock Ledger — the hub page underneath has nothing on
|
||||
// it (its card grid was removed once the sidebar grew these sub-items), same as Procurement.
|
||||
landingHref: "/dashboard/stock/ledger",
|
||||
icon: Warehouse,
|
||||
chevron: true,
|
||||
children: [
|
||||
{ title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText },
|
||||
{ title: "Stock Enquiry", code: "stock.enquiry", href: "/dashboard/stock/enquiry", icon: PackageSearch },
|
||||
{ title: "Valuation", code: "stock.valuation", href: "/dashboard/stock/valuation", icon: BadgeDollarSign },
|
||||
{ title: "Transfers", code: "stock.transfers", href: "/dashboard/stock/transfers", icon: ArrowLeftRight },
|
||||
{ title: "Adjustments", code: "stock.adjustments", href: "/dashboard/stock/adjustments", icon: SlidersHorizontal },
|
||||
{ title: "Counts", code: "stock.counts", href: "/dashboard/stock/counts", icon: ClipboardList },
|
||||
{ title: "Reorder Alerts", code: "stock.reorder-alerts", href: "/dashboard/stock/reorder-alerts", icon: AlertTriangle },
|
||||
{ title: "Wastage", code: "stock.wastage", href: "/dashboard/stock/wastage", icon: AlertOctagon },
|
||||
],
|
||||
},
|
||||
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
|
||||
{ title: "Orders", code: "orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true },
|
||||
{
|
||||
@@ -402,14 +426,15 @@ export function AppSidebar() {
|
||||
// flashing the full menu to a restricted role. Once resolved, a nav item
|
||||
// is visible if its own code is granted, or (for parents) if any child is.
|
||||
//
|
||||
// "procurement", "hrm", "sales" and "production" are exempted from that check (frontend-only): no
|
||||
// role is currently seeded with NAV:procurement/NAV:hrm/NAV:production or their children
|
||||
// server-side, which would hide the whole section for everyone. Remove each bypass once roles are granted
|
||||
// the permission properly (Settings → Roles → Sidebar permissions) or a backend seed
|
||||
// grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
|
||||
// "procurement", "hrm", "sales", "production" and "stock" are exempted from that check
|
||||
// (frontend-only): no role is currently seeded with NAV:procurement/NAV:hrm/NAV:production
|
||||
// or their children server-side (stock's children specifically have no SubNavItem rows at
|
||||
// all yet), which would hide the whole section for everyone. Remove each bypass once roles
|
||||
// are granted the permission properly (Settings → Roles → Sidebar permissions) or a backend
|
||||
// seed grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
|
||||
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
|
||||
// anything server-side.
|
||||
const bypassCodes = new Set(["procurement", "sales", "hrm", "production"])
|
||||
const bypassCodes = new Set(["procurement", "sales", "hrm", "production", "stock"])
|
||||
const visibleItems = loading
|
||||
? []
|
||||
: navItems
|
||||
|
||||
@@ -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 { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
|
||||
import {
|
||||
AllowedUom,
|
||||
CreateItemRequest,
|
||||
Item,
|
||||
ItemListItem,
|
||||
@@ -60,10 +61,20 @@ export const itemsApi = {
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace the item's conversions. Every row must be `<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> {
|
||||
return apiRequest<UpdateUomConversionsResponse>(`/items/${itemId}/uom-conversions`, {
|
||||
method: "PUT",
|
||||
body: request,
|
||||
})
|
||||
},
|
||||
|
||||
/** The UOMs this item can be transacted in — base UOM first, then each conversion source. */
|
||||
allowedUoms(itemId: number): Promise<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
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-item conversion row. Always directional: `fromUom → toUom`, where `toUom` is
|
||||
* required by the server to be the item's `baseUomId`. Quantity in `fromUom` × `factor`
|
||||
* = quantity in base UOM. Saving one in the opposite direction is rejected with 422.
|
||||
*/
|
||||
export interface UomConversion {
|
||||
conversionId: number
|
||||
fromUom: number
|
||||
@@ -40,6 +45,20 @@ export interface UomConversion {
|
||||
factor: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A UOM an item may actually be transacted in — from `GET /items/{itemId}/uoms`.
|
||||
*
|
||||
* Line forms must populate their UOM picker from this rather than the global `uoms` list:
|
||||
* choosing a unit the item has no conversion for is rejected when the line is saved.
|
||||
*/
|
||||
export interface AllowedUom {
|
||||
uomId: number
|
||||
name: string
|
||||
/** Multiply a quantity in this UOM by `factor` to get the item's base UOM. 1 for the base itself. */
|
||||
factor: number
|
||||
isBase: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Full Item resource (docs/11 §2.1 `GET /items/{itemId}`).
|
||||
*
|
||||
|
||||
@@ -138,11 +138,23 @@ export interface PoLine {
|
||||
itemId: number
|
||||
uomId: number
|
||||
warehouseId: number
|
||||
/** Ordered quantity, in `uomId`. */
|
||||
qty: number
|
||||
unitPrice: number
|
||||
/** Rate, 0..1 (e.g. 0.18), not an amount. */
|
||||
tax: number
|
||||
/**
|
||||
* Received to date, in `uomId`. **Display only** — the server derives it by dividing
|
||||
* `qtyReceivedBase` by `conversionFactor`, so it can drift. Use the base pair below for
|
||||
* any remaining/outstanding/closed logic.
|
||||
*/
|
||||
qtyReceived: number
|
||||
/** Ordered quantity in the item's base UOM — what over-receipt and PO closure compare on. */
|
||||
qtyBase: number
|
||||
/** Received to date in the item's base UOM. Authoritative. */
|
||||
qtyReceivedBase: number
|
||||
/** Factor used to derive the base pair from `uomId`; 1 when the line is in base UOM. */
|
||||
conversionFactor: number
|
||||
}
|
||||
|
||||
export interface PoTotals {
|
||||
|
||||
@@ -19,6 +19,9 @@ export interface OnHand {
|
||||
/** Always 0 in Phase 1 (sales reservation stub, FR-STK-11). */
|
||||
reserved: number
|
||||
asOf: string
|
||||
/** The item's base UOM — every quantity above is in it. For labelling, not conversion. */
|
||||
baseUomId: number
|
||||
baseUomName: string
|
||||
}
|
||||
|
||||
// --- 5.2 Ledger -------------------------------------------------------------------
|
||||
@@ -41,6 +44,9 @@ export interface LedgerEntry {
|
||||
sourceDocId: number
|
||||
userId: number
|
||||
createdAt: string
|
||||
/** The item's base UOM — `qtyBase` and `runningBalance` are in it. */
|
||||
baseUomId: number
|
||||
baseUomName: string
|
||||
}
|
||||
|
||||
// --- 5.3 Valuation ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user