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); 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> /// <summary>Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled.</summary>
[HttpPost("{poId:int}/approve")] [HttpPost("{poId:int}/approve")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
+30 -3
View File
@@ -3,9 +3,13 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities; namespace ERPCore.Domain.Entities;
/// <summary> /// <summary>
/// GRN line (FR-GRN-04..08). <see cref="UnitCost"/> is the PO-derived cost for /// GRN line (FR-GRN-04..08). <see cref="UnitCost"/> is the gross cost received at:
/// PO-based receipts (client cost ignored — 02-SECURITY C.3) or the entered cost /// entered on the line, defaulting to the PO price when omitted (a per-receipt price
/// for direct receipts. <see cref="ReceivedValue"/> = qty × unitCost. /// 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. /// <see cref="HoldStatus"/> gates issuability. Model: docs/10 Part C.3.
/// </summary> /// </summary>
public class GrnLine public class GrnLine
@@ -31,7 +35,30 @@ public class GrnLine
public Batch? Batch { get; set; } public Batch? Batch { get; set; }
public decimal Qty { 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; } 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; } 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; public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
} }
+1 -1
View File
@@ -22,7 +22,7 @@ public class PoLine
public Warehouse? Warehouse { get; set; } public Warehouse? Warehouse { get; set; }
public decimal Qty { get; set; } public decimal Qty { get; set; }
public decimal UnitPrice { get; set; } public decimal UnitPrice { get; set; }//
public decimal Tax { get; set; } public decimal Tax { get; set; }
public decimal QtyReceived { get; set; } public decimal QtyReceived { get; set; }
} }
+13 -2
View File
@@ -7,7 +7,10 @@ namespace ERPCore.Dtos.Grn;
public sealed record GrnLineDto( public sealed record GrnLineDto(
int GrnLineId, int? PoLineId, int ItemId, int UomId, int? BinId, 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( public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status, 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; } [Required] public int UomId { get; set; }
public int? BinId { get; set; } public int? BinId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { 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; } [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; [EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
public BatchInput? Batch { get; set; } public BatchInput? Batch { get; set; }
} }
@@ -37,6 +37,13 @@ public sealed class CreatePurchaseOrderRequest
[Required] public int VendorId { get; set; } [Required] public int VendorId { get; set; }
public int? RequisitionId { get; set; } public int? RequisitionId { get; set; }
[Required, MinLength(1)] public List<CreatePoLineInput> Lines { get; set; } = new(); [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 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.Qty).HasPrecision(18, 4);
builder.Property(l => l.UnitCost).HasPrecision(18, 6); builder.Property(l => l.UnitCost).HasPrecision(18, 6);
builder.Property(l => l.PoUnitPrice).HasPrecision(18, 6);
builder.Property(l => l.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.ReceivedValue).HasPrecision(18, 4);
builder.Property(l => l.LineTotal).HasPrecision(18, 4);
builder.Property(l => l.HoldStatus).HasConversion<string>().HasMaxLength(20).IsRequired(); 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); 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 = 15, Code = "NAV:products.uom", SubNavItemId = 5 },
new Permission { PermissionId = 16, Code = "NAV:products.configuration", SubNavItemId = 6 }, new Permission { PermissionId = 16, Code = "NAV:products.configuration", SubNavItemId = 6 },
new Permission { PermissionId = 17, Code = "NAV:settings.roles", SubNavItemId = 7 }, 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 = 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 = 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 = 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") b.Property<int?>("BinId")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<decimal>("DiscountPct")
.HasPrecision(9, 4)
.HasColumnType("numeric(9,4)");
b.Property<int>("GrnId") b.Property<int>("GrnId")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -288,9 +292,21 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Property<int>("ItemId") b.Property<int>("ItemId")
.HasColumnType("integer"); .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") b.Property<int?>("PoLineId")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<decimal?>("PoUnitPrice")
.HasPrecision(18, 6)
.HasColumnType("numeric(18,6)");
b.Property<decimal>("Qty") b.Property<decimal>("Qty")
.HasPrecision(18, 4) .HasPrecision(18, 4)
.HasColumnType("numeric(18,4)"); .HasColumnType("numeric(18,4)");
@@ -306,6 +322,14 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Property<int>("UomId") b.Property<int>("UomId")
.HasColumnType("integer"); .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.HasKey("GrnLineId");
b.HasIndex("BatchId"); b.HasIndex("BatchId");
@@ -828,6 +852,30 @@ namespace ERPCore.Infra.Persistence.Migrations
PermissionId = 18, PermissionId = 18,
Code = "NAV:settings.users", Code = "NAV:settings.users",
SubNavItemId = 8 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, NavItemId = 9,
SortOrder = 2, SortOrder = 2,
Status = "Active" 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)) if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422); throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422);
// 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 unitCost;
decimal? poUnitPrice = null;
if (input.PoLineId is not null) if (input.PoLineId is not null)
{ {
var poLine = po?.Lines.FirstOrDefault(l => l.PoLineId == input.PoLineId) var poLine = po?.Lines.FirstOrDefault(l => l.PoLineId == input.PoLineId)
@@ -152,13 +155,19 @@ public sealed class GrnService : IGrnService
throw new DomainException(ErrorCodes.OverReceiptTolerance, throw new DomainException(ErrorCodes.OverReceiptTolerance,
$"Receiving {input.Qty} exceeds the open quantity {openQty} on PO line {input.PoLineId}.", 422); $"Receiving {input.Qty} 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 else
{ {
unitCost = input.UnitCost; 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); var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
lines.Add(new GrnLine 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 Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted
Qty = input.Qty, Qty = input.Qty,
UnitCost = unitCost, 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 HoldStatus = input.HoldStatus
}); });
} }
@@ -220,7 +235,9 @@ public sealed class GrnService : IGrnService
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId)) foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
{ {
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token); 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( var layer = await _fifo.CreateInboundLayerAsync(
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId, 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( 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.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( 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<ETagged<PurchaseOrderDto>> UpdateAsync(int poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task<PurchaseOrderDto> ApproveAsync(int poId, CancellationToken ct = default); Task<PurchaseOrderDto> ApproveAsync(int poId, CancellationToken ct = default);
Task<PurchaseOrderDto> CancelAsync(int poId, string? reason, 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, DocNo = docNo,
VendorId = request.VendorId, VendorId = request.VendorId,
RequisitionId = request.RequisitionId, 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, ApprovalRequired = false,
Status = PurchaseOrderStatus.Approved, Status = request.SaveAsDraft ? PurchaseOrderStatus.Draft : PurchaseOrderStatus.Approved,
CreatedBy = actor, CreatedBy = actor,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
Lines = request.Lines.Select(ToLine).ToList() Lines = request.Lines.Select(ToLine).ToList()
@@ -182,8 +183,41 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
return Map(po); return Map(po);
} }
private static bool IsEditable(PurchaseOrderStatus status) => status is not ( public async Task<PurchaseOrderDto> SubmitAsync(int poId, CancellationToken ct = default)
PurchaseOrderStatus.FullyReceived or PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled); {
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() 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. > 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] 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] 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.) - [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. > **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 ## 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. > 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] 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). - [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 ## Done
<!-- move [x] items here with date + note if the active list grows long --> <!-- 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 ### 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. - 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. - 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 ## 3. Procurement screens
- [~] Requisition (`app/dashboard/procurement/requisitions` list, `/new` create, `/[id]` detail + Submit) — FR-PROC-01 - [~] 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 - [~] 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 - [~] 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 ## 4. Receiving screens
- [~] GRN list (`app/dashboard/receiving/grn/page.tsx`) — loading/empty/error states, links to detail - [~] 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 - [~] 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` - [~] 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` - 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 ## Done
<!-- move [x] items here with date + note if the active list grows long --> <!-- 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) ### 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. **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", 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", href: "/dashboard/procurement/purchase-orders",
icon: ShoppingCart, icon: ShoppingCart,
}, },
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation" import { useParams, useRouter } from "next/navigation"
import Link from "next/link" 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 { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
@@ -65,6 +65,8 @@ export default function PurchaseOrderDetailPage() {
const [showCancelForm, setShowCancelForm] = useState(false) const [showCancelForm, setShowCancelForm] = useState(false)
const [cancelReason, setCancelReason] = useState("") const [cancelReason, setCancelReason] = useState("")
const [cancelling, setCancelling] = useState(false) const [cancelling, setCancelling] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [deleting, setDeleting] = useState(false)
function toDraftLines(order: PurchaseOrder): DraftLine[] { function toDraftLines(order: PurchaseOrder): DraftLine[] {
return order.lines.map((l) => ({ 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() { async function handleCancel() {
if (!po) return if (!po) return
if (!cancelReason.trim()) { if (!cancelReason.trim()) {
@@ -226,6 +261,9 @@ export default function PurchaseOrderDetailPage() {
const editable = isPoEditable(po.status) && !conflict const editable = isPoEditable(po.status) && !conflict
const hasReceipts = po.lines.some((l) => l.qtyReceived > 0) 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 ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -245,18 +283,32 @@ export default function PurchaseOrderDetailPage() {
</div> </div>
</div> </div>
{isPoEditable(po.status) && !showCancelForm && ( <div className="flex items-center gap-3">
<Button {po.status === "Draft" && (
variant="destructive" <>
size="lg" <Button variant="outline" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
onClick={() => setShowCancelForm(true)} <Send className="size-5" />
disabled={hasReceipts} {submitting ? "Submitting…" : "Submit"}
title={hasReceipts ? "Cannot cancel — this PO already has receipts against it" : undefined} </Button>
> <Button variant="destructive" size="lg" onClick={handleDelete} disabled={deleting || submitting}>
<Ban className="size-5" /> <Trash2 className="size-5" />
Cancel PO {deleting ? "Deleting…" : "Delete draft"}
</Button> </Button>
)} </>
)}
{cancellable && !showCancelForm && (
<Button
variant="destructive"
size="lg"
onClick={() => setShowCancelForm(true)}
disabled={hasReceipts}
title={hasReceipts ? "Cannot cancel — this PO already has receipts against it" : undefined}
>
<Ban className="size-5" />
Cancel PO
</Button>
)}
</div>
</div> </div>
{showCancelForm && ( {showCancelForm && (
@@ -43,8 +43,12 @@ function newKey() {
return `poline-${keySeq}` 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 { 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() { function NewPurchaseOrderContent() {
@@ -98,8 +102,8 @@ function NewPurchaseOrderContent() {
uomId: null, uomId: null,
warehouseId: null, warehouseId: null,
qty: String(l.qty), qty: String(l.qty),
unitPrice: "", unitPrice: "0",
tax: "0.18", tax: "0",
}) })
) )
) )
@@ -124,8 +128,8 @@ function NewPurchaseOrderContent() {
uomId: null, uomId: null,
warehouseId: null, warehouseId: null,
qty: String(l.qty), qty: String(l.qty),
unitPrice: cell ? String(cell.unitPrice) : "", unitPrice: cell ? String(cell.unitPrice) : "0",
tax: "0.18", tax: "0",
} }
}) })
) )
@@ -151,7 +155,7 @@ function NewPurchaseOrderContent() {
return items?.find((i) => i.itemId === itemId) ?? null return items?.find((i) => i.itemId === itemId) ?? null
} }
async function handleSubmit() { async function handleSubmit(saveAsDraft: boolean) {
setHeaderError(null) setHeaderError(null)
setSubmitError(null) setSubmitError(null)
@@ -197,8 +201,12 @@ function NewPurchaseOrderContent() {
vendorId, vendorId,
requisitionId: requisitionId ?? (rfqId ? undefined : null), requisitionId: requisitionId ?? (rfqId ? undefined : null),
lines: payloadLines, 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}`) router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
} catch (err) { } catch (err) {
setSubmitError(errorMessage(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-28 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-40 px-3 text-sm">Warehouse</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-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" /> <TableHead className="h-12 w-10 px-3" />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -348,30 +354,6 @@ function NewPurchaseOrderContent() {
/> />
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} /> <FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell> </TableCell>
<TableCell className="px-3 py-3 align-top">
<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"> <TableCell className="px-3 py-3 align-top">
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line"> <Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
<Trash2 className="size-5" /> <Trash2 className="size-5" />
@@ -394,8 +376,11 @@ function NewPurchaseOrderContent() {
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}> <Button size="lg" type="button" variant="outline" onClick={() => handleSubmit(true)} disabled={submitting}>
{submitting ? "Creating…" : "Create PO"} {submitting ? "Saving…" : "Save as draft"}
</Button>
<Button size="lg" type="button" onClick={() => handleSubmit(false)} disabled={submitting}>
{submitting ? "Creating…" : "Create & submit"}
</Button> </Button>
</div> </div>
</> </>
@@ -158,6 +158,7 @@ export default function GrnDetailPage() {
</div> </div>
)} )}
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <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">UOM</TableHead>
<TableHead className="h-12 px-3 text-sm">Bin</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">Qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Unit cost</TableHead> <TableHead className="h-12 px-3 text-sm text-right">Unit cost</TableHead>
<TableHead className="h-12 px-3 text-sm">Received value</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> <TableHead className="h-12 px-3 text-sm">Hold status</TableHead>
{grn.status === "Confirmed" && <TableHead className="h-12 px-3 text-sm">Actions</TableHead>} {grn.status === "Confirmed" && <TableHead className="h-12 px-3 text-sm">Actions</TableHead>}
</TableRow> </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">{uomFor(line.uomId)}</TableCell>
<TableCell className="px-3 py-3.5">{binFor(line.binId)}</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.qty}</TableCell>
<TableCell className="px-3 py-3.5">{line.unitCost.toFixed(2)}</TableCell> <TableCell className="px-3 py-3.5 text-right tabular-nums">
<TableCell className="px-3 py-3.5">{line.receivedValue.toFixed(2)}</TableCell> {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"> <TableCell className="px-3 py-3.5">
<HoldStatusBadge status={line.holdStatus} /> <HoldStatusBadge status={line.holdStatus} />
</TableCell> </TableCell>
@@ -228,6 +247,22 @@ export default function GrnDetailPage() {
})} })}
</TableBody> </TableBody>
</Table> </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> </div>
) )
} }
@@ -37,12 +37,28 @@ interface DraftLine {
binId: number | null binId: number | null
qty: string qty: string
unitCost: string unitCost: string
/** PO line price when prefilled from a PO; drives the variance hint. */
poUnitPrice: number | null
discountPct: string
vatPct: string
holdStatus: HoldStatus holdStatus: HoldStatus
batchNo: string batchNo: string
expiryDate: string expiryDate: string
serialNumbersText: 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 let keySeq = 0
function newKey() { function newKey() {
keySeq += 1 keySeq += 1
@@ -58,6 +74,9 @@ function emptyLine(): DraftLine {
binId: null, binId: null,
qty: "", qty: "",
unitCost: "", unitCost: "",
poUnitPrice: null,
discountPct: "0",
vatPct: "0",
holdStatus: "Available", holdStatus: "Available",
batchNo: "", batchNo: "",
expiryDate: "", expiryDate: "",
@@ -152,6 +171,9 @@ export default function NewGrnPage() {
binId: null, binId: null,
qty: String(l.qty - l.qtyReceived), qty: String(l.qty - l.qtyReceived),
unitCost: String(l.unitPrice), unitCost: String(l.unitPrice),
poUnitPrice: l.unitPrice,
discountPct: "0",
vatPct: "0",
holdStatus: "Available", holdStatus: "Available",
batchNo: "", batchNo: "",
expiryDate: "", expiryDate: "",
@@ -211,6 +233,8 @@ export default function NewGrnPage() {
uomId: line.uomId, uomId: line.uomId,
qty: line.qty, qty: line.qty,
unitCost: line.unitCost, unitCost: line.unitCost,
discountPct: line.discountPct,
vatPct: line.vatPct,
trackingMode: itemFor(line.itemId)?.trackingMode ?? null, trackingMode: itemFor(line.itemId)?.trackingMode ?? null,
batchNo: line.batchNo, batchNo: line.batchNo,
serialNumbersText: line.serialNumbersText, serialNumbersText: line.serialNumbersText,
@@ -232,6 +256,8 @@ export default function NewGrnPage() {
binId: l.binId, binId: l.binId,
qty: Number(l.qty), qty: Number(l.qty),
unitCost: Number(l.unitCost), unitCost: Number(l.unitCost),
discountPct: Number(l.discountPct) || 0,
vatPct: Number(l.vatPct) || 0,
holdStatus: l.holdStatus, holdStatus: l.holdStatus,
batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null, batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null,
serialNumbers: trackingMode === "Serial" ? splitSerials(l.serialNumbersText) : null, serialNumbers: trackingMode === "Serial" ? splitSerials(l.serialNumbersText) : null,
@@ -371,16 +397,20 @@ export default function NewGrnPage() {
{poLoading && <Skeleton className="h-24 w-full" />} {poLoading && <Skeleton className="h-24 w-full" />}
{!poLoading && lines.length > 0 && ( {!poLoading && lines.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead> <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-24 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-32 px-3 text-sm">Bin</TableHead> <TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Qty</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-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-24 px-3 text-sm">Disc %</TableHead>
<TableHead className="h-12 w-48 px-3 text-sm">Batch / Serial</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" /> <TableHead className="h-12 w-10 px-3" />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -473,6 +503,50 @@ export default function NewGrnPage() {
className="h-11 text-base" className="h-11 text-base"
/> />
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} /> <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>
<TableCell className="px-3 py-3 align-top"> <TableCell className="px-3 py-3 align-top">
<Select<HoldStatus> <Select<HoldStatus>
@@ -533,6 +607,16 @@ export default function NewGrnPage() {
})} })}
</TableBody> </TableBody>
</Table> </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> </div>
@@ -8,12 +8,14 @@ import {
Building2, Building2,
ChevronRight, ChevronRight,
ClipboardList, ClipboardList,
FileText,
HelpCircle, HelpCircle,
LayoutGrid, LayoutGrid,
ListTree, ListTree,
Menu, Menu,
Package, Package,
PackageCheck, PackageCheck,
PackageX,
Ruler, Ruler,
Settings, Settings,
ShieldCheck, ShieldCheck,
@@ -56,7 +58,19 @@ const navItems: {
], ],
}, },
{ title: "Vendors", code: "vendors", href: "/dashboard/vendors", icon: Truck, chevron: true }, { 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: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
{ title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true }, { title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true },
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true }, { title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
@@ -91,18 +105,36 @@ function SidebarContent({
pathname: string pathname: string
isMobile: boolean 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 ( return (
<nav <nav
className={cn( 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") !isMobile && (collapsed ? "w-20" : "w-64")
)} )}
> >
{/* Header */} {/* Header */}
<div <div
className={cn( className={cn(
"mb-10 flex items-center gap-2.5 px-4 py-3", "mb-8 flex shrink-0 items-center gap-2.5 px-4 py-3",
!isMobile && collapsed ? "flex-col-reverse justify-center gap-3 px-0" : "justify-between" iconOnly ? "flex-col-reverse justify-center gap-3 px-0" : "justify-between"
)} )}
> >
<Link href="/dashboard" className="flex items-center gap-2.5" onClick={onClose}> <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" /> <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> </svg>
</div> </div>
{(!collapsed || isMobile) && ( {!iconOnly && (
<span className="text-lg font-bold tracking-tight text-slate-900">Hexa ERP</span> <span className="text-lg font-bold tracking-tight text-slate-900">Hexa ERP</span>
)} )}
</Link> </Link>
@@ -126,79 +158,116 @@ function SidebarContent({
</button> </button>
</div> </div>
{/* Nav items */} {/* Nav items — scrolls internally when it overflows, without a visible
<ul className="flex flex-col gap-1"> 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) => { {items.map((item) => {
const isActive = const isActive =
item.href === "/dashboard" ? pathname === item.href : pathname.startsWith(item.href) item.href === "/dashboard" ? pathname === item.href : pathname.startsWith(item.href)
const hasChildren = !!item.children?.length && !iconOnly
const isOpen = !!expanded[item.code]
return ( return (
<li key={item.href}> <li key={item.href}>
<Link <div
href={item.href}
title={!isMobile && collapsed ? item.title : undefined}
onClick={onClose}
className={cn( className={cn(
"flex items-center gap-3 rounded-2xl px-4 py-3 text-base font-semibold transition-colors", "flex items-center rounded-2xl transition-colors",
!isMobile && collapsed && "justify-center px-0", isActive ? "bg-indigo-50" : "hover:bg-slate-50"
isActive
? "bg-indigo-50 text-indigo-600"
: "text-slate-700 hover:bg-slate-50"
)} )}
> >
<item.icon <Link
className={cn("size-5 shrink-0", isActive ? "text-indigo-600" : "text-slate-400")} href={item.href}
/> title={iconOnly ? item.title : undefined}
{(!collapsed || isMobile) && ( onClick={onClose}
<> className={cn(
<span className="flex-1">{item.title}</span> "flex flex-1 items-center gap-3 rounded-2xl px-4 py-3 text-base font-semibold",
{item.chevron && !item.children && !isActive && ( iconOnly && "justify-center px-0",
<ChevronRight className="size-4 shrink-0 text-slate-300" /> isActive ? "text-indigo-600" : "text-slate-700"
)} )}
</> >
)} <item.icon
</Link> className={cn("size-5 shrink-0", isActive ? "text-indigo-600" : "text-slate-400")}
/>
{!iconOnly && (
<>
<span className="flex-1">{item.title}</span>
{item.chevron && !hasChildren && (
<ChevronRight
className={cn("size-4 shrink-0", isActive ? "text-indigo-400" : "text-slate-300")}
/>
)}
</>
)}
</Link>
{item.children && (!collapsed || isMobile) && ( {hasChildren && (
<ul className="mt-1 flex flex-col gap-0.5 pl-11"> <button
{(() => { type="button"
// Longest-matching href wins so a shared prefix (e.g. "Item" and onClick={() => toggleExpand(item.code)}
// "Category" both live under /dashboard/products) doesn't light up aria-label={isOpen ? `Collapse ${item.title}` : `Expand ${item.title}`}
// more than one sub-item at once. aria-expanded={isOpen}
const activeChild = [...item.children] className={cn(
.filter((c) => pathname === c.href || pathname.startsWith(`${c.href}/`)) "mr-2 flex size-7 shrink-0 items-center justify-center rounded-lg transition-colors hover:bg-white/60",
.sort((a, b) => b.href.length - a.href.length)[0] isActive ? "text-indigo-500" : "text-slate-400"
return item.children.map((child) => { )}
const childActive = child.href === activeChild?.href >
return ( <ChevronRight
<li key={child.href}> className={cn("size-4 transition-transform duration-300 ease-in-out", isOpen && "rotate-90")}
<Link />
href={child.href} </button>
onClick={onClose} )}
className={cn( </div>
"flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium transition-colors",
childActive {hasChildren && (
? "bg-indigo-50 text-indigo-600" <div
: "text-slate-500 hover:bg-slate-50 hover:text-slate-700" className={cn(
)} "grid transition-all duration-300 ease-in-out",
> isOpen ? "mt-1 grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
<child.icon )}
className={cn("size-4 shrink-0", childActive ? "text-indigo-600" : "text-slate-400")} >
/> <div className="overflow-hidden">
{child.title} <ul className="flex flex-col gap-0.5 pl-11">
</Link> {(() => {
</li> // 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!]
</ul> .filter((c) => pathname === c.href || pathname.startsWith(`${c.href}/`))
.sort((a, b) => b.href.length - a.href.length)[0]
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
? "bg-indigo-50 text-indigo-600"
: "text-slate-500 hover:bg-slate-50 hover:text-slate-700"
)}
>
<child.icon
className={cn("size-4 shrink-0", childActive ? "text-indigo-600" : "text-slate-400")}
/>
{child.title}
</Link>
</li>
)
})
})()}
</ul>
</div>
</div>
)} )}
</li> </li>
) )
})} })}
</ul> </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"> <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"> <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" /> <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 { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react" 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) { function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return ( return (
+13 -3
View File
@@ -20,10 +20,10 @@ export interface ListPurchaseOrdersParams {
sort?: string sort?: string
} }
/** Editable while open (FR-PROC-05, Option B). The server is authoritative — it returns /** Editable/deletable only while Draft (FR-PROC-05, revised — submitting locks the PO).
* 409 PO_NOT_EDITABLE regardless — this only drives UI affordances. */ * The server is authoritative (409 PO_NOT_EDITABLE otherwise); this only drives UI affordances. */
export function isPoEditable(status: PurchaseOrderStatus): boolean { export function isPoEditable(status: PurchaseOrderStatus): boolean {
return status !== "FullyReceived" && status !== "Closed" && status !== "Cancelled" return status === "Draft"
} }
export const purchaseOrdersApi = { export const purchaseOrdersApi = {
@@ -54,6 +54,16 @@ export const purchaseOrdersApi = {
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/approve`, { method: "POST" }) 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. */ /** 409 if any receipt exists against the PO. */
cancel(poId: number, request: CancelPurchaseOrderRequest): Promise<PurchaseOrder> { cancel(poId: number, request: CancelPurchaseOrderRequest): Promise<PurchaseOrder> {
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/cancel`, { method: "POST", body: request }) return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/cancel`, { method: "POST", body: request })
@@ -16,6 +16,8 @@ export function validateLine(input: {
uomId: number | null uomId: number | null
qty: string qty: string
unitCost: string unitCost: string
discountPct: string
vatPct: string
trackingMode: TrackingMode | null trackingMode: TrackingMode | null
batchNo: string batchNo: string
serialNumbersText: string serialNumbersText: string
@@ -31,6 +33,14 @@ export function validateLine(input: {
const unitCost = Number(input.unitCost) const unitCost = Number(input.unitCost)
if (input.unitCost === "" || Number.isNaN(unitCost) || unitCost < 0) errors.unitCost = "Unit cost cannot be negative" 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()) { if (input.trackingMode === "Batch" && !input.batchNo.trim()) {
errors.batchNo = "Batch number is required for this item" errors.batchNo = "Batch number is required for this item"
} }
+22
View File
@@ -29,7 +29,16 @@ export interface CreateGrnLineInput {
uomId: number uomId: number
binId?: number | null binId?: number | null
qty: number 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 unitCost: number
/** Trade discount % (0100). Reduces inventory cost. */
discountPct?: number
/** VAT % (0100). Recoverable — does not affect stock value. */
vatPct?: number
holdStatus: HoldStatus holdStatus: HoldStatus
batch?: BatchInput | null batch?: BatchInput | null
} }
@@ -49,8 +58,21 @@ export interface GrnLine {
uomId: number uomId: number
binId: number | null binId: number | null
qty: number qty: number
/** Gross unit cost received at. */
unitCost: number 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 receivedValue: number
/** qty × netUnitCost + vatAmount — payable to vendor. */
lineTotal: number
/** (unitCost poUnitPrice) × qty; 0 for direct receipts. */
priceVariance: number
holdStatus: HoldStatus holdStatus: HoldStatus
batchId: number | null batchId: number | null
} }
+4 -2
View File
@@ -189,10 +189,12 @@ export interface CreatePurchaseOrderRequest {
vendorId: number vendorId: number
requisitionId?: number | null requisitionId?: number | null
lines: CreatePoLineInput[] 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). */ /** PUT /purchase-orders/{poId} — edit a Draft only (FR-PROC-05, revised); same line shape as create. */
export type UpdatePurchaseOrderRequest = CreatePurchaseOrderRequest export type UpdatePurchaseOrderRequest = Omit<CreatePurchaseOrderRequest, "saveAsDraft">
export interface CancelPurchaseOrderRequest { export interface CancelPurchaseOrderRequest {
reason?: string | null 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 - [ ] Note in review: **AR-01/AR-02/AR-03** apply to these endpoints
### C.3 GRN ### C.3 GRN
- [ ] `unitCost` **derived from the PO line server-side**; any client-supplied cost is ignored *(decision locked)* - [ ] `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.
- [ ] `receivedValue` computed server-side (qty × PO-line cost), not accepted from client - [ ] **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) is the exception where cost is entered → extra scrutiny + review flag + audit (**AR-04**) - [ ] 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` - [ ] Over-receipt tolerance enforced server-side → `OVER_RECEIPT_TOLERANCE`
- [ ] On-hold stock is not issuable (FR-WH-07); expired batch blocked - [ ] 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-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-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-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-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-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 | | 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-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-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-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-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 | | 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. `GET /rfqs/{rfqId}/comparison` → vendor-by-line price matrix.
### 3.3 Purchase Orders ### 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` #### `POST /purchase-orders`
```json ```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 }, "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 } ] } { "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` **201 Created**`Location: /api/v1/purchase-orders/342`
```json ```json
{ "poId": 342, "docNo": "PO-2026-00342", "vendorId": 5, "requisitionId": 210, { "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. `GET /purchase-orders?status=Approved&vendorId=5` → list envelope of PO summaries.
#### `PUT /purchase-orders/{poId}` #### `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). #### `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 ```json
{ "poId": 342, "warehouseId": 1, { "poId": 342, "warehouseId": 1,
"lines": [ { "poLineId": 900, "itemId": 1001, "uomId": 1, "binId": 45, "qty": 5000, "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" } } ] } "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 ```json
{ "grnId": 780, "docNo": "GRN-2026-00780", "poId": 342, "vendorId": 5, "warehouseId": 1, { "grnId": 780, "docNo": "GRN-2026-00780", "poId": 342, "vendorId": 5, "warehouseId": 1,
"status": "Draft", "createdBy": 17, "status": "Draft", "createdBy": 17,
"lines": [ { "grnLineId": 1300, "poLineId": 900, "itemId": 1001, "uomId": 1, "binId": 45, "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. `422 OVER_RECEIPT_TOLERANCE` if qty exceeds open PO qty beyond tolerance.