diff --git a/Distribution/docs/15_procurement.md b/Distribution/docs/15_procurement.md index a4d36c5..aa2f165 100644 --- a/Distribution/docs/15_procurement.md +++ b/Distribution/docs/15_procurement.md @@ -1,74 +1,413 @@ # 15 — Procurement / Purchasing -**Status**: Not Started -**Depends On**: 01 — Item Catalog, 14 — Pricing & Supplier Master Data +**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) in full -— an entirely new subsystem, nothing in this area exists in the current schema. +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)) currently has no -purchase order to receive against — this step is what creates that PO in the first place. +[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 -### Purchase Requisition +Six new tables — three header/line-item pairs (mirrors the `PriceList`/`PriceListItem` split). -`RequestedByUserId`, `Status` (`Draft`, `Submitted`, `Approved`, `Rejected`), line items -(`ItemId`, `Quantity`). +### 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 +``` -### Purchase Order +### 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 +``` -`SupplierId`, `RequisitionId` (nullable — a PO can be raised without a prior requisition), `Status` -(`Issued`, `PartiallyReceived`, `Received`, `Closed`), line items (`ItemId`, `Quantity`, `UnitCost`). +### 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 +``` -### Purchase Return +### 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 +``` -`PurchaseOrderId`, reason, line items — goods sent back to the supplier post-receipt. +### 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 -| Function reference | Endpoint intent | +> 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 | |---|---| -| `createPurchaseRequisition(items[], requestedBy)` | `POST /api/purchase-requisitions` | | `createPurchaseOrder(supplierId, items[])` | `POST /api/purchase-orders` | -| `matchInvoiceToPO(invoiceId, poId, grnId)` | `POST /api/purchase-orders/{id}/match-invoice` | -| `evaluateSupplier(supplierId, criteria)` | `POST /api/suppliers/{id}/evaluate` | +| `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 -- `createGoodsReceipt` (§6, [04_loading_event.md](./04_loading_event.md)'s warehouse-inbound - counterpart) must reference an `Issued`/`PartiallyReceived` Purchase Order — no receipts against - a closed or nonexistent PO. -- `matchInvoiceToPO` (3-way match) must block payment posting in - [19_finance_accounting.md](./19_finance_accounting.md) until PO, GRN, and invoice quantities/ - amounts reconcile within a configurable tolerance. +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 -- Is procurement in scope for this project at all right now, or is inbound stock assumed to be - seeded/manual for the foreseeable future? Confirm before investing in this step — it's the - largest net-new subsystem in the reference doc. +- 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 -> Fill this section in during planning discussion before writing any code. +### 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 +``` diff --git a/Distribution/docs/mainPlan.md b/Distribution/docs/mainPlan.md index 1898a00..b719286 100644 --- a/Distribution/docs/mainPlan.md +++ b/Distribution/docs/mainPlan.md @@ -16,6 +16,7 @@ | Soft Deletes | Done | IsActive pattern on all entities | | Price Lists / Suppliers | Done | Region-scoped price lists + supplier master; see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) | | Promotions & Trade Schemes | Done | BuyXGetY/FreeBundle/SlabDiscount/FlatPercent schemes, free-goods fulfillment, spend/ROI tracking; see [17_promotions_schemes.md](./17_promotions_schemes.md) | +| Procurement / Purchasing | Done | Purchase Order → Goods Receipt → Purchase Return, supplier evaluation; see [15_procurement.md](./15_procurement.md) | ## Known Gaps in Existing System (Pre-Work) @@ -52,7 +53,7 @@ They must be resolved before new feature steps begin because later steps depend | 12 | Store Management (Beyond CRUD) | Done | 00 | [12_store_management.md](./12_store_management.md) | | 13 | Fleet Management (Beyond CRUD) | Not Started | 00 | [13_fleet_management.md](./13_fleet_management.md) | | 14 | Pricing & Supplier Master Data | Done | 01 | [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) | -| 15 | Procurement / Purchasing | Not Started | 01, 14 | [15_procurement.md](./15_procurement.md) | +| 15 | Procurement / Purchasing | Done | 01, 14 | [15_procurement.md](./15_procurement.md) | | 16 | Sales Force Automation / CRM | Not Started | 12 | [16_sfa_crm.md](./16_sfa_crm.md) | | 17 | Promotions & Trade Scheme Management | Done | 01, 14 | [17_promotions_schemes.md](./17_promotions_schemes.md) | | 18 | Returns & Claims Management | Not Started | 05, 06 | [18_returns_claims.md](./18_returns_claims.md) | @@ -82,9 +83,9 @@ here — see `SRS_User_Management_Module.pdf`. | Function | What it does | Status | |---|---|---| -| `createOrder(storeId, items[], salesRepId)` | Validates stock/credit, creates a new order in `pending` state. | Partially implemented - `OrderService.CreateAsync` exists, enforces store scope, blocks inactive stores, and applies the unverified-KYC order threshold from config. It still has no stock validation, credit-limit validation, approval flow, or order state machine. | +| `createOrder(storeId, items[], salesRepId)` | Validates stock/credit, creates a new order in `pending` state. | Partially implemented - `OrderService.CreateAsync` exists, enforces store scope, blocks inactive stores, applies the unverified-KYC order threshold from config, and now checks stock availability (see `checkStockAvailability` below). It still has no credit-limit validation, approval flow, or order state machine. | | `validateCreditLimit(storeId, orderAmount)` | Checks store's outstanding balance + new order value against its credit limit. | Not implemented - `Store` now has `CreditLimit` and `OutstandingBalance`, but there is no dedicated validator or order-time credit-limit enforcement yet. | -| `checkStockAvailability(items[])` | Confirms requested SKUs/quantities are available at the fulfilling warehouse. | Not implemented — depends on [02_stock_ledger.md](./02_stock_ledger.md). | +| `checkStockAvailability(items[])` | Confirms requested SKUs/quantities are available at the fulfilling warehouse. | Implemented — `OrderService.CreateAsync` rejects an order if any line's requested quantity exceeds total `StockBalance` for that item summed across every location (added alongside [15_procurement.md](./15_procurement.md)). Not location-specific yet (no fulfilling-warehouse concept on `Store`/`Order`), and does not reserve or decrement stock — that stays deferred to loading/delivery (§05-06). | | `applyPricingAndDiscounts(orderId)` | Runs the active price list and eligible scheme rules against order lines. | Partially implemented — `OrderService.CreateAsync` resolves the price-list half via `PriceListService.ResolveUnitPriceAsync` (region-scoped → global → `Item.SalePrice`, or a caller-supplied `UnitPrice`). The scheme-discount half now exists as a separate call — `SchemeService.EvaluateEligibilityAsync`/`ApplySchemeAsync` (§11) — but is not yet auto-invoked from `OrderService.CreateAsync`; the caller applies a scheme explicitly via `POST /api/orders/{id}/apply-scheme` after order creation. | | `submitOrderForApproval(orderId)` | Routes the order to the correct approver based on its value/discount threshold. | Not implemented — depends on [08_sales_agent_controls.md](./08_sales_agent_controls.md). | | `approveOrder(orderId, approverId)` / `rejectOrder(orderId, reason)` | Moves order to `approved` or `rejected`, logging the actor. | Not implemented. | @@ -156,7 +157,7 @@ here — see `SRS_User_Management_Module.pdf`. | Function | What it does | Status | |---|---|---| -| `createGoodsReceipt(poId, receivedItems[])` | Records inbound stock against a purchase order (GRN). | Not implemented — depends on §9 Procurement. | +| `createGoodsReceipt(poId, receivedItems[])` | Records inbound stock against a purchase order (GRN). | Implemented — `POST /api/purchase-orders/{id}/goods-receipts`, see [15_procurement.md](./15_procurement.md). Credits `StockBalance`/`StockMovement` for accepted quantities and transitions the PO's status. | | `inspectGoodsReceipt(grnId, inspectionResult)` | Logs quality-check outcome before putaway. | Not implemented. | | `putawayStock(grnId, locationAssignments[])` | Assigns received stock to warehouse locations (zone/ABC-based). | Not implemented — depends on [02_stock_ledger.md](./02_stock_ledger.md). | | `getStockByLocation(warehouseId, sku)` | Returns real-time quantity and location for a SKU. | Not implemented — depends on [02_stock_ledger.md](./02_stock_ledger.md). | @@ -195,10 +196,10 @@ here — see `SRS_User_Management_Module.pdf`. | Function | What it does | Status | |---|---|---| | `createPurchaseRequisition(items[], requestedBy)` | Raises an internal request to purchase stock. | Not implemented — no procurement entities exist. | -| `createPurchaseOrder(supplierId, items[])` | Issues a PO to a supplier. | Not implemented. | -| `matchInvoiceToPO(invoiceId, poId, grnId)` | Performs the 3-way match (PO–GRN–Invoice) before payment. | Not implemented. | -| `evaluateSupplier(supplierId, criteria)` | Scores a supplier on delivery/quality/price performance. | Not implemented. | -| `createPurchaseReturn(poId, items[], reason)` | Initiates a return of received goods to the supplier. | Not implemented. | +| `createPurchaseOrder(supplierId, items[])` | Issues a PO to a supplier. | Implemented — `POST /api/purchase-orders`, see [15_procurement.md](./15_procurement.md). Also has `updatePurchaseOrder`, `closePurchaseOrder`, `cancelPurchaseOrder`, `cancelPurchaseOrderLine`. | +| `matchInvoiceToPO(invoiceId, poId, grnId)` | Performs the 3-way match (PO–GRN–Invoice) before payment. | Partially implemented — `POST /api/purchase-orders/{id}/match-invoice` reconciles PurchaseOrderItem vs GoodsReceiptItem quantities/cost only; full 3-way match activates once Finance's `Invoice` entity (§13, not built) exists. `resolveInvoiceVariance` records the mismatch and its resolution. | +| `evaluateSupplier(supplierId, criteria)` | Scores a supplier on delivery/quality/price performance. | Implemented — `POST /api/suppliers/{id}/evaluate`, the only writer of `Supplier.PerformanceScore`. Scored from accepted-vs-rejected quantity ratio and cancelled-vs-fulfilled line ratio across that supplier's PurchaseOrders/GoodsReceipts. | +| `createPurchaseReturn(poId, items[], reason)` | Initiates a return of received goods to the supplier. | Implemented — `POST /api/purchase-orders/{id}/returns`, see [15_procurement.md](./15_procurement.md). Debits `StockBalance`/`StockMovement` at the PO's receiving warehouse. | ### 10. Sales Force Automation (SFA) / CRM