Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2a218e2f8 | |||
| a7ba3d3e04 | |||
| 0ae80395cf | |||
| 15ddac178c | |||
| d37824cecc | |||
| 271c940640 | |||
| f2825900aa | |||
| 6520930aeb | |||
| a8c6b4cb5e | |||
| 8e9974b735 | |||
| c4e016c460 | |||
| 7e8418685c | |||
| cbc72ef830 | |||
| 4324ba1a96 | |||
| 1af16d3dec | |||
| f140959b43 | |||
| c31e23c2b9 | |||
| 8e24ed6375 | |||
| 9f22026784 | |||
| ef105302bd | |||
| d7ee83828c | |||
| eb7b2691df | |||
| 1a0fb4603e | |||
| 5fc5ef59ac | |||
| d1fe164ea2 | |||
| 02f47bd485 | |||
| 45554ceb9a | |||
| 5d0ea3f035 | |||
| a414dfc4ea | |||
| 3cccaf4c63 | |||
| b7bd8dca5c |
+9
-8
@@ -30,12 +30,13 @@ yarn-error.log*
|
||||
Thumbs.db
|
||||
.idea/
|
||||
|
||||
# ── Playwright E2E (Testing/e2e) ──────────────────────────────────────
|
||||
Testing/e2e/playwright-report/
|
||||
Testing/e2e/test-results/
|
||||
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>();
|
||||
|
||||
@@ -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=ERPCoreDev;Username=postgres;Password=root"
|
||||
"DefaultConnection": "Host=127.0.0.1;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
|
||||
},
|
||||
"AuthHex": {
|
||||
"BaseUrl": "http://localhost:5011"
|
||||
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "ERPCore",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
+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:
|
||||
|
||||
@@ -129,6 +129,8 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
> **Fields not explicitly spelled out verbatim in GL's reference** (its own numeric-id column names for `ChequeBook`/`ChequePage`, and `ReceivedCheque`'s JSON id field) are built from the request-body field names GL *does* document plus this project's consistent `<entity>Id` convention, flagged in `types/general-ledger.ts`'s comments — `chequeNo`/`chequeBookNo` (both explicitly documented as the identifying route values) are used for keys/URLs throughout instead, sidestepping the guess entirely wherever possible. **Not done:** live smoke test against a running GL instance — this entire module is unverified against real Cheque Management data. Verified: `tsc --noEmit`/`eslint` clean on every touched file; `npm run build` compiles successfully (Turbopack), its full-project TypeScript check still blocked only by the pre-existing, unrelated `hrm/employees/[id]` error.
|
||||
>
|
||||
> **2026-07-20 (3) — General Ledger report corrected again: `accountId` dropped entirely, not just made direct-entry.** The GL service's own contract changed (confirmed against its updated docs): `GeneralLedger`'s `accountId` is now optional, and the *omitted* case is the real General Ledger (every postable account together, each with its own running balance, sorted by `accountCode` then `entryDate`) — supplying `accountId` is a separate "Account Ledger" (single account + descendants) mode this page doesn't use. Superseding the same-day entry above: the numeric Account ID input is gone, `reportsApi.generalLedger()` dropped the `accountId` parameter, and the page now fetches on `periodStart`/`periodEnd` alone with sensible defaults — auto-fetching on page load like every other report screen (this also resolves the earlier-reported "no network call when landing on the page," which was the now-removed account-required gate). Result rows are grouped into per-account sections in the table (a header row wherever `accountCode` changes), matching the API's per-account running-balance reset. No frontend change was needed for the same-day `BalanceSheet` response addition (a synthetic `"Current Year Earnings"` equity row) — the existing generic row renderer already displays whatever rows come back. Verified: `tsc --noEmit` clean, `npx eslint app/dashboard/ledgers lib/api/general-ledger.ts` produces zero output, `npm run build` succeeds.
|
||||
>
|
||||
> **2026-08-05 — Cheque Management status/type fields were rendering as raw integers, not names (user-reported + confirmed with GL's own `06_Enums_Reference.md`).** That doc's key fact: GL has no global `JsonStringEnumConverter`. A JSON-**body** enum field (e.g. the Issue-cheque form's `payeeType`) is independently declared `string` server-side and parsed via `Enum.TryParse`, and a query-string enum filter binds natively by name — both already correct here, unaffected. But `ChequeBook.status`, `ChequePage.issueStatus`, `ChequePage.payeeType`, `ReceivedCheque.receivedFromType`, and `ReceivedCheque.status` are genuine enum-typed properties on GL's own **response** DTOs, backed by real `integer` DB columns — with no converter, GL's JSON serializes each one as its raw number (`1`/`2`/`3`/...), not its name. This wasn't just a cosmetic label bug: every list badge, the dialogs' status-based available-actions logic, and any `===` comparison against this frontend's own string enums (`ChequeBookStatus.Active`, etc.) would have silently mismatched against these numbers. Fixed at the API boundary, not scattered across every consumer: added five `*_BY_CODE` lookup maps to `types/general-ledger.ts` (one per affected field, keyed by the exact integers `06_Enums_Reference.md` documents), and applied them in `lib/api/general-ledger.ts` via new `Raw*` types (describing GL's actual `number`/`number | null` response shape for these fields) plus `mapChequeBook`/`mapChequePage`/`mapReceivedCheque` helpers wired into every `chequeBooksApi`/`chequePagesApi`/`receivedChequesApi` method that returns one — so every page/dialog/badge map keeps working against the same string values as before, unchanged. Cross-checked every other enum in that doc's "Persisted enums" table against this frontend (`JournalEntryStatus`/`PeriodStatus`/`TaxCalculationBasis`/`TaxAppliesTo`/`DepreciationMethod`/`FixedAssetStatus`/`AuditCategory`/`AuditAction`) — none are consumed anywhere in this app, confirming Cheque Management was the complete fix, not a partial one. Verified: `tsc --noEmit`/`eslint` clean on both touched files.
|
||||
|
||||
## 7. UX states
|
||||
- [~] Loading / empty / error states on every list — done for the GRN list/create/detail screens; other screens still unbuilt
|
||||
|
||||
@@ -170,6 +170,7 @@ export default function EmployeeDetailPage() {
|
||||
emergencyContactName: employee.emergencyContactName,
|
||||
emergencyContactRelationship: employee.emergencyContactRelationship,
|
||||
emergencyContactPhone: employee.emergencyContactPhone,
|
||||
hireDate: employee.hireDate,
|
||||
confirmationDate: employee.confirmationDate,
|
||||
lastWorkingDate: employee.lastWorkingDate,
|
||||
departmentId: employee.departmentId,
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, Ban, Plus, Save, Send, Trash2 } from "lucide-react"
|
||||
|
||||
import { AlertTriangle, ArrowLeft, Ban, Check, Plus, Save, Trash2 } from "lucide-react"
|
||||
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"
|
||||
@@ -29,7 +29,6 @@ interface DraftLine {
|
||||
key: string
|
||||
poLineId: number | null
|
||||
itemId: number | null
|
||||
uomId: number | null
|
||||
warehouseId: number | null
|
||||
qty: string
|
||||
unitPrice: string
|
||||
@@ -73,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),
|
||||
@@ -112,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}`
|
||||
}
|
||||
@@ -142,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,
|
||||
@@ -158,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),
|
||||
@@ -195,10 +188,10 @@ export default function PurchaseOrderDetailPage() {
|
||||
const updated = await purchaseOrdersApi.submit(po.poId)
|
||||
setPo(updated)
|
||||
setLines(toDraftLines(updated))
|
||||
toast.success("Purchase order submitted", `${updated.docNo} — now ${updated.status} and locked for editing.`)
|
||||
toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status} and locked for editing.`)
|
||||
} catch (err) {
|
||||
setSaveError(errorMessage(err))
|
||||
toast.error("Could not submit purchase order", errorMessage(err))
|
||||
toast.error("Could not approve purchase order", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -283,9 +276,9 @@ export default function PurchaseOrderDetailPage() {
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{po.status === "Draft" && (
|
||||
<>
|
||||
<Button variant="outline" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
|
||||
<Send className="size-5" />
|
||||
{submitting ? "Submitting…" : "Submit"}
|
||||
<Button variant="success" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
|
||||
<Check className="size-5" />
|
||||
{submitting ? "Approving�" : "Approve"}
|
||||
</Button>
|
||||
<Button variant="destructive" size="lg" onClick={handleDelete} disabled={deleting || submitting}>
|
||||
<Trash2 className="size-5" />
|
||||
@@ -348,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>
|
||||
@@ -377,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>
|
||||
@@ -404,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 })}>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
import { ArrowLeft, ExternalLink, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { requisitionsApi } from "@/lib/api/requisitions"
|
||||
@@ -12,16 +12,26 @@ 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"
|
||||
import { generateVendorCode } from "@/lib/vendor-code"
|
||||
import { CreatePoLineInput } from "@/types/procurement"
|
||||
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -30,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
|
||||
@@ -43,12 +52,12 @@ function newKey() {
|
||||
return `poline-${keySeq}`
|
||||
}
|
||||
|
||||
// Unit price and tax are no longer entered at PO creation — pricing is captured at GRN
|
||||
// receipt (with discount/VAT there). They default to 0 here and stay off the form, but
|
||||
// remain on the payload because the backend line DTO still requires them; a PO prefilled
|
||||
// from an RFQ keeps its negotiated price (below).
|
||||
// Tax is still not entered at PO creation — it's captured at GRN receipt (with discount/VAT
|
||||
// there) and stays off this form, though it remains on the payload since the backend line
|
||||
// DTO still requires it. Unit price *is* entered here; a PO prefilled from an RFQ starts
|
||||
// from its negotiated price (below) but stays editable.
|
||||
function emptyLine(): DraftLine {
|
||||
return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "0", tax: "0" }
|
||||
return { key: newKey(), itemId: null, warehouseId: null, qty: "", unitPrice: "0", tax: "0" }
|
||||
}
|
||||
|
||||
function NewPurchaseOrderContent() {
|
||||
@@ -73,22 +82,83 @@ function NewPurchaseOrderContent() {
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const [vendorDialogOpen, setVendorDialogOpen] = useState(false)
|
||||
const [vName, setVName] = useState("")
|
||||
const [vTerms, setVTerms] = useState("")
|
||||
const [vTaxReg, setVTaxReg] = useState("")
|
||||
const [vCurrency, setVCurrency] = useState("LKR")
|
||||
const [vErrors, setVErrors] = useState<Record<string, string>>({})
|
||||
const [vSubmitting, setVSubmitting] = useState(false)
|
||||
|
||||
const generatedVendorCode = vName.trim() ? generateVendorCode(vName, (vendors ?? []).map((v) => v.code)) : ""
|
||||
|
||||
function loadItems() {
|
||||
return itemsApi.list({ pageSize: 200, status: "Active" }).then((it) => setItems(it.items))
|
||||
}
|
||||
function loadVendors() {
|
||||
return vendorsApi.list({ pageSize: 200, status: "Active" }).then((ve) => setVendors(ve.items))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
itemsApi.list({ pageSize: 200, status: "Active" }),
|
||||
uomsApi.list(),
|
||||
warehousesApi.list(),
|
||||
vendorsApi.list({ pageSize: 200, status: "Active" }),
|
||||
])
|
||||
.then(([it, uo, wh, ve]) => {
|
||||
setItems(it.items)
|
||||
setUoms(uo.items)
|
||||
setWarehouses(wh.items)
|
||||
setVendors(ve.items)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
loadItems(),
|
||||
uomsApi.list().then((uo) => setUoms(uo.items)),
|
||||
warehousesApi.list().then((wh) => setWarehouses(wh.items)),
|
||||
loadVendors(),
|
||||
]).catch((err) => setLoadError(errorMessage(err)))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// A new item is created on the standalone item builder (too many fields for a modal here),
|
||||
// typically in another tab — refetch on refocus so it shows up in the line pickers without
|
||||
// the user having to reload this page and lose their draft.
|
||||
useEffect(() => {
|
||||
function onFocus() {
|
||||
loadItems().catch(() => {})
|
||||
}
|
||||
window.addEventListener("focus", onFocus)
|
||||
return () => window.removeEventListener("focus", onFocus)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
function resetVendorForm() {
|
||||
setVName("")
|
||||
setVTerms("")
|
||||
setVTaxReg("")
|
||||
setVCurrency("LKR")
|
||||
setVErrors({})
|
||||
}
|
||||
|
||||
async function handleCreateVendor() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!vName.trim()) nextErrors.name = "Vendor name is required"
|
||||
if (!vCurrency.trim()) nextErrors.currency = "Currency is required"
|
||||
setVErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setVSubmitting(true)
|
||||
try {
|
||||
const result = await vendorsApi.create({
|
||||
code: generatedVendorCode,
|
||||
name: vName,
|
||||
terms: vTerms || null,
|
||||
taxReg: vTaxReg || null,
|
||||
currency: vCurrency,
|
||||
})
|
||||
await loadVendors()
|
||||
setVendorId(result.data.vendorId)
|
||||
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
|
||||
setVendorDialogOpen(false)
|
||||
resetVendorForm()
|
||||
} catch (err) {
|
||||
// A 409 here means another creation raced ours for the same generated code — the
|
||||
// proactive de-dupe above only knows about vendors loaded when the dialog opened.
|
||||
toast.error("Could not create vendor", errorMessage(err))
|
||||
} finally {
|
||||
setVSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (requisitionId) {
|
||||
requisitionsApi
|
||||
@@ -99,7 +169,6 @@ function NewPurchaseOrderContent() {
|
||||
(l): DraftLine => ({
|
||||
key: newKey(),
|
||||
itemId: l.itemId,
|
||||
uomId: null,
|
||||
warehouseId: null,
|
||||
qty: String(l.qty),
|
||||
unitPrice: "0",
|
||||
@@ -125,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",
|
||||
@@ -172,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,
|
||||
@@ -188,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),
|
||||
@@ -236,43 +302,72 @@ function NewPurchaseOrderContent() {
|
||||
{!loading && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Vendor code</Label>
|
||||
<Select<number | null>
|
||||
value={vendorId}
|
||||
onValueChange={setVendorId}
|
||||
items={(vendors ?? []).map((v) => ({ label: v.code, value: v.vendorId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select vendor code" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(vendors ?? []).map((v) => (
|
||||
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
||||
{v.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Vendor name</Label>
|
||||
<Select<number | null>
|
||||
value={vendorId}
|
||||
onValueChange={setVendorId}
|
||||
items={(vendors ?? []).map((v) => ({ label: v.name, value: v.vendorId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select vendor name" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(vendors ?? []).map((v) => (
|
||||
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
||||
{v.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex flex-col gap-2 sm:col-span-1">
|
||||
<Label className="text-base">Vendor</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select<number | null>
|
||||
value={vendorId}
|
||||
onValueChange={setVendorId}
|
||||
items={(vendors ?? []).map((v) => ({ label: `${v.code} — ${v.name}`, value: v.vendorId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select vendor" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(vendors ?? []).map((v) => (
|
||||
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
||||
{v.code} — {v.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Dialog open={vendorDialogOpen} onOpenChange={(o) => { setVendorDialogOpen(o); if (!o) resetVendorForm() }}>
|
||||
<DialogTrigger
|
||||
render={<Button type="button" variant="outline" size="icon-lg" aria-label="New vendor" title="New vendor" />}
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New vendor</DialogTitle>
|
||||
<DialogDescription>Create a supplier record without leaving this PO. Its code is generated from the name.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!vErrors.name}>
|
||||
<FieldLabel htmlFor="po-v-name">Name</FieldLabel>
|
||||
<Input id="po-v-name" value={vName} onChange={(e) => setVName(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!vErrors.name} />
|
||||
<FieldError errors={[vErrors.name ? { message: vErrors.name } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="po-v-code">Code (auto-generated)</FieldLabel>
|
||||
<Input id="po-v-code" value={generatedVendorCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="po-v-terms">Payment terms (optional)</FieldLabel>
|
||||
<Input id="po-v-terms" value={vTerms} onChange={(e) => setVTerms(e.target.value)} placeholder="NET30" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="po-v-taxreg">Tax registration (optional)</FieldLabel>
|
||||
<Input id="po-v-taxreg" value={vTaxReg} onChange={(e) => setVTaxReg(e.target.value)} placeholder="134567890-7000" />
|
||||
</Field>
|
||||
<Field data-invalid={!!vErrors.currency}>
|
||||
<FieldLabel htmlFor="po-v-currency">Currency</FieldLabel>
|
||||
<Input id="po-v-currency" value={vCurrency} onChange={(e) => setVCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!vErrors.currency} />
|
||||
<FieldError errors={[vErrors.currency ? { message: vErrors.currency } : undefined]} />
|
||||
</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={() => setVendorDialogOpen(false)} disabled={vSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreateVendor} disabled={vSubmitting}>
|
||||
{vSubmitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
{requisitionId && (
|
||||
<div className="flex flex-col justify-end pb-2.5 text-sm text-muted-foreground">From Requisition #{requisitionId}</div>
|
||||
@@ -287,21 +382,34 @@ function NewPurchaseOrderContent() {
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/dashboard/products/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(buttonVariants({ variant: "outline" }))}
|
||||
title="Opens in a new tab — the item list here refreshes when you come back"
|
||||
>
|
||||
<ExternalLink className="size-5" />
|
||||
New item
|
||||
</Link>
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lines.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-40 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-16 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-20 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -313,11 +421,15 @@ function NewPurchaseOrderContent() {
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{requisitionId || rfqId ? (
|
||||
<div className="flex h-11 items-center text-base">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</div>
|
||||
<div className="flex h-11 items-center truncate text-base" title={item ? `${item.sku} — ${item.name}` : undefined}>{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||
<SelectTrigger
|
||||
className="h-11! w-full text-base"
|
||||
aria-invalid={!!errors.itemId}
|
||||
title={item ? `${item.sku} — ${item.name}` : undefined}
|
||||
>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -333,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 })}>
|
||||
@@ -374,6 +476,18 @@ function NewPurchaseOrderContent() {
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.unitPrice}
|
||||
aria-invalid={!!errors.unitPrice}
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||
<Trash2 className="size-5" />
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ChevronLeft, ChevronRight, Plus, ShoppingCart } from "lucide-react"
|
||||
import { Check, ChevronLeft, ChevronRight, Eye, Pencil, Plus, ShoppingCart, Trash2 } from "lucide-react"
|
||||
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement"
|
||||
@@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { PoStatusBadge } from "@/components/procurement/status-badges"
|
||||
|
||||
type StatusFilter = PurchaseOrderStatus | "All"
|
||||
@@ -32,6 +33,8 @@ export default function PurchaseOrdersListPage() {
|
||||
const [query, setQuery] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [approvingId, setApprovingId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
@@ -60,6 +63,34 @@ export default function PurchaseOrdersListPage() {
|
||||
return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}`
|
||||
}
|
||||
|
||||
async function handleDelete(po: PurchaseOrderSummary) {
|
||||
if (!window.confirm(`Delete draft ${po.docNo}? This cannot be undone.`)) return
|
||||
setDeletingId(po.poId)
|
||||
try {
|
||||
await purchaseOrdersApi.remove(po.poId)
|
||||
toast.success("Draft deleted", po.docNo)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not delete purchase order", errorMessage(err))
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprove(po: PurchaseOrderSummary) {
|
||||
if (!window.confirm(`Approve ${po.docNo}? It will be locked for editing once approved.`)) return
|
||||
setApprovingId(po.poId)
|
||||
try {
|
||||
const updated = await purchaseOrdersApi.submit(po.poId)
|
||||
toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status}.`)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not approve purchase order", errorMessage(err))
|
||||
} finally {
|
||||
setApprovingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const hasFilters = query.length > 0 || status !== "All"
|
||||
|
||||
return (
|
||||
@@ -133,24 +164,76 @@ export default function PurchaseOrdersListPage() {
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Grand total</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pos.map((po) => (
|
||||
<TableRow key={po.poId}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
|
||||
{po.docNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{vendorCode(po.vendorId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<PoStatusBadge status={po.status} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{pos.map((po) => {
|
||||
const editable = isPoEditable(po.status)
|
||||
return (
|
||||
<TableRow key={po.poId}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
|
||||
{po.docNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{vendorCode(po.vendorId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<PoStatusBadge status={po.status} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||
aria-label="View"
|
||||
title="View"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
{editable && (
|
||||
<>
|
||||
<Link
|
||||
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||
aria-label="Edit draft"
|
||||
title="Edit draft"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Approve draft"
|
||||
title="Approve draft"
|
||||
disabled={approvingId === po.poId || deletingId === po.poId}
|
||||
onClick={() => handleApprove(po)}
|
||||
className="text-success hover:bg-success/10 hover:text-success"
|
||||
>
|
||||
<Check className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Delete draft"
|
||||
title="Delete draft"
|
||||
disabled={deletingId === po.poId || approvingId === po.poId}
|
||||
onClick={() => handleDelete(po)}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
+338
-269
@@ -1,9 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { Plus, Trash2, X } from "lucide-react"
|
||||
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,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
} from "./types"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
@@ -34,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">
|
||||
@@ -63,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,
|
||||
@@ -107,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 },
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -121,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>) {
|
||||
@@ -175,265 +225,284 @@ export function StageEditorPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:w-96">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-base font-bold text-foreground">Stage editor</h2>
|
||||
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground" aria-label="Close panel">
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<Dialog open onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="max-h-[85vh] w-full max-w-lg overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Stage editor</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input value={data.name} disabled={readOnly} onChange={(e) => onChange({ name: e.target.value })} placeholder="e.g. Welding" />
|
||||
</Field>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input value={data.name} disabled={readOnly} onChange={(e) => onChange({ name: e.target.value })} placeholder="e.g. Welding" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Role label</FieldLabel>
|
||||
<Input
|
||||
value={data.roleLabel}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange({ roleLabel: e.target.value })}
|
||||
placeholder="e.g. QA"
|
||||
list="role-suggestions"
|
||||
/>
|
||||
<datalist id="role-suggestions">
|
||||
{ROLE_SUGGESTIONS.map((r) => (
|
||||
<option key={r} value={r} />
|
||||
))}
|
||||
</datalist>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Role label</FieldLabel>
|
||||
<Input
|
||||
value={data.roleLabel}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange({ roleLabel: e.target.value })}
|
||||
placeholder="e.g. QA"
|
||||
list="role-suggestions"
|
||||
/>
|
||||
<datalist id="role-suggestions">
|
||||
{ROLE_SUGGESTIONS.map((r) => (
|
||||
<option key={r} value={r} />
|
||||
))}
|
||||
</datalist>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Estimated minutes</FieldLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={data.estimatedMinutes}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange({ estimatedMinutes: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Estimated minutes</FieldLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={data.estimatedMinutes}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange({ estimatedMinutes: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Inputs */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">Inputs</p>
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addInput}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.inputs.length === 0 && <p className="text-sm text-muted-foreground">No inputs yet.</p>}
|
||||
{data.inputs.map((input) => (
|
||||
<div key={input.localId} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<Select<StageInputSource>
|
||||
value={input.source}
|
||||
onValueChange={(v) => v && changeInputSource(input.localId, v)}
|
||||
>
|
||||
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Stock" className="text-sm">Stock</SelectItem>
|
||||
<SelectItem value="Upstream" className="text-sm">Upstream</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeInput(input.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove input">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{input.source === "Stock" ? (
|
||||
<Select<number>
|
||||
value={input.itemId}
|
||||
onValueChange={(v) => v && pickInputItem(input, v)}
|
||||
{/* Inputs */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">Inputs</p>
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addInput}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.inputs.length === 0 && <p className="text-sm text-muted-foreground">No inputs yet.</p>}
|
||||
{data.inputs.map((input) => (
|
||||
<div key={input.localId} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<Select<StageInputSource>
|
||||
value={input.source}
|
||||
onValueChange={(v) => v && changeInputSource(input.localId, v)}
|
||||
>
|
||||
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly}>
|
||||
<SelectValue placeholder="Pick an item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.name} · {i.sku}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Select<string>
|
||||
value={input.fromOutputKey}
|
||||
onValueChange={(v) => v && updateInput(input.localId, { fromOutputKey: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly || upstreamOptions.length === 0}>
|
||||
<SelectValue placeholder={upstreamOptions.length === 0 ? "No upstream stages connected" : "Pick an upstream output"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{upstreamOptions.map((o) => (
|
||||
<SelectItem key={o.outputKey} value={o.outputKey} className="text-sm">
|
||||
{o.stageName} — {o.outputName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<QtyRow
|
||||
qty={input.qtyPerBatch}
|
||||
uomId={input.uomId}
|
||||
uoms={uoms}
|
||||
readOnly={readOnly}
|
||||
onQtyChange={(qtyPerBatch) => updateInput(input.localId, { qtyPerBatch })}
|
||||
onUomChange={(uomId) => updateInput(input.localId, { uomId })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outputs */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Outputs{isTerminal && <span className="ml-1.5 font-normal text-muted-foreground">(terminal — finished good)</span>}
|
||||
</p>
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addOutput}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.outputs.length === 0 && <p className="text-sm text-muted-foreground">No outputs yet.</p>}
|
||||
{data.outputs.map((output) => (
|
||||
<div key={output.key} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{isTerminal ? (
|
||||
<Select<number> value={output.itemId} onValueChange={(v) => v && pickOutputItem(output, v)}>
|
||||
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectValue placeholder="Pick the finished-good item" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.name} · {i.sku}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="Stock" className="text-sm">Stock</SelectItem>
|
||||
<SelectItem value="Upstream" className="text-sm">Upstream</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={output.name}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateOutput(output.key, { name: e.target.value })}
|
||||
placeholder="Output name (work in progress)"
|
||||
className="h-8 flex-1 text-sm"
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeOutput(output.key)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove output">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<QtyRow
|
||||
qty={output.qtyPerBatch}
|
||||
uomId={output.uomId}
|
||||
uoms={uoms}
|
||||
readOnly={readOnly}
|
||||
onQtyChange={(qtyPerBatch) => updateOutput(output.key, { qtyPerBatch })}
|
||||
onUomChange={(uomId) => updateOutput(output.key, { uomId })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeInput(input.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove input">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Custom fields */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">Custom fields</p>
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addField}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.fieldDefs.length === 0 && <p className="text-sm text-muted-foreground">No custom fields.</p>}
|
||||
{data.fieldDefs.map((field) => (
|
||||
<div key={field.localId} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Input
|
||||
value={field.label}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateField(field.localId, { label: e.target.value })}
|
||||
placeholder="Label"
|
||||
className="h-8 flex-1 text-sm"
|
||||
/>
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeField(field.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove field">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{field.key && <p className="mb-2 font-mono text-xs text-muted-foreground">key: {field.key}</p>}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select<CustomFieldType>
|
||||
value={field.type}
|
||||
onValueChange={(v) => v && updateField(field.localId, { type: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t} className="text-sm">{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={field.required}
|
||||
disabled={readOnly}
|
||||
onCheckedChange={(checked) => updateField(field.localId, { required: checked })}
|
||||
<div className="flex flex-col gap-2">
|
||||
{input.source === "Stock" ? (
|
||||
<Select<number>
|
||||
value={input.itemId}
|
||||
onValueChange={(v) => v && pickInputItem(input, v)}
|
||||
>
|
||||
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly}>
|
||||
<SelectValue placeholder="Pick an item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.name} · {i.sku}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Select<string>
|
||||
value={input.fromOutputKey}
|
||||
onValueChange={(v) => v && updateInput(input.localId, { fromOutputKey: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly || upstreamOptions.length === 0}>
|
||||
<SelectValue placeholder={upstreamOptions.length === 0 ? "No upstream stages connected" : "Pick an upstream output"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{upstreamOptions.map((o) => (
|
||||
<SelectItem key={o.outputKey} value={o.outputKey} className="text-sm">
|
||||
{o.stageName} — {o.outputName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<QtyRow
|
||||
qty={input.qtyPerBatch}
|
||||
readOnly={readOnly}
|
||||
onQtyChange={(qtyPerBatch) => updateInput(input.localId, { qtyPerBatch })}
|
||||
unit={<InputUnitControl
|
||||
input={input}
|
||||
items={items}
|
||||
uoms={uoms}
|
||||
readOnly={readOnly}
|
||||
onChange={(qtyUnit) => updateInput(input.localId, { qtyUnit })}
|
||||
/>}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Required</span>
|
||||
</div>
|
||||
</div>
|
||||
{field.type === "Select" && (
|
||||
<Input
|
||||
value={field.options.join(", ")}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateField(field.localId, { options: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })}
|
||||
placeholder="Options, comma separated"
|
||||
className="mt-2 h-8 text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="outline" className={cn("mt-2 text-destructive hover:bg-destructive/10")} onClick={onDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete stage
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Outputs */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Outputs{isTerminal && <span className="ml-1.5 font-normal text-muted-foreground">(terminal — finished good)</span>}
|
||||
</p>
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addOutput}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.outputs.length === 0 && <p className="text-sm text-muted-foreground">No outputs yet.</p>}
|
||||
{data.outputs.map((output) => (
|
||||
<div key={output.key} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{isTerminal ? (
|
||||
<Select<number> value={output.itemId} onValueChange={(v) => v && pickOutputItem(output, v)}>
|
||||
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectValue placeholder="Pick the finished-good item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.name} · {i.sku}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={output.name}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateOutput(output.key, { name: e.target.value })}
|
||||
placeholder="Output name (work in progress)"
|
||||
className="h-8 flex-1 text-sm"
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeOutput(output.key)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove output">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<QtyRow
|
||||
qty={output.qtyPerBatch}
|
||||
readOnly={readOnly}
|
||||
onQtyChange={(qtyPerBatch) => updateOutput(output.key, { qtyPerBatch })}
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom fields */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">Custom fields</p>
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addField}>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.fieldDefs.length === 0 && <p className="text-sm text-muted-foreground">No custom fields.</p>}
|
||||
{data.fieldDefs.map((field) => (
|
||||
<div key={field.localId} className="rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Input
|
||||
value={field.label}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateField(field.localId, { label: e.target.value })}
|
||||
placeholder="Label"
|
||||
className="h-8 flex-1 text-sm"
|
||||
/>
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeField(field.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove field">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{field.key && <p className="mb-2 font-mono text-xs text-muted-foreground">key: {field.key}</p>}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select<CustomFieldType>
|
||||
value={field.type}
|
||||
onValueChange={(v) => v && updateField(field.localId, { type: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t} className="text-sm">{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={field.required}
|
||||
disabled={readOnly}
|
||||
onCheckedChange={(checked) => updateField(field.localId, { required: checked })}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Required</span>
|
||||
</div>
|
||||
</div>
|
||||
{field.type === "Select" && (
|
||||
<Input
|
||||
value={field.options.join(", ")}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => updateField(field.localId, { options: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })}
|
||||
placeholder="Options, comma separated"
|
||||
className="mt-2 h-8 text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<Button type="button" variant="outline" className={cn("mt-2 text-destructive hover:bg-destructive/10")} onClick={onDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete stage
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
@@ -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) => ({
|
||||
@@ -142,7 +142,7 @@ function stagesNamedIn(detail: string | undefined, stageNodes: Node[]): Set<stri
|
||||
return new Set(named.map((n) => n.id))
|
||||
}
|
||||
|
||||
export default function TemplateBuilderPage() {
|
||||
function TemplateBuilderContent() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
@@ -414,14 +414,21 @@ export default function TemplateBuilderPage() {
|
||||
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 @@ export default function TemplateBuilderPage() {
|
||||
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 @@ export default function TemplateBuilderPage() {
|
||||
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,
|
||||
})),
|
||||
}
|
||||
@@ -765,46 +773,52 @@ export default function TemplateBuilderPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
<div className="min-w-0 flex-1 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||
{mounted && (
|
||||
<ReactFlow
|
||||
nodes={displayNodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={locked ? undefined : onNodesChange}
|
||||
onEdgesChange={locked ? undefined : onEdgesChange}
|
||||
onNodesDelete={locked ? undefined : onNodesDelete}
|
||||
onConnect={locked ? undefined : onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
nodesDraggable={!locked}
|
||||
nodesConnectable={!locked}
|
||||
elementsSelectable
|
||||
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
||||
fitView
|
||||
>
|
||||
<Background />
|
||||
<Controls showInteractive={!locked} />
|
||||
<MiniMap pannable zoomable />
|
||||
</ReactFlow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedNode && (
|
||||
<StageEditorPanel
|
||||
data={selectedNode.data as StageNodeData}
|
||||
isTerminal={analysis.terminalIds.has(selectedNode.id)}
|
||||
upstreamOptions={upstreamOptions}
|
||||
items={items}
|
||||
uoms={uoms}
|
||||
readOnly={locked}
|
||||
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
|
||||
onDelete={() => deleteNode(selectedNode.id)}
|
||||
onClose={() => setSelectedNodeId(null)}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||
{mounted && (
|
||||
<ReactFlow
|
||||
nodes={displayNodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={locked ? undefined : onNodesChange}
|
||||
onEdgesChange={locked ? undefined : onEdgesChange}
|
||||
onNodesDelete={locked ? undefined : onNodesDelete}
|
||||
onConnect={locked ? undefined : onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
nodesDraggable={!locked}
|
||||
nodesConnectable={!locked}
|
||||
elementsSelectable
|
||||
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
||||
fitView
|
||||
>
|
||||
<Background />
|
||||
<Controls showInteractive={!locked} />
|
||||
<MiniMap pannable zoomable />
|
||||
</ReactFlow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedNode && (
|
||||
<StageEditorPanel
|
||||
data={selectedNode.data as StageNodeData}
|
||||
isTerminal={analysis.terminalIds.has(selectedNode.id)}
|
||||
upstreamOptions={upstreamOptions}
|
||||
items={items}
|
||||
uoms={uoms}
|
||||
readOnly={locked}
|
||||
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
|
||||
onDelete={() => deleteNode(selectedNode.id)}
|
||||
onClose={() => setSelectedNodeId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TemplateBuilderPage() {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton className="h-[60vh] w-full rounded-2xl" />}>
|
||||
<TemplateBuilderContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -188,7 +198,7 @@ export default function ItemDetailPage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">{item.sku}</h1>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}
|
||||
className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive")}
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -257,7 +257,15 @@ export default function BrandsPage() {
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.brandId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{b.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant={b.status === "Active" ? "default" : "secondary"}>{b.status}</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-transparent",
|
||||
b.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{b.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(b.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Ban, CheckCircle2, Network, Pencil, Plus } from "lucide-react"
|
||||
import { categoriesApi, subCategoriesApi } from "@/lib/api/categories"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateCategoryName } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Category, SubCategory } from "@/types/master-data"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
@@ -181,7 +182,15 @@ export default function CategorySubCategoriesPage() {
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{s.subCategoryId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{s.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant={s.status === "Active" ? "default" : "secondary"}>{s.status}</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-transparent",
|
||||
s.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{s.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(s.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
|
||||
@@ -256,7 +256,15 @@ export default function CategoriesPage() {
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant={c.status === "Active" ? "default" : "secondary"}>{c.status}</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-transparent",
|
||||
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{c.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -240,7 +240,7 @@ export default function ItemsPage() {
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||
item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{item.status}
|
||||
|
||||
@@ -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),
|
||||
@@ -440,220 +436,202 @@ export default function NewGrnPage() {
|
||||
|
||||
{!poLoading && lines.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-96 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
|
||||
<TableHead className="h-12 w-20 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Disc %</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">VAT %</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">Batch / Serial</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{line.poLineId ? (
|
||||
<div className="flex h-11 items-center text-base">
|
||||
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||
{i.sku} — {i.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{line.poLineId ? (
|
||||
<div className="flex h-11 items-center text-base">
|
||||
{uoms?.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
|
||||
<SelectValue placeholder="UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(uoms ?? []).map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bins.map((b) => (
|
||||
<SelectItem key={b.binId} value={b.binId} className="text-base">
|
||||
{b.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.qty}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.unitCost}
|
||||
aria-invalid={!!errors.unitCost}
|
||||
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
|
||||
{line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && (
|
||||
<p className="mt-1 text-xs text-warning">
|
||||
PO price {line.poUnitPrice.toFixed(2)} — variance recorded
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.vatPct}
|
||||
aria-invalid={!!errors.vatPct}
|
||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
||||
{(() => {
|
||||
const c = computeLine(line)
|
||||
return (
|
||||
<div className="flex h-11 flex-col justify-center">
|
||||
<span>{c.lineTotal.toFixed(2)}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
net {c.receivedValue.toFixed(2)} + VAT {c.vatAmount.toFixed(2)}
|
||||
</span>
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Disc %</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">VAT %</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">Batch / Serial</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((line) => {
|
||||
const item = itemFor(line.itemId)
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{line.poLineId ? (
|
||||
<div className="flex h-11 items-center text-base">
|
||||
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<HoldStatus>
|
||||
value={line.holdStatus}
|
||||
onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}
|
||||
>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Available" className="text-base">Available</SelectItem>
|
||||
<SelectItem value="OnHold" className="text-base">On hold (inspection)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{item?.trackingMode === "Batch" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Input
|
||||
placeholder="Batch no."
|
||||
value={line.batchNo}
|
||||
aria-invalid={!!errors.batchNo}
|
||||
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={line.expiryDate}
|
||||
onChange={(e) => updateLine(line.key, { expiryDate: e.target.value })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||
{i.sku} — {i.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<div className="flex h-11 items-center text-base text-muted-foreground">
|
||||
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
|
||||
</div>
|
||||
)}
|
||||
{item?.trackingMode === "Serial" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<textarea
|
||||
placeholder="One serial per line"
|
||||
value={line.serialNumbersText}
|
||||
aria-invalid={!!errors.serialNumbers}
|
||||
onChange={(e) => updateLine(line.key, { serialNumbersText: e.target.value })}
|
||||
className="min-h-16 w-full rounded-lg border border-input bg-transparent p-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
/>
|
||||
<FieldError errors={[errors.serialNumbers ? { message: errors.serialNumbers } : undefined]} />
|
||||
</div>
|
||||
)}
|
||||
{(!item || item.trackingMode === "None") && (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bins.map((b) => (
|
||||
<SelectItem key={b.binId} value={b.binId} className="text-base">
|
||||
{b.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.qty}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={line.unitCost}
|
||||
aria-invalid={!!errors.unitCost}
|
||||
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
|
||||
{line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && (
|
||||
<p className="mt-1 text-xs text-warning">
|
||||
PO price {line.poUnitPrice.toFixed(2)} — variance recorded
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.vatPct}
|
||||
aria-invalid={!!errors.vatPct}
|
||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
||||
{(() => {
|
||||
const c = computeLine(line)
|
||||
return (
|
||||
<div className="flex h-11 flex-col justify-center">
|
||||
<span>{c.lineTotal.toFixed(2)}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
net {c.receivedValue.toFixed(2)} + VAT {c.vatAmount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<HoldStatus>
|
||||
value={line.holdStatus}
|
||||
onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}
|
||||
>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Available" className="text-base">Available</SelectItem>
|
||||
<SelectItem value="OnHold" className="text-base">On hold (inspection)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{item?.trackingMode === "Batch" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Input
|
||||
placeholder="Batch no."
|
||||
value={line.batchNo}
|
||||
aria-invalid={!!errors.batchNo}
|
||||
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={line.expiryDate}
|
||||
onChange={(e) => updateLine(line.key, { expiryDate: e.target.value })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
|
||||
</div>
|
||||
)}
|
||||
{item?.trackingMode === "Serial" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<textarea
|
||||
placeholder="One serial per line"
|
||||
value={line.serialNumbersText}
|
||||
aria-invalid={!!errors.serialNumbers}
|
||||
onChange={(e) => updateLine(line.key, { serialNumbersText: e.target.value })}
|
||||
className="min-h-16 w-full rounded-lg border border-input bg-transparent p-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
/>
|
||||
<FieldError errors={[errors.serialNumbers ? { message: errors.serialNumbers } : undefined]} />
|
||||
</div>
|
||||
)}
|
||||
{(!item || item.trackingMode === "None") && (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!poLoading && lines.length > 0 && (
|
||||
<div className="flex justify-end gap-6 pr-12 text-base">
|
||||
<div className="flex justify-end gap-3 border-t border-border pt-4 text-base">
|
||||
<span className="text-muted-foreground">Document total (incl. VAT)</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
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"
|
||||
@@ -16,6 +17,7 @@ import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
@@ -26,9 +28,18 @@ 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,
|
||||
})
|
||||
|
||||
export default function NewBundleSalePage() {
|
||||
function NewBundleSaleContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const templateFromQuery = searchParams.get("templateId")
|
||||
@@ -80,10 +91,19 @@ export default function NewBundleSalePage() {
|
||||
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])
|
||||
@@ -93,9 +113,7 @@ export default function NewBundleSalePage() {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -150,6 +168,22 @@ export default function NewBundleSalePage() {
|
||||
|
||||
{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">
|
||||
@@ -221,13 +255,13 @@ export default function NewBundleSalePage() {
|
||||
{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" />
|
||||
@@ -241,28 +275,35 @@ export default function NewBundleSalePage() {
|
||||
</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>
|
||||
@@ -286,3 +327,11 @@ export default function NewBundleSalePage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NewBundleSalePage() {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
|
||||
<NewBundleSaleContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user