Files
ERP_Distribution/Distribution/docs/15_procurement.md

450 lines
24 KiB
Markdown

# 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
```
> **Update (GRN rework):** a goods receipt no longer requires a PO — see "Direct goods receipt" and
> the costing / free-issue / tax additions in
> [refactor_docs/grn_costing_and_taxes.md](./refactor_docs/grn_costing_and_taxes.md). The tree
> below shows the original design plus those changes, marked *(later)*.
### GoodsReceipt
```
GoodsReceipt
├── Id (Guid) — PK, from Template
├── FK_PurchaseOrderId (Guid?) — nullable *(later)*: null = direct receipt (no PO)
├── FK_SupplierId (Guid?) — *(later)* set on a direct receipt; a PO receipt reaches the supplier via the PO
├── DiscountPercent, VatPercent, NbtPercent (decimal 5,2) — *(later)* GRN-wide defaults for lines
├── 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?) — ties the receipt line back to its ordered line; null on a direct receipt
├── FK_ItemId (Guid?) — *(later)* the item, on a direct receipt line
├── AcceptedQuantity (int) — added to warehouse stock
├── RejectedQuantity (int) — damaged/short at receipt, default 0, not added to stock
├── FreeQuantity (int) — *(later)* free-issue units: added to stock, not costed, not counted against the PO
├── UnitCost (decimal 18,2) — *(later)* cost per unit; PO receipts default to the PO line's UnitCost
├── DiscountPercent, NbtPercent, VatPercent (decimal 5,2) — *(later)* per-line rates
```
### PurchaseReturn
> **Not the same as `SaleReturn`** ([18_returns_claims.md](./18_returns_claims.md)), despite the
> similar name. `PurchaseReturn` moves goods **Distributor → Supplier** (debits the receiving
> warehouse); `SaleReturn` moves goods **Shop → Distributor** (credits a delivery vehicle). Also
> distinct from `SupplierClaim` ([18_returns_claims.md](./18_returns_claims.md)), which is for
> supplier disputes that don't involve physically returning goods (short-shipment, price dispute)
> and — unlike this entity — requires approval. See that doc's "Four distinct entities" table for
> the full comparison.
```
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
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`.
2. **AddDirectGoodsReceipt** (`20261006070042`) — made the PO links nullable and added the supplier /
item links for direct receipts.
3. **AddGrnPricing**, **AddGrnFreeQuantity**, **AddGrnNbt** (`20261007…`, `20261008…`) — GRN costing,
free issues and NBT. Pricing and free quantity are applied; **AddGrnNbt was still pending** when
last checked, and GRN reads/writes fail until it is applied — see
[refactor_docs/grn_costing_and_taxes.md](./refactor_docs/grn_costing_and_taxes.md) §7.
---
## 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`)
> A PO is **not mandatory** for a goods receipt: `POST /api/goods-receipts/direct` receives stock
> with no PO (no ordered-quantity limit, no PO status, no purchase returns / invoice matching for
> it). See [refactor_docs/grn_costing_and_taxes.md](./refactor_docs/grn_costing_and_taxes.md) §1 for
> when to use which.
| Function reference | Endpoint |
|---|---|
| `createGoodsReceipt(poId, warehouseId, items[])` | `POST /api/purchase-orders/{id}/goods-receipts` (also `POST /api/goods-receipts` with `purchaseOrderId` in the body) |
| `createDirectGoodsReceipt(warehouseId, supplierId?, items[])` | `POST /api/goods-receipts/direct` |
| `getGoodsReceipt(grnId)` | `GET /api/goods-receipts/{id}` |
| `listGoodsReceipts(poId)` | `GET /api/purchase-orders/{id}/goods-receipts` |
| `listGoodsReceipts(page)` | `GET /api/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. `acceptedQuantity` **plus `freeQuantity`** (free-issue
units, default 0) is credited to `StockBalance`; only accepted units are costed and only accepted
units advance the PO line's `ReceivedQuantity`. Optional pricing fields (`unitCost`,
`discountPercent`, `vatPercent`, `nbtPercent` per line; `discountPercent`, `vatPercent`,
`nbtPercent` for the whole GRN) are described in
[refactor_docs/grn_costing_and_taxes.md](./refactor_docs/grn_costing_and_taxes.md).
### 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 allocated onto received stock) is out of scope. The GRN now records
unit cost, discount, NBT and VAT per line (see
[refactor_docs/grn_costing_and_taxes.md](./refactor_docs/grn_costing_and_taxes.md)), but these do
not feed stock valuation or `Item.CostPrice`, and the PO itself carries no discount or tax.
- 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
```