21 KiB
15 — Procurement / Purchasing
Status: Done — migration applied to the database Depends On: 01 — Item Catalog (Done), 14 — Pricing & Supplier Master Data (Done)
Goal
Covers mainPlan.md §9 — inbound stock from suppliers,
the mirror of Order Management's outbound flow to stores. Purchase Requisition is deliberately
not part of this scope (see Background/Context) — createPurchaseOrder is the entry point.
Background / Context
Inbound stock (createGoodsReceipt in 04_loading_event.md/
mainPlan.md §6) had no purchase order to receive against —
this step creates that PO in the first place.
Sales reps take orders from shops (existing Features/Order, outbound — decreases stock,
references Store, prices via PriceListItem.Price); procurement is the mirror — bringing stock
in from suppliers (Features/PurchaseOrder, inbound — increases stock, references Supplier,
costs via PurchaseOrderItem.UnitCost). Same header+line-item shape, opposite direction and
counterparty, which is why they're separate features rather than one generic "Order" entity.
No requisition step — how "raise a request to purchase" is actually handled
mainPlan.md §9 originally listed
createPurchaseRequisition(items[], requestedBy) as a candidate function. Dropped from this
app's design — the request and the decision to buy collapse into one step:
- Whoever notices stock is low (owner, manager, or eventually an automated low-stock notice) calls
createPurchaseOrder(supplierId, items[])directly. Issuing the PO is the request; it needs no separate approval because only an already-authenticated user can call it (see Permissions — enforcement itself is deferred, matching Supplier/PriceList). - There is no Draft → Submitted → Approved chain because restocking authority isn't delegated down to sales reps the way order-taking is.
- A lower-level "we're low on X" signal is
sendLowStockAlert(§15 Notifications & Alerts, not yet built) — a notification, not a requisition with its own approval workflow.
Connections to existing modules
- Item Catalog (01_item_catalog.md):
PurchaseOrderItem.FK_ItemId,GoodsReceiptItem(viaPurchaseOrderItemId→PurchaseOrderItem.FK_ItemId), andPurchaseReturnItem.FK_ItemIdall reference the sameItemtable sales orders use — one catalog, two directions of stock movement. - Stock Ledger (02_stock_ledger.md): procurement writes to the same
StockBalance/StockMovementtables as the rest of the system rather than a separate stock field —createGoodsReceiptcreditsStockBalance(MovementType.PurchaseReceipt),createPurchaseReturndebits it (MovementType.PurchaseReturn). No new stock model was needed. - Pricing (14_pricing_and_suppliers.md):
PriceListItem.Price(what you charge a shop) andPurchaseOrderItem.UnitCost(what you pay a supplier) are deliberately separate, unlinked fields — procurement never reads or writesPriceListrows. Cost-vs-sale margin reporting is left for a later reporting step (see Open Questions). - Supplier Master Data (14_pricing_and_suppliers.md):
PurchaseOrder.FK_SupplierIdlinks every PO to one supplier.SupplierService.EvaluateAsync(evaluateSupplier) is now the only writer ofSupplier.PerformanceScore, andSupplierService.DeleteAsyncnow blocks soft-delete if any activePurchaseOrderreferences the supplier — both were previously open items in 14's doc, now closed by this step. - Order Management (mainPlan.md §1): while implementing
this step,
OrderService.CreateAsyncwas found to have nocheckStockAvailabilitycheck at all (mainPlan's own documented gap) — fixed alongside this step: it now rejects an order if requested quantity exceeds total on-handStockBalancefor the item, summed across all locations. It still does not decrement stock at order time — that stays deferred to loading/delivery (§04-06), per the existing architecture, to avoid double-decrementing once those steps are built.
Data Model Changes
Six new tables — three header/line-item pairs (mirrors the PriceList/PriceListItem split).
PurchaseOrder
PurchaseOrder
├── Id (Guid) — PK, from Template
├── FK_SupplierId (Guid) — required
├── FK_CreatedByUserId (Guid) — required, the user who issued the PO
├── Status (enum) — Issued, PartiallyReceived, Received, Closed, Cancelled
├── CancelReason (string?) — set by cancelPurchaseOrder
├── IsActive, CreatedAt — from Template
PurchaseOrderItem
PurchaseOrderItem
├── Id (Guid) — PK, from Template
├── FK_PurchaseOrderId (Guid) — required
├── FK_ItemId (Guid) — required
├── Quantity (int) — ordered quantity
├── UnitCost (decimal) — agreed cost per unit at the time the PO was issued
├── ReceivedQuantity (int) — running total, incremented by createGoodsReceipt; drives PO Status
├── CancelledQuantity (int) — set by cancelPurchaseOrderLine, default 0; outstanding qty for
│ receiving = Quantity - ReceivedQuantity - CancelledQuantity
├── IsCancelled (bool) — true once this line is individually cancelled, default false
GoodsReceipt
GoodsReceipt
├── Id (Guid) — PK, from Template
├── FK_PurchaseOrderId (Guid) — required
├── FK_ReceivedByUserId (Guid) — required
├── FK_WarehouseId (Guid) — required; the warehouse the accepted quantities are received into.
│ Not anticipated in the original design — added because
│ StockBalance/StockMovement are keyed by (LocationId, LocationType),
│ so a receiving location is mandatory to credit stock at all.
├── ReceivedAt (DateTime)
├── IsActive, CreatedAt — from Template
GoodsReceiptItem
GoodsReceiptItem
├── Id (Guid) — PK, from Template
├── FK_GoodsReceiptId (Guid) — required
├── FK_PurchaseOrderItemId (Guid) — required, ties the receipt line back to its ordered line
├── AcceptedQuantity (int) — added to warehouse stock
├── RejectedQuantity (int) — damaged/short at receipt, default 0, not added to stock
PurchaseReturn
PurchaseReturn
├── Id (Guid) — PK, from Template
├── FK_PurchaseOrderId (Guid) — required
├── Reason (string)
├── IsActive, CreatedAt — from Template
PurchaseReturnItem
PurchaseReturnItem
├── Id (Guid) — PK, from Template
├── FK_PurchaseReturnId (Guid) — required
├── FK_ItemId (Guid) — required
├── Quantity (int) — returned quantity, decremented from warehouse stock
Stock is decremented at the warehouse of the PO's most recent GoodsReceipt (the current model
gives one receiving warehouse per PO in practice; see Open Questions for the multi-warehouse case).
Migrations
One migration, applied:
- AddProcurement (
20260824110925_AddProcurement) — createdPurchaseOrders,PurchaseOrderItems,GoodsReceipts,GoodsReceiptItems,PurchaseReturns,PurchaseReturnItems, with FKs toSuppliers/Items/Warehousesand indexes on every FK column. Applied viadotnet ef database update.
API Endpoints
No permission policy is enforced on any route below — matches the Supplier/PriceList convention from step 14 (bare
[Authorize], authentication required, no per-action policy). See Permissions to Add to Seed.
Purchase Orders (Features/PurchaseOrder)
| Function reference | Endpoint |
|---|---|
createPurchaseOrder(supplierId, items[]) |
POST /api/purchase-orders |
getPurchaseOrder(poId) |
GET /api/purchase-orders/{id} |
listPurchaseOrders(filters) |
GET /api/purchase-orders |
updatePurchaseOrder(poId, items[]) |
PUT /api/purchase-orders/{id} |
closePurchaseOrder(poId) |
POST /api/purchase-orders/{id}/close |
cancelPurchaseOrder(poId, reason) |
POST /api/purchase-orders/{id}/cancel |
cancelPurchaseOrderLine(poId, poItemId, reason) |
POST /api/purchase-orders/{id}/items/{itemId}/cancel |
matchInvoiceToPO(poId, invoiceId?, grnId?) |
POST /api/purchase-orders/{id}/match-invoice |
resolveInvoiceVariance(poId, variance, resolution) |
POST /api/purchase-orders/{id}/resolve-variance |
Goods Receipt (Features/Grn)
| Function reference | Endpoint |
|---|---|
createGoodsReceipt(poId, warehouseId, items[]) |
POST /api/purchase-orders/{id}/goods-receipts |
getGoodsReceipt(grnId) |
GET /api/goods-receipts/{id} |
listGoodsReceipts(poId) |
GET /api/purchase-orders/{id}/goods-receipts |
Purchase Returns (Features/PurchaseReturn)
| Function reference | Endpoint |
|---|---|
createPurchaseReturn(poId, items[], reason) |
POST /api/purchase-orders/{id}/returns |
getPurchaseReturn(returnId) |
GET /api/purchase-returns/{id} |
listPurchaseReturns(poId) |
GET /api/purchase-orders/{id}/returns |
Supplier Evaluation (Features/Supplier, extended)
| Function reference | Endpoint |
|---|---|
evaluateSupplier(supplierId) |
POST /api/suppliers/{id}/evaluate |
Request / Response Shapes
POST /api/purchase-orders
{ "supplierId": "...", "items": [{ "itemId": "...", "quantity": 100, "unitCost": 45.50 }] }
POST /api/purchase-orders/{id}/goods-receipts
{ "warehouseId": "...", "items": [
{ "purchaseOrderItemId": "...", "acceptedQuantity": 95, "rejectedQuantity": 5 }
] }
rejectedQuantity defaults to 0 if omitted; only acceptedQuantity is credited to
StockBalance.
POST /api/purchase-orders/{id}/returns
{ "reason": "Damaged in transit", "items": [{ "itemId": "...", "quantity": 10 }] }
POST /api/purchase-orders/{id}/resolve-variance
{ "variance": "Quantity mismatch: PO 100, GRN 95", "resolution": "Accepted — short shipment",
"adjustedUnitCost": null, "purchaseOrderItemId": "..." }
Business Rules
createPurchaseOrderis the entry point to procurement — no prior approval step. A PO starts inIssued.createGoodsReceiptmust reference anIssued/PartiallyReceivedPurchaseOrder — no receipts against aClosed/Cancelled/nonexistent PO. Each receipt incrementsPurchaseOrderItem.ReceivedQuantitybyAcceptedQuantity, creditsStockBalanceat the suppliedwarehouseId(+ aStockMovementrow,Type = PurchaseReceipt), and transitions the PO toPartiallyReceivedorReceived(every non-cancelled line'sReceivedQuantity == Quantity).closePurchaseOrderis only valid once a PO isReceived(or manually closed early with outstanding quantity written off);cancelPurchaseOrderis only valid before any goods receipt exists against the PO.cancelPurchaseOrderLinecancels a singlePurchaseOrderItem(IsCancelled = true,CancelledQuantity = Quantity - ReceivedQuantity) instead of the whole PO. Only valid whileReceivedQuantity < Quantityand the line isn't already fully received or cancelled. The PO auto-completes toReceivedonce every non-cancelled line is fully received.updatePurchaseOrdermay only change unreceived lines (ReceivedQuantity == 0) and only while the PO isIssued— a line that has already received stock, or a PO pastIssued, cannot be edited (usecancelPurchaseOrderLine/createPurchaseReturninstead).createPurchaseReturnrequires at least one priorcreateGoodsReceiptagainst the PO; return quantity per item cannot exceedreceivedTotal - alreadyReturnedfor that item. DebitsStockBalanceat the PO's (most recent) receiving warehouse (+StockMovement,Type = PurchaseReturn).matchInvoiceToPOperforms PO-vs-GRN quantity/cost reconciliation only — noInvoiceentity exists yet (step 19 Finance, not built), soinvoiceIdis accepted but not compared against anything. Full 3-way match activates once Finance'sInvoicemodel exists. Until then it comparesPurchaseOrderItem.Quantity/UnitCostagainstGoodsReceiptItem.AcceptedQuantityper line and returns a match/variance result — it never blocks or adjusts anything itself.resolveInvoiceVarianceis the explicit function that records a variance and its resolution (accept it, request a corrected invoice, or adjustPurchaseOrderItem.UnitCostretroactively) —matchInvoiceToPOonly detects, this function is what acts on a mismatch.evaluateSupplieris the only function permitted to writeSupplier.PerformanceScore(14_pricing_and_suppliers.md rule 4) — implemented inSupplierService.EvaluateAsync, scored from that supplier's PO/GRN history (accepted vs rejected quantity ratio, cancelled vs fulfilled line ratio).SupplierService.DeleteAsyncblocks (soft-)deleting aSupplierif any activePurchaseOrderreferences it — implemented, closing the previously-open item from 14_pricing_and_suppliers.md.OrderService.CreateAsync(checkStockAvailability, mainPlan §1) now rejects an order if any line's requested quantity exceeds totalStockBalancefor that item summed across every location — added alongside this step since it was a documented gap. Does not reserve or decrement stock; actual decrement stays deferred to loading/delivery (§04-06).
Permissions to Add to Seed
Status: Not implemented, matching Supplier/PriceList (step 14). All procurement controllers use
bare [Authorize] (authentication only, no per-action policy) — deliberately kept consistent with
how Supplier and PriceList currently ship rather than introducing enforcement piecemeal. Target
permission codes for when policy enforcement is turned on across steps 14/15 together:
procurement.view, procurement.create, procurement.update, procurement.receive, procurement.return,
suppliers.evaluate
No role assignments seeded yet — would mirror the pricing/supplier tier (AreaManager (30) and above) per 14's existing target design.
Open Questions
- Where should cost-vs-sale-price margin reporting (
PurchaseOrderItem.UnitCostvsPriceListItem.Price/Item.SalePrice) live — a read-only endpoint/report in this step, or a later analytics/reporting step? Not a blocker for the core PO→GRN flow. - Landed cost (freight, duty, taxes allocated onto received stock) is out of scope —
PurchaseOrderItem.UnitCostis the only cost basis captured. Defer unless accurate per-unit cost is needed sooner. - Reorder triggers (
setReorderPoint/sendLowStockAlert) are not wired to procurement — restocking stays fully manual until Notifications & Alerts exists. createPurchaseReturnassumes a single receiving warehouse per PO (uses the most recentGoodsReceipt.FK_WarehouseId). If a PO is ever received across multiple warehouses, the return needs awarehouseIdon the request instead of inferring one — not needed yet since nothing splits a receipt across warehouses today.- Permission policy enforcement (
procurement.*) is deferred — see Permissions to Add to Seed.
Implementation Notes
File structure (as built)
Features/
PurchaseOrder/
PoController.cs — /api/purchase-orders (create/get/list/update/close/cancel/
cancel-line/match-invoice/resolve-variance)
PoDTOs.cs — CreatePoRequest, UpdatePoRequest(+Line), CancelPoRequest(+Line),
PoResponse(+Item), InvoiceMatchResult(+Line), ResolveInvoiceVarianceRequest
PoService.cs — createPurchaseOrder, updatePurchaseOrder, closePurchaseOrder,
cancelPurchaseOrder, cancelPurchaseOrderLine, matchInvoiceToPO,
resolveInvoiceVariance
IPoRepository.cs / PoRepository.cs
Grn/
GrnController.cs — nested under /api/purchase-orders/{id}/goods-receipts,
/api/goods-receipts/{id}
GrnDTOs.cs — CreateGrnRequest, GrnResponse(+Item)
GrnService.cs — createGoodsReceipt (credits StockBalance/StockMovement, updates
PurchaseOrderItem.ReceivedQuantity, transitions PO status)
IGrnRepository.cs / GrnRepository.cs
PurchaseReturn/
PurchaseReturnController.cs — nested under /api/purchase-orders/{id}/returns,
/api/purchase-returns/{id}
PurchaseReturnDTOs.cs
PurchaseReturnService.cs — createPurchaseReturn (debits StockBalance/StockMovement)
IPurchaseReturnRepository.cs / PurchaseReturnRepository.cs
Supplier/
SupplierController.cs — extended: POST /{id}/evaluate
SupplierService.cs — extended: EvaluateAsync (only writer of PerformanceScore),
DeleteAsync now blocks if an active PurchaseOrder references the supplier
Order/
OrderService.cs — extended: CreateAsync now calls HasSufficientStockAsync
(checkStockAvailability) before resolving price/inserting the order
Models/
PurchaseOrder.cs, PurchaseOrderItem.cs (new)
GoodsReceipt.cs, GoodsReceiptItem.cs (new)
PurchaseReturn.cs, PurchaseReturnItem.cs (new)
Enums.cs (extended — PurchaseOrderStatus enum added)
Data/AppDBContext.cs
DbSets added for all six entities; FK relationships via Fluent API, DeleteBehavior.Restrict
Migrations/
20260824110925_AddProcurement (applied)
Program.cs
IPoRepository/PoRepository, IGrnRepository/GrnRepository,
IPurchaseReturnRepository/PurchaseReturnRepository registered; PoService, GrnService,
PurchaseReturnService registered (Supplier registrations already existed from step 14)
How the models connect
Supplier (existing, step 14)
▲
│ FK_SupplierId
PurchaseOrder ──► GoodsReceipt ──► StockBalance/StockMovement (Stock Ledger, §02)
│ │ (credited: AcceptedQuantity, MovementType.PurchaseReceipt)
│ └─ GoodsReceiptItem ─► PurchaseOrderItem
│ (AcceptedQuantity increments stock; also increments
│ PurchaseOrderItem.ReceivedQuantity, which drives PurchaseOrder.Status)
│
├─ PurchaseOrderItem (FK_ItemId, Quantity, UnitCost, ReceivedQuantity, CancelledQuantity)
│
└─ PurchaseReturn ──► PurchaseReturnItem ──► StockBalance/StockMovement
(FK_PurchaseOrderId) (FK_ItemId, Quantity; (debited: MovementType.PurchaseReturn,
via the PO's receiving at the PO's receiving warehouse)
warehouse)
Invoice (Finance, step 19 — not yet built)
└─ matchInvoiceToPO reconciles PurchaseOrderItem vs GoodsReceiptItem only, today (invoiceId
accepted but unused); full 3-way match activates once Invoice exists.
evaluateSupplier reads that supplier's PurchaseOrders/GoodsReceipts (accepted-vs-rejected quantity,
cancelled-vs-fulfilled line ratio) and writes Supplier.PerformanceScore — no other function may
write it.
Goods receipt flow (as implemented)
GrnService.CreateAsync(purchaseOrderId, request, receivedByUserId):
po = load PurchaseOrder with Items, must be Issued or PartiallyReceived else throw
for each (purchaseOrderItemId, acceptedQty, rejectedQty) in request.Items:
poItem = po.Items.Single(purchaseOrderItemId)
outstanding = poItem.Quantity - poItem.ReceivedQuantity - poItem.CancelledQuantity
guard acceptedQty + rejectedQty <= outstanding else throw
poItem.ReceivedQuantity += acceptedQty
credit StockBalance(itemId, request.WarehouseId, Warehouse) += acceptedQty
add StockMovement(Type = PurchaseReceipt, ToLocation = warehouse, ReferenceId = goodsReceipt.Id)
po.Status = every non-cancelled poItem.ReceivedQuantity == poItem.Quantity
? Received : PartiallyReceived
commit GoodsReceipt + GoodsReceiptItems + updated PurchaseOrderItem rows + StockBalance/Movement
in one transaction (IUnitOfWork.Begin/Commit)
Purchase return flow (as implemented)
PurchaseReturnService.CreateAsync(purchaseOrderId, request, createdByUserId):
po = load PurchaseOrder, must have >= 1 GoodsReceipt else throw
warehouseId = most recent GoodsReceipt.FK_WarehouseId for this Po
for each (itemId, qty) in request.Items:
received = sum(GoodsReceiptItem.AcceptedQuantity for itemId across po's receipts)
alreadyReturned = sum(PurchaseReturnItem.Quantity for itemId across po's prior returns)
guard qty <= received - alreadyReturned else throw
debit StockBalance(itemId, warehouseId) -= qty
add StockMovement(Type = PurchaseReturn, FromLocation = warehouseId, ReferenceId = purchaseReturn.Id)
commit PurchaseReturn + PurchaseReturnItems + StockBalance/Movement in one transaction