# 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](./mainPlan.md#9-procurement--purchasing) — 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](./04_loading_event.md)/ [mainPlan.md §6](./mainPlan.md#6-warehouse-management)) 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](./mainPlan.md#9-procurement--purchasing) 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](./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](./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](./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](./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](./mainPlan.md#1-order-management)): 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` ```json { "supplierId": "...", "items": [{ "itemId": "...", "quantity": 100, "unitCost": 45.50 }] } ``` ### POST `/api/purchase-orders/{id}/goods-receipts` ```json { "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` ```json { "reason": "Damaged in transit", "items": [{ "itemId": "...", "quantity": 10 }] } ``` ### POST `/api/purchase-orders/{id}/resolve-variance` ```json { "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](./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](./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 ```