Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2a218e2f8 | |||
| a7ba3d3e04 | |||
| 0ae80395cf | |||
| 15ddac178c | |||
| d37824cecc | |||
| 271c940640 | |||
| f2825900aa | |||
| 6520930aeb | |||
| a8c6b4cb5e | |||
| 8e9974b735 | |||
| c4e016c460 | |||
| 7e8418685c | |||
| cbc72ef830 | |||
| 4324ba1a96 | |||
| 1af16d3dec |
+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/
|
||||
|
||||
@@ -33,10 +33,11 @@ public sealed class BundleSalesController : ApiControllerBase
|
||||
[ProducesResponseType(typeof(PagedResponse<BundleSaleSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<BundleSaleSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] BundleSaleStatus? status,
|
||||
[FromQuery] int? customerId,
|
||||
[FromQuery] int? warehouseId,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _bundles.ListAsync(query, customerId, warehouseId, ct));
|
||||
=> Ok(await _bundles.ListAsync(query, status, customerId, warehouseId, ct));
|
||||
|
||||
[HttpGet("{bundleSaleId:int}")]
|
||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -81,11 +81,4 @@ public sealed class ItemsController : ApiControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ItemReorderSettingsDto>> UpdateReorder(int itemId, [FromBody] UpdateReorderRequest request, CancellationToken ct)
|
||||
=> Ok(await _items.UpdateReorderAsync(itemId, request, ct));
|
||||
|
||||
/// <summary>Replace the item's UOM conversions (FR-MD-02).</summary>
|
||||
[HttpPut("{itemId:int}/uom-conversions")]
|
||||
[ProducesResponseType(typeof(ItemUomConversionsDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ItemUomConversionsDto>> UpdateUomConversions(int itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct)
|
||||
=> Ok(await _items.UpdateUomConversionsAsync(itemId, request, ct));
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ public class BundleSaleLine
|
||||
public Item? Item { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Qty { get; set; }
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
@@ -8,8 +8,6 @@ public class BundleSaleTemplateLine
|
||||
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
|
||||
@@ -25,9 +25,6 @@ public class GrnLine
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
|
||||
public int? BinId { get; set; }
|
||||
public Bin? Bin { get; set; }
|
||||
|
||||
|
||||
@@ -24,6 +24,12 @@ public class Item
|
||||
public int? BrandId { get; set; }
|
||||
public Brand? Brand { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The stocking unit — the pack the item is counted in (BOTTLE, PACKET, BOX, PCS).
|
||||
/// <b>Every</b> quantity in the system is a count of these: stock layers, the ledger,
|
||||
/// and every document line. Nothing converts, so this is the sole meaning of a
|
||||
/// quantity and cannot be changed once the item has stock history.
|
||||
/// </summary>
|
||||
public int BaseUomId { get; set; }
|
||||
public Uom? BaseUom { get; set; }
|
||||
|
||||
@@ -41,6 +47,34 @@ public class Item
|
||||
/// </summary>
|
||||
public decimal? SalePrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How much one pack holds, as the user entered it — <c>500</c> with
|
||||
/// <see cref="ContentUnit"/> <c>Ml</c> for a 500 ml bottle, <c>1.5</c> with <c>L</c>
|
||||
/// for a 1.5 L one. Null (together with the other three) when the item has no
|
||||
/// measurable content: a screw, a label, a service.
|
||||
/// <para>
|
||||
/// Content never affects stock — that is always a pack count. It exists so production
|
||||
/// can express a formula in millilitres or grams and resolve it to packs
|
||||
/// (see <c>IItemMeasure</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A loose bulk item bought by weight is modelled the same way:
|
||||
/// <c>BaseUom = KG, ContentQty = 1, ContentUnit = Kg</c> ⇒ 1000 g per stocked unit.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public decimal? ContentQty { get; set; }
|
||||
public MeasureUnit? ContentUnit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ContentQty"/>/<see cref="ContentUnit"/> normalised to a base unit
|
||||
/// (L→Ml, Kg→G, both ×1000) at write time by <c>ItemContent.Normalize</c>. Server-derived
|
||||
/// and never accepted from a client. <see cref="ContentBaseUnit"/> is therefore only ever
|
||||
/// <see cref="MeasureUnit.Ml"/> or <see cref="MeasureUnit.G"/>.
|
||||
/// <para>Stored rather than recomputed so every consumer reads one settled number.</para>
|
||||
/// </summary>
|
||||
public decimal? ContentBaseQty { get; set; }
|
||||
public MeasureUnit? ContentBaseUnit { get; set; }
|
||||
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
@@ -50,5 +84,4 @@ public class Item
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<ItemReorder> ReorderSettings { get; set; } = new List<ItemReorder>();
|
||||
public ICollection<UomConversion> UomConversions { get; set; } = new List<UomConversion>();
|
||||
}
|
||||
|
||||
@@ -7,12 +7,15 @@ namespace ERPCore.Domain.Entities;
|
||||
/// Material.
|
||||
/// <para>
|
||||
/// <b>Deliberately unlinked.</b> Nothing references this entity and it references
|
||||
/// nothing: there is no value table and no join to <see cref="Item"/>. Its only job is
|
||||
/// to feed the frontend's item-builder dropdown via <c>GET /item-types</c>. The chosen
|
||||
/// nothing: there is no value table and no join to <see cref="Item"/>. The chosen
|
||||
/// values (Red, S, M) are encoded by the client into the generated SKU
|
||||
/// (e.g. <c>BL-100-0003</c>) and are never stored or parsed server-side — the item list
|
||||
/// is the record of what was built. See the accepted trade-off in docs/10 Part C.9.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It does, however, carry one piece of meaning the client acts on:
|
||||
/// <see cref="IsMeasurable"/>. So this is no longer purely a dropdown source.
|
||||
/// </para>
|
||||
/// Not to be confused with <see cref="Enums.StockNature"/> (Stocked/NonStocked/Service),
|
||||
/// which is what the old <c>ItemType</c> enum became.
|
||||
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
@@ -21,6 +24,24 @@ public class ItemType
|
||||
{
|
||||
public int ItemTypeId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// When true, this dimension's values are content <b>measurements</b> (500 ml, 1 L) rather
|
||||
/// than plain labels (Red, S). The item builder then captures a number + unit per value and
|
||||
/// stamps that pair onto each generated item's <see cref="Item.ContentQty"/> /
|
||||
/// <see cref="Item.ContentUnit"/>, instead of copying one form-level pair into every variant
|
||||
/// — which is what makes "Coca-Cola in 500 ml / 1 L / 250 ml" three correctly sized items.
|
||||
/// <para>
|
||||
/// This is what lets an apparel <c>Size</c> (S/M/L) stay plain text while a
|
||||
/// <c>Pack Size</c>/<c>Volume</c> dimension carries ml/g/L/kg.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A client hint only: the server never reads it when writing an item. Each item's pair is
|
||||
/// still validated and normalised on its own by <c>ItemContent</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public bool IsMeasurable { get; set; }
|
||||
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
@@ -15,9 +15,6 @@ public class PoLine
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
|
||||
@@ -31,10 +31,14 @@ public class RunStageInput
|
||||
public int? FromRunOutputId { get; set; }
|
||||
public RunStageOutput? FromRunOutput { get; set; }
|
||||
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
/// <summary>Copied from the template input: what <see cref="PlannedQty"/> is expressed in.</summary>
|
||||
public StageQtyUnit QtyUnit { get; set; } = StageQtyUnit.Pack;
|
||||
|
||||
/// <summary>Scaled at creation; per-run editable until the stage starts (FR-MFG-08, <c>409 STAGE_NOT_EDITABLE</c>).</summary>
|
||||
/// <summary>
|
||||
/// Scaled at creation; per-run editable until the stage starts (FR-MFG-08,
|
||||
/// <c>409 STAGE_NOT_EDITABLE</c>). Expressed in <see cref="QtyUnit"/> — so unlike the
|
||||
/// consumption figures below it is <b>not</b> necessarily a pack count.
|
||||
/// </summary>
|
||||
public decimal PlannedQty { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -23,10 +23,17 @@ public class RunStageOutput
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public int UomId { get; set; }
|
||||
/// <summary>
|
||||
/// Display label for intermediate WIP; null on the terminal output, whose unit is the
|
||||
/// finished item's base UOM. Never converted — see <see cref="StageOutput.UomId"/>.
|
||||
/// </summary>
|
||||
public int? UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
|
||||
/// <summary>Scaled at creation; per-run editable until the stage starts.</summary>
|
||||
/// <summary>
|
||||
/// Scaled at creation; per-run editable until the stage starts. Every quantity on an
|
||||
/// output is a pack count, so scrap is recorded in whole broken bottles rather than ml.
|
||||
/// </summary>
|
||||
public decimal PlannedQty { get; set; }
|
||||
|
||||
/// <summary>Recorded at complete. A re-complete after a rework <b>overwrites</b> this, never adds to it.</summary>
|
||||
|
||||
@@ -16,8 +16,6 @@ public class SalesInvoiceLine
|
||||
|
||||
public decimal Qty { get; set; }
|
||||
public decimal FreeQty { get; set; }
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ public class SalesSlipLine
|
||||
|
||||
public decimal Qty { get; set; }
|
||||
public decimal FreeQty { get; set; }
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
|
||||
@@ -32,8 +32,13 @@ public class StageInput
|
||||
public int? FromOutputId { get; set; }
|
||||
public StageOutput? FromOutput { get; set; }
|
||||
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
/// <summary>
|
||||
/// What <see cref="QtyPerBatch"/> is expressed in. Stock inputs may use
|
||||
/// <see cref="StageQtyUnit.Content"/> (ml/g) when the item has a content size; Upstream
|
||||
/// inputs are always <see cref="StageQtyUnit.Pack"/> — WIP is counted in the unit its
|
||||
/// source output declares.
|
||||
/// </summary>
|
||||
public StageQtyUnit QtyUnit { get; set; } = StageQtyUnit.Pack;
|
||||
|
||||
public decimal QtyPerBatch { get; set; }
|
||||
}
|
||||
|
||||
@@ -20,8 +20,14 @@ public class StageOutput
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public int UomId { get; set; }
|
||||
/// <summary>
|
||||
/// Display label for intermediate work-in-progress. Required when <see cref="ItemId"/>
|
||||
/// is null and must be null when it is set — a real item's unit is its own base UOM.
|
||||
/// WIP never touches stock or the ledger, so this is never converted, only shown.
|
||||
/// </summary>
|
||||
public int? UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
|
||||
/// <summary>Always a pack count: of the WIP unit above, or of the item's base UOM.</summary>
|
||||
public decimal QtyPerBatch { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Unit of Measure (FR-MD-02). Referenced as an item's base UOM and as the
|
||||
/// endpoints of a <see cref="UomConversion"/>. Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// Unit of Measure (FR-MD-02). A flat lookup, used as an item's base UOM — the pack every
|
||||
/// quantity in the system counts — and as the display label on an intermediate production
|
||||
/// output. There are no conversions between UOMs: an item is stocked in exactly one, and a
|
||||
/// differently sized pack is a different item. Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// </summary>
|
||||
public class Uom
|
||||
{
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-item conversion factor between two UOMs (FR-MD-02/03): quantity in
|
||||
/// <see cref="FromUomId"/> × <see cref="Factor"/> = quantity in <see cref="ToUomId"/>.
|
||||
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// </summary>
|
||||
public class UomConversion
|
||||
{
|
||||
public int ConversionId { get; set; }
|
||||
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public int FromUomId { get; set; }
|
||||
public Uom? FromUom { get; set; }
|
||||
|
||||
public int ToUomId { get; set; }
|
||||
public Uom? ToUom { get; set; }
|
||||
|
||||
public decimal Factor { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Unit of an item's <b>content size</b> — how much a single stocked pack holds
|
||||
/// (a 500 ml bottle, a 50 kg sack). Stored as a string in the database.
|
||||
/// <para>
|
||||
/// This is <b>not</b> a stocking unit. Stock is always counted in packs
|
||||
/// (<c>Item.BaseUomId</c>); content is a separate, optional attribute used by
|
||||
/// production to turn "2000 ml of syrup" into a pack count.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only <see cref="Ml"/> and <see cref="G"/> are ever stored as a <i>base</i> content
|
||||
/// unit. <see cref="L"/> and <see cref="Kg"/> are entry conveniences normalised ×1000
|
||||
/// on write by <c>ItemContent.Normalize</c>, so nothing downstream has to convert.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public enum MeasureUnit
|
||||
{
|
||||
Ml,
|
||||
L,
|
||||
G,
|
||||
Kg
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// What a stage input's quantity is expressed in (FR-MFG-04). Stored as a string.
|
||||
/// <para>
|
||||
/// Deliberately explicit rather than inferred from whether the item happens to have a
|
||||
/// content size: templates outlive item edits, so an inferred unit would let adding a
|
||||
/// content size to an existing item silently reinterpret every saved formula — "300"
|
||||
/// meaning 300 packs would become 300 ml.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public enum StageQtyUnit
|
||||
{
|
||||
/// <summary>A count of the item's base UOM — bottles, packets, pieces.</summary>
|
||||
Pack,
|
||||
|
||||
/// <summary>
|
||||
/// An amount of the item's content in its base content unit (ml or g), resolved to
|
||||
/// packs by <c>IItemMeasure</c> at stage start. Requires the item to have a content size.
|
||||
/// </summary>
|
||||
Content
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace ERPCore.Dtos.Grn;
|
||||
// Responses (docs/11 §4) --------------------------------------------------------
|
||||
|
||||
public sealed record GrnLineDto(
|
||||
int GrnLineId, int? PoLineId, int ItemId, int UomId, int? BinId,
|
||||
int GrnLineId, int? PoLineId, int ItemId, int? BinId,
|
||||
decimal Qty, decimal UnitCost, decimal? PoUnitPrice,
|
||||
decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount,
|
||||
decimal ReceivedValue, decimal LineTotal, decimal PriceVariance,
|
||||
@@ -44,7 +44,6 @@ public sealed class CreateGrnLineInput
|
||||
/// <summary>Set for a PO-based receipt; cost is then derived from the PO line (02-SECURITY C.3).</summary>
|
||||
public int? PoLineId { get; set; }
|
||||
[Required] public int ItemId { get; set; }
|
||||
[Required] public int UomId { get; set; }
|
||||
public int? BinId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
/// <summary>
|
||||
|
||||
@@ -5,12 +5,16 @@ namespace ERPCore.Dtos.ItemTypes;
|
||||
|
||||
/// <summary>
|
||||
/// Item type resource (docs/11-BACKEND-PHASE1.md §2.7) — a dimension name such as Color
|
||||
/// or Size. Carries no values and no item linkage: <c>GET /item-types</c> exists to
|
||||
/// populate the frontend builder's dropdown, and the chosen values are encoded into the
|
||||
/// or Size. Carries no values and no item linkage: the chosen values are encoded into the
|
||||
/// client-generated SKU rather than stored (docs/10 Part C.9).
|
||||
/// <para>
|
||||
/// <c>IsMeasurable</c> marks a dimension whose values are content measurements (500 ml, 1 L)
|
||||
/// rather than plain labels; the builder captures a number + unit per value and writes it to
|
||||
/// each generated item's content size.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record ItemTypeDto(
|
||||
int ItemTypeId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
int ItemTypeId, string Name, bool IsMeasurable, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
|
||||
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
|
||||
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
|
||||
@@ -18,11 +22,21 @@ public sealed record ItemTypeDto(
|
||||
public sealed class CreateItemTypeRequest
|
||||
{
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Omitted ⇒ false, i.e. plain-text values. See <see cref="ItemTypeDto"/>.</summary>
|
||||
public bool IsMeasurable { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateItemTypeRequest
|
||||
{
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Nullable on purpose: a plain <c>bool</c> binds an absent property as <c>false</c>, so any
|
||||
/// client that PUT only a name — as the item-types screen used to — would silently clear the
|
||||
/// flag on every rename. Omitting this field <b>preserves</b> the stored value.
|
||||
/// </summary>
|
||||
public bool? IsMeasurable { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateItemTypeStatusRequest
|
||||
|
||||
@@ -9,7 +9,10 @@ namespace ERPCore.Dtos.Items;
|
||||
public sealed record ItemListItemDto(
|
||||
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
|
||||
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
|
||||
string? TaxClass, decimal? SalePrice, EntityStatus Status);
|
||||
string? TaxClass, decimal? SalePrice,
|
||||
decimal? ContentQty, MeasureUnit? ContentUnit,
|
||||
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
|
||||
EntityStatus Status);
|
||||
|
||||
/// <summary>A single per-warehouse reorder policy row.</summary>
|
||||
public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
|
||||
@@ -17,26 +20,21 @@ public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decim
|
||||
/// <summary>
|
||||
/// Full item resource for <c>GET /items/{id}</c> and create/update responses.
|
||||
/// <para>
|
||||
/// <see cref="Conversions"/> is embedded because they are otherwise unreadable: they can
|
||||
/// only be written via <c>PUT /items/{id}/uom-conversions</c>, which returns them, but no
|
||||
/// endpoint reads them back — so a detail screen could never show current state before
|
||||
/// editing. Mirrors how <see cref="Reorder"/> is already inlined.
|
||||
/// <c>ContentBaseQty</c>/<c>ContentBaseUnit</c> are echoed back so a detail screen can show
|
||||
/// what the entered size normalised to (1.5 L ⇒ 1500 ml) — they are server-derived and are
|
||||
/// not accepted on write.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record ItemDetailDto(
|
||||
int ItemId, string Sku, string Name, string? Description, int CategoryId,
|
||||
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
|
||||
StockNature StockNature, TrackingMode TrackingMode,
|
||||
string? TaxClass, decimal? SalePrice, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
|
||||
IReadOnlyList<UomConversionDto> Conversions,
|
||||
string? TaxClass, decimal? SalePrice,
|
||||
decimal? ContentQty, MeasureUnit? ContentUnit,
|
||||
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
|
||||
EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
|
||||
DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
|
||||
/// <summary>UOM conversion row (docs/11 §2.2).</summary>
|
||||
public sealed record UomConversionDto(int ConversionId, int FromUom, int ToUom, decimal Factor);
|
||||
|
||||
/// <summary>Response body for <c>PUT /items/{id}/uom-conversions</c>.</summary>
|
||||
public sealed record ItemUomConversionsDto(int ItemId, int BaseUomId, IReadOnlyList<UomConversionDto> Conversions);
|
||||
|
||||
/// <summary>Response body for <c>PUT /items/{id}/reorder</c>.</summary>
|
||||
public sealed record ItemReorderSettingsDto(IReadOnlyList<ItemReorderDto> Settings);
|
||||
|
||||
@@ -64,6 +62,14 @@ public sealed class CreateItemRequest
|
||||
[StringLength(20)] public string? TaxClass { get; set; }
|
||||
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
|
||||
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How much one pack holds. Supply with <see cref="ContentUnit"/> or leave both null
|
||||
/// for items with no measurable content. The normalised base pair is derived by the
|
||||
/// server and is deliberately not accepted here.
|
||||
/// </summary>
|
||||
[Range(0.0001, double.MaxValue)] public decimal? ContentQty { get; set; }
|
||||
[EnumDataType(typeof(MeasureUnit))] public MeasureUnit? ContentUnit { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateItemRequest
|
||||
@@ -83,6 +89,14 @@ public sealed class UpdateItemRequest
|
||||
[StringLength(20)] public string? TaxClass { get; set; }
|
||||
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
|
||||
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How much one pack holds. Supply with <see cref="ContentUnit"/> or leave both null
|
||||
/// for items with no measurable content. The normalised base pair is derived by the
|
||||
/// server and is deliberately not accepted here.
|
||||
/// </summary>
|
||||
[Range(0.0001, double.MaxValue)] public decimal? ContentQty { get; set; }
|
||||
[EnumDataType(typeof(MeasureUnit))] public MeasureUnit? ContentUnit { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateItemStatusRequest
|
||||
@@ -101,15 +115,3 @@ public sealed class UpdateReorderRequest
|
||||
{
|
||||
[Required, MinLength(1)] public List<ReorderSettingInput> Settings { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class UomConversionInput
|
||||
{
|
||||
[Required] public int FromUom { get; set; }
|
||||
[Required] public int ToUom { get; set; }
|
||||
[Range(0.000001, double.MaxValue)] public decimal Factor { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateUomConversionsRequest
|
||||
{
|
||||
[Required, MinLength(1)] public List<UomConversionInput> Conversions { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ERPCore.Dtos.Procurement;
|
||||
// Responses (docs/11 §3.3) ------------------------------------------------------
|
||||
|
||||
public sealed record PoLineDto(
|
||||
int PoLineId, int ItemId, int UomId, int WarehouseId,
|
||||
int PoLineId, int ItemId, int WarehouseId,
|
||||
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived);
|
||||
|
||||
public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency);
|
||||
@@ -25,7 +25,6 @@ public sealed record PurchaseOrderSummaryDto(
|
||||
public sealed class CreatePoLineInput
|
||||
{
|
||||
[Required] public int ItemId { get; set; }
|
||||
[Required] public int UomId { get; set; }
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal UnitPrice { get; set; }
|
||||
|
||||
@@ -29,17 +29,23 @@ public sealed record RunSummaryDto(
|
||||
/// </summary>
|
||||
public sealed record CostPoolDto(decimal Consumed, decimal Returned, decimal Net);
|
||||
|
||||
/// <summary>
|
||||
/// One input of a run stage. <c>PlannedQty</c> is expressed in <c>QtyUnit</c> — content
|
||||
/// (ml/g) or packs — while every consumption figure is always a pack count, so the two are
|
||||
/// not directly comparable for a Content input.
|
||||
/// </summary>
|
||||
public sealed record RunStageInputDto(
|
||||
int RunInputId, StageInputSource Source, int? ItemId, int? FromRunOutputId, int UomId,
|
||||
int RunInputId, StageInputSource Source, int? ItemId, int? FromRunOutputId, StageQtyUnit QtyUnit,
|
||||
decimal PlannedQty, decimal ConsumedQty, decimal ConsumedValue,
|
||||
decimal DeliveredQty, decimal ReturnedQty, decimal ReturnedValue);
|
||||
|
||||
/// <summary>
|
||||
/// One output of a run stage. <c>AvailableToTransfer</c> is derived — produced − scrapped −
|
||||
/// transferred (FR-MFG-12) — and never stored.
|
||||
/// transferred (FR-MFG-12) — and never stored. <c>UomId</c> is the WIP label and is null on
|
||||
/// the terminal output, whose unit is the finished item's base UOM.
|
||||
/// </summary>
|
||||
public sealed record RunStageOutputDto(
|
||||
int RunOutputId, int? ItemId, string Name, int UomId,
|
||||
int RunOutputId, int? ItemId, string Name, int? UomId,
|
||||
decimal PlannedQty, decimal ProducedQty, decimal ScrappedQty, int? ScrapReasonCodeId,
|
||||
decimal TransferredQty, decimal AvailableToTransfer);
|
||||
|
||||
|
||||
@@ -35,10 +35,11 @@ public sealed record FieldDefDto(
|
||||
|
||||
public sealed record StageInputDto(
|
||||
int InputId, StageInputSource Source, int? ItemId,
|
||||
int? FromOutputId, string? FromOutputKey, int UomId, decimal QtyPerBatch);
|
||||
int? FromOutputId, string? FromOutputKey, StageQtyUnit QtyUnit, decimal QtyPerBatch);
|
||||
|
||||
/// <summary><c>UomId</c> is the WIP label and is null exactly when <c>ItemId</c> is set.</summary>
|
||||
public sealed record StageOutputDto(
|
||||
int OutputId, string Key, int? ItemId, string Name, int UomId, decimal QtyPerBatch);
|
||||
int OutputId, string Key, int? ItemId, string Name, int? UomId, decimal QtyPerBatch);
|
||||
|
||||
public sealed record TemplateStageDto(
|
||||
int StageId, string Key, string Name, string? RoleLabel, int EstimatedMinutes,
|
||||
@@ -134,8 +135,12 @@ public sealed class SaveInputRequest
|
||||
[StringLength(60)]
|
||||
public string? FromOutputKey { get; set; }
|
||||
|
||||
[Range(1, int.MaxValue)]
|
||||
public int UomId { get; set; }
|
||||
/// <summary>
|
||||
/// What <see cref="QtyPerBatch"/> means. <c>Content</c> (ml/g) is allowed only on a Stock
|
||||
/// input whose item has a content size; Upstream inputs must be <c>Pack</c>.
|
||||
/// </summary>
|
||||
[EnumDataType(typeof(StageQtyUnit))]
|
||||
public StageQtyUnit QtyUnit { get; set; } = StageQtyUnit.Pack;
|
||||
|
||||
[Range(0.0001, double.MaxValue)]
|
||||
public decimal QtyPerBatch { get; set; }
|
||||
@@ -153,8 +158,12 @@ public sealed class SaveOutputRequest
|
||||
[Required, StringLength(150, MinimumLength = 1)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The WIP display unit. Required when <see cref="ItemId"/> is null; must be null when it
|
||||
/// is set, because a real item's unit is its own base UOM.
|
||||
/// </summary>
|
||||
[Range(1, int.MaxValue)]
|
||||
public int UomId { get; set; }
|
||||
public int? UomId { get; set; }
|
||||
|
||||
[Range(0.0001, double.MaxValue)]
|
||||
public decimal QtyPerBatch { get; set; }
|
||||
|
||||
@@ -4,7 +4,7 @@ using ERPCore.Domain.Enums;
|
||||
namespace ERPCore.Dtos.Sales;
|
||||
|
||||
public sealed record BundleSaleLineDto(
|
||||
int BundleSaleLineId, int ItemId, string Description, decimal Qty, int UomId, int WarehouseId,
|
||||
int BundleSaleLineId, int ItemId, string Description, decimal Qty, int WarehouseId,
|
||||
decimal UnitPrice, decimal LineTotal, bool IncludeInBundle, bool IsComponent, int? ParentLineId);
|
||||
|
||||
public sealed record BundleSaleDto(
|
||||
@@ -20,7 +20,7 @@ public sealed record BundleSaleSummaryDto(
|
||||
decimal ComponentSubtotal, decimal BundlePrice, decimal GrandTotal, DateTime CreatedAt);
|
||||
|
||||
public sealed record BundleSaleTemplateLineDto(
|
||||
int BundleSaleTemplateLineId, int ItemId, int UomId, int WarehouseId, decimal Qty,
|
||||
int BundleSaleTemplateLineId, int ItemId, int WarehouseId, decimal Qty,
|
||||
decimal UnitPrice, bool IncludeInBundle, int SortOrder);
|
||||
|
||||
public sealed record BundleSaleTemplateDto(
|
||||
@@ -42,7 +42,6 @@ public sealed record BundleSalePostingCheckDto(
|
||||
public sealed class CreateBundleSaleTemplateLineRequest
|
||||
{
|
||||
[Required] public int ItemId { get; set; }
|
||||
[Required] public int UomId { get; set; }
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal UnitPrice { get; set; }
|
||||
|
||||
@@ -4,7 +4,7 @@ using ERPCore.Domain.Enums;
|
||||
namespace ERPCore.Dtos.Sales;
|
||||
|
||||
public sealed record SalesInvoiceLineDto(
|
||||
int SalesInvoiceLineId, int ItemId, string Description, decimal Qty, decimal FreeQty, int UomId,
|
||||
int SalesInvoiceLineId, int ItemId, string Description, decimal Qty, decimal FreeQty,
|
||||
int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
|
||||
decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal,
|
||||
decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId);
|
||||
@@ -35,7 +35,6 @@ public sealed record SalesInvoicePostingCheckDto(
|
||||
public sealed class CreateSalesInvoiceLineRequest
|
||||
{
|
||||
[Required] public int ItemId { get; set; }
|
||||
[Required] public int UomId { get; set; }
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal FreeQty { get; set; }
|
||||
|
||||
@@ -4,7 +4,7 @@ using ERPCore.Domain.Enums;
|
||||
namespace ERPCore.Dtos.Sales;
|
||||
|
||||
public sealed record SalesSlipLineDto(
|
||||
int SalesSlipLineId, int ItemId, string Description, decimal Qty, decimal FreeQty, int UomId,
|
||||
int SalesSlipLineId, int ItemId, string Description, decimal Qty, decimal FreeQty,
|
||||
int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
|
||||
decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal,
|
||||
decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId);
|
||||
@@ -34,7 +34,7 @@ public sealed record SalesSlipPostingCheckDto(
|
||||
public sealed record FreeIssueSummaryDto(
|
||||
int SalesSlipId, string SlipNo, SalesSlipStatus Status, DateTime CreatedAt,
|
||||
int WarehouseId, string WarehouseName, int ItemId, string ItemSku, string ItemName,
|
||||
int UomId, string UomName, decimal Qty, decimal FreeQty, string SchemeLabel);
|
||||
string UomName, decimal Qty, decimal FreeQty, string SchemeLabel);
|
||||
|
||||
public sealed record FreeIssueDto(
|
||||
int SalesSlipId, string SlipNo, DateTime SlipDate, SalesSlipStatus Status,
|
||||
@@ -45,7 +45,6 @@ public sealed record FreeIssueDto(
|
||||
public sealed class CreateSalesSlipLineRequest
|
||||
{
|
||||
[Required] public int ItemId { get; set; }
|
||||
[Required] public int UomId { get; set; }
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal FreeQty { get; set; }
|
||||
|
||||
@@ -18,7 +18,6 @@ public sealed class BundleSaleLineConfiguration : IEntityTypeConfiguration<Bundl
|
||||
builder.Property(x => x.IsComponent).HasDefaultValue(true);
|
||||
|
||||
builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(x => x.Uom).WithMany().HasForeignKey(x => x.UomId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -15,7 +15,6 @@ public sealed class BundleSaleTemplateLineConfiguration : IEntityTypeConfigurati
|
||||
builder.Property(x => x.SortOrder).HasDefaultValue(0);
|
||||
|
||||
builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(x => x.Uom).WithMany().HasForeignKey(x => x.UomId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
|
||||
builder.HasOne(l => l.Grn).WithMany(g => g.Lines).HasForeignKey(l => l.GrnId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne(l => l.PoLine).WithMany().HasForeignKey(l => l.PoLineId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(l => l.Uom).WithMany().HasForeignKey(l => l.UomId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(l => l.Bin).WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,16 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
|
||||
// Sales-only fixed selling price; nullable (null ⇒ sell at stock/FIFO value).
|
||||
builder.Property(i => i.SalePrice).HasPrecision(18, 4);
|
||||
|
||||
// Optional content size (how much one stocked pack holds). All four are nullable
|
||||
// together: null ⇒ the item has no measurable content. The base pair is derived
|
||||
// server-side by ItemContent.Normalize and is only ever Ml or G.
|
||||
builder.Property(i => i.ContentQty).HasPrecision(18, 4);
|
||||
builder.Property(i => i.ContentBaseQty).HasPrecision(18, 4);
|
||||
builder.Property(i => i.ContentUnit)
|
||||
.HasConversion<string>().HasMaxLength(20);
|
||||
builder.Property(i => i.ContentBaseUnit)
|
||||
.HasConversion<string>().HasMaxLength(20);
|
||||
|
||||
builder.Property(i => i.StockNature)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(i => i.TrackingMode)
|
||||
|
||||
@@ -19,6 +19,11 @@ public sealed class ItemTypeConfiguration : IEntityTypeConfiguration<ItemType>
|
||||
builder.Property(t => t.Name).IsRequired().HasMaxLength(200);
|
||||
builder.HasIndex(t => t.Name).IsUnique();
|
||||
|
||||
// false is the only safe default here: EF uses the CLR default as its "unset" sentinel,
|
||||
// so if the store default were true, inserting an explicit false would be mistaken for
|
||||
// "not set" and silently written as true. Sentinel and store default must agree.
|
||||
builder.Property(t => t.IsMeasurable).IsRequired().HasDefaultValue(false);
|
||||
|
||||
builder.Property(t => t.Status)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(EntityStatus.Active);
|
||||
|
||||
@@ -91,13 +91,13 @@ public sealed class StageInputConfiguration : IEntityTypeConfiguration<StageInpu
|
||||
builder.HasKey(i => i.InputId);
|
||||
|
||||
builder.Property(i => i.Source).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(i => i.QtyUnit).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(i => i.QtyPerBatch).HasPrecision(18, 4);
|
||||
|
||||
builder.HasOne(i => i.Stage).WithMany(s => s.Inputs)
|
||||
.HasForeignKey(i => i.StageId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(i => i.FromOutput).WithMany()
|
||||
.HasForeignKey(i => i.FromOutputId).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
@@ -206,6 +206,7 @@ public sealed class RunStageInputConfiguration : IEntityTypeConfiguration<RunSta
|
||||
builder.HasKey(i => i.RunInputId);
|
||||
|
||||
builder.Property(i => i.Source).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(i => i.QtyUnit).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(i => i.PlannedQty).HasPrecision(18, 4);
|
||||
builder.Property(i => i.ConsumedQty).HasPrecision(18, 4);
|
||||
builder.Property(i => i.ConsumedValue).HasPrecision(18, 4);
|
||||
@@ -217,7 +218,6 @@ public sealed class RunStageInputConfiguration : IEntityTypeConfiguration<RunSta
|
||||
.HasForeignKey(i => i.RunStageId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(i => i.FromRunOutput).WithMany()
|
||||
.HasForeignKey(i => i.FromRunOutputId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
|
||||
@@ -63,11 +63,6 @@ public sealed class PoLineConfiguration : IEntityTypeConfiguration<PoLine>
|
||||
.HasForeignKey(l => l.ItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne(l => l.Uom)
|
||||
.WithMany()
|
||||
.HasForeignKey(l => l.UomId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne(l => l.Warehouse)
|
||||
.WithMany()
|
||||
.HasForeignKey(l => l.WarehouseId)
|
||||
|
||||
@@ -79,11 +79,6 @@ public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration<Sal
|
||||
builder.Property(x => x.TaxPct).HasPrecision(9, 4);
|
||||
builder.Property(x => x.TaxAmount).HasPrecision(18, 4);
|
||||
|
||||
builder.HasOne(x => x.Uom)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.UomId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne(x => x.Warehouse)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.WarehouseId)
|
||||
|
||||
@@ -79,11 +79,6 @@ public sealed class SalesSlipLineConfiguration : IEntityTypeConfiguration<SalesS
|
||||
builder.Property(x => x.TaxPct).HasPrecision(9, 4);
|
||||
builder.Property(x => x.TaxAmount).HasPrecision(18, 4);
|
||||
|
||||
builder.HasOne(x => x.Uom)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.UomId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne(x => x.Warehouse)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.WarehouseId)
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class UomConversionConfiguration : IEntityTypeConfiguration<UomConversion>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<UomConversion> builder)
|
||||
{
|
||||
builder.ToTable("uom_conversions");
|
||||
builder.HasKey(c => c.ConversionId);
|
||||
|
||||
builder.Property(c => c.Factor).HasPrecision(18, 6);
|
||||
|
||||
builder.HasOne(c => c.Item)
|
||||
.WithMany(i => i.UomConversions)
|
||||
.HasForeignKey(c => c.ItemId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(c => c.FromUom)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.FromUomId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne(c => c.ToUom)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.ToUomId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// One conversion per (item, from, to) triple.
|
||||
builder.HasIndex(c => new { c.ItemId, c.FromUomId, c.ToUomId }).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -285,13 +285,18 @@ public static class DataSeeder
|
||||
new Item
|
||||
{
|
||||
Sku = "SKU-DEMO-002",
|
||||
Name = "Demo Item 2",
|
||||
Description = "Secondary seeded sample item for sales documents",
|
||||
Name = "Demo Item 2 (500 ml)",
|
||||
Description = "Secondary seeded sample item; carries a content size so the "
|
||||
+ "production content-unit path has a fixture",
|
||||
CategoryId = category.CategoryId,
|
||||
BaseUomId = uom.UomId,
|
||||
StockNature = StockNature.Stocked,
|
||||
TrackingMode = TrackingMode.None,
|
||||
SalePrice = 50m,
|
||||
ContentQty = 500m,
|
||||
ContentUnit = MeasureUnit.Ml,
|
||||
ContentBaseQty = 500m,
|
||||
ContentBaseUnit = MeasureUnit.Ml,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = now
|
||||
}
|
||||
@@ -438,7 +443,6 @@ public static class DataSeeder
|
||||
new BundleSaleTemplateLine
|
||||
{
|
||||
ItemId = items[0].ItemId,
|
||||
UomId = uom.UomId,
|
||||
WarehouseId = warehouse.WarehouseId,
|
||||
Qty = 1m,
|
||||
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
|
||||
@@ -448,7 +452,6 @@ public static class DataSeeder
|
||||
new BundleSaleTemplateLine
|
||||
{
|
||||
ItemId = items[1].ItemId,
|
||||
UomId = uom.UomId,
|
||||
WarehouseId = warehouse.WarehouseId,
|
||||
Qty = 1m,
|
||||
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
|
||||
@@ -489,7 +492,6 @@ public static class DataSeeder
|
||||
ItemId = items[0].ItemId,
|
||||
Description = items[0].Name,
|
||||
Qty = 1m,
|
||||
UomId = uom.UomId,
|
||||
WarehouseId = warehouse.WarehouseId,
|
||||
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
|
||||
LineTotal = items[0].SalePrice.GetValueOrDefault(),
|
||||
@@ -501,7 +503,6 @@ public static class DataSeeder
|
||||
ItemId = items[1].ItemId,
|
||||
Description = items[1].Name,
|
||||
Qty = 1m,
|
||||
UomId = uom.UomId,
|
||||
WarehouseId = warehouse.WarehouseId,
|
||||
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
|
||||
LineTotal = items[1].SalePrice.GetValueOrDefault(),
|
||||
@@ -537,7 +538,6 @@ public static class DataSeeder
|
||||
ItemId = items[0].ItemId,
|
||||
Description = items[0].Name,
|
||||
Qty = 1m,
|
||||
UomId = uom.UomId,
|
||||
WarehouseId = warehouse.WarehouseId,
|
||||
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
|
||||
LineTotal = items[0].SalePrice.GetValueOrDefault(),
|
||||
@@ -549,7 +549,6 @@ public static class DataSeeder
|
||||
ItemId = items[1].ItemId,
|
||||
Description = items[1].Name,
|
||||
Qty = 1m,
|
||||
UomId = uom.UomId,
|
||||
WarehouseId = warehouse.WarehouseId,
|
||||
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
|
||||
LineTotal = items[1].SalePrice.GetValueOrDefault(),
|
||||
@@ -585,7 +584,6 @@ public static class DataSeeder
|
||||
ItemId = items[0].ItemId,
|
||||
Description = items[0].Name,
|
||||
Qty = 1m,
|
||||
UomId = uom.UomId,
|
||||
WarehouseId = secondaryWarehouse.WarehouseId,
|
||||
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
|
||||
LineTotal = items[0].SalePrice.GetValueOrDefault(),
|
||||
@@ -662,7 +660,6 @@ public static class DataSeeder
|
||||
Description = postableItem.Name,
|
||||
Qty = 2m,
|
||||
FreeQty = 0m,
|
||||
UomId = uom.UomId,
|
||||
WarehouseId = warehouse.WarehouseId,
|
||||
UnitPrice = 100m,
|
||||
BaseCost = 0m,
|
||||
@@ -706,7 +703,6 @@ public static class DataSeeder
|
||||
Description = shortageItem.Name,
|
||||
Qty = 6m,
|
||||
FreeQty = 0m,
|
||||
UomId = uom.UomId,
|
||||
WarehouseId = secondaryWarehouse.WarehouseId,
|
||||
UnitPrice = 50m,
|
||||
BaseCost = 0m,
|
||||
@@ -751,7 +747,6 @@ public static class DataSeeder
|
||||
Description = postableItem.Name,
|
||||
Qty = 1m,
|
||||
FreeQty = 0m,
|
||||
UomId = uom.UomId,
|
||||
WarehouseId = warehouse.WarehouseId,
|
||||
UnitPrice = 100m,
|
||||
BaseCost = 0m,
|
||||
@@ -794,7 +789,6 @@ public static class DataSeeder
|
||||
Description = postableItem.Name,
|
||||
Qty = 1m,
|
||||
FreeQty = 0m,
|
||||
UomId = uom.UomId,
|
||||
WarehouseId = warehouse.WarehouseId,
|
||||
UnitPrice = 50m,
|
||||
BaseCost = 0m,
|
||||
@@ -834,7 +828,6 @@ public static class DataSeeder
|
||||
Description = shortageItem.Name,
|
||||
Qty = 3m,
|
||||
FreeQty = 0m,
|
||||
UomId = uom.UomId,
|
||||
WarehouseId = secondaryWarehouse.WarehouseId,
|
||||
UnitPrice = 50m,
|
||||
BaseCost = 0m,
|
||||
|
||||
@@ -30,7 +30,6 @@ public class ErpDbContext : DbContext
|
||||
/// <summary>Color/Size/Material dimension names. Unlinked to Item by design (docs/10 C.9).</summary>
|
||||
public DbSet<ItemType> ItemTypes => Set<ItemType>();
|
||||
public DbSet<Uom> Uoms => Set<Uom>();
|
||||
public DbSet<UomConversion> UomConversions => Set<UomConversion>();
|
||||
public DbSet<Item> Items => Set<Item>();
|
||||
public DbSet<ItemReorder> ItemReorders => Set<ItemReorder>();
|
||||
public DbSet<Vendor> Vendors => Set<Vendor>();
|
||||
|
||||
-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
@@ -99,7 +99,7 @@ builder.Services.AddScoped<IRfqService, RfqService>();
|
||||
builder.Services.AddScoped<IPurchaseOrderService, PurchaseOrderService>();
|
||||
|
||||
// Stock core + goods receipt (docs/11 §4–5)
|
||||
builder.Services.AddScoped<IUomConverter, UomConverter>();
|
||||
builder.Services.AddScoped<IItemMeasure, ItemMeasure>();
|
||||
builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
|
||||
builder.Services.AddScoped<IStockService, StockService>();
|
||||
builder.Services.AddScoped<IGrnService, GrnService>();
|
||||
|
||||
@@ -19,7 +19,6 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
private readonly IRepository<BundleSale> _bundles;
|
||||
private readonly IRepository<Customer> _customers;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<User> _users;
|
||||
private readonly ISalesDomainService _sales;
|
||||
@@ -33,7 +32,6 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
IRepository<BundleSaleTemplate> templates,
|
||||
IRepository<Customer> customers,
|
||||
IRepository<Item> items,
|
||||
IRepository<Uom> uoms,
|
||||
IRepository<Warehouse> warehouses,
|
||||
IRepository<User> users,
|
||||
ISalesDomainService sales,
|
||||
@@ -46,7 +44,6 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
_bundles = bundles;
|
||||
_customers = customers;
|
||||
_items = items;
|
||||
_uoms = uoms;
|
||||
_warehouses = warehouses;
|
||||
_users = users;
|
||||
_sales = sales;
|
||||
@@ -82,10 +79,10 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
template.CreatedAt,
|
||||
template.UpdatedAt,
|
||||
template.Lines.OrderBy(x => x.SortOrder).Select(x => new BundleSaleTemplateLineDto(
|
||||
x.BundleSaleTemplateLineId, x.ItemId, x.UomId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
|
||||
x.BundleSaleTemplateLineId, x.ItemId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default)
|
||||
public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<BundleSale> q = _bundles.Query().AsNoTracking().Include(x => x.Lines);
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
@@ -93,6 +90,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(x => EF.Functions.ILike(x.BundleNo, $"%{term}%") || EF.Functions.ILike(x.BundleName, $"%{term}%") || EF.Functions.ILike(x.BundleCode, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(x => x.Status == status);
|
||||
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
|
||||
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
|
||||
var total = await q.CountAsync(ct);
|
||||
@@ -192,7 +190,6 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
: template.Lines.OrderBy(x => x.SortOrder).Select(x => new CreateBundleSaleTemplateLineRequest
|
||||
{
|
||||
ItemId = x.ItemId,
|
||||
UomId = x.UomId,
|
||||
WarehouseId = x.WarehouseId,
|
||||
Qty = x.Qty,
|
||||
UnitPrice = x.UnitPrice,
|
||||
@@ -204,21 +201,21 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
{
|
||||
if (r.Qty <= 0)
|
||||
throw new DomainException(ErrorCodes.Validation, "Bundle line quantity must be greater than zero.", 422);
|
||||
if (r.WarehouseId != warehouseId)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"Bundle line warehouse {r.WarehouseId} must match header warehouse {warehouseId}.", 422);
|
||||
|
||||
// Bundle sales use the header warehouse as the source of truth for stock and pricing.
|
||||
// Keep any per-line warehouse input from drifting away from the header.
|
||||
var lineWarehouseId = warehouseId;
|
||||
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||
await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, 0m, null, ct);
|
||||
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, true, ct);
|
||||
var calc = _sales.ComputeLine(r.Qty, 0m, resolved.UnitPrice, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
|
||||
await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, lineWarehouseId, r.Qty, 0m, null, 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 = r.Qty,
|
||||
UomId = r.UomId,
|
||||
WarehouseId = r.WarehouseId,
|
||||
UnitPrice = resolved.UnitPrice,
|
||||
WarehouseId = lineWarehouseId,
|
||||
UnitPrice = r.UnitPrice,
|
||||
LineTotal = calc.LineTotal,
|
||||
IncludeInBundle = r.IncludeInBundle,
|
||||
IsComponent = true,
|
||||
@@ -245,6 +242,6 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.CashierUserId, x.BundleSaleTemplateId,
|
||||
x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.MarginAmount, x.DiscountTotal, x.TaxTotal, x.GrandTotal,
|
||||
x.CreatedAt, x.UpdatedAt,
|
||||
x.Lines.Select(l => new BundleSaleLineDto(l.BundleSaleLineId, l.ItemId, l.Description, l.Qty, l.UomId, l.WarehouseId, l.UnitPrice, l.LineTotal, l.IncludeInBundle, l.IsComponent, l.ParentLineId)).ToList());
|
||||
x.Lines.Select(l => new BundleSaleLineDto(l.BundleSaleLineId, l.ItemId, l.Description, l.Qty, l.WarehouseId, l.UnitPrice, l.LineTotal, l.IncludeInBundle, l.IsComponent, l.ParentLineId)).ToList());
|
||||
|
||||
}
|
||||
|
||||
@@ -28,14 +28,12 @@ public sealed class GrnService : IGrnService
|
||||
private readonly IRepository<PurchaseOrder> _pos;
|
||||
private readonly IRepository<PoLine> _poLines;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<Bin> _bins;
|
||||
private readonly IRepository<Vendor> _vendors;
|
||||
private readonly IRepository<Batch> _batches;
|
||||
private readonly IRepository<StockLayer> _layers;
|
||||
private readonly IRepository<StockLedger> _ledger;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
@@ -43,23 +41,21 @@ public sealed class GrnService : IGrnService
|
||||
|
||||
public GrnService(
|
||||
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
|
||||
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
|
||||
IRepository<Item> items, IRepository<Warehouse> warehouses,
|
||||
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
|
||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IUomConverter uomConverter,
|
||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_grns = grns;
|
||||
_pos = pos;
|
||||
_poLines = poLines;
|
||||
_items = items;
|
||||
_uoms = uoms;
|
||||
_warehouses = warehouses;
|
||||
_bins = bins;
|
||||
_vendors = vendors;
|
||||
_batches = batches;
|
||||
_layers = layers;
|
||||
_ledger = ledger;
|
||||
_uomConverter = uomConverter;
|
||||
_fifo = fifo;
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
@@ -133,8 +129,6 @@ public sealed class GrnService : IGrnService
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == input.ItemId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422);
|
||||
if (!await _uoms.Query().AnyAsync(u => u.UomId == input.UomId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"UOM {input.UomId} does not exist.", 422);
|
||||
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);
|
||||
|
||||
@@ -150,6 +144,8 @@ 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);
|
||||
|
||||
// Both sides are counts of the item's base UOM — the GRN line no longer carries
|
||||
// a unit of its own — so this comparison and the accrual below are like-for-like.
|
||||
var openQty = poLine.Qty - poLine.QtyReceived;
|
||||
if (input.Qty > openQty * (1 + OverReceiptTolerance))
|
||||
throw new DomainException(ErrorCodes.OverReceiptTolerance,
|
||||
@@ -174,7 +170,6 @@ public sealed class GrnService : IGrnService
|
||||
{
|
||||
PoLineId = input.PoLineId,
|
||||
ItemId = input.ItemId,
|
||||
UomId = input.UomId,
|
||||
BinId = input.BinId,
|
||||
Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted
|
||||
Qty = input.Qty,
|
||||
@@ -234,10 +229,11 @@ 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). The line quantity is already
|
||||
// a count of the item's base UOM, so it layers exactly as entered.
|
||||
var qtyBase = line.Qty;
|
||||
var unitCostBase = line.NetUnitCost;
|
||||
|
||||
var layer = await _fifo.CreateInboundLayerAsync(
|
||||
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId,
|
||||
@@ -345,15 +341,6 @@ 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;
|
||||
@@ -392,7 +379,7 @@ public sealed class GrnService : IGrnService
|
||||
private static GrnDto Map(Grn g) => new(
|
||||
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
|
||||
g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
|
||||
l.GrnLineId, l.PoLineId, l.ItemId, l.UomId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
|
||||
l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
|
||||
l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal,
|
||||
l.PoUnitPrice is null ? 0m : Math.Round((l.UnitCost - l.PoUnitPrice.Value) * l.Qty, 4, MidpointRounding.AwayFromZero),
|
||||
l.HoldStatus, l.BatchId)).ToList());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a production formula quantity to the only unit stock speaks: a count of the
|
||||
/// item's base UOM. Replaces the old per-item UOM conversion table — everything needed is
|
||||
/// on the item row, so this does no I/O.
|
||||
/// </summary>
|
||||
public interface IItemMeasure
|
||||
{
|
||||
/// <summary>
|
||||
/// Formula quantity → packs. <see cref="StageQtyUnit.Pack"/> passes straight through;
|
||||
/// <see cref="StageQtyUnit.Content"/> divides by the item's content size, so 2000 ml of
|
||||
/// a 500 ml bottle is 4 bottles and 300 ml is 0.6 of one.
|
||||
/// </summary>
|
||||
decimal ToPacks(Item item, decimal formulaQty, StageQtyUnit unit);
|
||||
|
||||
/// <summary>Packs → formula quantity. The exact inverse of <see cref="ToPacks"/>, for display.</summary>
|
||||
decimal FromPacks(Item item, decimal packs, StageQtyUnit unit);
|
||||
|
||||
/// <summary>Whether the item carries a usable content size.</summary>
|
||||
bool HasContent(Item item);
|
||||
}
|
||||
@@ -24,6 +24,4 @@ public interface IItemService
|
||||
Task SetStatusAsync(int itemId, EntityStatus status, CancellationToken ct = default);
|
||||
|
||||
Task<ItemReorderSettingsDto> UpdateReorderAsync(int itemId, UpdateReorderRequest request, CancellationToken ct = default);
|
||||
|
||||
Task<ItemUomConversionsDto> UpdateUomConversionsAsync(int itemId, UpdateUomConversionsRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ public interface ISalesDomainService
|
||||
Task ValidateSalesLineAsync(
|
||||
int headerWarehouseId,
|
||||
int lineItemId,
|
||||
int lineUomId,
|
||||
int lineWarehouseId,
|
||||
decimal qty,
|
||||
decimal freeQty,
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a quantity and its per-UOM cost into the item's <b>base</b> UOM.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Everything in the FIFO engine — <c>StockLayer</c>, <c>StockLedger</c>,
|
||||
/// <c>IFifoCostingService.ConsumeAsync</c> — works exclusively in base UOM, while
|
||||
/// documents let a user enter a line in any UOM the item has a conversion for. This is the
|
||||
/// one place that bridges the two.</para>
|
||||
/// <para>Extracted from <c>GrnService</c>'s private <c>ToBaseAsync</c> when manufacturing
|
||||
/// needed the same conversion for stage stock inputs (docs/30 never mentions UOM
|
||||
/// conversion, but <c>STAGE_INPUT.uom_id</c> is a free FK — without this, an input
|
||||
/// specified in "Box of 12" would consume 1 base unit instead of 12 and silently
|
||||
/// mis-cost the run).</para>
|
||||
/// </remarks>
|
||||
public interface IUomConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the quantity and unit cost restated in <paramref name="item"/>'s base UOM.
|
||||
/// A no-op when <paramref name="uomId"/> already is the base UOM. Throws 422 when no
|
||||
/// conversion is defined for the item from that UOM to its base.
|
||||
/// </summary>
|
||||
Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
|
||||
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Quantity-only conversion, for callers that have no per-UOM cost to restate (a
|
||||
/// production stage input declares a quantity; its cost comes from the FIFO layers it
|
||||
/// consumes, not from the document).
|
||||
/// </summary>
|
||||
Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.System.Errors;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Item content-size rules: validate the (qty, unit) pair and normalise it to a base
|
||||
/// unit. Pure — no DI, no database — because everything it needs is on the item row.
|
||||
/// <para>
|
||||
/// Litres and kilograms exist only at the point of entry. Everything stored and every
|
||||
/// downstream calculation works in millilitres or grams, so no consumer ever has to ask
|
||||
/// which unit it is holding.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ItemContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Rejects a half-filled pair. Both null is valid and means "this item has no
|
||||
/// measurable content" — a screw, a label, a service.
|
||||
/// </summary>
|
||||
public static void ValidatePair(decimal? contentQty, MeasureUnit? contentUnit)
|
||||
{
|
||||
if (contentQty is null && contentUnit is null) return;
|
||||
|
||||
if (contentQty is null || contentUnit is null)
|
||||
throw new DomainException(
|
||||
ErrorCodes.Validation,
|
||||
"contentQty and contentUnit must be supplied together, or both left null.", 422);
|
||||
|
||||
if (contentQty <= 0)
|
||||
throw new DomainException(
|
||||
ErrorCodes.Validation, "contentQty must be greater than zero.", 422);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an entered content size to its base unit: L → Ml and Kg → G, both ×1000;
|
||||
/// Ml and G pass through. Both-null in, both-null out.
|
||||
/// <para>
|
||||
/// Rounded to 4dp AwayFromZero to match the quantity columns' <c>(18,4)</c> scale and
|
||||
/// <c>ProductionRunService.Scale</c>, so a content size can never carry precision the
|
||||
/// database would silently drop.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static (decimal? BaseQty, MeasureUnit? BaseUnit) Normalize(
|
||||
decimal? contentQty, MeasureUnit? contentUnit)
|
||||
{
|
||||
if (contentQty is null || contentUnit is null) return (null, null);
|
||||
|
||||
var (factor, baseUnit) = contentUnit.Value switch
|
||||
{
|
||||
MeasureUnit.Ml => (1m, MeasureUnit.Ml),
|
||||
MeasureUnit.L => (1000m, MeasureUnit.Ml),
|
||||
MeasureUnit.G => (1m, MeasureUnit.G),
|
||||
MeasureUnit.Kg => (1000m, MeasureUnit.G),
|
||||
_ => throw new DomainException(
|
||||
ErrorCodes.Validation, $"Unsupported content unit '{contentUnit}'.", 422)
|
||||
};
|
||||
|
||||
return (Math.Round(contentQty.Value * factor, 4, MidpointRounding.AwayFromZero), baseUnit);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ public sealed class ItemService : IItemService
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly IRepository<Vendor> _vendors;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<StockLayer> _stockLayers;
|
||||
private readonly IRepository<StockLedger> _stockLedger;
|
||||
private readonly IProductConfigService _config;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
@@ -42,6 +44,8 @@ public sealed class ItemService : IItemService
|
||||
IRepository<Uom> uoms,
|
||||
IRepository<Vendor> vendors,
|
||||
IRepository<Warehouse> warehouses,
|
||||
IRepository<StockLayer> stockLayers,
|
||||
IRepository<StockLedger> stockLedger,
|
||||
IProductConfigService config,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
@@ -52,6 +56,8 @@ public sealed class ItemService : IItemService
|
||||
_uoms = uoms;
|
||||
_vendors = vendors;
|
||||
_warehouses = warehouses;
|
||||
_stockLayers = stockLayers;
|
||||
_stockLedger = stockLedger;
|
||||
_config = config;
|
||||
_uow = uow;
|
||||
}
|
||||
@@ -79,7 +85,9 @@ public sealed class ItemService : IItemService
|
||||
.Select(i => new ItemListItemDto(
|
||||
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId,
|
||||
i.BaseUomId, i.DefaultVendorId,
|
||||
i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice, i.Status))
|
||||
i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice,
|
||||
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
|
||||
i.Status))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
@@ -89,7 +97,6 @@ public sealed class ItemService : IItemService
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.Include(i => i.ReorderSettings)
|
||||
.Include(i => i.UomConversions)
|
||||
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
|
||||
|
||||
return item is null ? null : new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
|
||||
@@ -104,6 +111,9 @@ public sealed class ItemService : IItemService
|
||||
request.CategoryId, request.SubCategoryId, request.BrandId,
|
||||
request.BaseUomId, request.DefaultVendorId, ct);
|
||||
|
||||
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
||||
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
|
||||
|
||||
var item = new Item
|
||||
{
|
||||
Sku = request.Sku.Trim(),
|
||||
@@ -118,6 +128,10 @@ public sealed class ItemService : IItemService
|
||||
TrackingMode = request.TrackingMode,
|
||||
TaxClass = request.TaxClass,
|
||||
SalePrice = request.SalePrice,
|
||||
ContentQty = request.ContentQty,
|
||||
ContentUnit = request.ContentUnit,
|
||||
ContentBaseQty = contentBaseQty,
|
||||
ContentBaseUnit = contentBaseUnit,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -133,7 +147,6 @@ public sealed class ItemService : IItemService
|
||||
{
|
||||
var item = await _items.Query()
|
||||
.Include(i => i.ReorderSettings)
|
||||
.Include(i => i.UomConversions)
|
||||
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
|
||||
?? throw new NotFoundException($"Item {itemId} was not found.");
|
||||
|
||||
@@ -148,6 +161,18 @@ public sealed class ItemService : IItemService
|
||||
request.CategoryId, request.SubCategoryId, request.BrandId,
|
||||
request.BaseUomId, request.DefaultVendorId, ct);
|
||||
|
||||
// The base UOM is the sole meaning of every quantity recorded against this item —
|
||||
// stock layers, ledger rows and document lines are all plain counts of it. Once any
|
||||
// of that history exists, changing it would silently reinterpret every one of those
|
||||
// numbers (240 bottles becoming 240 cases), so it is frozen instead.
|
||||
if (item.BaseUomId != request.BaseUomId && await HasStockHistoryAsync(itemId, ct))
|
||||
throw new DomainException(
|
||||
ErrorCodes.MasterInUse,
|
||||
$"Item {itemId} has stock history; its base UOM can no longer be changed.", 409);
|
||||
|
||||
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
||||
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
|
||||
|
||||
item.Sku = request.Sku.Trim();
|
||||
item.Name = request.Name.Trim();
|
||||
item.Description = request.Description;
|
||||
@@ -160,6 +185,10 @@ public sealed class ItemService : IItemService
|
||||
item.TrackingMode = request.TrackingMode;
|
||||
item.TaxClass = request.TaxClass;
|
||||
item.SalePrice = request.SalePrice;
|
||||
item.ContentQty = request.ContentQty;
|
||||
item.ContentUnit = request.ContentUnit;
|
||||
item.ContentBaseQty = contentBaseQty;
|
||||
item.ContentBaseUnit = contentBaseUnit;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await SaveGuardingConcurrencyAsync(ct);
|
||||
@@ -224,52 +253,14 @@ public sealed class ItemService : IItemService
|
||||
return new ItemReorderSettingsDto(settings);
|
||||
}
|
||||
|
||||
public async Task<ItemUomConversionsDto> UpdateUomConversionsAsync(
|
||||
int itemId, UpdateUomConversionsRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var pairs = request.Conversions.Select(c => (c.FromUom, c.ToUom)).ToList();
|
||||
if (pairs.Distinct().Count() != pairs.Count)
|
||||
throw new DomainException(ErrorCodes.Validation, "Duplicate (fromUom, toUom) in conversions.", 400);
|
||||
|
||||
var item = await _items.Query()
|
||||
.Include(i => i.UomConversions)
|
||||
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
|
||||
?? throw new NotFoundException($"Item {itemId} was not found.");
|
||||
|
||||
foreach (var uomId in request.Conversions.SelectMany(c => new[] { c.FromUom, c.ToUom }).Distinct())
|
||||
if (!await _uoms.Query().AnyAsync(u => u.UomId == uomId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"UOM {uomId} does not exist.", 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)
|
||||
{
|
||||
var existing = item.UomConversions.FirstOrDefault(c => c.FromUomId == input.FromUom && c.ToUomId == input.ToUom);
|
||||
if (existing is null)
|
||||
{
|
||||
item.UomConversions.Add(new UomConversion
|
||||
{
|
||||
ItemId = itemId,
|
||||
FromUomId = input.FromUom,
|
||||
ToUomId = input.ToUom,
|
||||
Factor = input.Factor
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Factor = input.Factor;
|
||||
}
|
||||
}
|
||||
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
var conversions = item.UomConversions
|
||||
.OrderBy(c => c.ConversionId)
|
||||
.Select(c => new UomConversionDto(c.ConversionId, c.FromUomId, c.ToUomId, c.Factor))
|
||||
.ToList();
|
||||
return new ItemUomConversionsDto(item.ItemId, item.BaseUomId, conversions);
|
||||
}
|
||||
/// <summary>
|
||||
/// Whether anything has ever been recorded against this item's stock. Checks the ledger
|
||||
/// as well as live layers, because a fully consumed item has no layer left but its
|
||||
/// history still reads in the old unit.
|
||||
/// </summary>
|
||||
private async Task<bool> HasStockHistoryAsync(int itemId, CancellationToken ct)
|
||||
=> await _stockLayers.Query().AnyAsync(l => l.ItemId == itemId, ct)
|
||||
|| await _stockLedger.Query().AnyAsync(l => l.ItemId == itemId, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Validates every FK on an item write, and gates the optional ones on the product
|
||||
@@ -350,14 +341,12 @@ public sealed class ItemService : IItemService
|
||||
private static ItemDetailDto ToDetail(Item i) => new(
|
||||
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId,
|
||||
i.BaseUomId, i.DefaultVendorId,
|
||||
i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice, i.Status,
|
||||
i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice,
|
||||
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
|
||||
i.Status,
|
||||
i.ReorderSettings
|
||||
.OrderBy(r => r.WarehouseId)
|
||||
.Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty))
|
||||
.ToList(),
|
||||
i.UomConversions
|
||||
.OrderBy(c => c.ConversionId)
|
||||
.Select(c => new UomConversionDto(c.ConversionId, c.FromUomId, c.ToUomId, c.Factor))
|
||||
.ToList(),
|
||||
i.CreatedAt, i.UpdatedAt);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ public sealed class ItemTypeService : IItemTypeService
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(t => t.Name)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(t => new ItemTypeDto(t.ItemTypeId, t.Name, t.Status, t.CreatedAt, t.UpdatedAt))
|
||||
.Select(t => new ItemTypeDto(t.ItemTypeId, t.Name, t.IsMeasurable, t.Status, t.CreatedAt, t.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<ItemTypeDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
@@ -63,6 +63,7 @@ public sealed class ItemTypeService : IItemTypeService
|
||||
var itemType = new ItemType
|
||||
{
|
||||
Name = name,
|
||||
IsMeasurable = request.IsMeasurable,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -90,6 +91,9 @@ public sealed class ItemTypeService : IItemTypeService
|
||||
// Renaming does not touch existing items: their SKUs already encode the values that
|
||||
// were chosen, and nothing joins back to this row (docs/10 Part C.9).
|
||||
itemType.Name = name;
|
||||
// Omitted ⇒ keep what is stored. A plain bool would bind an absent property as false and
|
||||
// so let a name-only PUT silently clear the flag on every rename.
|
||||
itemType.IsMeasurable = request.IsMeasurable ?? itemType.IsMeasurable;
|
||||
itemType.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
@@ -114,5 +118,5 @@ public sealed class ItemTypeService : IItemTypeService
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static ItemTypeDto Map(ItemType t) => new(t.ItemTypeId, t.Name, t.Status, t.CreatedAt, t.UpdatedAt);
|
||||
private static ItemTypeDto Map(ItemType t) => new(t.ItemTypeId, t.Name, t.IsMeasurable, t.Status, t.CreatedAt, t.UpdatedAt);
|
||||
}
|
||||
|
||||
@@ -21,9 +21,10 @@ namespace ERPCore.Services.Production;
|
||||
/// </remarks>
|
||||
public static class ProductionGraphValidator
|
||||
{
|
||||
public sealed record InputDraft(int Index, StageInputSource Source, int? ItemId, string? FromOutputKey);
|
||||
public sealed record InputDraft(
|
||||
int Index, StageInputSource Source, int? ItemId, string? FromOutputKey, StageQtyUnit QtyUnit);
|
||||
|
||||
public sealed record OutputDraft(string Key, string Name, int? ItemId);
|
||||
public sealed record OutputDraft(string Key, string Name, int? ItemId, int? UomId);
|
||||
|
||||
public sealed record StageDraft(
|
||||
string Key, string Name, IReadOnlyList<InputDraft> Inputs, IReadOnlyList<OutputDraft> Outputs);
|
||||
@@ -171,6 +172,12 @@ public static class ProductionGraphValidator
|
||||
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
|
||||
$"Input {input.Index + 1} of stage '{s.Name}' is a Stock input and cannot reference an upstream output.", 422);
|
||||
}
|
||||
|
||||
// WIP has no content size — it is counted in whatever unit its source output
|
||||
// declares — so only a Stock input may be expressed in ml/g.
|
||||
if (input.Source == StageInputSource.Upstream && input.QtyUnit != StageQtyUnit.Pack)
|
||||
throw new DomainException(ErrorCodes.GraphInputSourceInvalid,
|
||||
$"Input {input.Index + 1} of stage '{s.Name}' is Upstream, so its quantity must be in whole units of its source output, not content units.", 422);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +196,20 @@ public static class ProductionGraphValidator
|
||||
foreach (var o in s.Outputs.Where(o => o.ItemId is not null))
|
||||
throw Invalid(
|
||||
$"Output '{o.Name}' of stage '{s.Name}' is intermediate work-in-progress and cannot reference an item — only the final stage produces a stocked item.");
|
||||
|
||||
// 7 — output units. An item-bearing output already has a unit (the item's base UOM),
|
||||
// so carrying a second one could only contradict it. WIP has no item to ask, so it
|
||||
// must name its own — the label the run board and any downstream input display.
|
||||
foreach (var s in stages)
|
||||
foreach (var o in s.Outputs)
|
||||
{
|
||||
if (o.ItemId is null && o.UomId is null)
|
||||
throw new DomainException(ErrorCodes.WipUnitRequired,
|
||||
$"Output '{o.Name}' of stage '{s.Name}' is work-in-progress and must declare a unit.", 422);
|
||||
if (o.ItemId is not null && o.UomId is not null)
|
||||
throw Invalid(
|
||||
$"Output '{o.Name}' of stage '{s.Name}' references an item, so its unit comes from that item and must not be set.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Set of keys reachable from <paramref name="roots"/> following <paramref name="next"/>.</summary>
|
||||
|
||||
@@ -26,7 +26,7 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<ReasonCode> _reasonCodes;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly IUomConverter _uomConverter;
|
||||
private readonly IItemMeasure _measure;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
@@ -35,7 +35,7 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
IRepository<ProductionRun> runs, IRepository<ProductionTemplate> templates,
|
||||
IRepository<Warehouse> warehouses, IRepository<Bin> bins,
|
||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes,
|
||||
IFifoCostingService fifo, IUomConverter uomConverter,
|
||||
IFifoCostingService fifo, IItemMeasure measure,
|
||||
INumberSequenceService numbers, IUnitOfWork uow, ICurrentUser currentUser)
|
||||
{
|
||||
_runs = runs;
|
||||
@@ -45,7 +45,7 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
_items = items;
|
||||
_reasonCodes = reasonCodes;
|
||||
_fifo = fifo;
|
||||
_uomConverter = uomConverter;
|
||||
_measure = measure;
|
||||
_numbers = numbers;
|
||||
_uow = uow;
|
||||
_currentUser = currentUser;
|
||||
@@ -245,7 +245,7 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
Source = i.Source,
|
||||
ItemId = i.ItemId,
|
||||
FromRunOutput = i.FromOutputId is null ? null : outputByTemplateOutputId[i.FromOutputId.Value],
|
||||
UomId = i.UomId,
|
||||
QtyUnit = i.QtyUnit,
|
||||
PlannedQty = Scale(i.QtyPerBatch, ratio)
|
||||
});
|
||||
}
|
||||
@@ -367,9 +367,11 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
?? throw new DomainException(ErrorCodes.Validation,
|
||||
$"Item {input.ItemId} on stage '{stage.Name}' no longer exists.", 422);
|
||||
|
||||
// PlannedQty is in the input's declared UOM; ConsumedQty is in the item's
|
||||
// base UOM (the only unit FIFO and the ledger speak). Compare in base.
|
||||
var plannedBase = await _uomConverter.ToBaseQtyAsync(item, input.UomId, input.PlannedQty, token);
|
||||
// PlannedQty is in the input's declared unit — content (ml/g) for an item with
|
||||
// a content size, otherwise packs. ConsumedQty is always packs, the only unit
|
||||
// FIFO and the ledger speak, so resolve before comparing. 2000 ml of a 500 ml
|
||||
// bottle is 4 bottles; 300 ml is 0.6 of one, which (18,4) stores exactly.
|
||||
var plannedBase = _measure.ToPacks(item, input.PlannedQty, input.QtyUnit);
|
||||
var delta = plannedBase - input.ConsumedQty;
|
||||
if (delta <= 0) continue; // rework restart with no increase — nothing to draw
|
||||
|
||||
@@ -1057,7 +1059,9 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
$"Item {item.Sku} is {item.TrackingMode}-tracked; batch/serial-tracked finished goods "
|
||||
+ "are not supported in this phase.", 422);
|
||||
|
||||
var qtyBase = await _uomConverter.ToBaseQtyAsync(item, output.UomId, good, ct);
|
||||
// Output quantities are always a count of the finished item's base UOM — produced and
|
||||
// scrapped are recorded in whole bottles, not millilitres — so nothing to resolve.
|
||||
var qtyBase = good;
|
||||
|
||||
var consumedValue = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue);
|
||||
var returnedValue = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue);
|
||||
@@ -1223,7 +1227,7 @@ public sealed class ProductionRunService : IProductionRunService
|
||||
ProductionJson.Deserialize<List<FieldDefDto>>(s.FieldDefs, []),
|
||||
ParseJson(s.FieldValues),
|
||||
s.Inputs.OrderBy(i => i.RunInputId).Select(i => new RunStageInputDto(
|
||||
i.RunInputId, i.Source, i.ItemId, i.FromRunOutputId, i.UomId,
|
||||
i.RunInputId, i.Source, i.ItemId, i.FromRunOutputId, i.QtyUnit,
|
||||
i.PlannedQty, i.ConsumedQty, i.ConsumedValue,
|
||||
i.DeliveredQty, i.ReturnedQty, i.ReturnedValue)).ToList(),
|
||||
s.Outputs.OrderBy(o => o.RunOutputId).Select(o => new RunStageOutputDto(
|
||||
|
||||
@@ -253,19 +253,20 @@ public sealed class ProductionTemplateService : IProductionTemplateService
|
||||
ProductionGraphValidator.Validate(
|
||||
request.Stages.Select(s => new ProductionGraphValidator.StageDraft(
|
||||
s.Key, s.Name,
|
||||
s.Inputs.Select((i, idx) => new ProductionGraphValidator.InputDraft(idx, i.Source, i.ItemId, i.FromOutputKey)).ToList(),
|
||||
s.Outputs.Select(o => new ProductionGraphValidator.OutputDraft(o.Key, o.Name, o.ItemId)).ToList())).ToList(),
|
||||
s.Inputs.Select((i, idx) => new ProductionGraphValidator.InputDraft(idx, i.Source, i.ItemId, i.FromOutputKey, i.QtyUnit)).ToList(),
|
||||
s.Outputs.Select(o => new ProductionGraphValidator.OutputDraft(o.Key, o.Name, o.ItemId, o.UomId)).ToList())).ToList(),
|
||||
request.Edges.Select(e => new ProductionGraphValidator.EdgeDraft(e.ParentKey, e.ChildKey)).ToList());
|
||||
|
||||
var itemIds = request.Stages
|
||||
.SelectMany(s => s.Inputs.Select(i => i.ItemId).Concat(s.Outputs.Select(o => o.ItemId)))
|
||||
.OfType<int>().Distinct().ToList();
|
||||
|
||||
var contentByItem = new Dictionary<int, decimal?>();
|
||||
if (itemIds.Count > 0)
|
||||
{
|
||||
var found = await _items.Query().AsNoTracking()
|
||||
.Where(i => itemIds.Contains(i.ItemId))
|
||||
.Select(i => new { i.ItemId, i.Status })
|
||||
.Select(i => new { i.ItemId, i.Status, i.ContentBaseQty })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var missing = itemIds.Except(found.Select(f => f.ItemId)).ToList();
|
||||
@@ -277,19 +278,42 @@ public sealed class ProductionTemplateService : IProductionTemplateService
|
||||
if (inactive.Count > 0)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"Item(s) {string.Join(", ", inactive)} are inactive and cannot be used in a template.", 422);
|
||||
|
||||
contentByItem = found.ToDictionary(f => f.ItemId, f => f.ContentBaseQty);
|
||||
}
|
||||
|
||||
// A Content quantity is only meaningful against an item that declares a content size,
|
||||
// and it must still land on a storable pack count: quantities persist at (18,4), so
|
||||
// anything under 0.0001 packs would round to zero and consume nothing at stage start.
|
||||
foreach (var s in request.Stages)
|
||||
foreach (var i in s.Inputs.Where(i => i.QtyUnit == StageQtyUnit.Content))
|
||||
{
|
||||
var contentBaseQty = contentByItem.GetValueOrDefault(i.ItemId ?? 0);
|
||||
if (contentBaseQty is not > 0m)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"Stage '{s.Name}' has an input in content units, but item {i.ItemId} has no content size.", 422);
|
||||
|
||||
if (i.QtyPerBatch / contentBaseQty.Value < 0.0001m)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"Stage '{s.Name}': {i.QtyPerBatch} is less than 0.0001 of item {i.ItemId}'s content size ({contentBaseQty}), which would round to no stock at all.", 422);
|
||||
}
|
||||
|
||||
// Only WIP outputs carry a UOM now; the validator has already rejected one on an
|
||||
// item-bearing output, so every non-null id here belongs to real work-in-progress.
|
||||
var uomIds = request.Stages
|
||||
.SelectMany(s => s.Inputs.Select(i => i.UomId).Concat(s.Outputs.Select(o => o.UomId)))
|
||||
.Distinct().ToList();
|
||||
.SelectMany(s => s.Outputs.Select(o => o.UomId))
|
||||
.OfType<int>().Distinct().ToList();
|
||||
|
||||
var knownUoms = await _uoms.Query().AsNoTracking()
|
||||
.Where(u => uomIds.Contains(u.UomId)).Select(u => u.UomId).ToListAsync(ct);
|
||||
if (uomIds.Count > 0)
|
||||
{
|
||||
var knownUoms = await _uoms.Query().AsNoTracking()
|
||||
.Where(u => uomIds.Contains(u.UomId)).Select(u => u.UomId).ToListAsync(ct);
|
||||
|
||||
var missingUoms = uomIds.Except(knownUoms).ToList();
|
||||
if (missingUoms.Count > 0)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"UOM(s) {string.Join(", ", missingUoms)} do not exist.", 422);
|
||||
var missingUoms = uomIds.Except(knownUoms).ToList();
|
||||
if (missingUoms.Count > 0)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"UOM(s) {string.Join(", ", missingUoms)} do not exist.", 422);
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -428,7 +452,7 @@ public sealed class ProductionTemplateService : IProductionTemplateService
|
||||
Source = i.Source,
|
||||
ItemId = i.Source == StageInputSource.Stock ? i.ItemId : null,
|
||||
FromOutput = i.Source == StageInputSource.Upstream ? outputsByKey[i.FromOutputKey!] : null,
|
||||
UomId = i.UomId,
|
||||
QtyUnit = i.QtyUnit,
|
||||
QtyPerBatch = i.QtyPerBatch
|
||||
});
|
||||
}
|
||||
@@ -476,7 +500,7 @@ public sealed class ProductionTemplateService : IProductionTemplateService
|
||||
s.Inputs.OrderBy(i => i.InputId).Select(i => new StageInputDto(
|
||||
i.InputId, i.Source, i.ItemId, i.FromOutputId,
|
||||
i.FromOutputId is null ? null : outputKeyById.GetValueOrDefault(i.FromOutputId.Value),
|
||||
i.UomId, i.QtyPerBatch)).ToList(),
|
||||
i.QtyUnit, i.QtyPerBatch)).ToList(),
|
||||
s.Outputs.OrderBy(o => o.OutputId).Select(o => new StageOutputDto(
|
||||
o.OutputId, o.OutputId.ToString(), o.ItemId, o.Name, o.UomId, o.QtyPerBatch)).ToList()))
|
||||
.ToList();
|
||||
|
||||
@@ -222,7 +222,6 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
private static PoLine ToLine(CreatePoLineInput l) => new()
|
||||
{
|
||||
ItemId = l.ItemId,
|
||||
UomId = l.UomId,
|
||||
WarehouseId = l.WarehouseId,
|
||||
Qty = l.Qty,
|
||||
UnitPrice = l.UnitPrice,
|
||||
@@ -258,7 +257,6 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
throw new DomainException(ErrorCodes.Validation, $"Requisition {requisitionId} does not exist.", 422);
|
||||
|
||||
await EnsureAllExistAsync(_items.Query().Select(i => i.ItemId), lines.Select(l => l.ItemId), "Item", ct);
|
||||
await EnsureAllExistAsync(_uoms.Query().Select(u => u.UomId), lines.Select(l => l.UomId), "UOM", ct);
|
||||
await EnsureAllExistAsync(_warehouses.Query().Select(w => w.WarehouseId), lines.Select(l => l.WarehouseId), "Warehouse", ct);
|
||||
}
|
||||
|
||||
@@ -276,5 +274,5 @@ 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.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived)).ToList());
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<User> _users;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly ISalesPricingService _pricing;
|
||||
|
||||
public SalesDomainService(
|
||||
@@ -21,14 +20,12 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
IRepository<Warehouse> warehouses,
|
||||
IRepository<User> users,
|
||||
IRepository<Item> items,
|
||||
IRepository<Uom> uoms,
|
||||
ISalesPricingService pricing)
|
||||
{
|
||||
_customers = customers;
|
||||
_warehouses = warehouses;
|
||||
_users = users;
|
||||
_items = items;
|
||||
_uoms = uoms;
|
||||
_pricing = pricing;
|
||||
}
|
||||
|
||||
@@ -48,7 +45,7 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
}
|
||||
|
||||
public async Task ValidateSalesLineAsync(
|
||||
int headerWarehouseId, int lineItemId, int lineUomId, int lineWarehouseId, decimal qty, decimal freeQty, int? parentLineId, CancellationToken ct = default)
|
||||
int headerWarehouseId, int lineItemId, int lineWarehouseId, decimal qty, decimal freeQty, int? parentLineId, CancellationToken ct = default)
|
||||
{
|
||||
if (qty <= 0)
|
||||
throw new DomainException(ErrorCodes.Validation, "Sales line quantity must be greater than zero.", 422);
|
||||
@@ -60,8 +57,6 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
throw new DomainException(ErrorCodes.Validation, $"Sales line warehouse {lineWarehouseId} must match header warehouse {headerWarehouseId}.", 422);
|
||||
if (!await _items.Query().AnyAsync(x => x.ItemId == lineItemId, ct))
|
||||
throw new NotFoundException($"Item {lineItemId} was not found.");
|
||||
if (!await _uoms.Query().AnyAsync(x => x.UomId == lineUomId, ct))
|
||||
throw new NotFoundException($"UOM {lineUomId} was not found.");
|
||||
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == lineWarehouseId, ct))
|
||||
throw new NotFoundException($"Warehouse {lineWarehouseId} was not found.");
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
foreach (var r in requests)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||
await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct);
|
||||
await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct);
|
||||
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
|
||||
var unitPrice = resolved.UnitPrice;
|
||||
var priceSource = resolved.PriceSource;
|
||||
@@ -158,7 +158,6 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
Description = item.Name,
|
||||
Qty = r.Qty,
|
||||
FreeQty = r.FreeQty,
|
||||
UomId = r.UomId,
|
||||
WarehouseId = r.WarehouseId,
|
||||
UnitPrice = unitPrice,
|
||||
BaseCost = unitPrice,
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class SalesMappingService : ISalesMappingService
|
||||
invoice.InvoiceType, invoice.Status, invoice.CreatedBy, invoice.CreatedAt, invoice.UpdatedAt,
|
||||
MapInvoiceTotals(invoice),
|
||||
invoice.Lines.Select(l => new SalesInvoiceLineDto(
|
||||
l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId,
|
||||
l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId,
|
||||
l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode,
|
||||
l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
|
||||
|
||||
@@ -45,7 +45,7 @@ public sealed class SalesMappingService : ISalesMappingService
|
||||
slip.WarehouseId, slip.CashierUserId, slip.Status, slip.CreatedAt, slip.UpdatedAt,
|
||||
MapSlipTotals(slip),
|
||||
slip.Lines.Select(l => new SalesSlipLineDto(
|
||||
l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId,
|
||||
l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId,
|
||||
l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode,
|
||||
l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
|
||||
}
|
||||
|
||||
@@ -119,15 +119,14 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
{
|
||||
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
|
||||
continue;
|
||||
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.FirstAsync(x => x.ItemId == line.ItemId, ct);
|
||||
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||
if (available >= line.Qty) continue;
|
||||
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
.Where(x => x.ItemId == line.ItemId)
|
||||
.Select(x => new { x.Sku, x.Name })
|
||||
.FirstAsync(ct);
|
||||
|
||||
issues.Add(new BundleSalePostingIssueDto(line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
|
||||
issues.Add(new BundleSalePostingIssueDto(
|
||||
line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
|
||||
}
|
||||
|
||||
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
|
||||
@@ -142,7 +141,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
||||
setPosted: x => x.Status = SalesInvoiceStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: nameof(SalesInvoice),
|
||||
sourceDocType: DocumentTypes.SalesInvoice,
|
||||
getDocId: x => x.SalesInvoiceId,
|
||||
ct: ct);
|
||||
|
||||
@@ -155,7 +154,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
||||
setPosted: x => x.Status = SalesSlipStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: nameof(SalesSlip),
|
||||
sourceDocType: DocumentTypes.SalesSlip,
|
||||
getDocId: x => x.SalesSlipId,
|
||||
ct: ct);
|
||||
|
||||
@@ -168,7 +167,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m)),
|
||||
setPosted: x => x.Status = BundleSaleStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: nameof(BundleSale),
|
||||
sourceDocType: DocumentTypes.BundleSale,
|
||||
getDocId: x => x.BundleSaleId,
|
||||
ct: ct);
|
||||
|
||||
@@ -199,6 +198,8 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
if (!await _sales.IsStockedItemAsync(line.ItemId, token))
|
||||
continue;
|
||||
|
||||
// Line quantities are already a count of the item's base UOM — sales documents
|
||||
// carry no unit of their own — so this is the quantity FIFO consumes verbatim.
|
||||
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty, token);
|
||||
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
|
||||
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId,
|
||||
|
||||
@@ -9,8 +9,6 @@ namespace ERPCore.Services;
|
||||
|
||||
public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionService
|
||||
{
|
||||
private const decimal FreeIssueThreshold = 10m;
|
||||
|
||||
private readonly IRepository<SalesSlip> _slips;
|
||||
private readonly IRepository<Item> _items;
|
||||
|
||||
@@ -27,7 +25,14 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS
|
||||
|
||||
if (slip is null) return null;
|
||||
|
||||
var itemIds = slip.Lines.Select(x => x.ItemId).Distinct().ToList();
|
||||
var freeIssueLines = slip.Lines
|
||||
.Where(x => x.IsFreeIssue || x.FreeQty > 0m)
|
||||
.ToList();
|
||||
|
||||
if (freeIssueLines.Count == 0)
|
||||
return new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, Array.Empty<SalesFreeIssueSuggestionLineDto>());
|
||||
|
||||
var itemIds = freeIssueLines.Select(x => x.ItemId).Distinct().ToList();
|
||||
var candidateItems = await _items.Query().AsNoTracking()
|
||||
.Where(x => itemIds.Contains(x.ItemId) && x.Status == EntityStatus.Active)
|
||||
.ToListAsync(ct);
|
||||
@@ -35,13 +40,10 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS
|
||||
var byItemId = candidateItems.ToDictionary(x => x.ItemId);
|
||||
var suggestions = new List<SalesFreeIssueSuggestionLineDto>();
|
||||
|
||||
foreach (var line in slip.Lines.Where(x => x.Qty >= FreeIssueThreshold))
|
||||
foreach (var line in freeIssueLines)
|
||||
{
|
||||
if (!byItemId.TryGetValue(line.ItemId, out var item)) continue;
|
||||
|
||||
var freeQty = Math.Floor(line.Qty / FreeIssueThreshold);
|
||||
if (freeQty <= 0m) continue;
|
||||
|
||||
var rewardOptions = new List<SalesFreeIssueRewardOptionDto>
|
||||
{
|
||||
new(item.ItemId, item.Sku, item.Name, item.SalePrice)
|
||||
@@ -62,8 +64,8 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS
|
||||
item.Sku,
|
||||
item.Name,
|
||||
line.Qty,
|
||||
freeQty,
|
||||
FreeIssueThreshold,
|
||||
line.FreeQty,
|
||||
line.Qty,
|
||||
rewardOptions));
|
||||
}
|
||||
|
||||
@@ -71,4 +73,4 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS
|
||||
? new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, Array.Empty<SalesFreeIssueSuggestionLineDto>())
|
||||
: new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, suggestions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
foreach (var r in requests)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||
await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct);
|
||||
await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct);
|
||||
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
|
||||
var unitPrice = resolved.UnitPrice;
|
||||
var priceSource = resolved.PriceSource;
|
||||
@@ -182,7 +182,6 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
Description = item.Name,
|
||||
Qty = r.Qty,
|
||||
FreeQty = r.FreeQty,
|
||||
UomId = r.UomId,
|
||||
WarehouseId = r.WarehouseId,
|
||||
UnitPrice = unitPrice,
|
||||
BaseCost = unitPrice,
|
||||
@@ -222,8 +221,10 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
.Where(i => i.ItemId == line.ItemId)
|
||||
.Select(i => new { i.ItemId, i.Sku, i.Name, i.BaseUomId })
|
||||
.FirstOrDefault();
|
||||
var uom = line is null ? null : _uoms.Query().AsNoTracking()
|
||||
.Where(u => u.UomId == line.UomId)
|
||||
// The line carries no unit of its own — its quantity is a count of the item's base
|
||||
// UOM — so the display name comes from there.
|
||||
var uom = item is null ? null : _uoms.Query().AsNoTracking()
|
||||
.Where(u => u.UomId == item.BaseUomId)
|
||||
.Select(u => new { u.UomId, u.Name })
|
||||
.FirstOrDefault();
|
||||
var warehouse = _warehouses.Query().AsNoTracking()
|
||||
@@ -240,8 +241,7 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
line?.ItemId ?? 0,
|
||||
item?.Sku ?? $"SKU-{line?.ItemId ?? 0}",
|
||||
item?.Name ?? line?.Description ?? "—",
|
||||
line?.UomId ?? 0,
|
||||
uom?.Name ?? $"UOM {line?.UomId ?? 0}",
|
||||
uom?.Name ?? $"UOM {item?.BaseUomId ?? 0}",
|
||||
line?.Qty ?? 0m,
|
||||
line?.FreeQty ?? 0m,
|
||||
line is null ? "No line" : $"Buy {line.Qty} Get {line.FreeQty}");
|
||||
@@ -263,7 +263,7 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
x.CreatedAt,
|
||||
x.UpdatedAt,
|
||||
summary,
|
||||
x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
|
||||
x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
|
||||
}
|
||||
|
||||
private SalesSlipDto Map(SalesSlip x) => _mapping.MapSlip(x);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
|
||||
namespace ERPCore.Services.Stock;
|
||||
|
||||
/// <summary>
|
||||
/// Content ↔ pack arithmetic (see <see cref="IItemMeasure"/>). Stateless and I/O-free:
|
||||
/// the content size is already on the <see cref="Item"/> every caller has loaded.
|
||||
/// </summary>
|
||||
public sealed class ItemMeasure : IItemMeasure
|
||||
{
|
||||
public bool HasContent(Item item) => item.ContentBaseQty is > 0m;
|
||||
|
||||
public decimal ToPacks(Item item, decimal formulaQty, StageQtyUnit unit)
|
||||
{
|
||||
if (unit == StageQtyUnit.Pack) return formulaQty;
|
||||
|
||||
// Rounded to the quantity columns' (18,4) scale with the same mode as
|
||||
// ProductionRunService.Scale, so what FIFO consumes and what the cost pool divides
|
||||
// by are the same number to the last stored digit.
|
||||
return Math.Round(formulaQty / RequireContent(item), 4, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
public decimal FromPacks(Item item, decimal packs, StageQtyUnit unit)
|
||||
{
|
||||
if (unit == StageQtyUnit.Pack) return packs;
|
||||
|
||||
return Math.Round(packs * RequireContent(item), 4, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static decimal RequireContent(Item item)
|
||||
=> item.ContentBaseQty is > 0m
|
||||
? item.ContentBaseQty.Value
|
||||
: throw new DomainException(
|
||||
ErrorCodes.Validation,
|
||||
$"Item {item.ItemId} has no content size, so its quantity cannot be expressed in content units.", 422);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services.Stock;
|
||||
|
||||
/// <summary>
|
||||
/// Shared UOM → base-UOM conversion (see <see cref="IUomConverter"/>). Behaviour is
|
||||
/// unchanged from the <c>GrnService.ToBaseAsync</c> it was extracted from, so the GRN
|
||||
/// receive path keeps costing exactly as before.
|
||||
/// </summary>
|
||||
public sealed class UomConverter : IUomConverter
|
||||
{
|
||||
private readonly IRepository<UomConversion> _conversions;
|
||||
|
||||
public UomConverter(IRepository<UomConversion> conversions) => _conversions = conversions;
|
||||
|
||||
public async Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
|
||||
Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default)
|
||||
{
|
||||
if (uomId == item.BaseUomId)
|
||||
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);
|
||||
}
|
||||
|
||||
public async Task<decimal> ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default)
|
||||
=> (await ToBaseAsync(item, uomId, qty, 0m, ct)).QtyBase;
|
||||
}
|
||||
@@ -56,6 +56,7 @@ public static class ErrorCodes
|
||||
public const string GraphDisconnected = "GRAPH_DISCONNECTED";
|
||||
public const string GraphInputSourceInvalid = "GRAPH_INPUT_SOURCE_INVALID";
|
||||
public const string TerminalOutputItemRequired = "TERMINAL_OUTPUT_ITEM_REQUIRED";
|
||||
public const string WipUnitRequired = "WIP_UNIT_REQUIRED";
|
||||
public const string StageNotReady = "STAGE_NOT_READY";
|
||||
public const string StageNotInProgress = "STAGE_NOT_IN_PROGRESS";
|
||||
public const string StageNotDone = "STAGE_NOT_DONE";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCoreTest;Username=postgres;Password=root"
|
||||
"DefaultConnection": "Host=127.0.0.1;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
|
||||
},
|
||||
"AuthHex": {
|
||||
"BaseUrl": "http://localhost:5011"
|
||||
|
||||
+68
-1
@@ -4,6 +4,73 @@ Legend: `[ ]` not started · `[~]` in progress · `[x]` done
|
||||
Spec: `docs/10-BACKEND-PHASE1.md` (model + rules) · `docs/11-BACKEND-PHASE1.md` (API)
|
||||
Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** as the code. When ticking `[x]`, append a short note + any deviation.
|
||||
|
||||
## Per-variant content size (2026-08-11) — follow-up to the UOM re-model below
|
||||
|
||||
The UOM re-model put `ContentQty`/`ContentUnit` on `Item` correctly, but the item **create page
|
||||
is a variant builder** and collected **one** form-level pair, copying it into every generated
|
||||
variant. Building "Coca-Cola in 500 ml / 1 L / 250 ml" produced three items all recorded as the
|
||||
same size — the exact case the builder exists for. The item contract already accepted a
|
||||
per-item pair, so the whole fix is in how values are captured.
|
||||
|
||||
- **`ItemType.IsMeasurable`** (bool, default `false`) — set on Products → Item Types. A flagged
|
||||
dimension's values are entered as a number + unit; the chip label, the SKU segment, the item
|
||||
name and the stored content size all derive from that one pair. Unflagged dimensions are
|
||||
unchanged free text, which is what an apparel `Size` (S/M/L) needs.
|
||||
- **`UpdateItemTypeRequest.IsMeasurable` is `bool?` and preserved when omitted.** A plain `bool`
|
||||
binds an absent property as `false`, so the admin screen's name-only PUT would have cleared the
|
||||
flag on every rename — the same bug class already recorded for `product-config` further down.
|
||||
- **The `BUILDER_ITEM_TYPES = ["color","size"]` hardcode is gone.** It had one consumer and had
|
||||
become a live bug: a user-created "Pack Size" would be flagged measurable and then never
|
||||
appear. Removal is behaviour-preserving on any current database (the seeder seeds exactly those
|
||||
two names, and the fetch was already `status: Active`) and restores the documented contract
|
||||
that users add their own types. Every Active item type is now offered; deactivation is the
|
||||
intended remedy and the admin page already says so.
|
||||
- **SKU collision fixed before it could bite.** `skuSegment` strips the decimal point and
|
||||
truncates to 3, so derived labels collided — `1.5L`/`15L` → `15L`, `500ml`/`500g` → `500`,
|
||||
`2.5ml`/`25ml` → `25M`. Since the create loop is sequential and non-transactional, that would
|
||||
have failed partway with `SKU_DUPLICATE` after creating some rows. Measurement segments now use
|
||||
`measureKey`, which mirrors `ItemContent.Normalize` (L/Kg ×1000) and renders the point as `P`.
|
||||
- **Values dedupe on the normalised size, not the label** — `500 ml` and `0.5 L` read differently
|
||||
but store identically, and `ItemContent.Normalize` is the server's notion of equality.
|
||||
- **At most one measurable dimension** per product: unchecked measurable types are disabled once
|
||||
one is checked, re-checked at submit.
|
||||
- **The form-level pair survives as a fallback** — correct when the varying dimension isn't size —
|
||||
and is hidden *and cleared* whenever a measurable dimension is active, so the two can never
|
||||
disagree. Its validation is skipped in that mode, since its error message would otherwise be
|
||||
invisible inside the hidden block.
|
||||
- The item **edit** page is untouched: one item, one size.
|
||||
|
||||
## UOM re-model (2026-08-11) — supersedes every "UOM conversion" note below
|
||||
|
||||
Per-item UOM conversion is **gone**. Entries further down this file that describe
|
||||
`IUomConverter`, `PUT /items/{id}/uom-conversions`, `uom_conversions`, or a line-level
|
||||
`uomId` are historical and no longer describe the code.
|
||||
|
||||
What replaced it:
|
||||
|
||||
- **One unit per item.** `Item.BaseUomId` is the pack an item is stocked and counted in, and
|
||||
every quantity in the system — stock layers, ledger rows, GRN/PO/sales/bundle/transfer lines
|
||||
— is a plain count of it. A differently sized pack is a different item. `UomId` was dropped
|
||||
from all six document-line entities; `Uom` itself survives as the lookup.
|
||||
- **Optional content size on `Item`**: `ContentQty` + `ContentUnit` as entered (`Ml|L|G|Kg`),
|
||||
normalised on write into `ContentBaseQty` + `ContentBaseUnit` (only ever `Ml` or `G`, L/Kg
|
||||
×1000). All four null ⇒ nothing measurable to hold. `ItemContent` is the pure normaliser.
|
||||
- **Production is the only content consumer.** `StageInput.QtyUnit` (`Pack|Content`) says what
|
||||
`QtyPerBatch` means; `IItemMeasure` divides a `Content` quantity by the item's content size
|
||||
to get packs. **Fractional packs are legal** — 300 ml of a 500 ml bottle consumes 0.6000.
|
||||
Outputs are always pack counts, so scrap stays in whole broken bottles.
|
||||
- **WIP keeps a label.** `StageOutput.UomId`/`RunStageOutput.UomId` are now nullable and
|
||||
required *only* when `ItemId` is null (`422 WIP_UNIT_REQUIRED`), since an item-bearing output
|
||||
takes its unit from the item. WIP never touches stock, so the label is never converted.
|
||||
- **Two live defects fixed as a consequence.** `SalesPostingService` injected `IUomConverter`
|
||||
and never called it, so a sales line in a non-base UOM consumed the wrong quantity outright;
|
||||
`GrnService` accrued `poLine.QtyReceived += line.Qty` and range-checked over-receipt across
|
||||
the same unit boundary. Both are now like-for-like by construction.
|
||||
- **`BaseUomId` is frozen once an item has stock history** (`409 MASTER_IN_USE`) — it is the
|
||||
sole meaning of every recorded quantity, so changing it would silently reinterpret all of it.
|
||||
- Smoke: `m4b_uom_conversion.py` deleted; `m4c_content_units.py` added (whole packs, fractional
|
||||
packs, and the contentless-item guard).
|
||||
|
||||
## 8. Sales
|
||||
- [x] Sales bootstrap data seeded locally for development: warehouses, UOMs, categories, items, customers, current-year `SI`/`SSL` sequences, plus sample invoice/slip headers and lines. Existing data is preserved.
|
||||
- [x] Sales report API consolidated into `GET /api/v1/reports/sales` (catalog), `GET /api/v1/reports/sales/{reportId}` (report metadata), and `POST /api/v1/reports/sales/query` (filtered data). Legacy per-report GET routes removed; invalid report/filter combinations now fail validation.
|
||||
@@ -29,7 +96,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
- [x] Warehouse + Bin (`/warehouses`, nested `/warehouses/{id}/bins`, bin code unique per warehouse)
|
||||
- [x] Item reorder settings (`PUT /items/{id}/reorder` full-replace upsert, warehouse-exists validation)
|
||||
- [x] Brand master (FR-MD-09) — CRUD + status + ETag; `Item.brandId` nullable FK
|
||||
- [x] Item Type master (FR-MD-10) — CRUD + status + ETag; **unreferenced by design**, feeds the builder dropdown only
|
||||
- [x] Item Type master (FR-MD-10) — CRUD + status + ETag; **unreferenced by design**. Feeds the builder's dimension list, and since 2026-08-11 carries `isMeasurable`, which decides whether its values are captured as free text or as a number + unit that becomes each item's content size (see the entry at the top of this file)
|
||||
- [x] SubCategory (FR-MD-04) — nested list/create under a category, `PUT`/`PATCH status` by id; `Item.subCategoryId` nullable FK, validated to belong to `categoryId`
|
||||
- [x] Product Configuration (FR-MD-11) — singleton `GET`/`PUT /product-config`; `CONFIG_DISABLED` gating on item writes
|
||||
- [x] Item **sale price** (FR-MD-01, 2026-07-22) — nullable `Item.SalePrice` (`numeric(18,4)`); on all Item DTOs (list/detail/create/update), validated `>= 0`. **Sales-only** — never enters GRN/FIFO/ledger. `null` ⇒ sell at stock value. Migration `AddItemSalePrice`. See the 2026-07-22 Done entry.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -44,25 +44,26 @@ def diamond(raw_item, finished_item, uom):
|
||||
"estimatedMinutes": 60, "posX": 80, "posY": 120,
|
||||
"fieldDefs": [{"key": "moisture_ok", "label": "Moisture check",
|
||||
"type": "Checkbox", "required": True}],
|
||||
"inputs": [{"source": "Stock", "itemId": raw_item, "uomId": uom, "qtyPerBatch": 8}],
|
||||
"inputs": [{"source": "Stock", "itemId": raw_item, "qtyPerBatch": 8}],
|
||||
"outputs": [{"key": "tmp-frame", "name": "Frame set", "uomId": uom, "qtyPerBatch": 1}],
|
||||
},
|
||||
{
|
||||
"key": "tmp-prep", "name": "Prep cushions", "roleLabel": "Upholstery",
|
||||
"estimatedMinutes": 30, "posX": 80, "posY": 320, "fieldDefs": [],
|
||||
"inputs": [{"source": "Stock", "itemId": raw_item, "uomId": uom, "qtyPerBatch": 2}],
|
||||
"inputs": [{"source": "Stock", "itemId": raw_item, "qtyPerBatch": 2}],
|
||||
"outputs": [{"key": "tmp-cushion", "name": "Cushion set", "uomId": uom, "qtyPerBatch": 1}],
|
||||
},
|
||||
{
|
||||
"key": "tmp-asm", "name": "Assemble & QA", "roleLabel": "QA",
|
||||
"estimatedMinutes": 45, "posX": 560, "posY": 200, "fieldDefs": [],
|
||||
"inputs": [
|
||||
{"source": "Upstream", "fromOutputKey": "tmp-frame", "uomId": uom, "qtyPerBatch": 1},
|
||||
{"source": "Upstream", "fromOutputKey": "tmp-cushion", "uomId": uom, "qtyPerBatch": 1},
|
||||
{"source": "Upstream", "fromOutputKey": "tmp-frame", "qtyPerBatch": 1},
|
||||
{"source": "Upstream", "fromOutputKey": "tmp-cushion", "qtyPerBatch": 1},
|
||||
],
|
||||
# Terminal output must name the finished item (FR-MFG-05).
|
||||
# Terminal output must name the finished item (FR-MFG-05), and takes its unit
|
||||
# from that item — sending a uomId as well is rejected.
|
||||
"outputs": [{"key": "tmp-chair", "name": "Chair", "itemId": finished_item,
|
||||
"uomId": uom, "qtyPerBatch": 1}],
|
||||
"qtyPerBatch": 1}],
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
@@ -243,7 +244,7 @@ def main():
|
||||
grandparent["stages"].append({
|
||||
"key": "tmp-mid", "name": "Middle", "estimatedMinutes": 5, "posX": 320, "posY": 120,
|
||||
"fieldDefs": [],
|
||||
"inputs": [{"source": "Upstream", "fromOutputKey": cut_output_key, "uomId": uom, "qtyPerBatch": 1}],
|
||||
"inputs": [{"source": "Upstream", "fromOutputKey": cut_output_key, "qtyPerBatch": 1}],
|
||||
"outputs": [{"key": "tmp-mid-out", "name": "Mid part", "uomId": uom, "qtyPerBatch": 1}],
|
||||
})
|
||||
grandparent["edges"] = [e for e in grandparent["edges"] if e["parentKey"] != cut_key]
|
||||
@@ -320,9 +321,11 @@ def rebuild_from_get(g: dict) -> dict:
|
||||
"inputs": [
|
||||
{"source": i["source"], "itemId": i.get("itemId"),
|
||||
"fromOutputKey": i.get("fromOutputKey"),
|
||||
"uomId": i["uomId"], "qtyPerBatch": i["qtyPerBatch"]}
|
||||
"qtyUnit": i["qtyUnit"], "qtyPerBatch": i["qtyPerBatch"]}
|
||||
for i in s["inputs"]
|
||||
],
|
||||
# uomId round-trips as null on an item-bearing output and as the WIP label
|
||||
# otherwise, so echoing it back verbatim is correct either way.
|
||||
"outputs": [
|
||||
{"key": o["key"], "itemId": o.get("itemId"), "name": o["name"],
|
||||
"uomId": o["uomId"], "qtyPerBatch": o["qtyPerBatch"]}
|
||||
|
||||
@@ -147,7 +147,7 @@ def main():
|
||||
"estimatedMinutes": s["estimatedMinutes"], "posX": s["posX"], "posY": s["posY"],
|
||||
"fieldDefs": s["fieldDefs"],
|
||||
"inputs": [{"source": i["source"], "itemId": i.get("itemId"),
|
||||
"fromOutputKey": i.get("fromOutputKey"), "uomId": i["uomId"],
|
||||
"fromOutputKey": i.get("fromOutputKey"), "qtyUnit": i["qtyUnit"],
|
||||
"qtyPerBatch": i["qtyPerBatch"]} for i in s["inputs"]],
|
||||
"outputs": [{"key": o["key"], "itemId": o.get("itemId"), "name": o["name"],
|
||||
"uomId": o["uomId"], "qtyPerBatch": o["qtyPerBatch"]} for o in s["outputs"]]}
|
||||
|
||||
@@ -65,16 +65,16 @@ def ledger_rows(c, run_id, source):
|
||||
return rows
|
||||
|
||||
|
||||
def seed_stock(c, wh, raw, pack, uom):
|
||||
def seed_stock(c, wh, raw, pack):
|
||||
"""
|
||||
Seed on-hand at explicit unit costs. Two raw layers at different costs mean the FIFO
|
||||
consumption at start has to weight them, so `consumedValue` is a real number the
|
||||
assertions can check rather than the 0.00 a positive adjustment would produce.
|
||||
"""
|
||||
seed_costed_stock(c, wh, [
|
||||
(raw, uom, SEED_RAW * 0.4, RAW_COST_1),
|
||||
(raw, uom, SEED_RAW * 0.6, RAW_COST_2),
|
||||
(pack, uom, SEED_PACK, PACK_COST),
|
||||
(raw, SEED_RAW * 0.4, RAW_COST_1),
|
||||
(raw, SEED_RAW * 0.6, RAW_COST_2),
|
||||
(pack, SEED_PACK, PACK_COST),
|
||||
])
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ def build_template(c, raw, pack, finished, uom):
|
||||
"estimatedMinutes": 60, "posX": 80, "posY": 100,
|
||||
"fieldDefs": [{"key": "moisture_ok", "label": "Moisture check",
|
||||
"type": "Checkbox", "required": True}],
|
||||
"inputs": [{"source": "Stock", "itemId": raw, "uomId": uom, "qtyPerBatch": 8}],
|
||||
"inputs": [{"source": "Stock", "itemId": raw, "qtyPerBatch": 8}],
|
||||
"outputs": [{"key": "tmp-frame", "name": "Frame", "uomId": uom, "qtyPerBatch": 1}],
|
||||
},
|
||||
{
|
||||
@@ -99,11 +99,11 @@ def build_template(c, raw, pack, finished, uom):
|
||||
"key": "tmp-asm", "name": "Assemble", "roleLabel": "QA",
|
||||
"estimatedMinutes": 45, "posX": 520, "posY": 100, "fieldDefs": [],
|
||||
"inputs": [
|
||||
{"source": "Upstream", "fromOutputKey": "tmp-frame", "uomId": uom, "qtyPerBatch": 1},
|
||||
{"source": "Stock", "itemId": pack, "uomId": uom, "qtyPerBatch": 2},
|
||||
{"source": "Upstream", "fromOutputKey": "tmp-frame", "qtyPerBatch": 1},
|
||||
{"source": "Stock", "itemId": pack, "qtyPerBatch": 2},
|
||||
],
|
||||
"outputs": [{"key": "tmp-chair", "name": "Chair", "itemId": finished,
|
||||
"uomId": uom, "qtyPerBatch": 1}],
|
||||
"qtyPerBatch": 1}],
|
||||
},
|
||||
],
|
||||
"edges": [{"parentKey": "tmp-cut", "childKey": "tmp-asm"}],
|
||||
@@ -141,7 +141,7 @@ def main():
|
||||
drained = drain_stock(c, wh)
|
||||
if drained:
|
||||
print(f"drained {len(drained)} leftover item(s) from a previous execution")
|
||||
seed_stock(c, wh, raw, pack, uom)
|
||||
seed_stock(c, wh, raw, pack)
|
||||
raw_before = on_hand(c, raw, wh)
|
||||
pack_before = on_hand(c, pack, wh)
|
||||
print(f"warehouse={wh} raw={raw}(on-hand {raw_before}) pack={pack}(on-hand {pack_before}) finished={finished}")
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"runId": 64, "templateId": 2, "warehouseId": 4, "assembleStageId": 118, "finishedItemId": 13, "rawItemId": 18, "packItemId": 14}
|
||||
{"runId": 20, "templateId": 6, "warehouseId": 4, "assembleStageId": 30, "finishedItemId": 1, "rawItemId": 4, "packItemId": 3}
|
||||
@@ -1,170 +0,0 @@
|
||||
"""M4b smoke test — UOM conversion on production stock inputs.
|
||||
|
||||
This covers the single highest-risk correctness gap in the manufacturing phase.
|
||||
`IFifoCostingService.ConsumeAsync` works exclusively in an item's BASE UOM, while
|
||||
`STAGE_INPUT.uom_id` is a free FK — docs/30 never mentions conversion at all. Without the
|
||||
shared `IUomConverter` (extracted from `GrnService.ToBaseAsync`), a stage input declared in
|
||||
"box of 12" would consume 1 base unit instead of 12 and silently mis-cost the whole run.
|
||||
|
||||
The dev database has no `uom_conversions` rows at all, so the non-base path was previously
|
||||
unexercised by any data. This script creates a real conversion and proves:
|
||||
|
||||
* a stage input in a non-base UOM consumes qtyPerBatch x scaleFactor x factor base units
|
||||
* the ledger records the BASE quantity, not the declared one
|
||||
* an input in a UOM with no conversion defined is refused with 422 rather than mis-consumed
|
||||
|
||||
python Backend/smoke/m4b_uom_conversion.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from smoke_common import bootstrap
|
||||
|
||||
WAREHOUSE_CODE = "SMOKE-PRD"
|
||||
TEMPLATE_CODE = "SMOKE-PT-M4B"
|
||||
FACTOR = 12 # 1 case = 12 base units
|
||||
QTY_PER_BATCH = 3 # cases per batch
|
||||
TARGET_QTY = 10 # -> scale 10 -> 30 cases -> 360 base units
|
||||
SEED = 5000
|
||||
|
||||
|
||||
def main():
|
||||
c, chk, args = bootstrap(__doc__)
|
||||
print(f"API {args.api}")
|
||||
|
||||
# --- fixtures ---------------------------------------------------------
|
||||
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).")
|
||||
|
||||
items = c.get("/items?pageSize=5&status=Active").body["items"]
|
||||
raw, finished = items[0], items[1]
|
||||
base_uom = raw["baseUomId"]
|
||||
|
||||
uoms = c.get("/uoms?pageSize=50").body["items"]
|
||||
case_uom = next((u["uomId"] for u in uoms if u["uomId"] != base_uom), None)
|
||||
if case_uom is None:
|
||||
sys.exit("FATAL: need at least 2 UOMs to test conversion.")
|
||||
print(f"item={raw['itemId']} baseUom={base_uom} caseUom={case_uom} factor={FACTOR}")
|
||||
|
||||
# --- define the conversion -------------------------------------------
|
||||
chk.section("1. Define a non-base UOM conversion for the item")
|
||||
conv = c.put(f"/items/{raw['itemId']}/uom-conversions",
|
||||
{"conversions": [{"fromUom": case_uom, "toUom": base_uom, "factor": FACTOR}]})
|
||||
chk.status("PUT /items/{id}/uom-conversions", conv, 200)
|
||||
if conv.status != 200:
|
||||
return chk.finish("M4b")
|
||||
chk.check("conversion stored", any(float(x["factor"]) == FACTOR for x in conv.body["conversions"]), True)
|
||||
|
||||
reason = c.get("/reason-codes?context=Adjustment&pageSize=5").body["items"][0]["reasonCodeId"]
|
||||
c.post("/stock-adjustments", {"warehouseId": wh, "reasonCodeId": reason,
|
||||
"lines": [{"itemId": raw["itemId"], "qtyDelta": SEED}]})
|
||||
before = float(c.get(f"/stock/on-hand?itemId={raw['itemId']}&warehouseId={wh}").body["onHand"])
|
||||
print(f"on-hand before: {before}")
|
||||
|
||||
# --- template whose stock input is declared in CASES ------------------
|
||||
chk.section("2. A stage input declared in the non-base UOM")
|
||||
payload = {
|
||||
"code": TEMPLATE_CODE, "name": "Smoke M4b conversion line",
|
||||
"stages": [{
|
||||
"key": "tmp-only", "name": "Pack", "estimatedMinutes": 10,
|
||||
"posX": 0, "posY": 0, "fieldDefs": [],
|
||||
# Declared in cases, not base units.
|
||||
"inputs": [{"source": "Stock", "itemId": raw["itemId"],
|
||||
"uomId": case_uom, "qtyPerBatch": QTY_PER_BATCH}],
|
||||
"outputs": [{"key": "tmp-out", "name": "Packed", "itemId": finished["itemId"],
|
||||
"uomId": base_uom, "qtyPerBatch": 1}],
|
||||
}],
|
||||
"edges": [],
|
||||
}
|
||||
|
||||
existing = next((t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"]
|
||||
if t["code"] == TEMPLATE_CODE), None)
|
||||
if existing:
|
||||
head = c.get(f"/production-templates/{existing['templateId']}")
|
||||
res = c.put(f"/production-templates/{existing['templateId']}", payload, if_match=head.etag)
|
||||
tid = existing["templateId"] if res.status in (200, 409) else None
|
||||
chk.check("template ready", tid is not None, True)
|
||||
else:
|
||||
res = c.post("/production-templates", payload)
|
||||
chk.status("create single-stage template", res, 201)
|
||||
tid = res.body["templateId"] if res.status == 201 else None
|
||||
|
||||
if tid is None:
|
||||
return chk.finish("M4b")
|
||||
|
||||
# A lone stage is both the entry and the terminal — worth asserting explicitly.
|
||||
run = c.post("/production-runs", {"templateId": tid, "targetQty": TARGET_QTY, "warehouseId": wh})
|
||||
chk.status("create the run", run, 201)
|
||||
if run.status != 201:
|
||||
return chk.finish("M4b")
|
||||
|
||||
stage = run.body["stages"][0]
|
||||
chk.check("single stage is both entry and terminal",
|
||||
(stage["isEntry"], stage["isTerminal"]), (True, True))
|
||||
chk.check("single stage starts Ready", stage["status"], "Ready")
|
||||
chk.check("plannedQty stays in the DECLARED uom (3 x 10 cases)",
|
||||
float(stage["inputs"][0]["plannedQty"]), float(QTY_PER_BATCH * TARGET_QTY))
|
||||
|
||||
# --- the actual conversion assertion ---------------------------------
|
||||
chk.section("3. Consumption converts cases to base units")
|
||||
expected_base = QTY_PER_BATCH * TARGET_QTY * FACTOR # 3 x 10 x 12 = 360
|
||||
started = c.post(f"/production-runs/{run.body['runId']}/stages/{stage['runStageId']}/start")
|
||||
chk.status("start the stage", started, 200)
|
||||
if started.status != 200:
|
||||
return chk.finish("M4b")
|
||||
|
||||
con = started.body["consumed"][0]
|
||||
chk.check(f"consumed {expected_base} BASE units, not {QTY_PER_BATCH * TARGET_QTY}",
|
||||
float(con["qty"]), float(expected_base))
|
||||
chk.check("on-hand fell by the base quantity",
|
||||
float(c.get(f"/stock/on-hand?itemId={raw['itemId']}&warehouseId={wh}").body["onHand"]),
|
||||
before - expected_base)
|
||||
|
||||
rows = c.get(f"/stock/ledger?sourceDocType=PRDI&sourceDocId={run.body['runId']}&pageSize=50").body["items"]
|
||||
chk.check("one PRDI row", len(rows), 1)
|
||||
if rows:
|
||||
chk.check("ledger qtyBase is the converted quantity", float(rows[0]["qtyBase"]), float(expected_base))
|
||||
|
||||
detail = c.get(f"/production-runs/{run.body['runId']}").body
|
||||
chk.check("consumedQty stored in base units",
|
||||
float(detail["stages"][0]["inputs"][0]["consumedQty"]), float(expected_base))
|
||||
|
||||
# --- missing conversion is refused, not silently mis-consumed --------
|
||||
chk.section("4. An undefined conversion is refused (422), never assumed 1:1")
|
||||
third_uom = next((u["uomId"] for u in c.get("/uoms?pageSize=50").body["items"]
|
||||
if u["uomId"] not in (base_uom, case_uom)), None)
|
||||
if third_uom is None:
|
||||
chk.check("skipped: need a third UOM", True, True)
|
||||
else:
|
||||
bad = dict(payload)
|
||||
bad["code"] = TEMPLATE_CODE + "-BAD"
|
||||
bad["stages"] = [dict(payload["stages"][0])]
|
||||
bad["stages"][0] = {**payload["stages"][0],
|
||||
"inputs": [{"source": "Stock", "itemId": raw["itemId"],
|
||||
"uomId": third_uom, "qtyPerBatch": 1}]}
|
||||
made = c.post("/production-templates", bad)
|
||||
if made.status != 201:
|
||||
head = c.get(f"/production-templates?q={TEMPLATE_CODE}-BAD")
|
||||
tid2 = next((t["templateId"] for t in head.body["items"]
|
||||
if t["code"] == TEMPLATE_CODE + "-BAD"), None)
|
||||
else:
|
||||
tid2 = made.body["templateId"]
|
||||
|
||||
if tid2:
|
||||
run2 = c.post("/production-runs", {"templateId": tid2, "targetQty": 1, "warehouseId": wh})
|
||||
if run2.status == 201:
|
||||
s2 = run2.body["stages"][0]["runStageId"]
|
||||
chk.status("start a stage whose input UOM has no conversion",
|
||||
c.post(f"/production-runs/{run2.body['runId']}/stages/{s2}/start"), 422)
|
||||
else:
|
||||
chk.check("could create the second run", run2.status, 201)
|
||||
|
||||
return chk.finish("M4b")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,226 @@
|
||||
"""M4c smoke test — production stage inputs expressed in content units (FR-MFG-04).
|
||||
|
||||
Replaces m4b_uom_conversion.py, which tested the per-item UOM conversion table that no longer
|
||||
exists. Stock is now always a count of the item's base UOM (a bottle, a packet), and the only
|
||||
place another unit appears is a production formula: a stage input may be written as an amount
|
||||
of the item's *content* (ml or g), which the server divides by the item's content size to get
|
||||
the pack count FIFO actually consumes.
|
||||
|
||||
What this pins down, none of which any other script covers:
|
||||
|
||||
* 2000 ml against a 500 ml bottle consumes exactly 4.0000 bottles, and the ledger agrees;
|
||||
* 300 ml against the same item consumes 0.6000 — **fractional packs are legal**, which is
|
||||
the whole reason the quantity columns are (18,4);
|
||||
* a Content input on an item with no content size is refused at template save (422), not
|
||||
silently treated as packs at stage start.
|
||||
|
||||
Self-contained: creates its own item, warehouse, template and runs.
|
||||
|
||||
python Backend/smoke/m4c_content_units.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from smoke_common import bootstrap, drain_stock, seed_costed_stock
|
||||
|
||||
WAREHOUSE_CODE = "SMOKE-CONTENT"
|
||||
TEMPLATE_CODE = "SMOKE-PT-M4C"
|
||||
SKU = "SMOKE-BOTTLE-500ML"
|
||||
PLAIN_SKU = "SMOKE-NO-CONTENT"
|
||||
|
||||
CONTENT_QTY = 500 # ml held by one bottle
|
||||
SEED_BOTTLES = 100
|
||||
UNIT_COST = 3.0
|
||||
|
||||
|
||||
def production_reason(c, code):
|
||||
for r in c.get("/reason-codes?context=Production&pageSize=50").body["items"]:
|
||||
if r["code"] == code:
|
||||
return r["reasonCodeId"]
|
||||
sys.exit(f"FATAL: Production reason {code} not seeded.")
|
||||
|
||||
|
||||
def ensure_warehouse(c):
|
||||
for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"]:
|
||||
if w["code"] == WAREHOUSE_CODE:
|
||||
return w["warehouseId"]
|
||||
created = c.post("/warehouses", {"code": WAREHOUSE_CODE, "name": "Content-unit smoke warehouse"})
|
||||
if created.status != 201:
|
||||
sys.exit(f"FATAL: could not create the smoke warehouse: {created.status} {created.body}")
|
||||
return created.body["warehouseId"]
|
||||
|
||||
|
||||
def ensure_item(c, sku, name, content_qty, content_unit):
|
||||
"""Find-or-create; content fields are only sent when the item is meant to have them."""
|
||||
for i in c.get(f"/items?q={sku}&pageSize=50").body["items"]:
|
||||
if i["sku"] == sku:
|
||||
return i
|
||||
|
||||
category = c.get("/categories?pageSize=1").body["items"]
|
||||
uoms = c.get("/uoms?pageSize=1").body["items"]
|
||||
if not category or not uoms:
|
||||
sys.exit("FATAL: need at least one category and one UOM seeded.")
|
||||
|
||||
body = {
|
||||
"sku": sku, "name": name,
|
||||
"categoryId": category[0]["categoryId"],
|
||||
"baseUomId": uoms[0]["uomId"],
|
||||
"stockNature": "Stocked", "trackingMode": "None",
|
||||
}
|
||||
if content_qty is not None:
|
||||
body["contentQty"] = content_qty
|
||||
body["contentUnit"] = content_unit
|
||||
|
||||
created = c.post("/items", body)
|
||||
if created.status != 201:
|
||||
sys.exit(f"FATAL: could not create item {sku}: {created.status} {created.body}")
|
||||
return created.body
|
||||
|
||||
|
||||
def save_template(c, raw_item, finished_item, qty_per_batch, qty_unit, suffix):
|
||||
"""
|
||||
One template per section, never a shared one.
|
||||
|
||||
Each section starts a run and leaves it InProgress, and a template with a live run is
|
||||
edit-locked (FR-MFG-06, 409 TEMPLATE_IN_USE) — so re-saving a single shared code would
|
||||
fail from the second section onward for reasons that have nothing to do with content units.
|
||||
"""
|
||||
code = f"{TEMPLATE_CODE}-{suffix}"
|
||||
payload = {
|
||||
"code": code, "name": f"Content-unit smoke line ({suffix})",
|
||||
"stages": [{
|
||||
"key": "tmp-mix", "name": "Mix", "estimatedMinutes": 5, "posX": 0, "posY": 0,
|
||||
"fieldDefs": [],
|
||||
"inputs": [{"source": "Stock", "itemId": raw_item,
|
||||
"qtyUnit": qty_unit, "qtyPerBatch": qty_per_batch}],
|
||||
"outputs": [{"key": "tmp-out", "name": "Mixed", "itemId": finished_item,
|
||||
"qtyPerBatch": 1}],
|
||||
}],
|
||||
"edges": [],
|
||||
}
|
||||
existing = next((t for t in c.get(f"/production-templates?q={code}").body["items"]
|
||||
if t["code"] == code), None)
|
||||
if existing:
|
||||
head = c.get(f"/production-templates/{existing['templateId']}")
|
||||
return c.put(f"/production-templates/{existing['templateId']}", payload, if_match=head.etag)
|
||||
return c.post("/production-templates", payload)
|
||||
|
||||
|
||||
def saved_ok(chk, label, response):
|
||||
"""Template save is find-or-create, so a POST gives 201 and a PUT gives 200."""
|
||||
ok = chk.check(f"{label} -> 200/201", response.status in (200, 201), True)
|
||||
if not ok:
|
||||
print(f" server said: {response.body}")
|
||||
return ok
|
||||
|
||||
|
||||
def consume_once(c, chk, tid, wh, target_qty, label, expected_packs):
|
||||
"""
|
||||
Create a run, start its only stage, and assert what FIFO actually took.
|
||||
|
||||
`expected_packs` is compared against the RAW formula quantity resolved to packs, which is
|
||||
only valid while the run scale factor is 1. Run creation computes
|
||||
`ratio = targetQty / terminalOutput.qtyPerBatch` and pre-scales every planned quantity, so
|
||||
this holds because `save_template` pins the terminal output to `qtyPerBatch: 1` and every
|
||||
caller here passes `target_qty=1`. Change either and these numbers move by that ratio.
|
||||
"""
|
||||
created = c.post("/production-runs", {"templateId": tid, "targetQty": target_qty, "warehouseId": wh})
|
||||
if not chk.status(f"{label}: create the run", created, 201):
|
||||
return
|
||||
run = created.body
|
||||
stage_id = run["stages"][0]["runStageId"]
|
||||
|
||||
started = c.post(f"/production-runs/{run['runId']}/stages/{stage_id}/start",
|
||||
idempotency_key=f"m4c-{run['runId']}")
|
||||
if not chk.status(f"{label}: POST .../start", started, 200):
|
||||
return
|
||||
|
||||
consumed = started.body["consumed"]
|
||||
if not chk.check(f"{label}: exactly one input consumed", len(consumed), 1):
|
||||
return
|
||||
chk.check(f"{label}: consumed {expected_packs} base units",
|
||||
round(float(consumed[0]["qty"]), 4), expected_packs)
|
||||
|
||||
rows = c.get(f"/stock/ledger?sourceDocType=PRDI&sourceDocId={run['runId']}&pageSize=50").body["items"]
|
||||
chk.check(f"{label}: one PRDI ledger row", len(rows), 1)
|
||||
if rows:
|
||||
chk.check(f"{label}: ledger qtyBase matches the consumption",
|
||||
round(abs(float(rows[0]["qtyBase"])), 4), expected_packs)
|
||||
|
||||
# Cancel so the script is re-runnable. A run left InProgress edit-locks its template
|
||||
# (FR-MFG-06), so the next execution could not re-save it and would fail with a 409 that
|
||||
# says nothing about content units. Cancelling also returns the consumed stock (FR-MFG-17),
|
||||
# which keeps the seeded on-hand stable across runs.
|
||||
c.post(f"/production-runs/{run['runId']}/cancel",
|
||||
{"reasonCodeId": production_reason(c, "PRD-CANCEL"), "note": "m4c cleanup"})
|
||||
|
||||
|
||||
def cancel_stale_runs(c):
|
||||
"""
|
||||
Cancel any InProgress run this script left behind previously.
|
||||
|
||||
Self-healing rather than merely tidy: a live run edit-locks its template, so without this
|
||||
a re-run (or an earlier interrupted run) fails at template save with 409 TEMPLATE_IN_USE —
|
||||
a failure that looks like a content-unit bug and is not one.
|
||||
"""
|
||||
ours = {t["templateId"] for t in c.get(f"/production-templates?q={TEMPLATE_CODE}&pageSize=50").body["items"]
|
||||
if t["code"].startswith(TEMPLATE_CODE)}
|
||||
if not ours:
|
||||
return
|
||||
stale = [r for r in c.get("/production-runs?status=InProgress&pageSize=200").body["items"]
|
||||
if r["templateId"] in ours]
|
||||
for r in stale:
|
||||
c.post(f"/production-runs/{r['runId']}/cancel",
|
||||
{"reasonCodeId": production_reason(c, "PRD-CANCEL"), "note": "m4c stale cleanup"})
|
||||
if stale:
|
||||
print(f"cancelled {len(stale)} stale run(s) from a previous execution")
|
||||
|
||||
|
||||
def main():
|
||||
c, chk, args = bootstrap(__doc__)
|
||||
print(f"API {args.api}")
|
||||
cancel_stale_runs(c)
|
||||
|
||||
bottle = ensure_item(c, SKU, "Smoke syrup 500ml bottle", CONTENT_QTY, "Ml")
|
||||
plain = ensure_item(c, PLAIN_SKU, "Smoke item with no content", None, None)
|
||||
finished = c.get("/items?pageSize=5&status=Active").body["items"][0]
|
||||
print(f"bottle={bottle['itemId']} contentBaseQty={bottle.get('contentBaseQty')} "
|
||||
f"plain={plain['itemId']} finished={finished['itemId']}")
|
||||
|
||||
chk.section("0. Content size normalises to a base unit on the item")
|
||||
chk.check("contentBaseQty is the entered ml", float(bottle["contentBaseQty"]), float(CONTENT_QTY))
|
||||
chk.check("contentBaseUnit is Ml", bottle["contentBaseUnit"], "Ml")
|
||||
|
||||
wh = ensure_warehouse(c)
|
||||
drain_stock(c, wh)
|
||||
seed_costed_stock(c, wh, [(bottle["itemId"], SEED_BOTTLES, UNIT_COST)])
|
||||
|
||||
# ------------------------------------------------- whole packs out of content units
|
||||
chk.section("1. A content quantity resolves to whole packs (2000 ml / 500 ml = 4)")
|
||||
saved = save_template(c, bottle["itemId"], finished["itemId"], 2000, "Content", "whole")
|
||||
if saved_ok(chk, "save the Content template", saved):
|
||||
consume_once(c, chk, saved.body["templateId"], wh, 1, "2000 ml", 4.0)
|
||||
|
||||
# --------------------------------------------------------------- fractional packs
|
||||
chk.section("2. A content quantity below one pack consumes a FRACTION of one (300 ml = 0.6)")
|
||||
saved = save_template(c, bottle["itemId"], finished["itemId"], 300, "Content", "frac")
|
||||
if saved_ok(chk, "save the fractional Content template", saved):
|
||||
consume_once(c, chk, saved.body["templateId"], wh, 1, "300 ml", 0.6)
|
||||
|
||||
# ------------------------------------------------------------------ the guard rail
|
||||
chk.section("3. Content units are refused on an item that has no content size")
|
||||
refused = save_template(c, plain["itemId"], finished["itemId"], 100, "Content", "nocontent")
|
||||
chk.status("Content input on a contentless item", refused, 422)
|
||||
|
||||
chk.section("4. The same item still works when the formula is written in packs")
|
||||
saved = save_template(c, bottle["itemId"], finished["itemId"], 3, "Pack", "pack")
|
||||
if saved_ok(chk, "save the Pack template", saved):
|
||||
consume_once(c, chk, saved.body["templateId"], wh, 1, "3 bottles", 3.0)
|
||||
|
||||
return chk.finish("M4c")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -157,18 +157,18 @@ def main():
|
||||
if i["itemId"] == raw)
|
||||
finished_b = state["finishedItemId"]
|
||||
|
||||
# A fresh single-stage template: base-UOM input so no conversion muddies the arithmetic,
|
||||
# qtyPerBatch 3.5 so the consumed quantity is NOT a multiple of the target (which is what
|
||||
# A fresh single-stage template: a pack-counted input, so no content division muddies
|
||||
# the arithmetic; qtyPerBatch 3.5 so consumed is NOT a multiple of the target (which is what
|
||||
# forces pool / goodQty to repeat).
|
||||
payload_b = {
|
||||
"code": BIG_TEMPLATE_CODE, "name": "Smoke M5 rounding line",
|
||||
"stages": [{
|
||||
"key": "tmp-b", "name": "Mix", "estimatedMinutes": 5, "posX": 0, "posY": 0,
|
||||
"fieldDefs": [],
|
||||
"inputs": [{"source": "Stock", "itemId": raw, "uomId": raw_uom,
|
||||
"inputs": [{"source": "Stock", "itemId": raw,
|
||||
"qtyPerBatch": BIG_QTY_PER_BATCH}],
|
||||
"outputs": [{"key": "tmp-bo", "name": "Mixed", "itemId": finished_b,
|
||||
"uomId": raw_uom, "qtyPerBatch": 1}],
|
||||
"qtyPerBatch": 1}],
|
||||
}],
|
||||
"edges": [],
|
||||
}
|
||||
@@ -190,7 +190,7 @@ def main():
|
||||
# and the pool would not match the figures this section reasons about — the assertions
|
||||
# would still "pass" while testing something else entirely.
|
||||
drain_stock(c, wh)
|
||||
seed_costed_stock(c, wh, [(raw, raw_uom, 5000, BIG_UNIT_COST)])
|
||||
seed_costed_stock(c, wh, [(raw, 5000, BIG_UNIT_COST)])
|
||||
|
||||
big = c.post("/production-runs", {"templateId": tid, "targetQty": BIG_TARGET, "warehouseId": wh})
|
||||
if big.status != 201:
|
||||
@@ -243,7 +243,7 @@ def main():
|
||||
"key": "tmp-one", "name": "Make", "estimatedMinutes": 1, "posX": 0, "posY": 0,
|
||||
"fieldDefs": [], "inputs": [],
|
||||
"outputs": [{"key": "tmp-o", "name": "Tracked", "itemId": tracked["itemId"],
|
||||
"uomId": tracked["baseUomId"], "qtyPerBatch": 1}],
|
||||
"qtyPerBatch": 1}],
|
||||
}],
|
||||
"edges": [],
|
||||
}
|
||||
|
||||
@@ -54,13 +54,13 @@ def ensure_template(c, raw, finished, uom):
|
||||
"stages": [
|
||||
{"key": "tmp-cut", "name": "Cut", "estimatedMinutes": 10, "posX": 0, "posY": 0,
|
||||
"fieldDefs": [],
|
||||
"inputs": [{"source": "Stock", "itemId": raw, "uomId": uom, "qtyPerBatch": RAW_QPB}],
|
||||
"inputs": [{"source": "Stock", "itemId": raw, "qtyPerBatch": RAW_QPB}],
|
||||
"outputs": [{"key": "tmp-f", "name": "Frame", "uomId": uom, "qtyPerBatch": 1}]},
|
||||
{"key": "tmp-asm", "name": "Assemble", "estimatedMinutes": 10, "posX": 400, "posY": 0,
|
||||
"fieldDefs": [],
|
||||
"inputs": [{"source": "Upstream", "fromOutputKey": "tmp-f", "uomId": uom, "qtyPerBatch": 1}],
|
||||
"inputs": [{"source": "Upstream", "fromOutputKey": "tmp-f", "qtyPerBatch": 1}],
|
||||
"outputs": [{"key": "tmp-c", "name": "Chair", "itemId": finished,
|
||||
"uomId": uom, "qtyPerBatch": 1}]},
|
||||
"qtyPerBatch": 1}]},
|
||||
],
|
||||
"edges": [{"parentKey": "tmp-cut", "childKey": "tmp-asm"}],
|
||||
}
|
||||
@@ -101,7 +101,7 @@ def main():
|
||||
uom = items[0]["baseUomId"]
|
||||
|
||||
drain_stock(c, wh)
|
||||
seed_costed_stock(c, wh, [(raw, uom, SEED, UNIT_COST)])
|
||||
seed_costed_stock(c, wh, [(raw, SEED, UNIT_COST)])
|
||||
tid = ensure_template(c, raw, finished, uom)
|
||||
consumed_units = RAW_QPB * TARGET # 50
|
||||
consumed_value = consumed_units * UNIT_COST # 150.00
|
||||
|
||||
@@ -22,7 +22,7 @@ SCRIPTS = [
|
||||
("M2 templates + graph validation", "m2_templates.py"),
|
||||
("M3 run creation / board / quantities", "m3_runs.py"),
|
||||
("M4 stage start / complete / approve / transfer", "m4_stage_actions.py"),
|
||||
("M4b UOM conversion on stock inputs", "m4b_uom_conversion.py"),
|
||||
("M4c content-unit stage inputs + fractional packs", "m4c_content_units.py"),
|
||||
("M5 terminal receipt + cost pool", "m5_receipt.py"),
|
||||
("M6+M7 leftover / rework / cancel", "m6_m7_leftover_rework_cancel.py"),
|
||||
]
|
||||
|
||||
@@ -240,15 +240,16 @@ def seed_costed_stock(c, warehouse_id: int, lines, vendor_id: int | None = None)
|
||||
"""
|
||||
Create on-hand at explicit unit costs via a direct GRN + confirm.
|
||||
|
||||
`lines` is an iterable of (item_id, uom_id, qty, unit_cost).
|
||||
`lines` is an iterable of (item_id, qty, unit_cost). Quantities are counts of the item's
|
||||
base UOM — document lines carry no unit of their own.
|
||||
"""
|
||||
vendor_id = vendor_id or ensure_vendor(c)
|
||||
grn = c.post("/grns", {
|
||||
"vendorId": vendor_id,
|
||||
"warehouseId": warehouse_id,
|
||||
"lines": [
|
||||
{"itemId": i, "uomId": u, "qty": q, "unitCost": cost, "discountPct": 0, "vatPct": 0}
|
||||
for (i, u, q, cost) in lines
|
||||
{"itemId": i, "qty": q, "unitCost": cost, "discountPct": 0, "vatPct": 0}
|
||||
for (i, q, cost) in lines
|
||||
],
|
||||
})
|
||||
if grn.status != 201:
|
||||
|
||||
@@ -8,6 +8,7 @@ import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validatePoLine } from "@/lib/validations/procurement"
|
||||
@@ -28,7 +29,6 @@ interface DraftLine {
|
||||
key: string
|
||||
poLineId: number | null
|
||||
itemId: number | null
|
||||
uomId: number | null
|
||||
warehouseId: number | null
|
||||
qty: string
|
||||
unitPrice: string
|
||||
@@ -72,7 +72,6 @@ export default function PurchaseOrderDetailPage() {
|
||||
key: newKey(),
|
||||
poLineId: l.poLineId,
|
||||
itemId: l.itemId,
|
||||
uomId: l.uomId,
|
||||
warehouseId: l.warehouseId,
|
||||
qty: String(l.qty),
|
||||
unitPrice: String(l.unitPrice),
|
||||
@@ -111,9 +110,6 @@ 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 warehouseCode(warehouseId: number) {
|
||||
return warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? `#${warehouseId}`
|
||||
}
|
||||
@@ -141,7 +137,6 @@ export default function PurchaseOrderDetailPage() {
|
||||
for (const line of lines) {
|
||||
const errors = validatePoLine({
|
||||
itemId: line.itemId,
|
||||
uomId: line.uomId,
|
||||
warehouseId: line.warehouseId,
|
||||
qty: line.qty,
|
||||
unitPrice: line.unitPrice,
|
||||
@@ -157,7 +152,6 @@ export default function PurchaseOrderDetailPage() {
|
||||
|
||||
const payloadLines: CreatePoLineInput[] = lines.map((l) => ({
|
||||
itemId: l.itemId as number,
|
||||
uomId: l.uomId as number,
|
||||
warehouseId: l.warehouseId as number,
|
||||
qty: Number(l.qty),
|
||||
unitPrice: Number(l.unitPrice),
|
||||
@@ -284,7 +278,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" />
|
||||
@@ -347,7 +341,7 @@ export default function PurchaseOrderDetailPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
{editable && (
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, { key: newKey(), poLineId: null, itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18", qtyReceived: 0 }])}>
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, { key: newKey(), poLineId: null, itemId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18", qtyReceived: 0 }])}>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
@@ -376,7 +370,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">{baseUomLabel(items, uoms, line.itemId)}</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>
|
||||
@@ -403,19 +397,9 @@ export default function PurchaseOrderDetailPage() {
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<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]} />
|
||||
<div className="flex h-11 items-center text-base text-muted-foreground">
|
||||
{baseUomLabel(items, uoms, line.itemId)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validatePoLine } from "@/lib/validations/procurement"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -39,7 +40,6 @@ import { toast } from "@/components/ui/toast"
|
||||
interface DraftLine {
|
||||
key: string
|
||||
itemId: number | null
|
||||
uomId: number | null
|
||||
warehouseId: number | null
|
||||
qty: string
|
||||
unitPrice: string
|
||||
@@ -57,7 +57,7 @@ function newKey() {
|
||||
// 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" }
|
||||
return { key: newKey(), itemId: null, warehouseId: null, qty: "", unitPrice: "0", tax: "0" }
|
||||
}
|
||||
|
||||
function NewPurchaseOrderContent() {
|
||||
@@ -169,7 +169,6 @@ function NewPurchaseOrderContent() {
|
||||
(l): DraftLine => ({
|
||||
key: newKey(),
|
||||
itemId: l.itemId,
|
||||
uomId: null,
|
||||
warehouseId: null,
|
||||
qty: String(l.qty),
|
||||
unitPrice: "0",
|
||||
@@ -195,7 +194,6 @@ function NewPurchaseOrderContent() {
|
||||
return {
|
||||
key: newKey(),
|
||||
itemId: l.itemId,
|
||||
uomId: null,
|
||||
warehouseId: null,
|
||||
qty: String(l.qty),
|
||||
unitPrice: cell ? String(cell.unitPrice) : "0",
|
||||
@@ -242,7 +240,6 @@ function NewPurchaseOrderContent() {
|
||||
for (const line of lines) {
|
||||
const errors = validatePoLine({
|
||||
itemId: line.itemId,
|
||||
uomId: line.uomId,
|
||||
warehouseId: line.warehouseId,
|
||||
qty: line.qty,
|
||||
unitPrice: line.unitPrice,
|
||||
@@ -258,7 +255,6 @@ function NewPurchaseOrderContent() {
|
||||
|
||||
const payloadLines: CreatePoLineInput[] = lines.map((l) => ({
|
||||
itemId: l.itemId as number,
|
||||
uomId: l.uomId as number,
|
||||
warehouseId: l.warehouseId as number,
|
||||
qty: Number(l.qty),
|
||||
unitPrice: Number(l.unitPrice),
|
||||
@@ -449,19 +445,9 @@ function NewPurchaseOrderContent() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<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]} />
|
||||
<div className="flex h-11 items-center text-base text-muted-foreground">
|
||||
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
TransferLine,
|
||||
} from "@/types/production"
|
||||
import { ItemListItem, Uom } from "@/types/master-data"
|
||||
import { baseUomLabel, contentUnitLabel, uomLabel } from "@/lib/uom-label"
|
||||
import { ReasonCode } from "@/types/stock"
|
||||
|
||||
import { AlertDialog, AlertDialogContent } from "@/components/ui/alert-dialog"
|
||||
@@ -154,10 +155,24 @@ export function StageDrawer({
|
||||
return (id: number | null) => (id === null ? "—" : byId.get(id) ?? `Item #${id}`)
|
||||
}, [items])
|
||||
|
||||
const uomName = useMemo(() => {
|
||||
const byId = new Map(uoms.map((u) => [u.uomId, u.name]))
|
||||
return (id: number) => byId.get(id) ?? `#${id}`
|
||||
}, [uoms])
|
||||
// Nothing on a run line carries a unit of its own any more. An output that references a
|
||||
// real item shows that item's base UOM; only intermediate WIP falls back to its own label.
|
||||
const outputUnit = useMemo(
|
||||
() => (output: RunStageOutput) =>
|
||||
output.itemId !== null ? baseUomLabel(items, uoms, output.itemId) : uomLabel(uoms, output.uomId),
|
||||
[items, uoms],
|
||||
)
|
||||
|
||||
// An input's unit follows how its quantity was expressed: ml/g for a content formula,
|
||||
// otherwise a count of the item's packs. Upstream inputs are WIP from a parent stage.
|
||||
const inputUnit = useMemo(
|
||||
() => (input: RunStageInput) => {
|
||||
if (input.source === "Upstream") return "WIP"
|
||||
if (input.qtyUnit === "Content") return contentUnitLabel(items, input.itemId) ?? "—"
|
||||
return baseUomLabel(items, uoms, input.itemId)
|
||||
},
|
||||
[items, uoms],
|
||||
)
|
||||
|
||||
const stageName = useMemo(() => {
|
||||
const byId = new Map(run.stages.map((s) => [s.runStageId, s.name]))
|
||||
@@ -323,7 +338,7 @@ export function StageDrawer({
|
||||
value={plannedInputs[input.runInputId] ?? String(input.plannedQty)}
|
||||
onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))}
|
||||
itemName={itemName}
|
||||
uomName={uomName}
|
||||
unitLabel={inputUnit}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
@@ -337,7 +352,7 @@ export function StageDrawer({
|
||||
<div key={output.runOutputId} className="rounded-lg border border-border p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="min-w-0 truncate text-sm font-medium text-foreground">{output.name}</p>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{uomName(output.uomId)}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{outputUnit(output)}</span>
|
||||
</div>
|
||||
{output.itemId !== null && (
|
||||
<p className="text-xs text-muted-foreground">Finished good: {itemName(output.itemId)}</p>
|
||||
@@ -522,7 +537,7 @@ export function StageDrawer({
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-bold text-foreground">Finish the run</h3>
|
||||
<div className="flex flex-col gap-1 rounded-lg border border-info/30 bg-info/5 p-3">
|
||||
<Row label="Good quantity">{fmt(goodQty)} {uomName(terminalOutput.uomId)}</Row>
|
||||
<Row label="Good quantity">{fmt(goodQty)} {outputUnit(terminalOutput)}</Row>
|
||||
<Row label="Materials consumed">{money(run.costPool.consumed)}</Row>
|
||||
<Row label="Leftovers returned">−{money(run.costPool.returned)}</Row>
|
||||
<Separator className="my-1" />
|
||||
@@ -653,14 +668,14 @@ function InputCard({
|
||||
value,
|
||||
onValueChange,
|
||||
itemName,
|
||||
uomName,
|
||||
unitLabel,
|
||||
}: {
|
||||
input: RunStageInput
|
||||
editable: boolean
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
itemName: (id: number | null) => string
|
||||
uomName: (id: number) => string
|
||||
unitLabel: (input: RunStageInput) => string
|
||||
}) {
|
||||
const isUpstream = input.source === "Upstream"
|
||||
const short = isUpstream && input.deliveredQty < input.plannedQty
|
||||
@@ -671,7 +686,7 @@ function InputCard({
|
||||
<p className="min-w-0 truncate text-sm font-medium text-foreground">
|
||||
{isUpstream ? "Upstream work in progress" : itemName(input.itemId)}
|
||||
</p>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{uomName(input.uomId)}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{unitLabel(input)}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
@@ -698,9 +713,10 @@ function InputCard({
|
||||
)}
|
||||
|
||||
{/*
|
||||
Consumed/returned figures are in the item's BASE uom, while `plannedQty` above is in the
|
||||
input's declared uom — an input declared in "box of 12" shows planned 3 and consumed 36.
|
||||
Labelled explicitly so the two are never read as the same unit.
|
||||
Consumed/returned figures are always a count of the item's packs, while `plannedQty`
|
||||
above is in the input's declared unit — a formula written as 2000 ml against a 500 ml
|
||||
bottle shows planned 2000 and consumed 4. Labelled explicitly so the two are never
|
||||
read as the same unit.
|
||||
*/}
|
||||
{input.consumedQty > 0 && (
|
||||
<>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CustomFieldType, StageInputSource } from "@/types/production"
|
||||
import { baseUomLabel, contentUnitLabel } from "@/lib/uom-label"
|
||||
import { CustomFieldType, StageInputSource, StageQtyUnit } from "@/types/production"
|
||||
import { ItemListItem, Uom } from "@/types/master-data"
|
||||
import {
|
||||
BuilderFieldDef,
|
||||
@@ -35,21 +37,24 @@ export interface UpstreamOutputOption {
|
||||
outputName: string
|
||||
}
|
||||
|
||||
/** One row per input/output quantity — UOM select plus qty, used three times below. */
|
||||
/**
|
||||
* One quantity row: the number, plus whatever names its unit.
|
||||
*
|
||||
* The unit is no longer a free choice. An input's is decided by its item (and, when that item
|
||||
* has a content size, by the Pack/Content toggle); an output's comes from its item, or from a
|
||||
* WIP label when it has none. So each caller supplies its own `unit` control and this row only
|
||||
* owns the number.
|
||||
*/
|
||||
function QtyRow({
|
||||
qty,
|
||||
uomId,
|
||||
uoms,
|
||||
readOnly,
|
||||
onQtyChange,
|
||||
onUomChange,
|
||||
unit,
|
||||
}: {
|
||||
qty: number
|
||||
uomId: number | null
|
||||
uoms: Uom[]
|
||||
readOnly: boolean
|
||||
onQtyChange: (qty: number) => void
|
||||
onUomChange: (uomId: number) => void
|
||||
unit: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -64,22 +69,59 @@ function QtyRow({
|
||||
placeholder="Qty per batch"
|
||||
aria-label="Quantity per batch"
|
||||
/>
|
||||
<Select<number> value={uomId} onValueChange={(v) => v && onUomChange(v)}>
|
||||
<SelectTrigger className="h-8! w-24 shrink-0 text-sm" disabled={readOnly} aria-label="UOM">
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-sm">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{unit}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Static unit name, for the rows whose unit is derived and therefore not editable. */
|
||||
function UnitLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="flex h-8 w-24 shrink-0 items-center text-sm text-muted-foreground">{children}</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the unit of a stage input's quantity.
|
||||
*
|
||||
* Only one case is a choice: a Stock input whose item declares a content size can be written
|
||||
* either as an amount of that content (2000 ml) or as a pack count (4 bottles). Everything
|
||||
* else has exactly one possible unit, so it renders as a label rather than a control.
|
||||
*/
|
||||
function InputUnitControl({
|
||||
input,
|
||||
items,
|
||||
uoms,
|
||||
readOnly,
|
||||
onChange,
|
||||
}: {
|
||||
input: BuilderInput
|
||||
items: ItemListItem[]
|
||||
uoms: Uom[]
|
||||
readOnly: boolean
|
||||
onChange: (unit: StageQtyUnit) => void
|
||||
}) {
|
||||
if (input.source === "Upstream") return <UnitLabel>WIP</UnitLabel>
|
||||
|
||||
const item = items.find((candidate) => candidate.itemId === input.itemId)
|
||||
const packName = baseUomLabel(items, uoms, input.itemId)
|
||||
const contentName = contentUnitLabel(items, input.itemId)
|
||||
|
||||
if (!item?.contentBaseQty || !contentName) return <UnitLabel>{packName}</UnitLabel>
|
||||
|
||||
return (
|
||||
<Select<string> value={input.qtyUnit} onValueChange={(v) => v && onChange(v as StageQtyUnit)}>
|
||||
<SelectTrigger className="h-8! w-24 shrink-0 text-sm" disabled={readOnly} aria-label="Quantity unit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Content" className="text-sm">{contentName}</SelectItem>
|
||||
<SelectItem value="Pack" className="text-sm">{packName}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export function StageEditorPanel({
|
||||
data,
|
||||
isTerminal,
|
||||
@@ -108,7 +150,7 @@ export function StageEditorPanel({
|
||||
onChange({
|
||||
inputs: [
|
||||
...data.inputs,
|
||||
{ localId: newLocalId(), source: "Stock", itemId: null, fromOutputKey: null, uomId: null, qtyPerBatch: 1 },
|
||||
{ localId: newLocalId(), source: "Stock", itemId: null, fromOutputKey: null, qtyUnit: "Pack", qtyPerBatch: 1 },
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -122,13 +164,20 @@ export function StageEditorPanel({
|
||||
* the validator rejects an input that carries both.
|
||||
*/
|
||||
function changeInputSource(localId: string, source: StageInputSource) {
|
||||
updateInput(localId, source === "Stock" ? { source, fromOutputKey: null } : { source, itemId: null })
|
||||
updateInput(
|
||||
localId,
|
||||
// WIP has no content size, so an Upstream input can only ever be counted in whole units.
|
||||
source === "Stock" ? { source, fromOutputKey: null } : { source, itemId: null, qtyUnit: "Pack" },
|
||||
)
|
||||
}
|
||||
|
||||
/** Default the UOM to the item's base unit — right most of the time, still overridable. */
|
||||
/**
|
||||
* Default to content units when the item has a content size — a recipe is far more often
|
||||
* written as "2000 ml of syrup" than "4 bottles" — and to packs otherwise. Still switchable.
|
||||
*/
|
||||
function pickInputItem(input: BuilderInput, itemId: number) {
|
||||
const item = items.find((i) => i.itemId === itemId)
|
||||
updateInput(input.localId, { itemId, uomId: input.uomId ?? item?.baseUomId ?? null })
|
||||
updateInput(input.localId, { itemId, qtyUnit: item?.contentBaseQty ? "Content" : "Pack" })
|
||||
}
|
||||
|
||||
function updateOutput(key: string, patch: Partial<BuilderOutput>) {
|
||||
@@ -287,11 +336,15 @@ export function StageEditorPanel({
|
||||
|
||||
<QtyRow
|
||||
qty={input.qtyPerBatch}
|
||||
uomId={input.uomId}
|
||||
uoms={uoms}
|
||||
readOnly={readOnly}
|
||||
onQtyChange={(qtyPerBatch) => updateInput(input.localId, { qtyPerBatch })}
|
||||
onUomChange={(uomId) => updateInput(input.localId, { uomId })}
|
||||
unit={<InputUnitControl
|
||||
input={input}
|
||||
items={items}
|
||||
uoms={uoms}
|
||||
readOnly={readOnly}
|
||||
onChange={(qtyUnit) => updateInput(input.localId, { qtyUnit })}
|
||||
/>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -347,11 +400,27 @@ export function StageEditorPanel({
|
||||
</div>
|
||||
<QtyRow
|
||||
qty={output.qtyPerBatch}
|
||||
uomId={output.uomId}
|
||||
uoms={uoms}
|
||||
readOnly={readOnly}
|
||||
onQtyChange={(qtyPerBatch) => updateOutput(output.key, { qtyPerBatch })}
|
||||
onUomChange={(uomId) => updateOutput(output.key, { uomId })}
|
||||
unit={
|
||||
// A finished good is counted in its item's own unit; only WIP names one.
|
||||
output.itemId !== null ? (
|
||||
<UnitLabel>{baseUomLabel(items, uoms, output.itemId)}</UnitLabel>
|
||||
) : (
|
||||
<Select<number> value={output.uomId} onValueChange={(v) => v && updateOutput(output.key, { uomId: v })}>
|
||||
<SelectTrigger className="h-8! w-24 shrink-0 text-sm" disabled={readOnly} aria-label="WIP unit">
|
||||
<SelectValue placeholder="Unit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-sm">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -77,7 +77,7 @@ function graphToFlow(graph: ProductionTemplateGraph): { nodes: Node[]; edges: Ed
|
||||
source: i.source,
|
||||
itemId: i.itemId,
|
||||
fromOutputKey: i.fromOutputKey,
|
||||
uomId: i.uomId,
|
||||
qtyUnit: i.qtyUnit,
|
||||
qtyPerBatch: i.qtyPerBatch,
|
||||
})),
|
||||
outputs: s.outputs.map((o) => ({
|
||||
@@ -414,14 +414,21 @@ function TemplateBuilderContent() {
|
||||
const where = `Input ${i + 1} of "${label}"`
|
||||
if (input.source === "Stock" && input.itemId === null) list.push(`${where} needs an item.`)
|
||||
if (input.source === "Upstream" && !input.fromOutputKey) list.push(`${where} needs an upstream output.`)
|
||||
if (input.uomId === null) list.push(`${where} needs a UOM.`)
|
||||
// Content quantities divide by the item's content size, so the item must declare one.
|
||||
if (input.qtyUnit === "Content") {
|
||||
const item = items.find((candidate) => candidate.itemId === input.itemId)
|
||||
if (item && !item.contentBaseQty) {
|
||||
list.push(`${where} is in content units, but ${item.sku} has no content size.`)
|
||||
}
|
||||
}
|
||||
if (input.qtyPerBatch <= 0) list.push(`${where} needs a quantity greater than zero.`)
|
||||
})
|
||||
|
||||
data.outputs.forEach((output, i) => {
|
||||
const where = `Output ${i + 1} of "${label}"`
|
||||
if (!isTerminal && !output.name.trim()) list.push(`${where} needs a name.`)
|
||||
if (output.uomId === null) list.push(`${where} needs a UOM.`)
|
||||
// Only work-in-progress declares its own unit; a finished good takes its item's.
|
||||
if (!isTerminal && output.uomId === null) list.push(`${where} needs a UOM.`)
|
||||
if (output.qtyPerBatch <= 0) list.push(`${where} needs a quantity greater than zero.`)
|
||||
})
|
||||
|
||||
@@ -502,7 +509,7 @@ function TemplateBuilderContent() {
|
||||
source: i.source,
|
||||
itemId: i.source === "Stock" ? i.itemId : null,
|
||||
fromOutputKey: i.source === "Upstream" ? i.fromOutputKey : null,
|
||||
uomId: i.uomId!,
|
||||
qtyUnit: i.source === "Upstream" ? "Pack" : i.qtyUnit,
|
||||
qtyPerBatch: i.qtyPerBatch,
|
||||
})),
|
||||
// Only the terminal stage's output may name an item (FR-MFG-05). A stage that *was*
|
||||
@@ -513,7 +520,8 @@ function TemplateBuilderContent() {
|
||||
key: o.key,
|
||||
itemId: isTerminal ? o.itemId : null,
|
||||
name: o.name.trim(),
|
||||
uomId: o.uomId!,
|
||||
// An item-bearing output takes its unit from the item, so it must send none.
|
||||
uomId: isTerminal ? null : o.uomId,
|
||||
qtyPerBatch: o.qtyPerBatch,
|
||||
})),
|
||||
}
|
||||
|
||||
@@ -7,15 +7,15 @@
|
||||
// the contract (only outputs do, because Upstream inputs reference them by key). Rendering
|
||||
// them by array index would make React reuse the wrong <input> when a row is removed, so
|
||||
// every editable row carries a throwaway `localId` that is stripped on save.
|
||||
// * **Half-filled rows.** `uomId` is `number | null` here but `number` on the wire: a row the
|
||||
// user just added has nothing picked yet. `page.tsx` blocks the save until every one is set,
|
||||
// which is what makes the non-null assertions in its payload builder sound.
|
||||
// * **Half-filled rows.** A row the user just added has nothing picked yet, so `itemId` and a
|
||||
// WIP output's `uomId` are nullable here. `page.tsx` blocks the save until the required ones
|
||||
// are set, which is what makes the non-null assertions in its payload builder sound.
|
||||
//
|
||||
// A stage's identity IS its React Flow node id, which is its server key — the stringified
|
||||
// stage id, or `tmp-<uuid>` for a stage drawn in this session. That is why edges need no
|
||||
// translation on save: `edge.source`/`edge.target` are already `parentKey`/`childKey`.
|
||||
|
||||
import { CustomFieldType, StageInputSource } from "@/types/production"
|
||||
import { CustomFieldType, StageInputSource, StageQtyUnit } from "@/types/production"
|
||||
|
||||
/** `tmp-` prefixed so the server can tell a newly drawn stage/output from one it already has. */
|
||||
export function newKey(): string {
|
||||
@@ -34,7 +34,11 @@ export interface BuilderInput {
|
||||
itemId: number | null
|
||||
/** Upstream inputs only — an output key belonging to a *direct* parent stage. */
|
||||
fromOutputKey: string | null
|
||||
uomId: number | null
|
||||
/**
|
||||
* Whether `qtyPerBatch` is a pack count or an amount of the item's content (ml/g).
|
||||
* `Content` is only offered for a Stock input whose item declares a content size.
|
||||
*/
|
||||
qtyUnit: StageQtyUnit
|
||||
qtyPerBatch: number
|
||||
}
|
||||
|
||||
@@ -44,6 +48,7 @@ export interface BuilderOutput {
|
||||
/** Terminal stage only — the finished good. Must stay null on WIP outputs (FR-MFG-05). */
|
||||
itemId: number | null
|
||||
name: string
|
||||
/** WIP label — required when `itemId` is null, and must stay null when it is set. */
|
||||
uomId: number | null
|
||||
qtyPerBatch: number
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { validateItemForm } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Item, StockNature, TrackingMode } from "@/types/master-data"
|
||||
import { Item, MeasureUnit, StockNature, TrackingMode } from "@/types/master-data"
|
||||
|
||||
/** Entry units. Only ml/g are stored — the server normalises L and Kg ×1000 on write. */
|
||||
const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"]
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
@@ -52,6 +55,9 @@ export default function ItemDetailPage() {
|
||||
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
|
||||
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
|
||||
const [taxClass, setTaxClass] = useState("")
|
||||
// Raw string: an empty box means "no content size", which is not the same as 0.
|
||||
const [contentQty, setContentQty] = useState("")
|
||||
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
|
||||
// Frontend-only: there's no warehouse field anywhere on the Item contract, so this
|
||||
// isn't sent on save — nothing to wire it to server-side.
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
@@ -75,6 +81,8 @@ export default function ItemDetailPage() {
|
||||
setStockNature(data.stockNature)
|
||||
setTrackingMode(data.trackingMode)
|
||||
setTaxClass(data.taxClass ?? "")
|
||||
setContentQty(data.contentQty === null ? "" : String(data.contentQty))
|
||||
setContentUnit(data.contentUnit)
|
||||
}
|
||||
|
||||
function load() {
|
||||
@@ -105,7 +113,7 @@ export default function ItemDetailPage() {
|
||||
async function handleSave() {
|
||||
if (!item || !etag) return
|
||||
setSaveError(null)
|
||||
const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId })
|
||||
const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId, contentQty, contentUnit })
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
@@ -117,6 +125,8 @@ export default function ItemDetailPage() {
|
||||
sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId,
|
||||
baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode,
|
||||
taxClass: taxClass || null,
|
||||
contentQty: contentQty.trim() ? Number(contentQty) : null,
|
||||
contentUnit: contentQty.trim() ? contentUnit : null,
|
||||
},
|
||||
etag
|
||||
)
|
||||
@@ -273,6 +283,53 @@ export default function ItemDetailPage() {
|
||||
</Select>
|
||||
<FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
|
||||
</div>
|
||||
{/* Content size (FR-MD-02): how much one pack holds. Optional, and independent of the
|
||||
base UOM above — stock is counted in packs either way. */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Content size</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={contentQty}
|
||||
disabled={conflict}
|
||||
onChange={(e) => setContentQty(e.target.value)}
|
||||
placeholder="e.g. 500"
|
||||
aria-invalid={!!errors.contentQty}
|
||||
className="h-12! text-base"
|
||||
/>
|
||||
<Select<MeasureUnit | null>
|
||||
value={contentUnit}
|
||||
onValueChange={setContentUnit}
|
||||
disabled={conflict}
|
||||
items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-28 shrink-0 text-base" aria-invalid={!!errors.contentUnit}>
|
||||
<SelectValue placeholder="Unit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTENT_UNITS.map((u) => (
|
||||
<SelectItem key={u} value={u} className="text-base">
|
||||
{u.toLowerCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<FieldError
|
||||
errors={[
|
||||
errors.contentQty ? { message: errors.contentQty } : undefined,
|
||||
errors.contentUnit ? { message: errors.contentUnit } : undefined,
|
||||
]}
|
||||
/>
|
||||
{/* The stored value, so the ×1000 normalisation is never a surprise. */}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.contentBaseQty !== null && item.contentBaseUnit
|
||||
? `Stored as ${item.contentBaseQty} ${item.contentBaseUnit.toLowerCase()} per ${uomName(item.baseUomId)}.`
|
||||
: "No content size — leave blank for items with nothing measurable to hold."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* "Item type" now means a Color/Size dimension master — this field is the
|
||||
stock-nature one it used to be confused with (docs/11 §8). */}
|
||||
@@ -321,7 +378,8 @@ 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)} is the base UOM — every transaction records quantities as a count of it (FR-MD-03).
|
||||
There are no conversions: a differently sized pack is a different item.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ItemType } from "@/types/master-data"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
@@ -33,6 +34,7 @@ export default function ItemTypesPage() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<ItemType | null>(null)
|
||||
const [name, setName] = useState("")
|
||||
const [isMeasurable, setIsMeasurable] = useState(false)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||
@@ -50,6 +52,7 @@ export default function ItemTypesPage() {
|
||||
function openCreateDialog() {
|
||||
setEditing(null)
|
||||
setName("")
|
||||
setIsMeasurable(false)
|
||||
setErrors({})
|
||||
setOpen(true)
|
||||
}
|
||||
@@ -57,6 +60,7 @@ export default function ItemTypesPage() {
|
||||
function openEditDialog(itemType: ItemType) {
|
||||
setEditing(itemType)
|
||||
setName(itemType.name)
|
||||
setIsMeasurable(itemType.isMeasurable)
|
||||
setErrors({})
|
||||
setOpen(true)
|
||||
}
|
||||
@@ -71,13 +75,16 @@ export default function ItemTypesPage() {
|
||||
if (editing) {
|
||||
// Re-read for a fresh If-Match; a concurrent edit surfaces as 412.
|
||||
const current = await itemTypesApi.get(editing.itemTypeId)
|
||||
await itemTypesApi.update(editing.itemTypeId, { name }, current.etag ?? "")
|
||||
// isMeasurable must travel on every PUT: the server preserves it when omitted, so a
|
||||
// name-only body would leave the switch the user just flipped unsaved.
|
||||
await itemTypesApi.update(editing.itemTypeId, { name, isMeasurable }, current.etag ?? "")
|
||||
} else {
|
||||
await itemTypesApi.create({ name })
|
||||
await itemTypesApi.create({ name, isMeasurable })
|
||||
}
|
||||
toast.success(editing ? "Item type updated" : "Item type created", name)
|
||||
setOpen(false)
|
||||
setName("")
|
||||
setIsMeasurable(false)
|
||||
setEditing(null)
|
||||
setErrors({})
|
||||
load()
|
||||
@@ -112,6 +119,7 @@ export default function ItemTypesPage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">Item Types</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Dimensions the item builder offers (e.g. Color, Size, Material). Values are captured per item and encoded in its SKU.
|
||||
A <span className="font-medium">measurement</span> dimension captures a number plus a unit instead, which becomes each item's content size.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,6 +143,23 @@ export default function ItemTypesPage() {
|
||||
/>
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<div className="flex items-start gap-4">
|
||||
<Switch
|
||||
checked={isMeasurable}
|
||||
onCheckedChange={setIsMeasurable}
|
||||
aria-label="Values are measurements"
|
||||
className="mt-1 shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-base font-medium text-foreground">Values are measurements</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Values are entered as a number plus a unit (500 ml, 1 L) and become each item's
|
||||
content size. Leave off for plain labels like Red or Small.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
@@ -173,6 +198,7 @@ export default function ItemTypesPage() {
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">ID</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Values</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created At</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
@@ -183,6 +209,9 @@ export default function ItemTypesPage() {
|
||||
<TableRow key={t.itemTypeId}>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{t.itemTypeId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{t.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline">{t.isMeasurable ? "Measurement" : "Text"}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant={t.status === "Active" ? "default" : "secondary"}>{t.status}</Badge>
|
||||
</TableCell>
|
||||
|
||||
@@ -13,9 +13,17 @@ import { productConfig } from "@/lib/api/product-config"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateVariantItemForm, validateVariantPrices } from "@/lib/validations/master-data"
|
||||
import {
|
||||
contentPairErrors,
|
||||
validateVariantContent,
|
||||
validateVariantItemForm,
|
||||
validateVariantPrices,
|
||||
} from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Brand, Category, ItemType, ProductConfig, StockNature, SubCategory } from "@/types/master-data"
|
||||
import { Brand, Category, ItemType, MeasureUnit, ProductConfig, StockNature, SubCategory } from "@/types/master-data"
|
||||
|
||||
/** Entry units. Only ml/g are stored — the server normalises L and Kg ×1000 on write. */
|
||||
const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"]
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
@@ -33,15 +41,43 @@ function skuSegment(text: string, maxLen: number): string {
|
||||
return cleaned.slice(0, maxLen) || "GEN"
|
||||
}
|
||||
|
||||
function buildVariantSku(categoryLabel: string, values: string[]): string {
|
||||
return [skuSegment(categoryLabel, 3), ...values.map((v) => skuSegment(v, 3))].join("-")
|
||||
/**
|
||||
* A value on a builder dimension. `qty`/`unit` are set only on a measurable dimension;
|
||||
* `label` is the single source of truth for the chip, the item name and the SKU segment.
|
||||
*/
|
||||
type DimensionValue = { label: string; qty: number | null; unit: MeasureUnit | null }
|
||||
|
||||
const isMeasured = (v: DimensionValue): v is DimensionValue & { qty: number; unit: MeasureUnit } =>
|
||||
v.qty !== null && v.unit !== null
|
||||
|
||||
/** Canonical casing, so 1.5 L renders as "1.5L" and not "1.5l". */
|
||||
const UNIT_LABEL: Record<MeasureUnit, string> = { Ml: "ml", L: "L", G: "g", Kg: "kg" }
|
||||
|
||||
const measuredLabel = (qty: number, unit: MeasureUnit) => `${qty}${UNIT_LABEL[unit]}`
|
||||
|
||||
/**
|
||||
* Normalised identity of a measurement, mirroring the server's `ItemContent.Normalize`
|
||||
* (L/Kg ×1000 → ml/g). Used for BOTH the SKU segment and value dedupe, so the client's
|
||||
* notion of "same size" is the server's.
|
||||
*
|
||||
* The decimal point becomes `P` rather than being stripped: `skuSegment` deletes it, which
|
||||
* would make 2.5ml and 25ml — and 1.5L and 15L — produce identical SKUs and fail the
|
||||
* create loop partway with SKU_DUPLICATE.
|
||||
*/
|
||||
function measureKey(qty: number, unit: MeasureUnit): string {
|
||||
const factor = unit === "L" || unit === "Kg" ? 1000 : 1
|
||||
const base = unit === "L" || unit === "Ml" ? "ML" : "G"
|
||||
const n = Math.round(qty * factor * 10000) / 10000
|
||||
const digits = n.toFixed(4).replace(/0+$/, "").replace(/\.$/, "")
|
||||
return `${digits.replace(".", "P")}${base}`
|
||||
}
|
||||
|
||||
/** The item builder only ever offers these two dimensions, regardless of what else exists
|
||||
* in the Item Types master list. */
|
||||
const BUILDER_ITEM_TYPES = ["color", "size"]
|
||||
function isBuilderItemType(name: string): boolean {
|
||||
return BUILDER_ITEM_TYPES.includes(name.trim().toLowerCase())
|
||||
function buildVariantSku(categoryLabel: string, values: DimensionValue[]): string {
|
||||
return [
|
||||
skuSegment(categoryLabel, 3),
|
||||
// Measurement segments are deliberately NOT truncated — truncation is what collides.
|
||||
...values.map((v) => (isMeasured(v) ? measureKey(v.qty, v.unit) : skuSegment(v.label, 3))),
|
||||
].join("-")
|
||||
}
|
||||
|
||||
export default function NewItemPage() {
|
||||
@@ -54,9 +90,13 @@ export default function NewItemPage() {
|
||||
const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
|
||||
/** Defaults to the first UOM once loaded; null only means none exist yet. */
|
||||
const [baseUomId, setBaseUomId] = useState<number | null>(null)
|
||||
// Kept as a raw string: an empty box means "no content size", which is different from 0.
|
||||
const [contentQty, setContentQty] = useState("")
|
||||
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
|
||||
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [productName, setProductName] = useState("")
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
|
||||
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
|
||||
@@ -67,8 +107,12 @@ export default function NewItemPage() {
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
|
||||
const [checkedItemTypeIds, setCheckedItemTypeIds] = useState<number[]>([])
|
||||
const [valuesByCategory, setValuesByCategory] = useState<Record<number, string[]>>({})
|
||||
const [valuesByCategory, setValuesByCategory] = useState<Record<number, DimensionValue[]>>({})
|
||||
const [inputByCategory, setInputByCategory] = useState<Record<number, string>>({})
|
||||
// Measurable dimensions enter a number + unit instead of free text.
|
||||
const [qtyByCategory, setQtyByCategory] = useState<Record<number, string>>({})
|
||||
const [unitByCategory, setUnitByCategory] = useState<Record<number, MeasureUnit | null>>({})
|
||||
const [valueErrors, setValueErrors] = useState<Record<number, string>>({})
|
||||
// Lets a specific generated combination be dropped from the preview table before
|
||||
// submit, without having to remove and re-add the whole value that produced it.
|
||||
const [removedVariantKeys, setRemovedVariantKeys] = useState<Set<string>>(new Set())
|
||||
@@ -122,11 +166,15 @@ export default function NewItemPage() {
|
||||
.catch(() => setSubCategories([]))
|
||||
}, [categoryId, config?.subcategoriesEnabled])
|
||||
|
||||
const categoryLabel = (categories ?? []).find((c) => c.categoryId === categoryId)?.name ?? ""
|
||||
const subCategoryLabel = subCategories.find((s) => s.subCategoryId === subCategoryId)?.name ?? ""
|
||||
/** SKU/name read best off the most specific level, but BOTH ids are sent to the server. */
|
||||
const effectiveLabel = subCategoryLabel || categoryLabel
|
||||
const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? ""
|
||||
/**
|
||||
* The product being built ("Coca Cola"), typed rather than derived. It names every generated
|
||||
* item and supplies the SKU's leading segment.
|
||||
*
|
||||
* Category and subcategory still classify the item and are both sent to the server; they just
|
||||
* no longer name it, which is what produced labels like "Beverages - 500ml".
|
||||
*/
|
||||
const productLabel = productName.trim()
|
||||
|
||||
function handleCategoryChange(value: number | null) {
|
||||
setCategoryId(value)
|
||||
@@ -139,22 +187,60 @@ export default function NewItemPage() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add is the real validation gate for a measurement — rejecting here means a half or
|
||||
* duplicate pair never reaches state, so nothing downstream has to defend against one.
|
||||
*/
|
||||
function addValue(itemTypeId: number) {
|
||||
const value = (inputByCategory[itemTypeId] ?? "").trim()
|
||||
if (value) {
|
||||
setValuesByCategory((prev) => {
|
||||
const existing = prev[itemTypeId] ?? []
|
||||
if (existing.some((v) => v.toLowerCase() === value.toLowerCase())) return prev
|
||||
return { ...prev, [itemTypeId]: [...existing, value] }
|
||||
})
|
||||
}
|
||||
setInputByCategory((prev) => ({ ...prev, [itemTypeId]: "" }))
|
||||
}
|
||||
const measurable = (itemTypes ?? []).find((t) => t.itemTypeId === itemTypeId)?.isMeasurable ?? false
|
||||
|
||||
if (!measurable) {
|
||||
const label = (inputByCategory[itemTypeId] ?? "").trim()
|
||||
if (label) {
|
||||
setValuesByCategory((prev) => {
|
||||
const existing = prev[itemTypeId] ?? []
|
||||
if (existing.some((v) => v.label.toLowerCase() === label.toLowerCase())) return prev
|
||||
return { ...prev, [itemTypeId]: [...existing, { label, qty: null, unit: null }] }
|
||||
})
|
||||
}
|
||||
setInputByCategory((prev) => ({ ...prev, [itemTypeId]: "" }))
|
||||
return
|
||||
}
|
||||
|
||||
const raw = (qtyByCategory[itemTypeId] ?? "").trim()
|
||||
const unit = unitByCategory[itemTypeId] ?? null
|
||||
const qty = Number(raw)
|
||||
|
||||
if (!raw || Number.isNaN(qty) || qty <= 0) {
|
||||
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "Enter a size greater than 0" }))
|
||||
return
|
||||
}
|
||||
if (!unit) {
|
||||
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "Select a unit" }))
|
||||
return
|
||||
}
|
||||
|
||||
// Dedupe on the NORMALISED size, not the label: 500 ml and 0.5 L read differently but
|
||||
// store identically, so a label-only check would create two items of the same size.
|
||||
const key = measureKey(qty, unit)
|
||||
const existing = valuesByCategory[itemTypeId] ?? []
|
||||
if (existing.some((v) => isMeasured(v) && measureKey(v.qty, v.unit) === key)) {
|
||||
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "That size is already in the list" }))
|
||||
return
|
||||
}
|
||||
|
||||
function removeValue(itemTypeId: number, value: string) {
|
||||
setValuesByCategory((prev) => ({
|
||||
...prev,
|
||||
[itemTypeId]: (prev[itemTypeId] ?? []).filter((v) => v !== value),
|
||||
[itemTypeId]: [...(prev[itemTypeId] ?? []), { label: measuredLabel(qty, unit), qty, unit }],
|
||||
}))
|
||||
setQtyByCategory((prev) => ({ ...prev, [itemTypeId]: "" }))
|
||||
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "" }))
|
||||
}
|
||||
|
||||
function removeValue(itemTypeId: number, label: string) {
|
||||
setValuesByCategory((prev) => ({
|
||||
...prev,
|
||||
[itemTypeId]: (prev[itemTypeId] ?? []).filter((v) => v.label !== label),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -169,13 +255,13 @@ export default function NewItemPage() {
|
||||
|
||||
const allVariants = useMemo(() => {
|
||||
if (activeCategories.length === 0) return []
|
||||
let combinations: { key: string; parts: { name: string; value: string }[] }[] = [{ key: "", parts: [] }]
|
||||
let combinations: { key: string; parts: { name: string; value: DimensionValue }[] }[] = [{ key: "", parts: [] }]
|
||||
for (const cat of activeCategories) {
|
||||
const next: typeof combinations = []
|
||||
for (const combo of combinations) {
|
||||
for (const value of cat.values) {
|
||||
next.push({
|
||||
key: combo.key ? `${combo.key}::${value}` : value,
|
||||
key: combo.key ? `${combo.key}::${value.label}` : value.label,
|
||||
parts: [...combo.parts, { name: cat.name, value }],
|
||||
})
|
||||
}
|
||||
@@ -184,9 +270,9 @@ export default function NewItemPage() {
|
||||
}
|
||||
return combinations.map((c) => ({
|
||||
...c,
|
||||
sku: buildVariantSku(effectiveLabel, c.parts.map((p) => p.value)),
|
||||
sku: buildVariantSku(productLabel, c.parts.map((p) => p.value)),
|
||||
}))
|
||||
}, [activeCategories, effectiveLabel])
|
||||
}, [activeCategories, productLabel])
|
||||
|
||||
const variants = useMemo(
|
||||
() => allVariants.filter((v) => !removedVariantKeys.has(v.key)),
|
||||
@@ -197,9 +283,76 @@ export default function NewItemPage() {
|
||||
setRemovedVariantKeys((prev) => new Set(prev).add(key))
|
||||
}
|
||||
|
||||
/**
|
||||
* True once a checked dimension supplies each variant's content size. The form-level
|
||||
* content field is then hidden and cleared, so the two can never disagree.
|
||||
*/
|
||||
const measurableCheckedCount = (itemTypes ?? []).filter(
|
||||
(t) => checkedItemTypeIds.includes(t.itemTypeId) && t.isMeasurable
|
||||
).length
|
||||
// Keyed off *checked*, not "checked and already has values": the moment a measurement
|
||||
// dimension is ticked the form-level pair is irrelevant, so it should not linger while the
|
||||
// first value is being typed.
|
||||
const hasMeasurableDimension = measurableCheckedCount > 0
|
||||
|
||||
/**
|
||||
* The shared content pair is offered only when it is the ONLY way to set a content size:
|
||||
* dimensions are chosen, and none of them measures anything (a Colour-only product, whose
|
||||
* variants really do all hold the same amount).
|
||||
*
|
||||
* Deliberately hidden on the untouched page. Showing it there invites someone to fill in one
|
||||
* size before discovering that ticking Size would have captured a size per value — which
|
||||
* reads as though sizes still apply to every variant at once.
|
||||
*/
|
||||
const showSharedContent = checkedItemTypeIds.length > 0 && !hasMeasurableDimension
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMeasurableDimension) return
|
||||
setContentQty("")
|
||||
setContentUnit(null)
|
||||
}, [hasMeasurableDimension])
|
||||
|
||||
/** A variant's own measured part, else the form-level fallback pair. */
|
||||
function contentForVariant(variant: { parts: { value: DimensionValue }[] }) {
|
||||
const measured = variant.parts.find((p) => isMeasured(p.value))
|
||||
if (measured && isMeasured(measured.value)) {
|
||||
return { qty: measured.value.qty, unit: measured.value.unit }
|
||||
}
|
||||
// Only fall back to the shared pair when that pair is the one on offer, so a value left
|
||||
// behind by an earlier selection can never be applied invisibly.
|
||||
if (!showSharedContent || !contentQty.trim() || !contentUnit) return null
|
||||
return { qty: Number(contentQty), unit: contentUnit }
|
||||
}
|
||||
|
||||
function contentLabelFor(variant: { parts: { value: DimensionValue }[] }) {
|
||||
const content = contentForVariant(variant)
|
||||
return content ? measuredLabel(content.qty, content.unit) : "—"
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitError(null)
|
||||
const nextErrors = validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 })
|
||||
const nextErrors: Record<string, string> = {
|
||||
...validateVariantItemForm({ productName, categoryId, hasVariants: variants.length > 0 }),
|
||||
// Validate the shared pair exactly when it is on screen. Validating it while hidden
|
||||
// would block submit with a message the user cannot see.
|
||||
...(showSharedContent ? contentPairErrors(contentQty, contentUnit) : {}),
|
||||
}
|
||||
if (measurableCheckedCount > 1) {
|
||||
nextErrors.measurable = "Only one measurement dimension can be used at a time."
|
||||
}
|
||||
// Should never fire — addValue is the real gate — so it catches stale state only.
|
||||
const contentSweep = validateVariantContent(
|
||||
variants.map((v) => v.key),
|
||||
(key) => {
|
||||
const variant = variants.find((v) => v.key === key)
|
||||
const content = variant ? contentForVariant(variant) : null
|
||||
return content
|
||||
? { qty: String(content.qty), unit: content.unit }
|
||||
: { qty: "", unit: null }
|
||||
},
|
||||
)
|
||||
const sweepMessage = Object.values(contentSweep)[0]
|
||||
if (sweepMessage) nextErrors.variantContent = sweepMessage
|
||||
setErrors(nextErrors)
|
||||
// In fixed mode, block the whole submit until every variant has a price > 0.
|
||||
const nextPriceErrors =
|
||||
@@ -215,9 +368,10 @@ export default function NewItemPage() {
|
||||
let created = 0
|
||||
try {
|
||||
for (const variant of variants) {
|
||||
const content = contentForVariant(variant)
|
||||
await itemsApi.create({
|
||||
sku: variant.sku,
|
||||
name: `${brandLabel ? brandLabel + " " : ""}${effectiveLabel} - ${variant.parts.map((p) => p.value).join("/")}`,
|
||||
name: `${brandLabel ? brandLabel + " " : ""}${productLabel} - ${variant.parts.map((p) => p.value.label).join("/")}`,
|
||||
// Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the
|
||||
// category, which lost the parent entirely. The server rejects a mismatched
|
||||
// pair with 422.
|
||||
@@ -228,6 +382,11 @@ export default function NewItemPage() {
|
||||
stockNature,
|
||||
trackingMode: "None",
|
||||
salePrice: priceMode === "fixed" ? Number(priceFor(variant.key)) : null,
|
||||
// Each variant carries its OWN size when a measurement dimension supplied one;
|
||||
// otherwise the shared form-level pair, which is correct when the varying dimension
|
||||
// isn't size (colour variants of one 500 ml bottle all hold 500 ml).
|
||||
contentQty: content?.qty ?? null,
|
||||
contentUnit: content?.unit ?? null,
|
||||
})
|
||||
created += 1
|
||||
}
|
||||
@@ -279,6 +438,22 @@ export default function NewItemPage() {
|
||||
{!loading && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Product name</Label>
|
||||
<Input
|
||||
value={productName}
|
||||
onChange={(e) => setProductName(e.target.value)}
|
||||
placeholder="e.g. Coca Cola"
|
||||
aria-invalid={!!errors.productName}
|
||||
className="h-12! text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.productName ? { message: errors.productName } : undefined]} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Names every variant and starts its SKU
|
||||
{productLabel ? ` — "${brandLabel ? brandLabel + " " : ""}${productLabel} - 500ml", SKU ${skuSegment(productLabel, 3)}-…` : "."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Category</Label>
|
||||
<Select<number | null>
|
||||
@@ -382,6 +557,53 @@ export default function NewItemPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Content size (FR-MD-02): how much one stocked pack holds. Optional — a screw
|
||||
or a label has none. Litres/kilograms are normalised to ml/g by the server.
|
||||
Hidden once a measurement dimension is in play, because that dimension then
|
||||
gives each variant its own size and this pair would only contradict it. */}
|
||||
{showSharedContent && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Content size</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={contentQty}
|
||||
onChange={(e) => setContentQty(e.target.value)}
|
||||
placeholder="e.g. 500"
|
||||
aria-invalid={!!errors.contentQty}
|
||||
className="h-12! text-base"
|
||||
/>
|
||||
<Select<MeasureUnit | null>
|
||||
value={contentUnit}
|
||||
onValueChange={setContentUnit}
|
||||
items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-28 shrink-0 text-base" aria-invalid={!!errors.contentUnit}>
|
||||
<SelectValue placeholder="Unit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTENT_UNITS.map((u) => (
|
||||
<SelectItem key={u} value={u} className="text-base">
|
||||
{u.toLowerCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<FieldError
|
||||
errors={[
|
||||
errors.contentQty ? { message: errors.contentQty } : undefined,
|
||||
errors.contentUnit ? { message: errors.contentUnit } : undefined,
|
||||
]}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Applies to every variant. Leave blank for items with no measurable content — stock is
|
||||
counted in the base UOM either way.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Stock nature</Label>
|
||||
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)}>
|
||||
@@ -459,23 +681,54 @@ export default function NewItemPage() {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Check the item types that apply, then add their values to generate a SKU per combination.
|
||||
</p>
|
||||
{/* Without this, the per-size content feature is invisible: nothing is flagged as a
|
||||
measurement out of the box, so the builder silently falls back to one shared
|
||||
content size and the user has no reason to suspect another page is involved. */}
|
||||
{(itemTypes ?? []).length > 0 && !(itemTypes ?? []).some((t) => t.isMeasurable) && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Selling the same product in several sizes (500 ml, 1 L)?{" "}
|
||||
<Link href="/dashboard/products/item-types" className="font-medium underline">
|
||||
Mark that item type as a measurement
|
||||
</Link>{" "}
|
||||
and each size will capture its own number and unit, giving every variant its own content size.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{(itemTypes ?? [])
|
||||
.filter((t) => isBuilderItemType(t.name))
|
||||
.map((t) => (
|
||||
<label key={t.itemTypeId} className="flex items-center gap-2.5 rounded-lg border px-3 py-2 hover:bg-muted/50">
|
||||
{(itemTypes ?? []).map((t) => {
|
||||
const checked = checkedItemTypeIds.includes(t.itemTypeId)
|
||||
// Only one dimension may supply the content size, or a variant would have two.
|
||||
const blocked = t.isMeasurable && !checked && measurableCheckedCount > 0
|
||||
return (
|
||||
<label
|
||||
key={t.itemTypeId}
|
||||
title={blocked ? "Only one measurement dimension can be used at a time." : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 rounded-lg border px-3 py-2",
|
||||
blocked ? "opacity-50" : "hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checkedItemTypeIds.includes(t.itemTypeId)}
|
||||
checked={checked}
|
||||
disabled={blocked}
|
||||
onCheckedChange={() => toggleItemType(t.itemTypeId)}
|
||||
/>
|
||||
<span className="text-base font-medium">{t.name}</span>
|
||||
{t.isMeasurable && (
|
||||
<Badge variant="outline" className="text-xs">measurement</Badge>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<FieldError errors={[errors.variants ? { message: errors.variants } : undefined]} />
|
||||
<FieldError
|
||||
errors={[
|
||||
errors.variants ? { message: errors.variants } : undefined,
|
||||
errors.measurable ? { message: errors.measurable } : undefined,
|
||||
]}
|
||||
/>
|
||||
|
||||
{checkedItemTypeIds.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
@@ -487,33 +740,79 @@ export default function NewItemPage() {
|
||||
return (
|
||||
<div key={t.itemTypeId} className="flex flex-col gap-2">
|
||||
<Label className="text-base">{t.name} values</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={currentInput}
|
||||
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
addValue(t.itemTypeId)
|
||||
}
|
||||
}}
|
||||
placeholder={t.name}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}>
|
||||
<Plus className="size-4" />
|
||||
Add {t.name}
|
||||
</Button>
|
||||
</div>
|
||||
{t.isMeasurable ? (
|
||||
// A measurement is entered as a number + unit; the chip label, the SKU
|
||||
// segment and the item's stored content size all derive from this pair.
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={qtyByCategory[t.itemTypeId] ?? ""}
|
||||
onChange={(e) => setQtyByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
addValue(t.itemTypeId)
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. 500"
|
||||
aria-invalid={!!valueErrors[t.itemTypeId]}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<Select<MeasureUnit | null>
|
||||
value={unitByCategory[t.itemTypeId] ?? null}
|
||||
onValueChange={(v) => setUnitByCategory((prev) => ({ ...prev, [t.itemTypeId]: v }))}
|
||||
items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}
|
||||
>
|
||||
<SelectTrigger className="h-11! w-24 shrink-0 text-base">
|
||||
<SelectValue placeholder="Unit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTENT_UNITS.map((u) => (
|
||||
<SelectItem key={u} value={u} className="text-base">
|
||||
{UNIT_LABEL[u]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={currentInput}
|
||||
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
addValue(t.itemTypeId)
|
||||
}
|
||||
}}
|
||||
placeholder={t.name}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}>
|
||||
<Plus className="size-4" />
|
||||
Add {t.name}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<FieldError
|
||||
errors={[valueErrors[t.itemTypeId] ? { message: valueErrors[t.itemTypeId] } : undefined]}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(valuesByCategory[t.itemTypeId] ?? []).map((v) => (
|
||||
<Badge key={v} variant="outline" className="h-7 gap-1 pr-1 text-sm">
|
||||
{v}
|
||||
<Badge key={v.label} variant="outline" className="h-7 gap-1 pr-1 text-sm">
|
||||
{v.label}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeValue(t.itemTypeId, v)}
|
||||
onClick={() => removeValue(t.itemTypeId, v.label)}
|
||||
className="rounded-full p-0.5 hover:bg-muted"
|
||||
aria-label={`Remove ${v}`}
|
||||
aria-label={`Remove ${v.label}`}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
@@ -526,6 +825,10 @@ export default function NewItemPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form-level, not per-row: a variant's content is derived, so there is no cell to
|
||||
attach this to. Should never appear — addValue rejects a bad pair at entry. */}
|
||||
<FieldError errors={[errors.variantContent ? { message: errors.variantContent } : undefined]} />
|
||||
|
||||
{variants.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
@@ -535,6 +838,7 @@ export default function NewItemPage() {
|
||||
<TableHead key={cat.itemTypeId} className="h-11 px-3 text-sm">{cat.name}</TableHead>
|
||||
))}
|
||||
<TableHead className="h-11 px-3 text-sm">SKU</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Content</TableHead>
|
||||
{priceMode === "fixed" && (
|
||||
<TableHead className="h-11 px-3 text-sm">Sale price</TableHead>
|
||||
)}
|
||||
@@ -550,10 +854,15 @@ export default function NewItemPage() {
|
||||
<TableRow key={variant.key}>
|
||||
{variant.parts.map((part, i) => (
|
||||
<TableCell key={i} className="px-3 py-2.5">
|
||||
{part.value}
|
||||
{part.value.label}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell className="py-2.5 pr-1 pl-3 font-medium">{variant.sku}</TableCell>
|
||||
{/* Read-only: content is derived, so an editable cell here could only
|
||||
disagree with the value that produced it. */}
|
||||
<TableCell className="px-3 py-2.5 text-muted-foreground">
|
||||
{contentLabelFor(variant)}
|
||||
</TableCell>
|
||||
{priceMode === "fixed" && (
|
||||
<TableCell className="px-3 py-2.5">
|
||||
<Input
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function UomsPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Units of Measure</h1>
|
||||
<p className="text-base text-muted-foreground">Flat UOM master, used as item base UOMs and in per-item conversions (FR-MD-02).</p>
|
||||
<p className="text-base text-muted-foreground">Flat UOM master, used as item base UOMs and as work-in-progress labels in production (FR-MD-02).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { grnsApi } from "@/lib/api/grns"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ConfirmGrnResponse, Grn } from "@/types/grn"
|
||||
@@ -56,9 +57,6 @@ export default function GrnDetailPage() {
|
||||
function itemFor(itemId: number) {
|
||||
return items.find((i) => i.itemId === itemId)
|
||||
}
|
||||
function uomFor(uomId: number) {
|
||||
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
|
||||
}
|
||||
function binFor(binId: number | null) {
|
||||
if (!binId) return "—"
|
||||
return bins.find((b) => b.binId === binId)?.code ?? `#${binId}`
|
||||
@@ -179,7 +177,7 @@ export default function GrnDetailPage() {
|
||||
return (
|
||||
<TableRow key={line.grnLineId}>
|
||||
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{uomFor(line.uomId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{baseUomLabel(items, uoms, line.itemId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{binFor(line.binId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">
|
||||
|
||||
@@ -11,6 +11,7 @@ import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateLine, splitSerials, grnHeaderSchema } from "@/lib/validations/grn"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -33,7 +34,6 @@ interface DraftLine {
|
||||
key: string
|
||||
poLineId: number | null
|
||||
itemId: number | null
|
||||
uomId: number | null
|
||||
binId: number | null
|
||||
qty: string
|
||||
unitCost: string
|
||||
@@ -70,7 +70,6 @@ function emptyLine(): DraftLine {
|
||||
key: newKey(),
|
||||
poLineId: null,
|
||||
itemId: null,
|
||||
uomId: null,
|
||||
binId: null,
|
||||
qty: "",
|
||||
unitCost: "",
|
||||
@@ -168,7 +167,6 @@ export default function NewGrnPage() {
|
||||
key: newKey(),
|
||||
poLineId: l.poLineId,
|
||||
itemId: l.itemId,
|
||||
uomId: l.uomId,
|
||||
binId: null,
|
||||
qty: String(l.qty - l.qtyReceived),
|
||||
unitCost: String(l.unitPrice),
|
||||
@@ -246,7 +244,6 @@ export default function NewGrnPage() {
|
||||
for (const line of lines) {
|
||||
const errors = validateLine({
|
||||
itemId: line.itemId,
|
||||
uomId: line.uomId,
|
||||
qty: line.qty,
|
||||
unitCost: line.unitCost,
|
||||
discountPct: line.discountPct,
|
||||
@@ -268,7 +265,6 @@ export default function NewGrnPage() {
|
||||
return {
|
||||
poLineId: l.poLineId,
|
||||
itemId: l.itemId as number,
|
||||
uomId: l.uomId as number,
|
||||
binId: l.binId,
|
||||
qty: Number(l.qty),
|
||||
unitCost: Number(l.unitCost),
|
||||
@@ -486,27 +482,9 @@ export default function NewGrnPage() {
|
||||
)}
|
||||
</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]} />
|
||||
</>
|
||||
)}
|
||||
<div className="flex h-11 items-center text-base text-muted-foreground">
|
||||
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { ArrowLeft, CheckCircle2, Edit, Minus, Plus, Printer, Save, XCircle } from "lucide-react"
|
||||
import { ArrowLeft, CheckCircle2, Edit, ExternalLink, Minus, Plus, Printer, Save, XCircle } from "lucide-react"
|
||||
|
||||
import { bundleApi } from "@/lib/api/bundles"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -16,7 +17,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"
|
||||
@@ -26,6 +27,17 @@ import { ManagedUser } from "@/types/users"
|
||||
|
||||
type EditableLine = BundleSaleTemplateLine & { key: string }
|
||||
|
||||
const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine => ({
|
||||
key: crypto.randomUUID(),
|
||||
bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0,
|
||||
itemId: templateLine?.itemId ?? 0,
|
||||
warehouseId: templateLine?.warehouseId ?? 0,
|
||||
qty: templateLine?.qty ?? 1,
|
||||
unitPrice: templateLine?.unitPrice ?? 0,
|
||||
includeInBundle: templateLine?.includeInBundle ?? true,
|
||||
sortOrder: templateLine?.sortOrder ?? 0,
|
||||
})
|
||||
|
||||
function statusClass(status: BundleSale["status"]) {
|
||||
switch (status) {
|
||||
case "Draft":
|
||||
@@ -44,6 +56,7 @@ export default function BundleSaleDetailPage() {
|
||||
const [bundle, setBundle] = useState<BundleSale | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
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[]>([])
|
||||
@@ -94,7 +107,6 @@ export default function BundleSaleDetailPage() {
|
||||
key: `${line.bundleSaleLineId}`,
|
||||
bundleSaleTemplateLineId: line.bundleSaleLineId,
|
||||
itemId: line.itemId,
|
||||
uomId: line.uomId,
|
||||
warehouseId: line.warehouseId,
|
||||
qty: line.qty,
|
||||
unitPrice: line.unitPrice,
|
||||
@@ -104,7 +116,12 @@ export default function BundleSaleDetailPage() {
|
||||
)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [bundleSaleId, params.id])
|
||||
}, [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])
|
||||
@@ -116,9 +133,9 @@ export default function BundleSaleDetailPage() {
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
const source = lines[lines.length - 1]
|
||||
const source = template?.lines[0]
|
||||
if (!source) return
|
||||
setLines((prev) => [...prev, { ...source, key: crypto.randomUUID() }])
|
||||
setLines((prev) => [...prev, createBlankLine(source)])
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
@@ -170,6 +187,20 @@ export default function BundleSaleDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const shortageIssues = bundle && bundle.status === "Draft"
|
||||
? bundle.lines
|
||||
.filter((line) => line.includeInBundle)
|
||||
.filter((line) => Number(line.qty || 0) > 0)
|
||||
.map((line) => ({
|
||||
bundleSaleLineId: line.bundleSaleLineId,
|
||||
itemId: line.itemId,
|
||||
itemSku: items.find((item) => item.itemId === line.itemId)?.sku ?? `#${line.itemId}`,
|
||||
itemName: items.find((item) => item.itemId === line.itemId)?.name ?? line.itemId.toString(),
|
||||
warehouseId: line.warehouseId,
|
||||
requestedQty: line.qty,
|
||||
}))
|
||||
: []
|
||||
|
||||
async function cancelBundle() {
|
||||
if (!bundle) return
|
||||
setBusy(true)
|
||||
@@ -236,6 +267,47 @@ export default function BundleSaleDetailPage() {
|
||||
|
||||
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
|
||||
|
||||
{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-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
@@ -318,26 +390,37 @@ export default function BundleSaleDetailPage() {
|
||||
</SelectContent>
|
||||
</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={!editing || !isDraft}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((uom) => (
|
||||
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
|
||||
{uom.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TableCell className="min-w-40 text-sm text-muted-foreground">
|
||||
{baseUomLabel(items, uoms, line.itemId)}
|
||||
</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-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">{line.includeInBundle ? "Yes" : "No"}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{editing && isDraft ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-xs font-medium",
|
||||
line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{line.includeInBundle ? "Included" : "Excluded"}
|
||||
</button>
|
||||
) : (
|
||||
line.includeInBundle ? "Included" : "Excluded"
|
||||
)}
|
||||
</TableCell>
|
||||
{editing && isDraft ? <TableCell className="text-right"><Button type="button" variant="ghost" size="icon-sm" onClick={() => removeLine(line.key)} disabled={lines.length === 1}><Minus className="size-4" /></Button></TableCell> : null}
|
||||
</TableRow>
|
||||
))}
|
||||
{lines.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={editing && isDraft ? 6 : 5} className="py-10 text-center text-sm text-muted-foreground">
|
||||
No component rows yet. Click Add line to start.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import { Suspense, useEffect, useMemo, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Minus, Plus, Save } from "lucide-react"
|
||||
import { ArrowLeft, ExternalLink, Minus, Plus, Save } from "lucide-react"
|
||||
|
||||
import { bundleApi } from "@/lib/api/bundles"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -27,7 +28,16 @@ import { BundleSaleTemplate, BundleSaleTemplateLine, BundleSaleTemplateSummary,
|
||||
|
||||
type EditableLine = BundleSaleTemplateLine & { key: string }
|
||||
|
||||
const blankLine = (source: BundleSaleTemplateLine): EditableLine => ({ ...source, key: crypto.randomUUID() })
|
||||
const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine => ({
|
||||
key: crypto.randomUUID(),
|
||||
bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0,
|
||||
itemId: templateLine?.itemId ?? 0,
|
||||
warehouseId: templateLine?.warehouseId ?? 0,
|
||||
qty: templateLine?.qty ?? 1,
|
||||
unitPrice: templateLine?.unitPrice ?? 0,
|
||||
includeInBundle: templateLine?.includeInBundle ?? true,
|
||||
sortOrder: templateLine?.sortOrder ?? 0,
|
||||
})
|
||||
|
||||
function NewBundleSaleContent() {
|
||||
const router = useRouter()
|
||||
@@ -81,10 +91,19 @@ function NewBundleSaleContent() {
|
||||
if (!templateId) return
|
||||
bundleApi.getTemplate(templateId).then((res) => {
|
||||
setTemplate(res)
|
||||
setLines(res.lines.map(blankLine))
|
||||
setLines(
|
||||
res.lines.length > 0
|
||||
? res.lines.map((line) => {
|
||||
const item = items.find((candidate) => candidate.itemId === line.itemId)
|
||||
return createBlankLine({
|
||||
...line,
|
||||
})
|
||||
})
|
||||
: [createBlankLine()]
|
||||
)
|
||||
setBundlePrice(res.lines.reduce((sum, line) => sum + line.qty * line.unitPrice, 0))
|
||||
}).catch((err) => setSubmitError(errorMessage(err)))
|
||||
}, [templateId])
|
||||
}, [items, templateId])
|
||||
|
||||
const templateLabel = useMemo(() => template?.templateName ?? "Select template", [template])
|
||||
const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines])
|
||||
@@ -94,9 +113,7 @@ function NewBundleSaleContent() {
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
const source = lines[lines.length - 1] ?? template?.lines[0]
|
||||
if (!source) return
|
||||
setLines((prev) => [...prev, blankLine(source)])
|
||||
setLines((prev) => [...prev, createBlankLine(template?.lines[0])])
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
@@ -151,6 +168,22 @@ function NewBundleSaleContent() {
|
||||
|
||||
{submitError ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{submitError}</div> : null}
|
||||
|
||||
<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">Need stock before saving this bundle?</div>
|
||||
<div className="mt-1">Save the bundle as a draft first. After that, open the draft detail page to check shortages and create a GRN if needed.</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>
|
||||
|
||||
<section className="rounded-2xl border bg-card p-4 shadow-sm">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
@@ -222,13 +255,13 @@ function NewBundleSaleContent() {
|
||||
{lines.map((line) => (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="min-w-72">
|
||||
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
|
||||
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
|
||||
const itemId = Number(v)
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
updateLine(line.key, {
|
||||
itemId,
|
||||
unitPrice: item?.salePrice ?? line.unitPrice,
|
||||
})
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
updateLine(line.key, {
|
||||
itemId,
|
||||
unitPrice: item?.salePrice ?? line.unitPrice,
|
||||
})
|
||||
}}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select item" />
|
||||
@@ -242,28 +275,35 @@ function NewBundleSaleContent() {
|
||||
</SelectContent>
|
||||
</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) })}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((uom) => (
|
||||
<SelectItem key={uom.uomId} value={String(uom.uomId)}>
|
||||
{uom.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TableCell className="min-w-40 text-sm text-muted-foreground">
|
||||
{baseUomLabel(items, uoms, line.itemId)}
|
||||
</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-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">{line.includeInBundle ? "Yes" : "No"}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-xs font-medium",
|
||||
line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{line.includeInBundle ? "Included" : "Excluded"}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => removeLine(line.key)} disabled={lines.length === 1}><Minus className="size-4" /></Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{lines.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-10 text-center text-sm text-muted-foreground">
|
||||
No component rows yet. Click Add component to start.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -84,7 +84,18 @@ export default function BundleSalesPage() {
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [page, status, query, customerId, warehouseId])
|
||||
|
||||
const visibleRows = useMemo(() => rows ?? [], [rows])
|
||||
const visibleRows = useMemo(
|
||||
() =>
|
||||
rows?.filter((row) => {
|
||||
const matchesStatus = status === "All" || row.status === status
|
||||
const matchesQuery =
|
||||
`${row.bundleNo} ${row.bundleName} ${row.customerSnapshotName} ${row.bundleCode}`
|
||||
.toLowerCase()
|
||||
.includes(query.toLowerCase())
|
||||
return matchesStatus && matchesQuery
|
||||
}) ?? [],
|
||||
[rows, query, status]
|
||||
)
|
||||
const hasFilters = status !== "All" || query.length > 0 || customerId !== null || warehouseId !== null
|
||||
const bundleTotal = visibleRows.reduce((sum, row) => sum + row.grandTotal, 0)
|
||||
const printHref = `/print/sales/bundles?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}&customerId=${customerId ?? ""}&warehouseId=${warehouseId ?? ""}`
|
||||
@@ -125,7 +136,12 @@ export default function BundleSalesPage() {
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
|
||||
<Input value={searchInput} onChange={(e) => setSearchInput(e.target.value)} placeholder="Filter by bundle, code, or customer" className="h-12 w-full lg:max-w-sm" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Filter by bundle, code, or customer"
|
||||
className="h-12 w-full lg:max-w-sm"
|
||||
/>
|
||||
<Button variant="outline" size="sm" className="lg:ml-auto" onClick={() => setShowFilters((v) => !v)}>
|
||||
<Filter className="size-4" />
|
||||
Advanced
|
||||
|
||||
@@ -14,6 +14,7 @@ import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
@@ -40,7 +41,6 @@ type FreeIssueRow = {
|
||||
const blankLine = (key: string): Line => ({
|
||||
key,
|
||||
itemId: 0,
|
||||
uomId: 0,
|
||||
warehouseId: 0,
|
||||
qty: 1,
|
||||
freeQty: 0,
|
||||
@@ -78,7 +78,6 @@ export default function NewFreeIssuePage() {
|
||||
const detail = await salesApi.getFreeIssue(summary.salesSlipId)
|
||||
const firstLine = detail.data.lines[0]
|
||||
const item = items.find((x) => x.itemId === firstLine?.itemId)
|
||||
const uom = uoms.find((x) => x.uomId === firstLine?.uomId)
|
||||
const warehouse = warehouses.find((x) => x.warehouseId === detail.data.warehouseId)
|
||||
return {
|
||||
salesSlipId: detail.data.salesSlipId,
|
||||
@@ -88,7 +87,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: baseUomLabel(items, uoms, firstLine?.itemId),
|
||||
qty: firstLine?.qty ?? 0,
|
||||
freeQty: firstLine?.freeQty ?? 0,
|
||||
} satisfies FreeIssueRow
|
||||
@@ -118,7 +117,6 @@ export default function NewFreeIssuePage() {
|
||||
{
|
||||
...blankLine("line-1"),
|
||||
itemId: itemRes.items[0]?.itemId ?? 0,
|
||||
uomId: itemRes.items[0]?.baseUomId ?? uomRes.items[0]?.uomId ?? 0,
|
||||
warehouseId: whRes.items[0]?.warehouseId ?? 0,
|
||||
},
|
||||
])
|
||||
@@ -145,20 +143,17 @@ export default function NewFreeIssuePage() {
|
||||
}
|
||||
|
||||
function selectItem(key: string, itemId: number) {
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
updateLine(key, { itemId, uomId: item?.baseUomId ?? 0 })
|
||||
updateLine(key, { itemId })
|
||||
}
|
||||
|
||||
function selectEditingItem(key: string, itemId: number) {
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
updateEditingLine(key, { itemId, uomId: item?.baseUomId ?? 0 })
|
||||
updateEditingLine(key, { itemId })
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const activeLines = editingRowId ? editingLines : lines
|
||||
if (!customerId || !warehouseId || !cashierUserId) return setError("Select customer, warehouse, and cashier.")
|
||||
if (activeLines.some((line) => !line.itemId)) return setError("Select an item for every line.")
|
||||
if (activeLines.some((line) => !line.uomId)) return setError("Select a valid UOM for every line.")
|
||||
if (activeLines.some((line) => !line.warehouseId)) return setError("Select a warehouse for every line.")
|
||||
|
||||
setSaving(true)
|
||||
@@ -170,7 +165,6 @@ export default function NewFreeIssuePage() {
|
||||
cashierUserId,
|
||||
lines: activeLines.map((line) => ({
|
||||
itemId: Number(line.itemId),
|
||||
uomId: Number(line.uomId),
|
||||
warehouseId: Number(line.warehouseId),
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
@@ -219,7 +213,6 @@ export default function NewFreeIssuePage() {
|
||||
{
|
||||
key: "edit-line-1",
|
||||
itemId: detailLine?.itemId ?? 0,
|
||||
uomId: detailLine?.uomId ?? 0,
|
||||
warehouseId: detailLine?.warehouseId ?? detail.data.warehouseId,
|
||||
qty: detailLine?.qty ?? 1,
|
||||
freeQty: detailLine?.freeQty ?? 0,
|
||||
@@ -319,19 +312,8 @@ export default function NewFreeIssuePage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2 min-w-44">
|
||||
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateEditingLine(line.key, { uomId: Number(v) })}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TableCell className="px-4 py-2 min-w-44 text-sm text-muted-foreground">
|
||||
{baseUomLabel(items, uoms, line.itemId)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2">
|
||||
<Input type="number" min="0" step="1" value={line.qty} onChange={(e) => updateEditingLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
|
||||
@@ -385,18 +367,7 @@ export default function NewFreeIssuePage() {
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2 min-w-44">
|
||||
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateLine(line.key, { uomId: Number(v) })}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{baseUomLabel(items, uoms, line.itemId)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2">
|
||||
<Input type="number" min="0" step="1" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import { use, useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ArrowLeft, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
||||
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -30,7 +31,6 @@ const money = new Intl.NumberFormat("en-LK", {
|
||||
const blankLine = (key: string): Line => ({
|
||||
key,
|
||||
itemId: 0,
|
||||
uomId: 0,
|
||||
warehouseId: 0,
|
||||
qty: 1,
|
||||
freeQty: 0,
|
||||
@@ -103,7 +103,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
doc.data.lines.map((line) => ({
|
||||
key: String(line.salesInvoiceLineId),
|
||||
itemId: line.itemId,
|
||||
uomId: line.uomId,
|
||||
warehouseId: line.warehouseId,
|
||||
qty: line.qty,
|
||||
freeQty: line.freeQty,
|
||||
@@ -152,11 +151,15 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
updateLine(key, {
|
||||
itemId,
|
||||
uomId: item?.baseUomId ?? 0,
|
||||
unitPrice: getSuggestedUnitPrice(items, itemId),
|
||||
})
|
||||
}
|
||||
|
||||
function resolveLineUnitPrice(line: Line): number | null {
|
||||
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
|
||||
return getSuggestedUnitPrice(items, line.itemId)
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
setLines((prev) => [...prev, { ...blankLine(`line-${Date.now()}`), warehouseId: warehouseId ?? 0 }])
|
||||
}
|
||||
@@ -167,7 +170,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
|
||||
async function save() {
|
||||
if (!customerId || !warehouseId || !etag) return
|
||||
if (lines.some((line) => !line.itemId || !line.uomId || !line.warehouseId)) {
|
||||
if (lines.some((line) => !line.itemId || !line.warehouseId)) {
|
||||
setError("Select item, UOM and warehouse for every line.")
|
||||
return
|
||||
}
|
||||
@@ -181,14 +184,13 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
customerId,
|
||||
warehouseId,
|
||||
invoiceType,
|
||||
lines: lines.map((line) => ({
|
||||
itemId: Number(line.itemId),
|
||||
uomId: Number(line.uomId),
|
||||
warehouseId: Number(line.warehouseId),
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
|
||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||
lines: lines.map((line) => ({
|
||||
itemId: Number(line.itemId),
|
||||
warehouseId: Number(line.warehouseId),
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
unitPrice: resolveLineUnitPrice(line),
|
||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||
discountMode: line.discountMode,
|
||||
discountPct: Number(line.discountPct),
|
||||
discountAmount: Number(line.discountAmount),
|
||||
@@ -348,7 +350,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">{baseUomLabel(items, uoms, line.itemId)}</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>
|
||||
@@ -378,8 +380,19 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
{invoice.status === "Draft" && postingCheck && !postingCheck.canPost ? (
|
||||
<div className="border-t pt-5">
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<div className="font-semibold">Stock shortage detected before posting</div>
|
||||
<div className="mt-1 text-sm">The invoice cannot be posted until every line has enough available stock in the selected warehouse.</div>
|
||||
<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 text-sm">The invoice cannot be posted until every line 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">
|
||||
@@ -432,6 +445,15 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
<Save className="size-4" />
|
||||
{saving ? "Saving..." : "Save invoice"}
|
||||
</button>
|
||||
{postingCheck && !postingCheck.canPost ? (
|
||||
<Link
|
||||
href="/dashboard/receiving/grn/new"
|
||||
className="inline-flex h-9 items-center gap-2 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" />
|
||||
Resolve stock in GRN
|
||||
</Link>
|
||||
) : null}
|
||||
<button type="button" onClick={post} disabled={!canPost} className="inline-flex h-9 items-center gap-2 rounded-full bg-black px-4 text-sm font-medium text-white shadow-sm hover:bg-black/90 disabled:cursor-not-allowed disabled:opacity-40">
|
||||
<Send className="size-4" />
|
||||
{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : busy === "post" ? "Posting..." : "Post invoice"}
|
||||
@@ -471,19 +493,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3 min-w-40">
|
||||
<select
|
||||
value={line.uomId ? String(line.uomId) : ""}
|
||||
onChange={(e) => updateLine(line.key, { uomId: Number(e.target.value) })}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">UOM</option>
|
||||
{uoms.map((u) => (
|
||||
<option key={u.uomId} value={String(u.uomId)}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<td className="px-4 py-3 min-w-40 text-sm text-muted-foreground">
|
||||
{baseUomLabel(items, uoms, line.itemId)}
|
||||
</td>
|
||||
<td className="px-4 py-3 w-28">
|
||||
<input
|
||||
@@ -496,15 +507,15 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 w-28">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={line.freeQty}
|
||||
onChange={(e) => updateLine(line.key, { freeQty: 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"
|
||||
/>
|
||||
</td>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={line.freeQty}
|
||||
onChange={(e) => updateLine(line.key, { freeQty: 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"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 w-32">
|
||||
<input
|
||||
type="number"
|
||||
@@ -514,6 +525,11 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
||||
/>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{getSuggestedUnitPrice(items, line.itemId) !== null
|
||||
? `Suggested: ${money.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
|
||||
: "No price suggestion available"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 w-24 text-right">
|
||||
<button
|
||||
|
||||
@@ -8,6 +8,7 @@ import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
@@ -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>{baseUomLabel(items, uoms, line.itemId)}</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>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
@@ -36,7 +37,6 @@ type ActiveFocScheme = {
|
||||
const blankLine = (key: string): Line => ({
|
||||
key,
|
||||
itemId: 0,
|
||||
uomId: 0,
|
||||
warehouseId: 0,
|
||||
qty: 1,
|
||||
freeQty: 0,
|
||||
@@ -92,7 +92,6 @@ export default function NewSalesInvoicePage() {
|
||||
{
|
||||
...blankLine("line-1"),
|
||||
itemId: itemRes.items[0]?.itemId ?? 0,
|
||||
uomId: itemRes.items[0]?.baseUomId ?? uomRes.items[0]?.uomId ?? 0,
|
||||
warehouseId: defaultWarehouseId ?? 0,
|
||||
unitPrice: getSuggestedUnitPrice(itemRes.items, itemRes.items[0]?.itemId),
|
||||
},
|
||||
@@ -131,7 +130,6 @@ export default function NewSalesInvoicePage() {
|
||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||
updateLine(key, {
|
||||
itemId,
|
||||
uomId: item?.baseUomId ?? 0,
|
||||
unitPrice: getSuggestedUnitPrice(items, itemId),
|
||||
})
|
||||
}
|
||||
@@ -181,7 +179,6 @@ export default function NewSalesInvoicePage() {
|
||||
if (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.")
|
||||
if (lines.some((line) => !line.itemId)) return setSubmitError("Select an item for every line.")
|
||||
if (lines.some((line) => !line.warehouseId)) return setSubmitError("Select a warehouse for every line.")
|
||||
if (lines.some((line) => !line.uomId)) return setSubmitError("Select a valid UOM for every line.")
|
||||
|
||||
const payload: CreateSalesInvoiceRequest = {
|
||||
customerId,
|
||||
@@ -189,7 +186,6 @@ export default function NewSalesInvoicePage() {
|
||||
invoiceType,
|
||||
lines: lines.map((line) => ({
|
||||
itemId: Number(line.itemId),
|
||||
uomId: Number(line.uomId),
|
||||
warehouseId: Number(line.warehouseId),
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
@@ -357,19 +353,8 @@ export default function NewSalesInvoicePage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2 min-w-36">
|
||||
<Select value={line.uomId ? String(line.uomId) : ""} onValueChange={(v) => updateLine(line.key, { uomId: Number(v) })}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
<SelectItem key={u.uomId} value={String(u.uomId)}>
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TableCell className="px-4 py-2 min-w-36 text-sm text-muted-foreground">
|
||||
{baseUomLabel(items, uoms, line.itemId)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2">
|
||||
<Input
|
||||
@@ -400,6 +385,11 @@ export default function NewSalesInvoicePage() {
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
||||
className="h-9 w-28 text-right font-mono text-sm tabular-nums"
|
||||
/>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{getSuggestedUnitPrice(items, line.itemId) !== null
|
||||
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
|
||||
: "No price suggestion available"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2">
|
||||
<Input
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user