Files
ERP_Distribution/Distribution/docs/15_procurement.md
T

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 (via PurchaseOrderItemId → PurchaseOrderItem.FK_ItemId), and PurchaseReturnItem.FK_ItemId all reference the same Item table sales orders use — one catalog, two directions of stock movement.
  • Stock Ledger (02_stock_ledger.md): procurement writes to the same StockBalance/StockMovement tables as the rest of the system rather than a separate stock field — createGoodsReceipt credits StockBalance (MovementType.PurchaseReceipt), createPurchaseReturn debits it (MovementType.PurchaseReturn). No new stock model was needed.
  • Pricing (14_pricing_and_suppliers.md): PriceListItem.Price (what you charge a shop) and PurchaseOrderItem.UnitCost (what you pay a supplier) are deliberately separate, unlinked fields — procurement never reads or writes PriceList rows. 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_SupplierId links every PO to one supplier. SupplierService.EvaluateAsync (evaluateSupplier) is now the only writer of Supplier.PerformanceScore, and SupplierService.DeleteAsync now blocks soft-delete if any active PurchaseOrder references 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.CreateAsync was found to have no checkStockAvailability check at all (mainPlan's own documented gap) — fixed alongside this step: it now rejects an order if requested quantity exceeds total on-hand StockBalance for 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:

  1. AddProcurement (20260824110925_AddProcurement) — created PurchaseOrders, PurchaseOrderItems, GoodsReceipts, GoodsReceiptItems, PurchaseReturns, PurchaseReturnItems, with FKs to Suppliers/Items/Warehouses and indexes on every FK column. Applied via dotnet 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

  1. createPurchaseOrder is the entry point to procurement — no prior approval step. A PO starts in Issued.
  2. createGoodsReceipt must reference an Issued/PartiallyReceived PurchaseOrder — no receipts against a Closed/Cancelled/nonexistent PO. Each receipt increments PurchaseOrderItem.ReceivedQuantity by AcceptedQuantity, credits StockBalance at the supplied warehouseId (+ a StockMovement row, Type = PurchaseReceipt), and transitions the PO to PartiallyReceived or Received (every non-cancelled line's ReceivedQuantity == Quantity).
  3. closePurchaseOrder is only valid once a PO is Received (or manually closed early with outstanding quantity written off); cancelPurchaseOrder is only valid before any goods receipt exists against the PO.
  4. cancelPurchaseOrderLine cancels a single PurchaseOrderItem (IsCancelled = true, CancelledQuantity = Quantity - ReceivedQuantity) instead of the whole PO. Only valid while ReceivedQuantity < Quantity and the line isn't already fully received or cancelled. The PO auto-completes to Received once every non-cancelled line is fully received.
  5. updatePurchaseOrder may only change unreceived lines (ReceivedQuantity == 0) and only while the PO is Issued — a line that has already received stock, or a PO past Issued, cannot be edited (use cancelPurchaseOrderLine/createPurchaseReturn instead).
  6. createPurchaseReturn requires at least one prior createGoodsReceipt against the PO; return quantity per item cannot exceed receivedTotal - alreadyReturned for that item. Debits StockBalance at the PO's (most recent) receiving warehouse (+ StockMovement, Type = PurchaseReturn).
  7. matchInvoiceToPO performs PO-vs-GRN quantity/cost reconciliation only — no Invoice entity exists yet (step 19 Finance, not built), so invoiceId is accepted but not compared against anything. Full 3-way match activates once Finance's Invoice model exists. Until then it compares PurchaseOrderItem.Quantity/UnitCost against GoodsReceiptItem.AcceptedQuantity per line and returns a match/variance result — it never blocks or adjusts anything itself.
  8. resolveInvoiceVariance is the explicit function that records a variance and its resolution (accept it, request a corrected invoice, or adjust PurchaseOrderItem.UnitCost retroactively) — matchInvoiceToPO only detects, this function is what acts on a mismatch.
  9. evaluateSupplier is the only function permitted to write Supplier.PerformanceScore (14_pricing_and_suppliers.md rule 4) — implemented in SupplierService.EvaluateAsync, scored from that supplier's PO/GRN history (accepted vs rejected quantity ratio, cancelled vs fulfilled line ratio).
  10. SupplierService.DeleteAsync blocks (soft-)deleting a Supplier if any active PurchaseOrder references it — implemented, closing the previously-open item from 14_pricing_and_suppliers.md.
  11. OrderService.CreateAsync (checkStockAvailability, mainPlan §1) now rejects an order if any line's requested quantity exceeds total StockBalance for 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.UnitCost vs PriceListItem.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.UnitCost is 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.
  • createPurchaseReturn assumes a single receiving warehouse per PO (uses the most recent GoodsReceipt.FK_WarehouseId). If a PO is ever received across multiple warehouses, the return needs a warehouseId on 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