Merge pull request 'Feat/po grn' (#12) from feat/po-GRN into Dev

Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
2026-07-21 11:04:58 +00:00
28 changed files with 719 additions and 158 deletions
@@ -57,6 +57,25 @@ public sealed class PurchaseOrdersController : ApiControllerBase
return Ok(result.Value);
}
/// <summary>Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft.</summary>
[HttpPost("{poId:int}/submit")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseOrderDto>> Submit(int poId, CancellationToken ct)
=> Ok(await _pos.SubmitAsync(poId, ct));
/// <summary>Delete a PO — permitted only while Draft (409 PO_NOT_EDITABLE otherwise).</summary>
[HttpDelete("{poId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int poId, CancellationToken ct)
{
await _pos.DeleteAsync(poId, ct);
return NoContent();
}
/// <summary>Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled.</summary>
[HttpPost("{poId:int}/approve")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
+30 -3
View File
@@ -3,9 +3,13 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// GRN line (FR-GRN-04..08). <see cref="UnitCost"/> is the PO-derived cost for
/// PO-based receipts (client cost ignored — 02-SECURITY C.3) or the entered cost
/// for direct receipts. <see cref="ReceivedValue"/> = qty × unitCost.
/// GRN line (FR-GRN-04..08). <see cref="UnitCost"/> is the gross cost received at:
/// entered on the line, defaulting to the PO price when omitted (a per-receipt price
/// override is now permitted — see docs/02-SECURITY C.3, revised). <see cref="PoUnitPrice"/>
/// snapshots the PO price at receipt so the variance survives later PO edits.
/// <see cref="NetUnitCost"/> = unitCost after trade discount — this is what the FIFO layer
/// costs at (VAT never enters stock value; it is recoverable input tax).
/// <see cref="ReceivedValue"/> = qty × netUnitCost (after discount, before VAT).
/// <see cref="HoldStatus"/> gates issuability. Model: docs/10 Part C.3.
/// </summary>
public class GrnLine
@@ -31,7 +35,30 @@ public class GrnLine
public Batch? Batch { get; set; }
public decimal Qty { get; set; }
/// <summary>Gross unit cost received at (entered, or PO price when omitted).</summary>
public decimal UnitCost { get; set; }
/// <summary>Snapshot of the PO line price at receipt; null for direct receipts.</summary>
public decimal? PoUnitPrice { get; set; }
/// <summary>Trade discount percentage (0100), entered.</summary>
public decimal DiscountPct { get; set; }
/// <summary>UnitCost × (1 DiscountPct/100) — the inventory (FIFO layer) cost.</summary>
public decimal NetUnitCost { get; set; }
/// <summary>VAT percentage (0100), entered. Recoverable — does not affect stock value.</summary>
public decimal VatPct { get; set; }
/// <summary>Qty × NetUnitCost × VatPct/100.</summary>
public decimal VatAmount { get; set; }
/// <summary>Qty × NetUnitCost (after discount, before VAT).</summary>
public decimal ReceivedValue { get; set; }
/// <summary>Qty × NetUnitCost + VatAmount — payable to the vendor.</summary>
public decimal LineTotal { get; set; }
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
}
+1 -1
View File
@@ -22,7 +22,7 @@ public class PoLine
public Warehouse? Warehouse { get; set; }
public decimal Qty { get; set; }
public decimal UnitPrice { get; set; }
public decimal UnitPrice { get; set; }//
public decimal Tax { get; set; }
public decimal QtyReceived { get; set; }
}
+13 -2
View File
@@ -7,7 +7,10 @@ namespace ERPCore.Dtos.Grn;
public sealed record GrnLineDto(
int GrnLineId, int? PoLineId, int ItemId, int UomId, int? BinId,
decimal Qty, decimal UnitCost, decimal ReceivedValue, HoldStatus HoldStatus, int? BatchId);
decimal Qty, decimal UnitCost, decimal? PoUnitPrice,
decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount,
decimal ReceivedValue, decimal LineTotal, decimal PriceVariance,
HoldStatus HoldStatus, int? BatchId);
public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
@@ -44,8 +47,16 @@ public sealed class CreateGrnLineInput
[Required] public int UomId { get; set; }
public int? BinId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
/// <summary>Used only for direct (no-PO) receipts; ignored when <see cref="PoLineId"/> is set.</summary>
/// <summary>
/// Gross unit cost. Required for direct (no-PO) receipts. For a PO line it is an optional
/// per-receipt price override — when 0/omitted the PO line price is used; when supplied it
/// wins and a variance is recorded against the PO snapshot (docs/02-SECURITY C.3, revised).
/// </summary>
[Range(0, double.MaxValue)] public decimal UnitCost { get; set; }
/// <summary>Trade discount percentage (0100). Reduces the inventory cost.</summary>
[Range(0, 100)] public decimal DiscountPct { get; set; }
/// <summary>VAT percentage (0100). Recoverable — does not affect stock value.</summary>
[Range(0, 100)] public decimal VatPct { get; set; }
[EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
public BatchInput? Batch { get; set; }
}
@@ -37,6 +37,13 @@ public sealed class CreatePurchaseOrderRequest
[Required] public int VendorId { get; set; }
public int? RequisitionId { get; set; }
[Required, MinLength(1)] public List<CreatePoLineInput> Lines { get; set; } = new();
/// <summary>
/// When true the PO is created in <c>Draft</c> (editable/deletable, not yet issued).
/// When false (default) it auto-approves on creation, preserving the Requisition→PO
/// and RFQ→PO flows unchanged (docs/11 §3.3).
/// </summary>
public bool SaveAsDraft { get; set; }
}
public sealed class UpdatePurchaseOrderRequest
@@ -37,7 +37,13 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
builder.Property(l => l.Qty).HasPrecision(18, 4);
builder.Property(l => l.UnitCost).HasPrecision(18, 6);
builder.Property(l => l.PoUnitPrice).HasPrecision(18, 6);
builder.Property(l => l.DiscountPct).HasPrecision(9, 4);
builder.Property(l => l.NetUnitCost).HasPrecision(18, 6);
builder.Property(l => l.VatPct).HasPrecision(9, 4);
builder.Property(l => l.VatAmount).HasPrecision(18, 4);
builder.Property(l => l.ReceivedValue).HasPrecision(18, 4);
builder.Property(l => l.LineTotal).HasPrecision(18, 4);
builder.Property(l => l.HoldStatus).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.HasOne(l => l.Grn).WithMany(g => g.Lines).HasForeignKey(l => l.GrnId).OnDelete(DeleteBehavior.Cascade);
@@ -41,7 +41,11 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
new Permission { PermissionId = 15, Code = "NAV:products.uom", SubNavItemId = 5 },
new Permission { PermissionId = 16, Code = "NAV:products.configuration", SubNavItemId = 6 },
new Permission { PermissionId = 17, Code = "NAV:settings.roles", SubNavItemId = 7 },
new Permission { PermissionId = 18, Code = "NAV:settings.users", SubNavItemId = 8 }
new Permission { PermissionId = 18, Code = "NAV:settings.users", SubNavItemId = 8 },
new Permission { PermissionId = 19, Code = "NAV:procurement.requisitions", SubNavItemId = 9 },
new Permission { PermissionId = 20, Code = "NAV:procurement.rfqs", SubNavItemId = 10 },
new Permission { PermissionId = 21, Code = "NAV:procurement.purchase-orders", SubNavItemId = 11 },
new Permission { PermissionId = 22, Code = "NAV:procurement.purchase-returns", SubNavItemId = 12 }
);
}
}
@@ -32,7 +32,12 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 5, NavItemId = 2, Code = "products.uom", Label = "UOM", Href = "/dashboard/products/uoms", SortOrder = 5 },
new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 },
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 }
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
// Procurement (NavItemId 4) children — mirror the hub page order.
new SubNavItem { SubNavItemId = 9, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
new SubNavItem { SubNavItemId = 10, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 11, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
new SubNavItem { SubNavItemId = 12, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 }
);
}
}
@@ -277,6 +277,10 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Property<int?>("BinId")
.HasColumnType("integer");
b.Property<decimal>("DiscountPct")
.HasPrecision(9, 4)
.HasColumnType("numeric(9,4)");
b.Property<int>("GrnId")
.HasColumnType("integer");
@@ -288,9 +292,21 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Property<int>("ItemId")
.HasColumnType("integer");
b.Property<decimal>("LineTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("NetUnitCost")
.HasPrecision(18, 6)
.HasColumnType("numeric(18,6)");
b.Property<int?>("PoLineId")
.HasColumnType("integer");
b.Property<decimal?>("PoUnitPrice")
.HasPrecision(18, 6)
.HasColumnType("numeric(18,6)");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
@@ -306,6 +322,14 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Property<int>("UomId")
.HasColumnType("integer");
b.Property<decimal>("VatAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("VatPct")
.HasPrecision(9, 4)
.HasColumnType("numeric(9,4)");
b.HasKey("GrnLineId");
b.HasIndex("BatchId");
@@ -828,6 +852,30 @@ namespace ERPCore.Infra.Persistence.Migrations
PermissionId = 18,
Code = "NAV:settings.users",
SubNavItemId = 8
},
new
{
PermissionId = 19,
Code = "NAV:procurement.requisitions",
SubNavItemId = 9
},
new
{
PermissionId = 20,
Code = "NAV:procurement.rfqs",
SubNavItemId = 10
},
new
{
PermissionId = 21,
Code = "NAV:procurement.purchase-orders",
SubNavItemId = 11
},
new
{
PermissionId = 22,
Code = "NAV:procurement.purchase-returns",
SubNavItemId = 12
});
});
@@ -1916,6 +1964,46 @@ namespace ERPCore.Infra.Persistence.Migrations
NavItemId = 9,
SortOrder = 2,
Status = "Active"
},
new
{
SubNavItemId = 9,
Code = "procurement.requisitions",
Href = "/dashboard/procurement/requisitions",
Label = "Requisitions",
NavItemId = 4,
SortOrder = 1,
Status = "Active"
},
new
{
SubNavItemId = 10,
Code = "procurement.rfqs",
Href = "/dashboard/procurement/rfqs",
Label = "RFQs",
NavItemId = 4,
SortOrder = 2,
Status = "Active"
},
new
{
SubNavItemId = 11,
Code = "procurement.purchase-orders",
Href = "/dashboard/procurement/purchase-orders",
Label = "Purchase Orders",
NavItemId = 4,
SortOrder = 3,
Status = "Active"
},
new
{
SubNavItemId = 12,
Code = "procurement.purchase-returns",
Href = "/dashboard/procurement/purchase-returns",
Label = "Purchase Returns",
NavItemId = 4,
SortOrder = 4,
Status = "Active"
});
});
+25 -5
View File
@@ -138,8 +138,11 @@ public sealed class GrnService : IGrnService
if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422);
// Cost: PO-derived for PO lines (client cost ignored, C.3); entered for direct.
// Cost: for a PO line, the PO price is used unless an override is entered (then it
// wins and a variance is recorded against the PO snapshot — docs/02-SECURITY C.3,
// revised). Direct receipts always use the entered cost.
decimal unitCost;
decimal? poUnitPrice = null;
if (input.PoLineId is not null)
{
var poLine = po?.Lines.FirstOrDefault(l => l.PoLineId == input.PoLineId)
@@ -152,13 +155,19 @@ public sealed class GrnService : IGrnService
throw new DomainException(ErrorCodes.OverReceiptTolerance,
$"Receiving {input.Qty} exceeds the open quantity {openQty} on PO line {input.PoLineId}.", 422);
unitCost = poLine.UnitPrice;
poUnitPrice = poLine.UnitPrice;
unitCost = input.UnitCost > 0 ? input.UnitCost : poLine.UnitPrice;
}
else
{
unitCost = input.UnitCost;
}
// Derived figures are always computed server-side, never accepted from the client.
var netUnitCost = Math.Round(unitCost * (1 - input.DiscountPct / 100m), 6, MidpointRounding.AwayFromZero);
var receivedValue = Math.Round(input.Qty * netUnitCost, 4, MidpointRounding.AwayFromZero);
var vatAmount = Math.Round(receivedValue * input.VatPct / 100m, 4, MidpointRounding.AwayFromZero);
var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
lines.Add(new GrnLine
@@ -170,7 +179,13 @@ public sealed class GrnService : IGrnService
Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted
Qty = input.Qty,
UnitCost = unitCost,
ReceivedValue = Math.Round(input.Qty * unitCost, 4, MidpointRounding.AwayFromZero),
PoUnitPrice = poUnitPrice,
DiscountPct = input.DiscountPct,
NetUnitCost = netUnitCost,
VatPct = input.VatPct,
VatAmount = vatAmount,
ReceivedValue = receivedValue,
LineTotal = receivedValue + vatAmount,
HoldStatus = input.HoldStatus
});
}
@@ -220,7 +235,9 @@ 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);
var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.UnitCost, 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);
var layer = await _fifo.CreateInboundLayerAsync(
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId,
@@ -380,5 +397,8 @@ 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.ReceivedValue, l.HoldStatus, l.BatchId)).ToList());
l.GrnLineId, l.PoLineId, l.ItemId, l.UomId, 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());
}
@@ -16,4 +16,10 @@ public interface IPurchaseOrderService
Task<ETagged<PurchaseOrderDto>> UpdateAsync(int poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task<PurchaseOrderDto> ApproveAsync(int poId, CancellationToken ct = default);
Task<PurchaseOrderDto> CancelAsync(int poId, string? reason, CancellationToken ct = default);
/// <summary>Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft.</summary>
Task<PurchaseOrderDto> SubmitAsync(int poId, CancellationToken ct = default);
/// <summary>Delete a PO — permitted only while Draft, else 409 PO_NOT_EDITABLE.</summary>
Task DeleteAsync(int poId, CancellationToken ct = default);
}
@@ -92,9 +92,10 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
DocNo = docNo,
VendorId = request.VendorId,
RequisitionId = request.RequisitionId,
// Phase 1: approvalRequired defaults off → auto-approved on creation (FR-PROC-04).
// Phase 1: approvalRequired defaults off → auto-approved on creation (FR-PROC-04),
// unless the caller explicitly saves a Draft (editable/deletable until submitted).
ApprovalRequired = false,
Status = PurchaseOrderStatus.Approved,
Status = request.SaveAsDraft ? PurchaseOrderStatus.Draft : PurchaseOrderStatus.Approved,
CreatedBy = actor,
CreatedAt = DateTime.UtcNow,
Lines = request.Lines.Select(ToLine).ToList()
@@ -182,8 +183,41 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
return Map(po);
}
private static bool IsEditable(PurchaseOrderStatus status) => status is not (
PurchaseOrderStatus.FullyReceived or PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled);
public async Task<PurchaseOrderDto> SubmitAsync(int poId, CancellationToken ct = default)
{
var po = await _pos.Query()
.Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
?? throw new NotFoundException($"Purchase order {poId} was not found.");
if (po.Status != PurchaseOrderStatus.Draft)
throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be submitted.", 409);
// Phase 1: no value gate, so a submitted draft goes straight to Approved (FR-PROC-04).
po.Status = PurchaseOrderStatus.Approved;
po.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return Map(po);
}
public async Task DeleteAsync(int poId, CancellationToken ct = default)
{
var po = await _pos.Query()
.Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
?? throw new NotFoundException($"Purchase order {poId} was not found.");
if (po.Status != PurchaseOrderStatus.Draft)
throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be deleted; only a Draft can be deleted.", 409);
_pos.Remove(po);
await _uow.SaveChangesAsync(ct);
}
// FR-PROC-05 (revised): a PO is editable/deletable only while Draft. Submitting locks it.
// Supersedes Phase-1 Option B "freely editable while open" — see docs/10 FR-PROC-05.
private static bool IsEditable(PurchaseOrderStatus status) => status is PurchaseOrderStatus.Draft;
private static PoLine ToLine(CreatePoLineInput l) => new()
{
+12 -2
View File
@@ -55,7 +55,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
> Requisition/RFQ/PO implemented 2026-07-10. **Live smoke test PASSED** (docNo `PR/RFQ/PO-2026-#####` gap-controlled + incrementing, requestedBy/createdBy = seeded system user, RFQ comparison matrix, duplicate quotation→409, PO auto-approve + server-computed totals matching the spec example `112100/20178/132278`, edit-while-open with recomputed totals + ETag 200/412, PO_NOT_EDITABLE→409 on cancelled, cancel→200, bad reference→422). Same `[~]` reason as §1: the §6 security gate (auth + audit) is not yet wired.
- [x] Requisition (+ lines) + submit (`POST /requisitions`, `/{id}/submit`, list, get)
- [x] RFQ + quotations + comparison (`POST /rfqs`, `/{id}/quotations` [one per vendor], `GET /{id}/comparison` matrix)
- [x] Purchase Order: create (auto-approve, `approvalRequired` flag), edit-while-open (If-Match), approve (no-op), cancel
- [x] Purchase Order: create (auto-approve **or `saveAsDraft`**, `approvalRequired` flag), edit **Draft-only** (If-Match), **submit** (Draft→Approved), **delete** (Draft-only), approve (no-op), cancel — see the 2026-07-20 entry (FR-PROC-05 revised: draft-lock supersedes edit-while-open)
- [x] Purchase Return (outbound movement, reason code) — `POST /purchase-returns` auto-posts an outbound FIFO consume via shared `StockMutator`; mandatory Return-context reason (`REASON_CODE_REQUIRED`→400, wrong context→422), references the GRN line for traceability, over-return→`409 STOCK_NEGATIVE_BLOCKED`. Verified. (Cumulative return-vs-received cap still relies on the stock-availability guard.)
> **Deviation (recorded):** `VendorQuotation` is modelled as header + `VendorQuotationLine` (per-item pricing) to satisfy the API contract (docs/11 §3.2); docs/10 Part C.2's scalar `VENDOR_QUOTATION(unit_price, lead_days)` with no item ref cannot represent it. Update the ER model doc to match.
@@ -74,7 +74,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
## 3. Goods Receipt
> Implemented + **smoke test PASSED** 2026-07-13 (see §4 note for the shared stock verification). Same `[~]` reason as §1/§2: the §6 auth+audit gate.
- [x] GRN create (against PO / direct), over-receipt tolerance — `unitCost` **PO-derived server-side** (client `999` verified ignored → PO price used, 02-SECURITY C.3); direct receipt requires `vendorId` + entered cost (AR-04); over-receipt → `422 OVER_RECEIPT_TOLERANCE` (verified at open-qty boundary); batch created/reused per (item, batchNo). Serial capture deferred.
- [x] GRN create (against PO / direct), over-receipt tolerance — `unitCost` **defaults to the PO price but is now overridable per line** (variance recorded vs `poUnitPrice` snapshot — 02-SECURITY C.3 revised 2026-07-20; the old "client cost ignored" block is gone); direct receipt requires `vendorId` + entered cost (AR-04); over-receipt → `422 OVER_RECEIPT_TOLERANCE` (verified at open-qty boundary); batch created/reused per (item, batchNo). Serial capture deferred. **Discount/VAT added** — see the 2026-07-20 entry.
- [x] GRN confirm → FIFO layer + ledger + PO `qtyReceived` (single UoW txn) — verified: layers+ledger posted, running balance, PO → PartiallyReceived/FullyReceived, UOM→base conversion (10 Box-12 → 120 base @10). **Idempotent** re-confirm verified (no double-post). Note: `Idempotency-Key` accepted but idempotency is resource-state based (already-Confirmed replays existing result); a keyed idempotency store is deferred.
- [x] Inspection hold release / reject — Release fully verified (OnHold excluded from `available`, then released). Reject removes on-hand + posts a reversing ledger entry; formal link to a Purchase Return is deferred (§3.4).
@@ -111,6 +111,16 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
## Done
<!-- move [x] items here with date + note if the active list grows long -->
### 2026-07-20 — PO draft lifecycle + GRN discount/VAT/price-override (migration `AddGrnPricingAndPoDraft`)
- **PO draft lifecycle (FR-PROC-05 revised).** `CreatePurchaseOrderRequest.SaveAsDraft` (default `false` → auto-approve unchanged; `true``Draft`). New `POST /purchase-orders/{id}/submit` (Draft→Approved, else `409 PO_NOT_EDITABLE`) and `DELETE /purchase-orders/{id}` (Draft-only, else 409). `IsEditable` narrowed from "not FullyReceived/Closed/Cancelled" to **`Draft` only** — so `PUT` now 409s on any submitted PO. **Option B ("freely edit while open") is superseded**; docs/10 FR-PROC-05, docs/11 §3.3 updated. ⚠️ **Every pre-existing PO is `Approved` and therefore now uneditable/undeletable** — intended, not a regression. No schema change (reuses the existing `Draft` enum value).
- **GRN discount + VAT + price override.** `GrnLine` gained `PoUnitPrice`(nullable snapshot), `DiscountPct`, `NetUnitCost`, `VatPct`, `VatAmount`, `LineTotal`. All derived figures **server-computed**, never client-supplied. FIFO layer + ledger now cost at **`NetUnitCost`** (after discount) — VAT is recoverable and never enters stock value (docs/10 FR-GRN-06 revised). For a PO line, `unitCost` defaults to the PO price but an entered override wins and a **variance** is recorded against `PoUnitPrice` (02-SECURITY C.3 revised — the "client cost ignored, decision locked" control is deliberately loosened; the variance trail + audit log are the compensating control). Multi-GRN-per-PO at differing prices (the 20/50/30 case) already worked via `openQty`/`QtyReceived` and is untouched.
- **Migration** `AddGrnPricingAndPoDraft` — hand-added a data backfill (`UPDATE grn_lines SET NetUnitCost = UnitCost, LineTotal = ReceivedValue`) so existing GRN lines stay consistent with their already-posted FIFO layers; `PoUnitPrice` left NULL for historical rows (no retroactive variance). `Down()` drops the six columns cleanly.
- **Verified:** `dotnet build` clean (0/0); migration **Up and Down** exercised against the live DB (rollback to `AddRolesNavPermissions` then re-apply — both `Done`). **Runtime end-to-end PASSED — 22/22 assertions** (Node script, register→cookie session): PO draft→edit→submit→edit/delete-locked (409), draft delete (204→404), plain create still auto-approves; **costing proof** (100 @10, 10% disc, 18% VAT → net 9.00, receivedValue 900, VAT 162, lineTotal 1062, **FIFO layer @9.00, valuation 900 — VAT absent from stock**); multi-GRN 20@10/50@11/30@12 → variances +50/+60, PO FullyReceived, blended valuation 2010.
### 2026-07-20 (2) — Procurement sidebar submenu (migration `AddProcurementSubNav`)
- The sidebar submenu is driven by seeded `SubNavItem` rows + `GET /auth/me` navCodes; only Products/Settings had children, so **Purchase Orders had no sidebar section**. Added 4 `SubNavItem`s (ids 912, `NavItemId 4`) + 4 `Permission`s (ids 1922) for Requisitions/RFQs/Purchase Orders/Purchase Returns via `AddProcurementSubNav`. The migration also grants the 4 to any role already holding the parent `NAV:procurement` (raw SQL, `ON CONFLICT DO NOTHING`); `Down()` removes the grants then the rows.
- **Found:** the `Admin` role (`RoleId 2`) was never granted `NAV:procurement` at all (nor Vendors), so its whole Procurement branch was hidden — granted the parent + 4 children directly. **Verified:** `/auth/me` for Admin returns `procurement` + all 4 children; frontend `tsc`/`eslint` clean.
### 2026-07-09 — Bootstrap verified + Master Data (§1) implemented
- Bootstrap scaffolding confirmed against 00-CORE §5 (solution, packages, `Program.cs` wiring, UoW, generic repo, `ICurrentUser`, ProblemDetails handler). Added enum-as-string JSON (`JsonStringEnumConverter`) and registered the 5 master-data services.
- Domain: 3 enums (`ItemType`, `TrackingMode`, `EntityStatus`) + 8 entities (Category, Uom, UomConversion, Item, ItemReorder, Vendor, Warehouse, Bin) with one `IEntityTypeConfiguration` each; FKs `Restrict` (masters deactivate, not cascade-delete), unique indexes (SKU, vendor/warehouse code, uom name, bin code per-warehouse), decimal precision, `xmin` concurrency token on Item/Vendor.
+10 -2
View File
@@ -39,12 +39,12 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
## 3. Procurement screens
- [~] Requisition (`app/dashboard/procurement/requisitions` list, `/new` create, `/[id]` detail + Submit) — FR-PROC-01
- [~] RFQ + quotations + comparison view (`.../rfqs` list, `/new` create with vendor multi-invite, `/[id]` detail: lines, comparison matrix, record-quotation form, "Create PO from vendor") — FR-PROC-02
- [~] Purchase Order (`.../purchase-orders` list, `/new` create — auto-approved, prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: edit-while-open with ETag/If-Match, Cancel with reason) — FR-PROC-03..07
- [~] Purchase Order (`.../purchase-orders` list, `/new` create — **Save as draft** or **Create & submit** (auto-approve), prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: **Draft** is editable (ETag/If-Match) with **Submit** + **Delete**; a submitted/open PO is read-only with **Cancel** (with reason)) — FR-PROC-03..07. **2026-07-20:** rewired to the draft lifecycle — `isPoEditable` is now `Draft`-only, `submit`/`remove` added to `lib/api/purchase-orders.ts`, `saveAsDraft` on the create request. See the 2026-07-20 entry.
- [~] Purchase Return (`.../purchase-returns` list, `/new` create against a Confirmed/Closed GRN's lines) — FR-PROC-08; also reachable from a `Rejected` GRN line via a "Create Return" button on the GRN detail page
## 4. Receiving screens
- [~] GRN list (`app/dashboard/receiving/grn/page.tsx`) — loading/empty/error states, links to detail
- [~] GRN create (`app/dashboard/receiving/grn/new/page.tsx`) — "Against PO" (picks an Approved/PartiallyReceived PO, prefills open lines) and "Direct receipt" (vendor + manual lines) modes; per-line bin, qty, unit cost, hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode`
- [~] GRN create (`app/dashboard/receiving/grn/new/page.tsx`) — "Against PO" (picks an Approved/PartiallyReceived PO, prefills open lines) and "Direct receipt" (vendor + manual lines) modes; per-line bin, qty, unit cost, **discount % / VAT %** (with live after-discount/after-VAT line total + a per-row PO-price variance hint and a document total), hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode`. **2026-07-20:** discount/VAT/variance added — see the 2026-07-20 entry.
- [~] GRN confirm (`app/dashboard/receiving/grn/[id]/page.tsx`) — renders returned `createdLayers`/`ledgerRefs`/`poStatus` as a confirmation panel (20-FRONTEND §4); sends a stable `Idempotency-Key` per detail-page session
- [~] Inspection hold release / reject — Release/Reject buttons per on-hold line, shown once the GRN is `Confirmed`
- Sidebar: added "Receiving" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/receiving/grn`
@@ -90,6 +90,14 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
## Done
<!-- move [x] items here with date + note if the active list grows long -->
### 2026-07-20 — PO draft lifecycle + GRN discount/VAT/price-override (backend + frontend, same pass)
- **PO draft lifecycle.** `lib/api/purchase-orders.ts`: `isPoEditable` narrowed to `status === "Draft"` (was the three-status exclusion); added `submit(poId)` and `remove(poId)`. `types/procurement.ts`: `saveAsDraft?` on `CreatePurchaseOrderRequest`; `UpdatePurchaseOrderRequest` now `Omit`s it. `/new`: the single "Create PO" button split into **Save as draft** / **Create & submit**. `/[id]`: **Draft** shows the editable line grid + **Submit** + **Delete draft**; an issued-but-open PO (`Approved`/`PartiallyReceived`) is read-only with **Cancel PO** (with reason, disabled once receipts exist); closed/cancelled are terminal. The old Cancel button was gated on `isPoEditable` — that would have shown Cancel only for Draft, so the affordances were re-split into `editable` (Draft) vs `cancellable` (Approved/PartiallyReceived).
- **GRN discount/VAT/variance.** `types/grn.ts`: `discountPct`/`vatPct` on `CreateGrnLineInput`; `poUnitPrice`/`discountPct`/`netUnitCost`/`vatPct`/`vatAmount`/`lineTotal`/`priceVariance` on `GrnLine`. `/new`: per-line Disc %/VAT % inputs, a client mirror of the server arithmetic (`computeLine`, display-only — server stays authoritative) driving a live Line total + document total, and a PO-price variance hint when the entered unit cost differs from the prefilled PO price. `/[id]`: renders the full cost breakdown (unit cost + variance, disc %, net cost, received value, VAT, line total) and a stock-value/VAT/document-total footer. `lib/validations/grn.ts`: 0100 range checks on the two percentages.
- **Deliberately not touched:** the item picker already showed `sku — name` (the request's stated need); multi-GRN-per-PO and adding a non-PO item to a PO GRN already worked. Vendor stays PO-derived (non-selectable) for a PO-based GRN — selecting a different vendor than the PO's would be wrong.
- **Select trigger showed the id, not the label (global fix).** Base UI's `Select.Value` renders the raw selected value unless the `Select.Root` is given an `items` map — the popup items unmount when closed, so their text isn't available to the trigger (confirmed in `@base-ui/react`'s `resolveSelectedLabel`, which `find`s `items` by value and only falls back to stringifying the value when none is supplied). Fixed once in the shared wrapper (`components/ui/select.tsx`): `Select` now walks its own `SelectItem` children and derives the `items` array automatically, so all ~60 `<SelectValue>` call sites across 26 files show the selected label without any per-site change. `tsc`/`eslint` clean; verified against Base UI's label-resolution source.
- **Procurement sidebar submenu.** The sidebar builds submenus from backend-seeded `SubNavItem` rows filtered by `GET /auth/me`'s `navCodes`; only Products and Settings had children, so Purchase Orders had no sidebar section (only reachable via the Procurement hub card). Added a `children` array to the Procurement nav entry (`components/Layouts/AppSidebar.tsx`) — Requisitions, RFQs, Purchase Orders, Purchase Returns — matching new backend sub-nav codes. Also found the Admin role (`RoleId 2`) was never granted `NAV:procurement` at all, so the whole Procurement branch was hidden for it; granted the parent + 4 children. **Verified:** `/auth/me` for Admin now returns all five procurement codes → submenu renders. Stale PO hub-card copy ("freely editable while open") updated to the draft/submit wording.
- **Verified:** `tsc --noEmit` clean; `eslint` unchanged from baseline (7 pre-existing `set-state-in-effect` on the PO/GRN screens before and after — 0 new issues, confirmed by stashing and re-counting). Runtime browser verification is the next step in this pass.
### 2026-07-17 — connected to the real API (mock-data.ts deleted)
**The app now talks to ERPCore.** Every `lib/api/*.ts` module calls the backend; `lib/api/mock-data.ts` is gone. This is the pass the 2026-07-15 note anticipated.
@@ -18,7 +18,7 @@ const areas: { title: string; description: string; href: string; icon: LucideIco
},
{
title: "Purchase Orders",
description: "Auto-approved on creation, freely editable while open, cancellable before receipt.",
description: "Save as draft (editable/deletable) or submit to lock; cancel an issued PO before receipt.",
href: "/dashboard/procurement/purchase-orders",
icon: ShoppingCart,
},
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import Link from "next/link"
import { AlertTriangle, ArrowLeft, Ban, Plus, Save, Trash2 } from "lucide-react"
import { AlertTriangle, ArrowLeft, Ban, Plus, Save, Send, Trash2 } from "lucide-react"
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { warehousesApi } from "@/lib/api/warehouses"
@@ -65,6 +65,8 @@ export default function PurchaseOrderDetailPage() {
const [showCancelForm, setShowCancelForm] = useState(false)
const [cancelReason, setCancelReason] = useState("")
const [cancelling, setCancelling] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [deleting, setDeleting] = useState(false)
function toDraftLines(order: PurchaseOrder): DraftLine[] {
return order.lines.map((l) => ({
@@ -185,6 +187,39 @@ export default function PurchaseOrderDetailPage() {
}
}
async function handleSubmitPo() {
if (!po) return
setSaveError(null)
setSubmitting(true)
try {
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.`)
} catch (err) {
setSaveError(errorMessage(err))
toast.error("Could not submit purchase order", errorMessage(err))
} finally {
setSubmitting(false)
}
}
async function handleDelete() {
if (!po) return
if (!window.confirm(`Delete draft ${po.docNo}? This cannot be undone.`)) return
setSaveError(null)
setDeleting(true)
try {
await purchaseOrdersApi.remove(po.poId)
toast.success("Draft deleted", po.docNo)
router.push("/dashboard/procurement/purchase-orders")
} catch (err) {
setSaveError(errorMessage(err))
toast.error("Could not delete purchase order", errorMessage(err))
setDeleting(false)
}
}
async function handleCancel() {
if (!po) return
if (!cancelReason.trim()) {
@@ -226,6 +261,9 @@ export default function PurchaseOrderDetailPage() {
const editable = isPoEditable(po.status) && !conflict
const hasReceipts = po.lines.some((l) => l.qtyReceived > 0)
// A submitted-but-still-open PO (issued to the vendor) is cancellable with a reason;
// a Draft is deleted instead, and closed/cancelled POs are terminal.
const cancellable = po.status === "Approved" || po.status === "PartiallyReceived"
return (
<div className="flex flex-col gap-6">
@@ -245,7 +283,20 @@ export default function PurchaseOrderDetailPage() {
</div>
</div>
{isPoEditable(po.status) && !showCancelForm && (
<div className="flex 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>
<Button variant="destructive" size="lg" onClick={handleDelete} disabled={deleting || submitting}>
<Trash2 className="size-5" />
{deleting ? "Deleting…" : "Delete draft"}
</Button>
</>
)}
{cancellable && !showCancelForm && (
<Button
variant="destructive"
size="lg"
@@ -258,6 +309,7 @@ export default function PurchaseOrderDetailPage() {
</Button>
)}
</div>
</div>
{showCancelForm && (
<div className="flex flex-col gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-5">
@@ -43,8 +43,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).
function emptyLine(): DraftLine {
return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18" }
return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "0", tax: "0" }
}
function NewPurchaseOrderContent() {
@@ -98,8 +102,8 @@ function NewPurchaseOrderContent() {
uomId: null,
warehouseId: null,
qty: String(l.qty),
unitPrice: "",
tax: "0.18",
unitPrice: "0",
tax: "0",
})
)
)
@@ -124,8 +128,8 @@ function NewPurchaseOrderContent() {
uomId: null,
warehouseId: null,
qty: String(l.qty),
unitPrice: cell ? String(cell.unitPrice) : "",
tax: "0.18",
unitPrice: cell ? String(cell.unitPrice) : "0",
tax: "0",
}
})
)
@@ -151,7 +155,7 @@ function NewPurchaseOrderContent() {
return items?.find((i) => i.itemId === itemId) ?? null
}
async function handleSubmit() {
async function handleSubmit(saveAsDraft: boolean) {
setHeaderError(null)
setSubmitError(null)
@@ -197,8 +201,12 @@ function NewPurchaseOrderContent() {
vendorId,
requisitionId: requisitionId ?? (rfqId ? undefined : null),
lines: payloadLines,
saveAsDraft,
})
toast.success("Purchase order created", `${po.docNo} — auto-approved (FR-PROC-04).`)
toast.success(
"Purchase order created",
saveAsDraft ? `${po.docNo} — saved as draft.` : `${po.docNo} — auto-approved (FR-PROC-04).`
)
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
} catch (err) {
setSubmitError(errorMessage(err))
@@ -274,8 +282,6 @@ function NewPurchaseOrderContent() {
<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-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-24 px-3 text-sm">Tax</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
@@ -348,30 +354,6 @@ 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">
<Input
type="number"
min="0"
step="0.01"
value={line.tax}
aria-invalid={!!errors.tax}
onChange={(e) => updateLine(line.key, { tax: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.tax ? { message: errors.tax } : 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" />
@@ -394,8 +376,11 @@ function NewPurchaseOrderContent() {
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create PO"}
<Button size="lg" type="button" variant="outline" onClick={() => handleSubmit(true)} disabled={submitting}>
{submitting ? "Saving…" : "Save as draft"}
</Button>
<Button size="lg" type="button" onClick={() => handleSubmit(false)} disabled={submitting}>
{submitting ? "Creating…" : "Create & submit"}
</Button>
</div>
</>
@@ -158,6 +158,7 @@ export default function GrnDetailPage() {
</div>
)}
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
@@ -165,8 +166,12 @@ export default function GrnDetailPage() {
<TableHead className="h-12 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 px-3 text-sm">Bin</TableHead>
<TableHead className="h-12 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Unit cost</TableHead>
<TableHead className="h-12 px-3 text-sm">Received value</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Unit cost</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Disc %</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Net cost</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Received value</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">VAT</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Line total</TableHead>
<TableHead className="h-12 px-3 text-sm">Hold status</TableHead>
{grn.status === "Confirmed" && <TableHead className="h-12 px-3 text-sm">Actions</TableHead>}
</TableRow>
@@ -180,8 +185,22 @@ export default function GrnDetailPage() {
<TableCell className="px-3 py-3.5">{uomFor(line.uomId)}</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">{line.unitCost.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5">{line.receivedValue.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">
{line.unitCost.toFixed(2)}
{line.poUnitPrice !== null && line.priceVariance !== 0 && (
<span className="block text-xs text-warning">
PO {line.poUnitPrice.toFixed(2)} · var {line.priceVariance > 0 ? "+" : ""}{line.priceVariance.toFixed(2)}
</span>
)}
</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{line.discountPct.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{line.netUnitCost.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{line.receivedValue.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">
{line.vatAmount.toFixed(2)}
<span className="block text-xs text-muted-foreground">{line.vatPct.toFixed(2)}%</span>
</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums font-medium">{line.lineTotal.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5">
<HoldStatusBadge status={line.holdStatus} />
</TableCell>
@@ -229,5 +248,21 @@ export default function GrnDetailPage() {
</TableBody>
</Table>
</div>
<div className="flex justify-end gap-8 border-t border-border pt-4 text-base">
<div className="flex gap-3">
<span className="text-muted-foreground">Stock value (excl. VAT)</span>
<span className="tabular-nums">{grn.lines.reduce((s, l) => s + l.receivedValue, 0).toFixed(2)}</span>
</div>
<div className="flex gap-3">
<span className="text-muted-foreground">VAT</span>
<span className="tabular-nums">{grn.lines.reduce((s, l) => s + l.vatAmount, 0).toFixed(2)}</span>
</div>
<div className="flex gap-3">
<span className="text-muted-foreground">Document total</span>
<span className="font-semibold tabular-nums">{grn.lines.reduce((s, l) => s + l.lineTotal, 0).toFixed(2)}</span>
</div>
</div>
</div>
)
}
@@ -37,12 +37,28 @@ interface DraftLine {
binId: number | null
qty: string
unitCost: string
/** PO line price when prefilled from a PO; drives the variance hint. */
poUnitPrice: number | null
discountPct: string
vatPct: string
holdStatus: HoldStatus
batchNo: string
expiryDate: string
serialNumbersText: string
}
/** Mirror of the server's line arithmetic — display only (docs/20 §3, server stays authoritative). */
function computeLine(l: DraftLine) {
const qty = Number(l.qty) || 0
const gross = Number(l.unitCost) || 0
const disc = Number(l.discountPct) || 0
const vat = Number(l.vatPct) || 0
const netUnitCost = gross * (1 - disc / 100)
const receivedValue = qty * netUnitCost
const vatAmount = receivedValue * (vat / 100)
return { netUnitCost, receivedValue, vatAmount, lineTotal: receivedValue + vatAmount }
}
let keySeq = 0
function newKey() {
keySeq += 1
@@ -58,6 +74,9 @@ function emptyLine(): DraftLine {
binId: null,
qty: "",
unitCost: "",
poUnitPrice: null,
discountPct: "0",
vatPct: "0",
holdStatus: "Available",
batchNo: "",
expiryDate: "",
@@ -152,6 +171,9 @@ export default function NewGrnPage() {
binId: null,
qty: String(l.qty - l.qtyReceived),
unitCost: String(l.unitPrice),
poUnitPrice: l.unitPrice,
discountPct: "0",
vatPct: "0",
holdStatus: "Available",
batchNo: "",
expiryDate: "",
@@ -211,6 +233,8 @@ export default function NewGrnPage() {
uomId: line.uomId,
qty: line.qty,
unitCost: line.unitCost,
discountPct: line.discountPct,
vatPct: line.vatPct,
trackingMode: itemFor(line.itemId)?.trackingMode ?? null,
batchNo: line.batchNo,
serialNumbersText: line.serialNumbersText,
@@ -232,6 +256,8 @@ export default function NewGrnPage() {
binId: l.binId,
qty: Number(l.qty),
unitCost: Number(l.unitCost),
discountPct: Number(l.discountPct) || 0,
vatPct: Number(l.vatPct) || 0,
holdStatus: l.holdStatus,
batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null,
serialNumbers: trackingMode === "Serial" ? splitSerials(l.serialNumbersText) : null,
@@ -371,16 +397,20 @@ export default function NewGrnPage() {
{poLoading && <Skeleton className="h-24 w-full" />}
{!poLoading && lines.length > 0 && (
<div className="overflow-x-auto">
<Table className="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-32 px-3 text-sm">Bin</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Qty</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-36 px-3 text-sm">Hold status</TableHead>
<TableHead className="h-12 w-48 px-3 text-sm">Batch / Serial</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-32 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>
@@ -473,6 +503,50 @@ export default function NewGrnPage() {
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>
@@ -533,6 +607,16 @@ export default function NewGrnPage() {
})}
</TableBody>
</Table>
</div>
)}
{!poLoading && lines.length > 0 && (
<div className="flex justify-end gap-6 pr-12 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)}
</span>
</div>
)}
</div>
@@ -8,12 +8,14 @@ import {
Building2,
ChevronRight,
ClipboardList,
FileText,
HelpCircle,
LayoutGrid,
ListTree,
Menu,
Package,
PackageCheck,
PackageX,
Ruler,
Settings,
ShieldCheck,
@@ -56,7 +58,19 @@ const navItems: {
],
},
{ title: "Vendors", code: "vendors", href: "/dashboard/vendors", icon: Truck, chevron: true },
{ title: "Procurement", code: "procurement", href: "/dashboard/procurement", icon: ClipboardList, chevron: true },
{
title: "Procurement",
code: "procurement",
href: "/dashboard/procurement",
icon: ClipboardList,
chevron: true,
children: [
{ title: "Requisitions", code: "procurement.requisitions", href: "/dashboard/procurement/requisitions", icon: ClipboardList },
{ title: "RFQs", code: "procurement.rfqs", href: "/dashboard/procurement/rfqs", icon: FileText },
{ title: "Purchase Orders", code: "procurement.purchase-orders", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart },
{ title: "Purchase Returns", code: "procurement.purchase-returns", href: "/dashboard/procurement/purchase-returns", icon: PackageX },
],
},
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
{ title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true },
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
@@ -91,18 +105,36 @@ function SidebarContent({
pathname: string
isMobile: boolean
}) {
const iconOnly = !isMobile && collapsed
// Which parent menus are open. Starts with the parent that owns the active
// route auto-expanded; user toggles are preserved across navigation.
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
useEffect(() => {
const parent = items.find((i) =>
i.children?.some((c) => pathname === c.href || pathname.startsWith(`${c.href}/`))
)
if (parent) {
setExpanded((prev) => (prev[parent.code] ? prev : { ...prev, [parent.code]: true }))
}
}, [pathname, items])
const toggleExpand = (code: string) =>
setExpanded((prev) => ({ ...prev, [code]: !prev[code] }))
return (
<nav
className={cn(
"flex h-full flex-col rounded-3xl bg-white p-3 shadow-sm ring-1 ring-black/5 transition-[width] duration-200",
"flex h-full flex-col rounded-3xl bg-white p-3 shadow-sm ring-1 ring-black/5 transition-[width] duration-300 ease-in-out",
!isMobile && (collapsed ? "w-20" : "w-64")
)}
>
{/* Header */}
<div
className={cn(
"mb-10 flex items-center gap-2.5 px-4 py-3",
!isMobile && collapsed ? "flex-col-reverse justify-center gap-3 px-0" : "justify-between"
"mb-8 flex shrink-0 items-center gap-2.5 px-4 py-3",
iconOnly ? "flex-col-reverse justify-center gap-3 px-0" : "justify-between"
)}
>
<Link href="/dashboard" className="flex items-center gap-2.5" onClick={onClose}>
@@ -111,7 +143,7 @@ function SidebarContent({
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
</svg>
</div>
{(!collapsed || isMobile) && (
{!iconOnly && (
<span className="text-lg font-bold tracking-tight text-slate-900">Hexa ERP</span>
)}
</Link>
@@ -126,55 +158,90 @@ function SidebarContent({
</button>
</div>
{/* Nav items */}
<ul className="flex flex-col gap-1">
{/* Nav items — scrolls internally when it overflows, without a visible
scrollbar so the rounded panel stays clean. */}
<ul className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{items.map((item) => {
const isActive =
item.href === "/dashboard" ? pathname === item.href : pathname.startsWith(item.href)
const hasChildren = !!item.children?.length && !iconOnly
const isOpen = !!expanded[item.code]
return (
<li key={item.href}>
<div
className={cn(
"flex items-center rounded-2xl transition-colors",
isActive ? "bg-indigo-50" : "hover:bg-slate-50"
)}
>
<Link
href={item.href}
title={!isMobile && collapsed ? item.title : undefined}
title={iconOnly ? item.title : undefined}
onClick={onClose}
className={cn(
"flex items-center gap-3 rounded-2xl px-4 py-3 text-base font-semibold transition-colors",
!isMobile && collapsed && "justify-center px-0",
isActive
? "bg-indigo-50 text-indigo-600"
: "text-slate-700 hover:bg-slate-50"
"flex flex-1 items-center gap-3 rounded-2xl px-4 py-3 text-base font-semibold",
iconOnly && "justify-center px-0",
isActive ? "text-indigo-600" : "text-slate-700"
)}
>
<item.icon
className={cn("size-5 shrink-0", isActive ? "text-indigo-600" : "text-slate-400")}
/>
{(!collapsed || isMobile) && (
{!iconOnly && (
<>
<span className="flex-1">{item.title}</span>
{item.chevron && !item.children && !isActive && (
<ChevronRight className="size-4 shrink-0 text-slate-300" />
{item.chevron && !hasChildren && (
<ChevronRight
className={cn("size-4 shrink-0", isActive ? "text-indigo-400" : "text-slate-300")}
/>
)}
</>
)}
</Link>
{item.children && (!collapsed || isMobile) && (
<ul className="mt-1 flex flex-col gap-0.5 pl-11">
{hasChildren && (
<button
type="button"
onClick={() => toggleExpand(item.code)}
aria-label={isOpen ? `Collapse ${item.title}` : `Expand ${item.title}`}
aria-expanded={isOpen}
className={cn(
"mr-2 flex size-7 shrink-0 items-center justify-center rounded-lg transition-colors hover:bg-white/60",
isActive ? "text-indigo-500" : "text-slate-400"
)}
>
<ChevronRight
className={cn("size-4 transition-transform duration-300 ease-in-out", isOpen && "rotate-90")}
/>
</button>
)}
</div>
{hasChildren && (
<div
className={cn(
"grid transition-all duration-300 ease-in-out",
isOpen ? "mt-1 grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
)}
>
<div className="overflow-hidden">
<ul className="flex flex-col gap-0.5 pl-11">
{(() => {
// Longest-matching href wins so a shared prefix (e.g. "Item" and
// "Category" both live under /dashboard/products) doesn't light up
// more than one sub-item at once.
const activeChild = [...item.children]
const activeChild = [...item.children!]
.filter((c) => pathname === c.href || pathname.startsWith(`${c.href}/`))
.sort((a, b) => b.href.length - a.href.length)[0]
return item.children.map((child) => {
return item.children!.map((child) => {
const childActive = child.href === activeChild?.href
return (
<li key={child.href}>
<Link
href={child.href}
onClick={onClose}
tabIndex={isOpen ? undefined : -1}
className={cn(
"flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium transition-colors",
childActive
@@ -192,13 +259,15 @@ function SidebarContent({
})
})()}
</ul>
</div>
</div>
)}
</li>
)
})}
</ul>
<div className="mt-auto flex items-center justify-center pt-6">
<div className="flex items-center justify-center pt-6">
<div className="flex size-12 items-center justify-center rounded-2xl bg-slate-50 ring-1 ring-black/5">
<svg viewBox="0 0 48 32" className="h-4 w-6 fill-slate-400">
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
+34 -1
View File
@@ -6,7 +6,40 @@ import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
// Base UI's `Select.Value` renders the raw selected value (e.g. an id) unless the Root is
// given an `items` map to resolve the label from — the popup items are unmounted when closed,
// so their text isn't otherwise available. Rather than pass `items` at all ~60 call sites,
// this wrapper walks its own `SelectItem` children and derives that map automatically, so the
// trigger shows the selected item's label instead of its value.
function collectItems(
children: React.ReactNode,
acc: { value: unknown; label: React.ReactNode }[]
) {
React.Children.forEach(children, (child) => {
if (!React.isValidElement(child)) return
if (child.type === SelectItem) {
const p = child.props as { value?: unknown; children?: React.ReactNode }
acc.push({ value: p.value, label: p.children })
return
}
const nested = (child.props as { children?: React.ReactNode }).children
if (nested) collectItems(nested, acc)
})
}
function Select<Value, Multiple extends boolean | undefined = false>(
props: SelectPrimitive.Root.Props<Value, Multiple>
) {
const { items, children } = props
const derivedItems = React.useMemo(() => {
if (items) return items
const acc: { value: unknown; label: React.ReactNode }[] = []
collectItems(children, acc)
return acc.length ? (acc as ReadonlyArray<{ value: Value; label: React.ReactNode }>) : undefined
}, [items, children])
return <SelectPrimitive.Root {...props} items={derivedItems} />
}
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
+13 -3
View File
@@ -20,10 +20,10 @@ export interface ListPurchaseOrdersParams {
sort?: string
}
/** Editable while open (FR-PROC-05, Option B). The server is authoritative — it returns
* 409 PO_NOT_EDITABLE regardless — this only drives UI affordances. */
/** Editable/deletable only while Draft (FR-PROC-05, revised — submitting locks the PO).
* The server is authoritative (409 PO_NOT_EDITABLE otherwise); this only drives UI affordances. */
export function isPoEditable(status: PurchaseOrderStatus): boolean {
return status !== "FullyReceived" && status !== "Closed" && status !== "Cancelled"
return status === "Draft"
}
export const purchaseOrdersApi = {
@@ -54,6 +54,16 @@ export const purchaseOrdersApi = {
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/approve`, { method: "POST" })
},
/** Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft. */
submit(poId: number): Promise<PurchaseOrder> {
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/submit`, { method: "POST" })
},
/** Delete a Draft PO. 409 PO_NOT_EDITABLE once submitted. */
remove(poId: number): Promise<void> {
return apiRequest<void>(`/purchase-orders/${poId}`, { method: "DELETE" })
},
/** 409 if any receipt exists against the PO. */
cancel(poId: number, request: CancelPurchaseOrderRequest): Promise<PurchaseOrder> {
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/cancel`, { method: "POST", body: request })
@@ -16,6 +16,8 @@ export function validateLine(input: {
uomId: number | null
qty: string
unitCost: string
discountPct: string
vatPct: string
trackingMode: TrackingMode | null
batchNo: string
serialNumbersText: string
@@ -31,6 +33,14 @@ export function validateLine(input: {
const unitCost = Number(input.unitCost)
if (input.unitCost === "" || Number.isNaN(unitCost) || unitCost < 0) errors.unitCost = "Unit cost cannot be negative"
const discountPct = Number(input.discountPct)
if (input.discountPct !== "" && (Number.isNaN(discountPct) || discountPct < 0 || discountPct > 100))
errors.discountPct = "Discount must be 0100%"
const vatPct = Number(input.vatPct)
if (input.vatPct !== "" && (Number.isNaN(vatPct) || vatPct < 0 || vatPct > 100))
errors.vatPct = "VAT must be 0100%"
if (input.trackingMode === "Batch" && !input.batchNo.trim()) {
errors.batchNo = "Batch number is required for this item"
}
+22
View File
@@ -29,7 +29,16 @@ export interface CreateGrnLineInput {
uomId: number
binId?: number | null
qty: number
/**
* Gross unit cost. For a PO line it is an optional per-receipt override — 0/omitted uses
* the PO price; a value wins and the server records a variance (docs/02-SECURITY C.3,
* revised). Required (> 0) for a direct receipt.
*/
unitCost: number
/** Trade discount % (0100). Reduces inventory cost. */
discountPct?: number
/** VAT % (0100). Recoverable — does not affect stock value. */
vatPct?: number
holdStatus: HoldStatus
batch?: BatchInput | null
}
@@ -49,8 +58,21 @@ export interface GrnLine {
uomId: number
binId: number | null
qty: number
/** Gross unit cost received at. */
unitCost: number
/** PO price snapshot at receipt; null for direct receipts. */
poUnitPrice: number | null
discountPct: number
/** After-discount cost — what the FIFO layer is valued at. */
netUnitCost: number
vatPct: number
vatAmount: number
/** qty × netUnitCost (after discount, before VAT). */
receivedValue: number
/** qty × netUnitCost + vatAmount — payable to vendor. */
lineTotal: number
/** (unitCost poUnitPrice) × qty; 0 for direct receipts. */
priceVariance: number
holdStatus: HoldStatus
batchId: number | null
}
+4 -2
View File
@@ -189,10 +189,12 @@ export interface CreatePurchaseOrderRequest {
vendorId: number
requisitionId?: number | null
lines: CreatePoLineInput[]
/** When true the PO is created as an editable/deletable Draft; false (default) auto-approves. */
saveAsDraft?: boolean
}
/** PUT /purchase-orders/{poId} — edit-while-open, same line shape as create (FR-PROC-05, Option B). */
export type UpdatePurchaseOrderRequest = CreatePurchaseOrderRequest
/** PUT /purchase-orders/{poId} — edit a Draft only (FR-PROC-05, revised); same line shape as create. */
export type UpdatePurchaseOrderRequest = Omit<CreatePurchaseOrderRequest, "saveAsDraft">
export interface CancelPurchaseOrderRequest {
reason?: string | null
+3 -3
View File
@@ -83,9 +83,9 @@ These are known, deliberately-accepted Phase-1 exposures. Each has a compensatin
- [ ] Note in review: **AR-01/AR-02/AR-03** apply to these endpoints
### C.3 GRN
- [ ] `unitCost` **derived from the PO line server-side**; any client-supplied cost is ignored *(decision locked)*
- [ ] `receivedValue` computed server-side (qty × PO-line cost), not accepted from client
- [ ] Direct GRN (no PO) is the exception where cost is entered → extra scrutiny + review flag + audit (**AR-04**)
- [ ] `unitCost` **defaults to the PO line price**; a per-line override **is now permitted** *(decision revised 2026-07-20 — was "locked, client cost ignored")*. When an override is entered it is used, and the PO price is snapshotted (`poUnitPrice`) so a **`priceVariance` is recorded** against it for review. Rationale: one PO legitimately spans batches received at different prices; the variance trail (plus the audit log) is the compensating control that replaces the old hard block.
- [ ] **Derived figures stay server-computed**`netUnitCost`/`receivedValue`/`vatAmount`/`lineTotal` are never accepted from the client, so the client cannot inflate stock value except by an *auditable* unit-cost override. Discount reduces inventory cost; **VAT is recoverable and never enters stock value**.
- [ ] Direct GRN (no PO) remains the higher-scrutiny path where cost is entered with no PO to compare against → review flag + audit (**AR-04**)
- [ ] Over-receipt tolerance enforced server-side → `OVER_RECEIPT_TOLERANCE`
- [ ] On-hold stock is not issuable (FR-WH-07); expired batch blocked
+2 -2
View File
@@ -137,7 +137,7 @@ One base currency; invoicing/3-way match in Accounting (GRN carries data); users
| FR-PROC-02 | Optional **RFQ**: issue to vendors, record quotations for comparison. | S |
| FR-PROC-03 | Generate **PO** from PR/RFQ or directly (item, UOM, qty, price, tax, delivery date, warehouse). | M |
| FR-PROC-04 | **[Phase 1: auto-approve]** Auto-approve PO on creation (status `Approved`). Config flag `approvalRequired` (default off) gates a future approval workflow (authorization matrix); when on, PO cannot issue until approved. `PendingApproval` state + approval fields retained in schema (no migration to enable). | M |
| FR-PROC-05 | **[Phase 1: Option B — edit-while-open]** PO may be **freely edited while open** (not fully received/closed); changes take effect immediately with an audit entry. Versioned amendments deferred; schema must not preclude adding a version field later. | S |
| FR-PROC-05 | **[Phase 1: Option B *superseded* 2026-07-20 — draft-lock]** A PO is **editable and deletable only while `Draft`**; **submitting locks it** (Draft → Approved) and no further edit/delete/add-line is allowed — an issued PO is corrected by Cancel-with-reason (blocked once receipts exist) or a reversing document, never edited. Create takes `saveAsDraft` (default `false` → auto-approve, preserving the Requisition→PO / RFQ→PO flows). *Why the reversal:* Option B ("freely edit while open") let an already-issued, vendor-facing PO change silently after the fact; the draft/submit boundary makes "issued to vendor" a real, immutable commitment. Versioned amendments still deferred; schema unchanged (reuses the existing `Draft` enum value). | S |
| FR-PROC-06 | PO lifecycle: Draft → (PendingApproval →) Approved → PartiallyReceived → FullyReceived → Closed/Cancelled. Phase 1 bypasses PendingApproval via auto-approve. | M |
| FR-PROC-07 | Support **partial receipt**; PO stays open until fully received or manually closed. | M |
| FR-PROC-08 | Support **Purchase Return** referencing original GRN/PO line; generates outbound movement. | M |
@@ -151,7 +151,7 @@ One base currency; invoicing/3-way match in Accounting (GRN carries data); users
| FR-GRN-03 | Support **over/under-receipt tolerances** (per item or global); warn or block beyond tolerance. | S |
| FR-GRN-04 | Capture **batch + expiry** and/or **serial numbers** for tracked items on receipt. | M |
| FR-GRN-05 | Allow receipt into **inspection/quarantine hold** (not issuable) pending QC, before QC module exists. | M |
| FR-GRN-06 | On confirm, each line **creates a FIFO cost layer** at unit cost (PO price + attributable charges; landed cost per §B.1.2.1) and posts an inbound ledger entry. | M |
| FR-GRN-06 | On confirm, each line **creates a FIFO cost layer** at the **after-discount net unit cost** (`unitCost × (1 discountPct/100)`) and posts an inbound ledger entry. **VAT never enters stock value** — it is recoverable input tax (revised 2026-07-20). PO price is the default unit cost; a per-line override is permitted and recorded as a variance (see 02-SECURITY C.3, revised). | M |
| FR-GRN-07 | Record **received value per line** and PO reference for downstream matching. | M |
| FR-GRN-08 | Assign received stock to a **bin/location** (putaway). | S |
+20 -6
View File
@@ -457,14 +457,15 @@ Query: `q`, `status` (`Open|Closed`), + paging. → list envelope of
`GET /rfqs/{rfqId}/comparison` → vendor-by-line price matrix.
### 3.3 Purchase Orders
> **Phase 1:** `approvalRequired` defaults `false` → PO **auto-approved on creation**. Approve endpoint exists but is a no-op unless enabled (FR-PROC-04). PO **freely editable while open** (Option B, FR-PROC-05).
> **Phase 1:** `approvalRequired` defaults `false`. Create takes **`saveAsDraft`** (default `false` → **auto-approved on creation**; `true` → `Draft`). Approve endpoint exists but is a no-op unless enabled (FR-PROC-04). **A PO is editable/deletable only while `Draft`; submitting locks it** (FR-PROC-05, revised 2026-07-20 — Option B "freely edit while open" superseded).
#### `POST /purchase-orders`
```json
{ "vendorId": 5, "requisitionId": 210,
{ "vendorId": 5, "requisitionId": 210, "saveAsDraft": false,
"lines": [ { "itemId": 1001, "uomId": 1, "warehouseId": 1, "qty": 5000, "unitPrice": 12.50, "tax": 0.18 },
{ "itemId": 1002, "uomId": 1, "warehouseId": 1, "qty": 8000, "unitPrice": 6.20, "tax": 0.18 } ] }
```
`saveAsDraft` optional (default `false`). When `true` the response `status` is `Draft`.
**201 Created**`Location: /api/v1/purchase-orders/342`
```json
{ "poId": 342, "docNo": "PO-2026-00342", "vendorId": 5, "requisitionId": 210,
@@ -477,7 +478,11 @@ Query: `q`, `status` (`Open|Closed`), + paging. → list envelope of
`GET /purchase-orders?status=Approved&vendorId=5` → list envelope of PO summaries.
#### `PUT /purchase-orders/{poId}`
Edit while open (not FullyReceived/Closed/Cancelled); requires `If-Match`. → **200 OK** updated resource; `409 PO_NOT_EDITABLE` if closed.
Edit a **Draft only**; requires `If-Match`. → **200 OK** updated resource; `409 PO_NOT_EDITABLE` once submitted (any non-Draft status).
#### `POST /purchase-orders/{poId}/submit` → **200 OK** — `Draft → Approved`. `409 PO_NOT_EDITABLE` if not Draft.
#### `DELETE /purchase-orders/{poId}` → **204 No Content** — permitted **only while Draft**; `409 PO_NOT_EDITABLE` once submitted.
#### `POST /purchase-orders/{poId}/approve` → **200 OK** (no-op in Phase 1; transitions PendingApproval→Approved when enabled).
@@ -524,15 +529,24 @@ Against a PO (lines default from open PO lines) or direct (`poId: null`, by perm
```json
{ "poId": 342, "warehouseId": 1,
"lines": [ { "poLineId": 900, "itemId": 1001, "uomId": 1, "binId": 45, "qty": 5000,
"unitCost": 12.50, "holdStatus": "OnHold",
"unitCost": 12.50, "discountPct": 10, "vatPct": 18, "holdStatus": "OnHold",
"batch": { "batchNo": "B-2607", "expiryDate": "2028-07-01" } } ] }
```
**201 Created** — status `Draft`
`discountPct`/`vatPct` optional (default 0, range 0100). `unitCost` on a **PO line** is an optional
override: 0/omitted uses the PO price; a value wins and a variance is recorded (02-SECURITY C.3, revised).
On a direct receipt `unitCost` is required.
**201 Created** — status `Draft`. All derived figures are **server-computed**:
`netUnitCost = unitCost × (1 discountPct/100)`, `receivedValue = qty × netUnitCost` (after discount,
**before** VAT — this is the stock value), `vatAmount = receivedValue × vatPct/100`,
`lineTotal = receivedValue + vatAmount`, `priceVariance = (unitCost poUnitPrice) × qty`.
```json
{ "grnId": 780, "docNo": "GRN-2026-00780", "poId": 342, "vendorId": 5, "warehouseId": 1,
"status": "Draft", "createdBy": 17,
"lines": [ { "grnLineId": 1300, "poLineId": 900, "itemId": 1001, "uomId": 1, "binId": 45,
"qty": 5000, "unitCost": 12.50, "receivedValue": 62500.00, "holdStatus": "OnHold", "batchId": 410 } ] }
"qty": 5000, "unitCost": 12.50, "poUnitPrice": 12.50, "discountPct": 10.0,
"netUnitCost": 11.25, "vatPct": 18.0, "vatAmount": 10125.00,
"receivedValue": 56250.00, "lineTotal": 66375.00, "priceVariance": 0.00,
"holdStatus": "OnHold", "batchId": 410 } ] }
```
`422 OVER_RECEIPT_TOLERANCE` if qty exceeds open PO qty beyond tolerance.