This commit is contained in:
Kalana B. Thilakarathna
2026-09-16 10:41:16 +05:30
178 changed files with 17418 additions and 2069 deletions
+53 -5
View File
@@ -50,6 +50,36 @@ default — the same negotiation-boundary pattern already used for discount limi
(400, via a null resolution) if the requested price falls outside the band, or if no price can be
resolved at all.
### Retail price and agency margin (informational, not enforced on sales)
Two more numbers can be tracked per `(PriceListItem)` beyond the wholesale `Price`/`MinPrice` band,
covering the rest of the chain from distributor down to the end consumer:
- **`RetailPrice` (decimal?)** — the MRP the store is expected to charge the end consumer. Not
enforced anywhere (a store can charge whatever it wants at the till) — it exists so the
wholesale `Price` can be validated against it (a store must always have a positive margin) and so
the sales rep can see it while negotiating with a store.
- **`AgencyMarginPercent` (decimal?)** — the percentage of `Price` the regional agency operating
that territory retains as commission for running local distribution (sales force, vehicles,
warehousing). Purely informational/reporting — not deducted from anything automatically; it feeds
agency settlement/payout reporting, the same `RegionalAgency` role that raises Agency Claims
([18_returns_claims.md](./18_returns_claims.md)).
Neither field participates in `ResolveUnitPriceAsync`'s pricing logic or the `[MinPrice, Price]`
negotiation band — they ride along in the same `PriceListItem` row and `PriceResolution` response
purely for display and reporting.
- `PriceListItem.RetailPrice (decimal?)` — must be `>= Price` when set (409 if violated); `null` =
not tracked for this item/list.
- `PriceListItem.AgencyMarginPercent (decimal?)` — must be between 0 and 100 when set (409 if
violated); `null` = not tracked for this item/list.
- `PUT /api/price-lists/{id}/items/{sku}` accepts the extended body
`{ "price": ..., "minPrice": ..., "retailPrice": ..., "agencyMarginPercent": ... }` — both new
fields are optional and independent of each other and of `minPrice`.
- `PriceResolution` (returned by `ResolvePriceAsync`/`ResolveByStoreAsync`) carries both fields
through so the frontend can display them without a second lookup; both are `null` on the
`Item.SalePrice` fallback path, since neither is tracked outside an actual `PriceListItem`.
---
## Data Model Changes
@@ -75,6 +105,10 @@ PriceListItem
│ the parent PriceList is active and applicable to the order's region
├── MinPrice (decimal?) — nullable floor for negotiated pricing; null = Price is fixed.
│ Must be <= Price when set.
├── RetailPrice (decimal?) — nullable MRP the store charges the end consumer; informational,
│ not enforced on sales. Must be >= Price when set.
├── AgencyMarginPercent (decimal?) — nullable, 0-100, the regional agency's commission % of Price;
│ informational only, feeds agency settlement reporting.
├── IsActive, CreatedAt — from Template
```
Unique constraint on `(PriceListId, ItemId)` — one price per item per price list.
@@ -138,10 +172,11 @@ One migration needed:
### PUT `/api/price-lists/{id}/items/{sku}`
```json
{ "price": 125.50, "minPrice": 115.00 }
{ "price": 125.50, "minPrice": 115.00, "retailPrice": 150.00, "agencyMarginPercent": 15 }
```
Resolves `sku` to `ItemId`, upserts the `PriceListItem` row. `minPrice` is optional — omit or send
`null` for a fixed, non-negotiable price. Rejected with 409 if `minPrice > price`.
Resolves `sku` to `ItemId`, upserts the `PriceListItem` row. `minPrice`, `retailPrice`, and
`agencyMarginPercent` are all optional — omit or send `null` for none. Rejected with 409 if
`minPrice > price`, `retailPrice < price`, or `agencyMarginPercent` is outside `[0, 100]`.
### POST `/api/suppliers`
```json
@@ -178,6 +213,14 @@ Resolves `sku` to `ItemId`, upserts the `PriceListItem` row. `minPrice` is optio
`[MinPrice ?? Price, Price]` for the item's resolved `PriceListItem` (region-scoped, then
global). The `Item.SalePrice` fallback has no `MinPrice` — always fixed, no negotiation. An
out-of-band price, or no resolvable price at all, rejects the whole order (400).
8. `PriceListItem.RetailPrice`, when set, must be `>= Price` — enforced on every write via
`PUT /api/price-lists/{id}/items/{sku}` (409 if violated); a store must never be expected to
sell below what it paid.
9. `PriceListItem.AgencyMarginPercent`, when set, must be within `[0, 100]` — enforced the same
way (409 if violated).
10. Neither `RetailPrice` nor `AgencyMarginPercent` participates in order pricing/validation
(rule 7) — both are informational only, carried through `PriceResolution` for display and
reporting, and are `null` on the `Item.SalePrice` fallback path.
---
@@ -311,9 +354,14 @@ POST /api/price-lists
→ PriceList A (RegionId = North)
PUT /api/price-lists/{A.Id}/items/COKE-330
{ "price": 90, "minPrice": 80 }
{ "price": 90, "minPrice": 80, "retailPrice": 120, "agencyMarginPercent": 15 }
```
This publishes a band for `COKE-330` in the North region: ceiling 90, floor 80.
This publishes a band for `COKE-330` in the North region: ceiling 90, floor 80. It also records that
the store is expected to retail it at 120 (so the store's own margin is 90–120, or up to 40 if the
rep negotiated down to 80), and that the North region's agency retains 15% of whatever wholesale
price was actually charged (e.g. 15% of 90 = 13.50 if sold at the ceiling) as their distribution
commission — informational only, used for agency settlement reporting, not deducted automatically
from anything.
**Order at the default (no negotiation):**
```
+32
View File
@@ -0,0 +1,32 @@
Module,Feature,Description
Auth,Authentication,"Handles user login, JWT token issuance, refresh tokens, and session management."
User,User Management,"Create, update, and manage system users, their roles, and account status."
Role,Role Management,"Define roles and manage permission assignments for access control."
Brand,Brand Management,"Maintain product brands used to categorize items."
Category,Category Management,"Maintain product categories for organizing items."
Uom,Unit of Measure (UOM),"Manage units of measure (e.g., box, piece, kg) used for items and conversions."
Item,Item Management,"Manage product/item master data including pricing, stock units, and attributes."
PriceList,Price List Management,"Create and manage price lists with item-wise pricing for customers/regions, including view and update flows."
Scheme,Scheme Management,"Manage promotional schemes/discounts applicable to items or orders."
Stock,Stock Management,"Track and manage inventory stock levels across warehouses."
Warehouse,Warehouse Management,"Manage warehouse master data and locations for stock storage."
Store,Store Management,"Manage store/outlet master data for customers or sales points."
Supplier,Supplier Management,"Manage supplier master data used for purchase orders and GRNs."
SupplierClaims,Supplier Claims Management,"Manage claims raised against suppliers, including claim creation, viewing, and settlement."
Claims,Claims Management,"General claims management module for tracking and settling claims."
PurchaseOrder,Purchase Order Management,"Create and manage purchase orders placed with suppliers."
PurchaseReturn,Purchase Return Management,"Manage returns of purchased goods back to suppliers."
Grn,Goods Receipt Note (GRN),"Record and manage receipt of goods from suppliers against purchase orders."
Order,Order Management,"Manage customer sales orders from creation through fulfillment."
Sale,Sales Management,"Manage sales transactions and related sales records."
Returns,Sales Returns Management,"Manage return of goods from customers after sale/delivery."
Payment,Payment Management,"Record and track payments received/made against orders, sales, or claims."
Region,Region Management,"Manage geographic regions used for organizing routes, stores, and sales areas."
Route,Route Management,"Manage delivery/sales routes used for planning rides and deliveries."
Ride,Ride Management,"Manage delivery rides/trips assigned to drivers and vehicles."
DeliveryRide,Delivery Ride Tracking,"Track delivery ride execution, status, and progress."
LoadingEvent,Loading Event Tracking,"Track vehicle loading events for goods dispatched on rides."
Vehicle,Vehicle Management,"Manage vehicle master data used for deliveries."
DriverProfile,Driver Profile Management,"Manage driver master data and profile details."
DriverComplianceConfig,Driver Compliance Configuration,"Configure compliance rules and requirements for drivers."
Sync,Data Synchronization,"Handle data sync operations, likely for offline/mobile clients syncing with the server."
1 Module Feature Description
2 Auth Authentication Handles user login, JWT token issuance, refresh tokens, and session management.
3 User User Management Create, update, and manage system users, their roles, and account status.
4 Role Role Management Define roles and manage permission assignments for access control.
5 Brand Brand Management Maintain product brands used to categorize items.
6 Category Category Management Maintain product categories for organizing items.
7 Uom Unit of Measure (UOM) Manage units of measure (e.g., box, piece, kg) used for items and conversions.
8 Item Item Management Manage product/item master data including pricing, stock units, and attributes.
9 PriceList Price List Management Create and manage price lists with item-wise pricing for customers/regions, including view and update flows.
10 Scheme Scheme Management Manage promotional schemes/discounts applicable to items or orders.
11 Stock Stock Management Track and manage inventory stock levels across warehouses.
12 Warehouse Warehouse Management Manage warehouse master data and locations for stock storage.
13 Store Store Management Manage store/outlet master data for customers or sales points.
14 Supplier Supplier Management Manage supplier master data used for purchase orders and GRNs.
15 SupplierClaims Supplier Claims Management Manage claims raised against suppliers, including claim creation, viewing, and settlement.
16 Claims Claims Management General claims management module for tracking and settling claims.
17 PurchaseOrder Purchase Order Management Create and manage purchase orders placed with suppliers.
18 PurchaseReturn Purchase Return Management Manage returns of purchased goods back to suppliers.
19 Grn Goods Receipt Note (GRN) Record and manage receipt of goods from suppliers against purchase orders.
20 Order Order Management Manage customer sales orders from creation through fulfillment.
21 Sale Sales Management Manage sales transactions and related sales records.
22 Returns Sales Returns Management Manage return of goods from customers after sale/delivery.
23 Payment Payment Management Record and track payments received/made against orders, sales, or claims.
24 Region Region Management Manage geographic regions used for organizing routes, stores, and sales areas.
25 Route Route Management Manage delivery/sales routes used for planning rides and deliveries.
26 Ride Ride Management Manage delivery rides/trips assigned to drivers and vehicles.
27 DeliveryRide Delivery Ride Tracking Track delivery ride execution, status, and progress.
28 LoadingEvent Loading Event Tracking Track vehicle loading events for goods dispatched on rides.
29 Vehicle Vehicle Management Manage vehicle master data used for deliveries.
30 DriverProfile Driver Profile Management Manage driver master data and profile details.
31 DriverComplianceConfig Driver Compliance Configuration Configure compliance rules and requirements for drivers.
32 Sync Data Synchronization Handle data sync operations, likely for offline/mobile clients syncing with the server.
+89 -128
View File
@@ -46,128 +46,80 @@ start the dispatch/receipt workflow on it*.
| Column | Type | Notes |
|---|---|---|
| Id | Guid | PK |
| ~~`FK_OrderId`~~ | — | **Removed.** Replaced by `LoadingSheetOrder` linking table. |
| **`FK_RideId`** | Guid | **Required. Set at population time** — dispatcher provides the ride when triggering populate. |
| ~~`FK_SourceWarehouseId`~~ | — | **Removed.** Warehouse is now tracked per item, not per sheet — a single sheet can be fulfilled from multiple warehouses (see `LoadingSheetItem` below). |
| Status | LoadingSheetStatus | See state machine below. |
| **`PopulatedBy`** | Guid | **New.** UserId of dispatcher who triggered population. |
| **`PopulatedAt`** | DateTime | **New.** When population was triggered. |
| ~~`AssignedAt`~~ | — | **Removed.** No separate assign step — ride is set at populate time. |
| ~~`AssignedBy`~~ | — | **Removed.** Same reason. |
| ConfirmedAt | DateTime? | Set when loading sheet is confirmed (locked). |
| ConfirmedBy | Guid? | UserId of whoever confirmed the sheet. |
| DispatchConfirmedAt | DateTime? | Path B only — see Open Questions. |
| DispatchConfirmedBy | Guid? | Path B only. |
| ReceiptConfirmedAt | DateTime? | Path B only. |
| ReceiptConfirmedBy | Guid? | Path B only. |
| **`FK_OrderId`** | Guid | **New, required.** FK → `Order` (PO). Set at creation; never changes. |
| **`FK_RideId`** | Guid? | **Now nullable.** Null while `Planned`; set by the new assign-ride action. |
| **`FK_SourceWarehouseId`** | Guid? | **Now nullable**, for the same reason — set together with `FK_RideId` at assign time, not at creation. |
| Status | LoadingSheetStatus | State machine below — gains a `Planned`/`Assigned` prefix. |
| **`AssignedAt`** | DateTime? | **New.** Set when a Ride + source warehouse are assigned. |
| **`AssignedBy`** | Guid? | **New.** UserId of whoever assigned the ride. |
| DispatchConfirmedAt | DateTime? | Unchanged — set when warehouse confirms dispatch. |
| DispatchConfirmedBy | Guid? | Unchanged. |
| ReceiptConfirmedAt | DateTime? | Unchanged — set when driver confirms receipt. |
| ReceiptConfirmedBy | Guid? | Unchanged. |
| CreatedAt | DateTime | Unchanged. |
### New Entity — `LoadingSheetOrder` (linking table)
One loading sheet aggregates multiple Confirmed POs. This table records which POs were included.
| Column | Type | Notes |
|---|---|---|
| FK_LoadingSheetId | Guid | FK → LoadingSheet |
| FK_OrderId | Guid | FK → Order (must be a Confirmed PO at population time) |
### Updated Entity — `LoadingSheetItem`
`LoadingSheetItem` gains versioning, a source reference, and a per-item warehouse:
| Column | Type | Notes |
|---|---|---|
| Id | Guid | PK |
| FK_LoadingSheetId | Guid | FK → LoadingSheet |
| **`FK_WarehouseId`** | Guid | **New. Required.** The warehouse this specific item is loaded from. Different items on the same sheet can come from different warehouses. Set by dispatcher when editing items. |
| **`FK_OrderItemId`** | Guid? | **New.** Traces back to the source `OrderItem` from the Confirmed PO. Null for manually added lines. |
| FK_ItemId | Guid | FK → Item (product) |
| QuantityDispatched | decimal | Quantity to load from `FK_WarehouseId` |
| QuantityReceived | decimal? | Driver's received qty (Path B only) |
| QuantityResolved | decimal? | Supervisor's resolved qty after discrepancy (Path B only) |
| **`IsActive`** | bool | **Versioning.** Every edit soft-deletes current items (`IsActive = false`) and writes a fresh set. Active items = latest version; soft-deleted = history. |
| CreatedAt | DateTime | From Template |
`LoadingSheetItem` is **unchanged** (`FK_ItemId`, `QuantityDispatched`, `QuantityReceived`,
`QuantityResolved`) — still populated the same way, just from a different source (see
[Item Population](#item-population) below).
### Updated Enum — `LoadingSheetStatus`
```csharp
public enum LoadingSheetStatus
{
Planned, // populated by dispatcher (ride set, items editable/versioned)
Confirmed, // items locked — stock movement path TBD (see Open Questions)
DispatchConfirmed, // Path B only: warehouse confirms dispatch
ReceiptConfirmed, // Path B only: driver confirms receipt, stock moves
DiscrepancyFlagged, // Path B only: received qty mismatch
Resolved // Path B only: supervisor resolves discrepancy, stock moves
Planned, // new: created from a confirmed PO, no ride yet
Assigned, // new: ride + source warehouse attached, ready to dispatch
DispatchConfirmed,
ReceiptConfirmed,
DiscrepancyFlagged,
Resolved
}
```
`Pending` from `04_loading_event.md` is replaced by `Planned` and `Confirmed`. `Assigned` is
removed — ride is now set at populate time, so there is no separate assign step.
`DispatchConfirmed`, `ReceiptConfirmed`, `DiscrepancyFlagged`, and `Resolved` are retained for
Path B — see Open Questions.
`Pending` from `04_loading_event.md` is replaced by two states (`Planned`, `Assigned`) to make the
new ride-less creation step explicit. `DispatchConfirmed`, `ReceiptConfirmed`,
`DiscrepancyFlagged`, and `Resolved` keep their existing meaning and stock-movement behavior
unchanged.
---
## State Machine
```text
Planned -> Confirmed -> [Path A: stock moves here]
\-> [Path B: DispatchConfirmed -> ReceiptConfirmed]
\-> DiscrepancyFlagged -> Resolved
Planned -> Assigned -> DispatchConfirmed -> ReceiptConfirmed
\-> DiscrepancyFlagged -> Resolved
```
| Transition | Trigger | What it does |
|---|---|---|
| (create) -> Planned | `POST /api/loading-sheets/populate` | Dispatcher provides filters (route/agent/shop) + `rideId`. System finds all matching Confirmed POs, creates one `LoadingSheet` with `FK_RideId` set, populates `LoadingSheetItem` rows from their `OrderItem` lines, records PO links in `LoadingSheetOrder`. `PopulatedBy`/`PopulatedAt` set. Items editable (versioned via `IsActive`). Dispatcher also sets `FK_WarehouseId` per item (which warehouse each item loads from). |
| Planned -> Confirmed | `POST /{id}/confirm` | Locks the sheet — `LoadingSheetItem` rows frozen (no further edits). Sets `ConfirmedAt`/`ConfirmedBy`. Stock movement path TBD (see Open Questions). |
| Confirmed -> DispatchConfirmed | `POST /{id}/confirm-dispatch` | **Path B only.** Warehouse manager confirms dispatch quantities. |
| DispatchConfirmed -> ReceiptConfirmed | `POST /{id}/confirm-receipt` | **Path B only.** Driver's received quantities all match. Stock movements created — one debit per `LoadingSheetItem.FK_WarehouseId`, one credit to the ride's vehicle. No ride status transition. |
| DispatchConfirmed -> DiscrepancyFlagged | `POST /{id}/confirm-receipt` | **Path B only.** Any line mismatch. No stock movements yet. |
| DiscrepancyFlagged -> Resolved | `POST /{id}/resolve` | **Path B only.** Supervisor enters `QuantityResolved` per line. Stock movements created using resolved quantities. |
| (implicit) -> Planned | PO confirmed (`POST /api/orders/{id}/confirm`, per [order_invoice_lifecycle.md](./order_invoice_lifecycle.md)) | `LoadingSheet` is created automatically with `FK_OrderId` set, items populated from the PO's `OrderItem` lines. No ride, no warehouse yet. |
| Planned -> Assigned | `POST /{id}/assign-ride` | Dispatcher attaches `FK_RideId` (must be an `Open` ride) and `FK_SourceWarehouseId`. Sets `AssignedAt`/`AssignedBy`. This is the new step that replaces `04_loading_event.md`'s ride-scoped sheet creation. |
| Assigned -> DispatchConfirmed | `POST /{id}/confirm-dispatch` | Unchanged from `04_loading_event.md` — warehouse manager locks in `QuantityDispatched` per line. **Ride status is untouched** (no more `Open` requirement check beyond what assign-ride already validated). |
| DispatchConfirmed -> ReceiptConfirmed | `POST /{id}/confirm-receipt` | Unchanged — driver's received quantities all match dispatched. Creates stock movements. **No longer triggers any ride transition** (there is no `Active` to trigger). |
| DispatchConfirmed -> DiscrepancyFlagged | `POST /{id}/confirm-receipt` | Unchanged — any line mismatch. No stock movements created. |
| DiscrepancyFlagged -> Resolved | `POST /{id}/resolve` | Unchanged mechanics — supervisor enters `QuantityResolved` per line, creates stock movements using resolved quantities. **No longer triggers any ride transition.** |
---
## Item Population
Replaces `04_loading_event.md`'s "Item Population by Ride Type" section entirely.
Replaces `04_loading_event.md`'s "Item Population by Ride Type" section — there is no more
Delivery/Sale split:
A dispatcher manually triggers population via `POST /api/loading-sheets/populate` providing:
| Input | Notes |
|---|---|
| `rideId` | **Required.** The ride this loading sheet is for. Must be an `Open` ride. |
| `routeId` | Filter — all Confirmed POs for shops on this route |
| `agentId` | Filter — all Confirmed POs created by this agent |
| `shopId` | Filter — all Confirmed POs for this specific shop |
At least one filter must be provided alongside `rideId`. The system finds all matching `Order`
records where items are locked (Confirmed PO), aggregates their active `OrderItem` lines into one
`LoadingSheet`, and records each PO in `LoadingSheetOrder`.
`LoadingSheetItem.FK_OrderItemId` traces each line back to its source `OrderItem` for audit.
After population, the dispatcher sets `FK_WarehouseId` on each item — specifying which warehouse
that item is loaded from. Different items can come from different warehouses. Items are editable
while `Planned` — every edit soft-deletes all current `LoadingSheetItem` rows (`IsActive = false`)
and writes a fresh set. Latest version = `IsActive = true` rows.
Every `LoadingSheet` is auto-populated on creation (at `Planned`) from the aggregate of its own
PO's (`Order`) `OrderItem` lines — the same mechanism for every ride scenario, since
[order_invoice_lifecycle.md](./order_invoice_lifecycle.md) generates one `LoadingSheet` per
confirmed PO regardless of how that PO originated. `PUT /{id}/items` remains available to override
quantities before dispatch is confirmed (through `Planned` and `Assigned`).
---
## Stock Movements
One stock movement pair per `LoadingSheetItem` (active rows only). Created exactly once.
- **Debit**: `LoadingSheetItem.FK_WarehouseId` (the warehouse that item loads from)
- **Credit**: the vehicle on the assigned ride (`LoadingSheet.FK_RideId → DeliveryRun.FK_VehicleId`)
- **Type**: `Load`
Since different items can have different `FK_WarehouseId` values, one loading sheet can produce
stock movements from multiple warehouses into one vehicle.
**When** movements are created depends on the path chosen (see Open Questions):
- **Path A** — stock moves at `Confirmed`
- **Path B** — stock moves at `ReceiptConfirmed` or `Resolved`
**Unchanged** from `04_loading_event.md`. Still created exactly once, at `ReceiptConfirmed` or
`Resolved`, still one pair per line item (debit `FK_SourceWarehouseId`, credit the assigned ride's
vehicle), still using `QuantityFinal` = `QuantityReceived` or `QuantityResolved`, still
`Type = Load`.
---
@@ -177,37 +129,43 @@ Base route: `/api/loading-sheets`
| Method | Route | Who | Description |
|---|---|---|---|
| POST | `/populate` | Dispatcher | Trigger population: `{ rideId, routeId?, agentId?, shopId? }` — `rideId` required, at least one filter required. Finds all matching Confirmed POs, creates one `LoadingSheet` in `Planned` status with `FK_RideId` set, populates items. |
| GET | `/` | Any | List sheets. Filter by `routeId`, `agentId`, `shopId`, `rideId`, `status`. Paginated. |
| GET | `/{id}` | Any | Get sheet detail. Active items only by default; `?history=true` includes soft-deleted versions. |
| PUT | `/{id}/items` | Dispatcher | Rewrite items while `Planned`. Each item must include `FK_WarehouseId`. Soft-deletes current rows (`IsActive = false`) and writes a fresh set. |
| POST | `/{id}/confirm` | Dispatcher | Lock the sheet. `Planned -> Confirmed`. Items frozen. |
| POST | `/{id}/confirm-dispatch` | Warehouse mgr | **Path B only.** `Confirmed -> DispatchConfirmed`. |
| PUT | `/{id}/receipt-items` | Driver | **Path B only.** Set `QuantityReceived` per line. |
| POST | `/{id}/confirm-receipt` | Driver | **Path B only.** `-> ReceiptConfirmed` or `DiscrepancyFlagged`. |
| POST | `/{id}/resolve` | Supervisor | **Path B only.** Enter `QuantityResolved` per line `-> Resolved`. |
| ~~POST `/`~~ | — | — | **Removed.** Sheets are no longer manually created — they're generated automatically when a PO is confirmed. |
| GET | `/` | Any | List sheets. Filter by `orderId`, `rideId`, `status`. Paginated. |
| GET | `/{id}` | Any | Get sheet detail with all line items. |
| PUT | `/{id}/items` | Warehouse mgr | Set or override item quantities — allowed through `Planned` and `Assigned`. |
| **POST `/{id}/assign-ride`** | **Dispatcher** | **New.** `{ rideId, sourceWarehouseId }`. Requires the ride to be `Open`. `Planned -> Assigned`. |
| POST | `/{id}/confirm-dispatch` | Warehouse mgr | `Assigned -> DispatchConfirmed`. |
| PUT | `/{id}/receipt-items` | Driver | Set `QuantityReceived` per line. Unchanged. |
| POST | `/{id}/confirm-receipt` | Driver | `-> ReceiptConfirmed` or `DiscrepancyFlagged`. No ride-status side effect. |
| POST | `/{id}/resolve` | Supervisor | Enter `QuantityResolved` per line `-> Resolved`. No ride-status side effect. |
---
## Business Rules
1. A `LoadingSheet` is created manually by a dispatcher via `POST /api/loading-sheets/populate` —
not automatically. `rideId` and at least one PO filter (routeId, agentId, or shopId) are required.
2. The ride (`FK_RideId`) is set at populate time — there is no separate assign-ride step.
3. Only Confirmed POs (locked items) are included in population — Shop POs are excluded.
4. One `LoadingSheet` aggregates all matching Confirmed POs — not one sheet per PO.
5. A ride may have more than one `LoadingSheet` — each progresses independently.
6. The target ride must be `Open` at populate time.
7. Each `LoadingSheetItem` carries its own `FK_WarehouseId` — items on the same sheet can come
from different warehouses. There is no sheet-level source warehouse.
8. Items are editable (versioned via `IsActive`) while `Planned` — frozen at `Confirmed`.
9. Every item edit soft-deletes all current `LoadingSheetItem` rows (`IsActive = false`) and writes
a fresh set. Active rows = latest version; soft-deleted = history.
10. Stock movements are created exactly once — one debit per item warehouse, one credit to the
vehicle. Timing depends on path chosen (see Open Questions).
11. No `Ride.Status` transition is triggered at any point in this flow.
12. All list endpoints return paginated responses.
13. Vehicle capacity check is not enforced in this step.
1. A `LoadingSheet` is created automatically, exactly once per PO, when that PO is confirmed — there
is no manual "create sheet" action and no `Open`-ride precondition at creation time (replaces
`04_loading_event.md` rule 1).
2. **A ride may now have more than one `LoadingSheet` assigned to it** — one per PO whose sheet was
assigned there. `04_loading_event.md`'s "a ride can only have one active loading sheet at a time"
no longer holds, since a single ride/trip can carry stock for several stores' POs at once. Each
assigned sheet still progresses through dispatch/receipt/discrepancy independently.
3. `assign-ride` requires the target ride to be `Open`; a `Cancelled` or `Closed` ride cannot receive
a new assignment.
4. `QuantityDispatched != QuantityReceived` on any line -> `DiscrepancyFlagged`. No partial receipt
confirmation (unchanged from `04_loading_event.md`, minus the "activation" language since there's
no `Active` status anymore).
5. Stock movements are created only once — at `ReceiptConfirmed` or `Resolved`. Never on dispatch or
on assignment. Unchanged.
6. Discrepancies must be resolved per line item with an explicit `QuantityResolved` value. Unchanged.
7. All list endpoints return paginated responses. Unchanged.
8. Vehicle capacity check is still not enforced in this step. Unchanged.
9. Loading confirmation and receipt no longer trigger any `Ride.Status` change — replaces
`04_loading_event.md` rules that referenced `Open -> Active`.
`04_loading_event.md`'s rule 8 ("Immediate handover from a Sale ride still creates an order") is
dropped along with `RideType` — every PO already creates its own `Invoice`/`LoadingSheet` uniformly,
regardless of how quickly it moves through Pending → Draft → Finalized.
---
@@ -236,10 +194,10 @@ Base route: `/api/loading-sheets`
| Code | Who |
|---|---|
| `loading.view` | All roles |
| **`loading.populate`** | **New.** Dispatcher — trigger population, edit items, confirm sheet |
| `loading.dispatch` | Warehouse manager — Path B dispatch confirmation |
| `loading.receive` | Driver — Path B receipt confirmation |
| `loading.resolve` | Supervisor — Path B discrepancy resolution |
| **`loading.assign`** | **New.** Dispatcher and above |
| `loading.dispatch` | Warehouse manager and above |
| `loading.receive` | Driver |
| `loading.resolve` | Supervisor and above |
---
@@ -256,14 +214,17 @@ a specific ride/vehicle, which didn't exist as a separate step before.
## Open Questions
1. **Path A vs Path B — when do stock movements happen?** Stock can move at `Confirmed` (Path A:
warehouse confirms and dispatches in one step) or at `ReceiptConfirmed`/`Resolved` (Path B:
driver receipt confirmation). Both paths are retained until this is decided.
2. **Who confirms the loading sheet?** — the dispatcher who populated it, the warehouse manager, or
either? Not specified here.
3. **What happens to a `Planned` sheet if it is never assigned to a ride?** — no cancellation or
timeout path is designed here.
4. Vehicle capacity check — still deferred.
1. **Who is authorized to assign a ride to a Planned sheet?** — a new "dispatcher" role/permission is
introduced (`loading.assign`) but not otherwise defined in this pass.
2. **What happens to a `Planned` sheet if its PO is never assigned to a ride?** — no timeout, retry,
or cancellation path is designed here.
3. **Does editing a PO's items after its sheet is `Planned`/`Assigned` re-sync the sheet?** — carried
over from `order_invoice_lifecycle.md`'s Open Question 8, still unresolved here.
4. **Should multiple `Planned` sheets ever merge into one `Assigned` sheet** (e.g. one dispatch
covering several stores' POs on the same ride), rather than staying as separate per-PO sheets that
each go through their own dispatch/receipt cycle? Not designed here — this document keeps them as
independent per-PO sheets (see Business Rule 2).
5. Vehicle capacity check — still deferred, unchanged from `04_loading_event.md`.
---
@@ -15,28 +15,25 @@ the PO/Invoice directly, not from a Route. How that `LoadingSheet` later attache
## Goal
Replace the Delivery-vs-Sale ride-type split with a single unified pipeline. The PO and Invoice are
two separate but linked concerns — the PO tracks what the shop ordered; the Invoice tracks the
billing document that goes with the delivery.
Replace the Delivery-vs-Sale ride-type split as the organizing concept for how a store's order gets
turned into money. Today `05_delivery_ride.md` (rep takes an order now, delivers days later) and
`06_sale_ride.md` (rep+driver visit together and sell on the spot) are two separate flows. Underneath,
both are the same shape: a rep collects a **PO** from the shop — whether fulfilled immediately or
days later — and once the PO exists, an **Invoice** is generated from it. That invoice always moves
through the same three statuses regardless of which scenario produced it:
### PO lifecycle (on `Order`)
1. **Pending** — invoice just created from the PO; the PO's line items are freely editable.
2. **Draft** — a PO-confirm action moves it out of Pending (no customer signature/OTP — see
[State Machine](#state-machine)). Confirming also automatically generates a `LoadingSheet` from
the PO's current line items. The PO's line items **remain editable** in this state too — Draft is
a working document, not a lock.
3. **Finalize** — the reconciliation step: item add/delete and amount deductions (returns, damage,
short-delivery, discounts) get applied here, producing the final, locked invoice amount — this is
what the shop actually received and paid, and is the record used for day-end calculations. This
is the only state where free-form editing stops.
1. **Shop PO** — PO created by sales agent, line items freely editable (versioned via `IsActive`).
2. **Confirmed PO** — office user confirms the PO, locking its items. Invoice creation depends on
the system's active mode (see [Invoice Creation Modes](#invoice-creation-modes) below).
### Invoice lifecycle (on `Invoice`)
An Invoice is **not** auto-created when a PO is created. It is created by an office user action —
either combined with PO confirmation (Mode A) or as a separate step after confirmation (Mode B).
Once created, the Invoice always moves through these states:
1. **Draft** — Invoice created, ready to be printed.
2. **Printed** — Invoice physically printed and handed to the agent for the field. The agent makes
handwritten edits on the paper copy during the ride.
3. **Finalized** — after the ride or day-end, the agent returns the printed invoice. The office
worker enters any edits the agent made into the system and submits — locking the invoice as the
final billing record.
The two ride scenarios differ only in *timing* through this one pipeline, not in structure — no
ride-type-specific branching is needed in the PO/Invoice model itself.
---
@@ -59,9 +56,6 @@ Order ("PO")
├── FK_StoreId (Guid) — required, unchanged
├── FK_SaleId (Guid?) — now OPTIONAL (was required). See Impact section — a PO can now
│ exist before any Sale/payment event completes.
├── InvoiceId (Guid?) — NOT a FK constraint. Set when the Invoice is created for this PO
│ (Mode A or B). Null until then. Stored for lookup convenience —
│ no referential enforcement at DB level.
├── TotalAmount (decimal) — unchanged, though only authoritative once the linked Invoice is
│ Finalized (see below)
├── Status (OrderStatus) — unchanged: Pending, PartiallyFulfilled, Fulfilled, Cancelled.
@@ -80,101 +74,88 @@ below), not by `Order.Status` directly.
```
Invoice
├── Id (Guid) — PK, from Template
├── FK_OrderId (Guid) — required. One PO → one Invoice (1:1).
├── Status (InvoiceStatus) — Draft, Printed, Finalized
├── CreatedBy (Guid) — UserId of office user who created the invoice
├── PrintedAt (DateTime?) — set on Draft → Printed
├── PrintedBy (Guid?) — UserId of whoever triggered the print
├── FinalizedAt (DateTime?) — set on Printed → Finalized
├── FinalizedBy (Guid?) — UserId of office worker who entered agent edits and submitted
├── TotalAmount (decimal) — reflects current line items through Draft/Printed; becomes the
│ authoritative locked amount once Finalized
├── FK_OrderId (Guid) — required. One PO → one Invoice for v1 (see Open Questions).
├── Status (InvoiceStatus) — Pending, Draft, Finalized
├── ConfirmedAt (DateTime?) — set on Pending → Draft, when the PO is confirmed
├── FinalizedAt (DateTime?) — set on Draft → Finalized
├── FinalizedBy (Guid?) — UserId of whoever finalized it
├── TotalAmount (decimal) — mirrors the PO total while Pending/Draft; becomes the
│ authoritative, locked amount once Finalized
├── IsActive, CreatedAt — from Template
```
There is no signature/OTP field on `Invoice` — confirming a PO is a plain action, not a captured
customer signature (see [State Machine](#state-machine); this also resolves
[Open Question 1](#open-questions) from the prior pass of this document).
### `LoadingSheet` — now generated from the PO, not the Route
`LoadingSheet` already exists in the codebase (`Features/LoadingEvent/`), previously populated by
aggregating orders on a ride's route (`LoadingSheetRepository.GetAggregatedOrderItemsByRouteIdAsync`
— see `ride_lifecycle.md`'s Impact item 2). Under this design, a `LoadingSheet` is instead created
automatically the moment an `Order`/`Invoice` moves Pending → Draft (PO confirmed), populated from
that PO's `OrderItem` lines directly:
```
LoadingSheet
├── FK_OrderId (Guid) — required. Links the sheet to the PO/Invoice it was generated from.
```
It is **not** linked to a Ride or vehicle at creation time — it exists purely as "what needs to be
loaded for this PO." How/when it later gets attached to an actual Ride for physical stock loading is
left open — see [Open Questions](#open-questions).
### New Enum — `InvoiceStatus`
```csharp
public enum InvoiceStatus { Draft, Printed, Finalized } // Finalized is the terminal state
public enum InvoiceStatus { Pending, Draft, Finalized }
```
### Invoice Creation Modes
In a given system instance, **exactly one** of the following modes is active. The active mode is
controlled by a system-level configuration flag (`InvoiceCreationMode: "A" | "B"`). Both modes
cannot be active at the same time.
**Mode A — Confirm PO + Create Invoice (combined action)**
- Office user presses "Confirm PO & Create Invoice"
- Single endpoint: `POST /api/orders/{id}/confirm-and-create-invoice`
- Confirms the PO (locks `OrderItem` lines) and creates the Invoice at `Draft` in one action.
**Mode B — Create Invoice from Confirmed POs (separate action)**
- PO confirmation is a separate step: `POST /api/orders/{id}/confirm`
- Office user then navigates to invoice creation, filters confirmed POs by agent ID, route ID, or
shop ID to find the right PO, and creates the invoice: `POST /api/invoices { orderId }`
- Invoice created at `Draft`.
### `LoadingSheet` — populated from Confirmed POs, separate flow
Loading sheets are populated from Confirmed POs as a separate dispatcher-triggered flow — not
auto-triggered at PO confirm or invoice create time. One loading sheet aggregates multiple Confirmed
POs based on route/agent/shop filters. See [loading_event.md](./loading_event.md) and
[dispatch_pipeline.md](./dispatch_pipeline.md) for the full loading sheet lifecycle.
An `Invoice` is created automatically the moment a PO (`Order`) is created, starting at `Pending` —
there is no separate "create invoice" action.
---
## State Machine
### PO (`Order`) transitions
```
Pending → Draft → Finalized
```
```
(create) → Shop PO → Confirmed PO
```
No branch/cancel path is designed in this pass (unlike the Ride state machine) — cancellation of a
PO/Invoice is not addressed here; flagged as an open question below.
| Transition | Trigger | What it does |
|---|---|---|
| (create) → Shop PO | `POST /api/orders` | PO created. `OrderItem` lines editable — every edit soft-deletes current rows (`IsActive = false`) and writes a fresh set. Active rows = latest version. |
| Shop PO → Confirmed PO | **Mode A:** `POST /api/orders/{id}/confirm-and-create-invoice` | Confirms PO and creates Invoice at `Draft` in one atomic action. `OrderItem` lines locked. `Order.InvoiceId` set to the new Invoice's Id. |
| Shop PO → Confirmed PO | **Mode B:** `POST /api/orders/{id}/confirm` | Confirms PO only. `OrderItem` lines locked. `Order.InvoiceId` remains null until invoice is created separately. |
### Invoice transitions
```
Draft → Printed → Finalized
```
| Transition | Trigger | What it does |
|---|---|---|
| (create) → Draft | **Mode A:** combined with PO confirm above | Invoice created at `Draft`. |
| (create) → Draft | **Mode B:** `POST /api/invoices` `{ orderId }` | Office user selects a Confirmed PO (filtered by agentId / routeId / shopId) and creates the Invoice at `Draft`. `Order.InvoiceId` set to the new Invoice's Id. |
| Draft → Printed | `POST /api/invoices/{id}/print` | Invoice is physically printed. `PrintedAt`/`PrintedBy` set. Agent takes the printed copy to field and makes handwritten edits during the ride. |
| Printed → Finalized | `POST /api/invoices/{id}/complete` | After ride/day-end, agent returns printed invoice. Office worker enters any edits the agent made (quantity adjustments, returns, damage, discounts) and submits. `TotalAmount` locked. `FinalizedAt`/`FinalizedBy` set. |
| (implicit) → Pending | PO (`Order`) created | Invoice is created automatically, `Status = Pending`. `OrderItem` lines fully editable. |
| Pending → Draft | `POST /api/orders/{id}/confirm` | Confirms the PO — a plain action, not a customer signature/OTP. Lives on the `Order` (PO) resource, not the `Invoice`; does **not** add or change any `Order.Status` value — PO confirmation and fulfillment tracking stay separate axes. Sets `Invoice.ConfirmedAt`, `Invoice.Status = Draft`, and automatically creates a `LoadingSheet` from the PO's current `OrderItem` lines. **`OrderItem` lines remain editable** after confirm — Draft is not a lock; whether/how a later edit re-syncs the already-generated `LoadingSheet` is an open question (see below). |
| Draft → Finalized | `POST /api/invoices/{id}/finalize` | Accepts an item-adjustment/deduction payload (add/delete lines, amount deductions for returns/damage/short-delivery/discounts), recomputes and locks `TotalAmount`. This is a distinct, audited action — not the same add/edit/remove path used in Pending/Draft. Once Finalized, the Invoice and its PO's items are fully locked. |
---
## Business Rules
1. `OrderItem` editing is allowed **only** while the PO is a Shop PO (before confirmation). Every
edit soft-deletes all current `OrderItem` rows (`IsActive = false`) and creates a fresh set —
active rows are the latest version, soft-deleted rows are history. Once confirmed, items are
locked permanently.
2. Invoice is **not** auto-created at PO creation — it is always a deliberate office user action.
3. Exactly one Invoice Creation Mode (A or B) is active per system instance — controlled by a
system-level configuration flag (`InvoiceCreationMode`). Both modes cannot be active at the
same time.
4. Mode A only: a PO cannot be confirmed without simultaneously creating its invoice — the two
actions are atomic.
5. Mode B only: a PO can be confirmed without an invoice existing; the invoice is created later as
a separate action by the office user.
6. An Invoice cannot skip `Printed` — `Draft → Finalized` directly is not a valid transition.
7. `Invoice.TotalAmount` is only authoritative once `Finalized`; through `Draft`/`Printed` it
reflects the current line items.
8. `Order.Status` (fulfillment tracking: Pending/PartiallyFulfilled/Fulfilled/Cancelled) is a
separate axis from `Invoice.Status` — not merged in this pass.
9. `Order.FK_SaleId` is optional — a PO can exist and be confirmed before any `Sale` is recorded.
10. Loading sheet population is a separate dispatcher-triggered flow — not triggered by PO
confirmation or invoice creation. See [dispatch_pipeline.md](./dispatch_pipeline.md).
1. `OrderItem` add/edit/remove is allowed while the linked `Invoice.Status` is `Pending` **or**
`Draft` — both are working-document states.
2. An `Invoice` cannot be `Finalized` without first passing through `Draft` — `Pending → Finalized`
directly is not a valid transition.
3. `Order.TotalAmount`/`Invoice.TotalAmount` is only authoritative once `Finalized`; while
Pending/Draft it reflects the current (still-changeable) line items.
4. `Order.Status` (fulfillment tracking: Pending/PartiallyFulfilled/Fulfilled/Cancelled) is
untouched and operates on a separate axis from `Invoice.Status` (billing/document lifecycle) —
whether these two should eventually merge is an open question, not resolved here.
5. `Order.FK_SaleId` is now optional — a PO/Invoice can exist and progress through Pending/Draft
before any `Sale` is recorded, covering the take-order-now/deliver-later scenario. See Impact
section for what this touches.
6. Both ride scenarios (take-order-now-deliver-later vs. on-the-spot sale) create a PO the same way
and pass through the same three Invoice statuses — the only difference is how quickly they move
through Pending → Draft → Finalized. No `RideType`-specific branching applies to the PO/Invoice
model.
7. Confirming a PO (Pending → Draft) requires no customer signature or OTP — it is a plain action
available to whoever is authorized to confirm a PO (e.g. the rep/dispatcher), not a
customer-facing sign-off step.
8. A `LoadingSheet` is generated exactly once per PO, automatically, at PO-confirm time — there is no
separate manual "create loading sheet" action.
---
@@ -218,19 +199,26 @@ visible and intentional rather than discovered later as a silent regression.
## Open Questions
1. ~~**Signature/OTP on PO confirmation**~~ — **Resolved:** dropped. Plain action, no OTP.
2. ~~**One Invoice per PO or multiple?**~~ — **Resolved:** 1:1 for v1.
3. **Should `Order.Status` and `Invoice.Status` eventually merge?** — kept separate in this pass;
not decided.
4. **How does `Payment` attach to `Invoice`?** — see Impact item 2. Not designed here.
5. **Cancellation/void path for a PO or Invoice?** — not addressed here.
6. **What edits can the agent make on the printed invoice?** — quantity adjustments, returns,
damage, and discounts are assumed but the exact adjustment payload for `POST /complete` is not
specified here.
7. ~~**How does a LoadingSheet get matched to a Ride?**~~ — **Resolved in
[loading_event.md](./loading_event.md):** `POST /{id}/assign-ride`.
8. ~~**Does editing PO items after confirm re-sync the LoadingSheet?**~~ — **Resolved:** items lock
at confirm; no re-sync needed.
1. ~~**Signature/OTP mechanism for the Pending → Draft transition**~~ — **Resolved:** dropped
entirely. Pending → Draft is driven by a plain PO-confirm action (`POST /api/orders/{id}/confirm`),
not a customer signature or OTP. See [State Machine](#state-machine).
2. **One Invoice per PO, or multiple?** — this document assumes a 1:1 `Order`→`Invoice` relationship
for v1. Whether a PO could ever need multiple invoices (e.g. split billing) is not designed here.
3. **Should `Order.Status` and `Invoice.Status` eventually merge into one status axis?** — kept
deliberately separate in this pass (fulfillment progress vs. document/billing lifecycle); not
decided whether that separation should be permanent.
4. **How does `Payment` attach once `Invoice` exists?** — see Impact item 2. Not designed here.
5. **Is there a cancellation/void path for a PO or Invoice?** — the state machine in this document
has no cancel branch (unlike the Ride state machine's `CancelRequested`/`Cancelled`). Not
addressed here.
6. **Does `DeliveryTask.CustomerConfirmed` merge with the Draft transition?** — see Impact item 4.
7. ~~**How does a PO-scoped `LoadingSheet` get matched to an actual Ride/vehicle?**~~ — **Resolved
in [loading_event.md](./loading_event.md):** the sheet starts `Planned` (ride-less), and a new
`POST /{id}/assign-ride` action attaches a `Ride` + source warehouse, moving it to `Assigned`.
8. **Does editing a PO's items after confirm (while still Draft) re-sync the already-generated
`LoadingSheet`?** — this document allows PO edits through Draft, but doesn't decide whether the
`LoadingSheet` regenerates, gets manually re-synced, or is left to drift until Finalize reconciles
it. Not designed here.
---
@@ -238,27 +226,26 @@ visible and intentional rather than discovered later as a silent regression.
Not part of this docs-only pass, but captured for the eventual implementation step:
- Make `Order.FK_SaleId` nullable in `Models/Order.cs`; audit all code paths that assume it's
non-null.
- Add `Order.InvoiceId` (Guid?, no FK constraint) to `Models/Order.cs` — configure explicitly in
Fluent API to ensure no referential constraint is generated. Set it in the same transaction as
Invoice creation (both Mode A and Mode B).
- Add `Models/Invoice.cs` (`FK_OrderId`, `Status`, `CreatedBy`, `PrintedAt`, `PrintedBy`,
`FinalizedAt`, `FinalizedBy`, `TotalAmount`, plus `Template` base fields) and the `InvoiceStatus`
enum (`Draft`, `Printed`, `Finalized`).
- Make `Order.FK_SaleId` nullable in `Models/Order.cs` and its EF/Fluent configuration; audit all
code paths that currently assume it's non-null.
- Add `Models/Invoice.cs` (`FK_OrderId`, `Status`, `ConfirmedAt`, `FinalizedAt`, `FinalizedBy`,
`TotalAmount`, plus `Template` base fields) and the `InvoiceStatus` enum.
- New `Features/Invoice/` folder (`InvoiceController`, `InvoiceService`, `IInvoiceRepository`,
`InvoiceRepository`, `InvoiceDTOs`) — do not place under `Features/PurchaseOrder/`.
- Add a system configuration flag `InvoiceCreationMode` (`"A"` or `"B"`) — read at startup; gate
the Mode A and Mode B endpoints accordingly so only the active mode's endpoints are reachable.
- **Mode A endpoint:** `POST /api/orders/{id}/confirm-and-create-invoice` — atomic: locks
`OrderItem` rows and creates `Invoice` at `Draft` in one transaction.
- **Mode B endpoints:** `POST /api/orders/{id}/confirm` (locks items only) + `POST /api/invoices`
`{ orderId }` (creates Invoice at `Draft` from a Confirmed PO). Add `GET /api/orders` filter
params `agentId`, `routeId`, `shopId` to support the office user's PO selection UI.
- `POST /api/invoices/{id}/print` — sets `PrintedAt`/`PrintedBy`, transitions to `Printed`.
- `POST /api/invoices/{id}/complete` — accepts adjustment payload (quantities, returns, damage,
discounts); locks `TotalAmount`; sets `FinalizedAt`/`FinalizedBy`. Resolve Open Question 6
before specifying the exact payload shape.
- `LoadingSheet` population is a separate flow — see [dispatch_pipeline.md](./dispatch_pipeline.md).
- A new PO-creation path independent of `SaleService.CreateAsync` is needed for the
take-order-now/deliver-later scenario (Impact item 1) — not specified here.
`InvoiceRepository`, `InvoiceDTOs`) — do not place this under `Features/PurchaseOrder/` or reuse
any of its DTOs/names, to avoid the supplier-PO naming collision flagged above.
- Add `POST /api/orders/{id}/confirm` on the `Order`/PO feature (not on `Invoice`) — it sets
`Invoice.ConfirmedAt`/`Status = Draft` and creates the `LoadingSheet` in one action; no
`POST /api/invoices/{id}/draft` endpoint and no signature/OTP capture are needed.
- `POST /api/invoices/{id}/finalize` endpoint; the finalize endpoint's adjustment/deduction payload
should reuse the `CreditNote` shape from [18_returns_claims.md](../18_returns_claims.md) where
applicable rather than inventing a parallel mechanism.
- Add `FK_OrderId` to `Models/LoadingSheet.cs` (or equivalent) and change
`LoadingSheetService`/`LoadingSheetRepository` to generate a sheet from a PO's `OrderItem` lines
directly, replacing `GetAggregatedOrderItemsByRouteIdAsync`'s route-based aggregation — wire this
call into the new PO-confirm action.
- A new PO-creation path independent of `SaleService.CreateAsync`, to support creating an
`Order`/`Invoice` before any `Sale` exists (Impact item 1) — needs its own design, not specified
here.
- Resolve Open Question 2 (one Invoice per PO) before finalizing the `FK_OrderId` uniqueness
constraint on `Invoice`; resolve Open Questions 7–8 (LoadingSheet-to-Ride matching, and re-sync on
post-confirm PO edits) before wiring the PO-confirm action into `LoadingSheetService`.
@@ -0,0 +1,240 @@
# [Refactor] Ride Lifecycle — Route-Free, Type-Free Rides
**Status**: Proposed (design only — no code changes yet)
**Refactors**: [../03_ride_lifecycle.md](../03_ride_lifecycle.md) (kept as-is, this is the target design)
**Depends On**: 02 — Stock Ledger
---
## Goal
Ride should not require a `Route`, should not carry a `RideType`, should not be gated by a warehouse's `Type`, and should not even pin down a source warehouse on the ride record itself. Per direction from the business owner, the ride record carries only what a real dispatch needs to track — **"a ride only carries vehicle, driver assign, and meter reading from start to end."** Everything route-shaped (which stores get visited, which orders/items travel on this trip) and everything warehouse-shaped (where stock loads from/returns to) moves out of the Ride entity's responsibility entirely; neither is replaced by another matching mechanism here.
This is a genuine pivot from [03_ride_lifecycle.md](../03_ride_lifecycle.md), where `Route` and `FK_SourceWarehouseId` are both **required** on every ride, and `Route` is the *sole* mechanism two other features use to resolve "which orders belong to this ride." See [Impact on Dependent Features](#impact-on-dependent-features) below — those gaps are flagged, not solved, in this pass. The Draft → Open → Active → Closed workflow and the CancelRequested/approval flow from `03_ride_lifecycle.md` are **not** kept as-is: per further direction from the business owner, the workflow collapses to three stages — **Open, Cancelled, Closed** — and cancellation happens directly with no supervisor approval. See [State Machine](#state-machine).
---
## Data Model Changes
### Updated Entity — Ride (`DeliveryRun`)
```
Ride (DeliveryRun)
├── Id (Guid) — PK, from Template
├── FK_VehicleId (Guid) — required
├── FK_DriverProfileId (Guid) — required
├── Meterstart (int)
├── Meterend (int)
├── Status (RideStatus) — Open | Cancelled | Closed
├── StartedAt (DateTime?) — set on create, ride starts Open immediately
├── ClosedAt (DateTime?) — set when Open → Closed
├── CancelTypeCode (CancelRequestType?) — set when Open → Cancelled
├── CancelReason (string?) — set when Open → Cancelled
├── CancelledAt (DateTime?) — set when Open → Cancelled
├── IsActive, CreatedAt — from Template
```
That's the whole record: a vehicle, a driver, a meter start/end reading, a status, and the
workflow/cancellation timestamps. Everything else is removed:
- **`FK_RouteId`** — gone (see the original goal above; unchanged from the first pass of this doc).
- **`RideType`** — gone. Nothing in the Ride record itself needs to distinguish Delivery from Sale
once the ride no longer drives order/task auto-population (that was the only place `RideType`
mattered inside the Ride entity's own logic — see
[Impact on Dependent Features](#impact-on-dependent-features)).
- **`ActivatedAt`** — gone. There is no separate Draft or Active stage anymore (see
[State Machine](#state-machine)), so there's no "activation" event to timestamp.
- **`FK_SourceWarehouseId`** — gone. The ride record no longer pins down a warehouse to load from
or reconcile stock against at all. `Warehouse.Type` accordingly becomes moot for rides (there's no
ride-side warehouse selection left to gate); the enum itself is untouched on `Warehouse`.
**`Status` (`RideStatus`) is kept, just narrowed.** The original workflow (Draft, Open, Active,
CancelRequested, Cancelled, Closed) collapses to three stages — **Open, Cancelled, Closed** — per
direction from the business owner: no separate setup/activation stages, and cancellation needs no
supervisor approval. With only three stages, storing `Status` directly is simple again, so this
document no longer needs to resolve "how is current state tracked without a stored enum" (see
[Open Questions](#open-questions), item 4 — now resolved).
### Warehouse
No changes to the `Warehouse` entity — `Warehouse.Type` (`WareHouseType`: `DistributionCenter`,
`FulfillmentCenter`, `CrossDocking`, `ColdStorage`, `BondedWarehouse`, `AutomatedWarehouse`) stays
exactly as-is; it's still useful for warehouse management/reporting elsewhere in the system. What's
different from [03_ride_lifecycle.md](../03_ride_lifecycle.md) is that **rides no longer reference
a warehouse at all**, so there's nothing on the ride side left to filter or gate by `Type` — this
supersedes the first pass of this document, which kept `FK_SourceWarehouseId` and only dropped the
type constraint on it. See [Impact on Dependent Features](#impact-on-dependent-features) for what
that means for stock reconciliation, which previously depended on `FK_SourceWarehouseId`.
---
## State Machine
The workflow is simplified from [03_ride_lifecycle.md](../03_ride_lifecycle.md#state-machine) to
three stages, per direction from the business owner: no Draft or Active setup stages, and
cancellation needs no supervisor approval — a driver/dispatcher cancels directly.
```
Open → Closed
Open → Cancelled
```
| Transition | Trigger | What it does |
|---|---|---|
| (create) → Open | `POST /api/rides` | Validates driver compliance. Checks vehicle has no other Open ride. Ride is created directly in `Open` status with `StartedAt` set — no separate "open" step. |
| Open → Closed | `POST /api/rides/{id}/close` | Called after ride reconciliation. Sets `ClosedAt`, `Status = Closed`. Previously required "all stock returned to source warehouse" — with no `FK_SourceWarehouseId` on the ride, this check has nothing to reconcile against; see [Impact on Dependent Features](#impact-on-dependent-features). |
| Open → Cancelled | `POST /api/rides/{id}/cancel` | Driver/dispatcher cancels directly with a type code and reason. No approval step. Sets `CancelTypeCode`, `CancelReason`, `CancelledAt`, `Status = Cancelled`. Previously required stock returned to source warehouse first — same reconciliation gap as Close above. |
---
## Business Rules
Carried over from [03_ride_lifecycle.md](../03_ride_lifecycle.md#business-rules), with the
route-dependent rules removed:
1. A ride cannot be created (→ Open) if `IsActive = false` on the driver profile, or (when the
relevant `DriverComplianceConfig` flags are enabled) if license/medical clearance is expired.
2. A vehicle cannot be on more than one Open ride at a time.
3. A cancel action must include a `TypeCode` — free text reason alone is not sufficient.
4. All list endpoints return paginated responses.
5. **A ride carries no order, task, route, or warehouse linkage of its own.** Creating a ride never
touches `Order`, `DeliveryTask`, `LoadingSheet`, or `StockBalance`/`StockMovement` records —
those remain entirely separate concerns, attached (if at all) by whatever future mechanism
replaces route-derived linkage and warehouse reconciliation.
**Removed rules** (no longer apply once `Route`, `RideType`, `FK_SourceWarehouseId`, Draft/Active
stages, and the CancelRequested approval flow are gone):
- ~~"A ride uses a single route for now."~~
- ~~"Orders can only be assigned to a Delivery ride while it is in Draft status."~~ — there is no
order-assignment mechanism on the ride at all anymore, and there is no Draft stage.
- ~~"Loading and unloading warehouse must be the same — enforced at close/cancel."~~ — there is no
warehouse on the ride record to compare "loading" against "unloading" anymore. See
[Impact on Dependent Features](#impact-on-dependent-features).
- ~~"A CancelRequested ride cannot close or activate until the request is resolved."~~ — there is no
CancelRequested stage or approval step; cancellation is immediate.
---
## API Endpoints
Same routes as [03_ride_lifecycle.md](../03_ride_lifecycle.md#api-endpoints), with `routeId`,
`rideType`, and `sourceWarehouseId` removed from the request/response shapes:
| Method | Route | Description |
|---|---|---|
| POST | `/api/rides` | Create ride: `{ vehicleId, driverProfileId }`. Ride starts `Open` immediately — no separate open step. |
| GET | `/api/rides` | List rides (paginated). Filter by driverProfileId, vehicleId, status (`Open`/`Cancelled`/`Closed`). |
| GET | `/api/rides/{id}` | Get ride detail — response no longer includes `routeId`/`routeName`, `rideType`, or `sourceWarehouseId`/`sourceWarehouseName`. |
| PUT | `/api/rides/{id}/driver` | Assign or change driver profile |
| POST | `/api/rides/{id}/close` | Transition Open → Closed |
| POST | `/api/rides/{id}/cancel` | Cancel an Open ride directly: `{ typeCode, reason }`. Transitions Open → Cancelled immediately, no approval step. |
| GET | `/api/rides/breakdown-assist/{rideId}` | Returns other active rides' vehicle stock levels only. No longer returns an open-orders listing — see [Impact on Dependent Features](#impact-on-dependent-features). |
`DriverComplianceConfig` and `DriverProfile` endpoints are unaffected by this refactor.
---
## Impact on Dependent Features
Removing `FK_RouteId`, `RideType`, and `FK_SourceWarehouseId` from `Ride` (and collapsing `Status`
to three values with direct cancellation) is not a purely additive/subtractive field change —
several existing behaviors currently derive their entire input from the removed fields, and none of
them get a replacement in this pass. Each is flagged here as **known-broken, deferred follow-up
work**, not solved:
1. **Delivery task generation** (`Features/DeliveryRide/DeliveryTaskService.GenerateAsync`) —
currently calls `orderRepo.GetPendingByRouteIdAsync(ride.FK_RouteId)` to auto-create delivery
tasks for a Delivery ride. With no route (and no `RideType` to even tell Delivery from Sale) on
the ride, this call has nothing to key off. Needs a redesign once "how orders attach to a ride"
is decided — out of scope here. Note: `Order` already carries a direct `FK_StoreId`
(`Distribution/Models/Order.cs`), independent of any Route — the current route-based queries only
reach it indirectly via `Store.FK_RouteId`. Whatever mechanism replaces route-derived linkage can
key off `Order.FK_StoreId` directly; Route is confirmed unnecessary here, not a gap that needs
another route-shaped mechanism to fill.
2. **Loading sheet auto-population** (`Features/LoadingEvent/LoadingSheetService` +
`LoadingSheetRepository.GetAggregatedOrderItemsByRouteIdAsync`) — auto-fills a Delivery ride's
loading-sheet items by aggregating orders on `Order.Store.FK_RouteId == ride.FK_RouteId`. Same
gap as above; **resolved in [loading_event.md](./loading_event.md)**, which generates a
`LoadingSheet` per PO instead (see [order_invoice_lifecycle.md](./order_invoice_lifecycle.md)).
3. **Breakdown-assist endpoint** (`RideService.GetBreakdownAssistAsync`) — previously used
`strandedRide.FK_RouteId` to list the stranded route's open orders for manual reassignment. Per
direction from the business owner, this listing is **removed outright**, not redesigned to scope
some other way — the endpoint now only returns other active rides' vehicle stock levels.
Reassigning the stranded ride's orders is left entirely to whatever future order-assignment
mechanism is designed (see item 1 and [Open Question 1](#open-questions)).
4. **Stock reconciliation at Close/Cancel** — [03_ride_lifecycle.md](../03_ride_lifecycle.md) closes
or cancels a ride only once "all stock is returned to source warehouse," checked against
`FK_SourceWarehouseId`. With no warehouse on the ride, there is nothing to reconcile the vehicle's
stock balance against at all. This is the biggest open gap in this pass — closing/cancelling a
ride previously guaranteed the vehicle ends up empty (stock-wise); without a target warehouse,
that guarantee has no mechanism to enforce it. Needs its own design — not attempted here.
5. **[05_delivery_ride.md](../05_delivery_ride.md)** describes the whole order-to-ride relationship
as `Ride → Route → Stores → Pending Orders`. That document goes stale the moment this refactor
ships and needs its own rewrite — tracked as follow-up, not attempted in this pass.
6. **[04_loading_event.md](../04_loading_event.md)**'s loading-event flow currently records which
warehouse stock was loaded from via the ride's `FK_SourceWarehouseId`; without that field, each
loading event presumably needs to carry its own warehouse reference directly instead of inheriting
it from the ride — **resolved in [loading_event.md](./loading_event.md)**: `LoadingSheet` already
carries its own `FK_SourceWarehouseId` independent of `Ride`, set at the new assign-ride step.
None of the above are implemented or redesigned by this document. They're called out so the gap is
visible and intentional rather than discovered later as a silent regression.
---
## Open Questions
1. **How does a Delivery ride's orders/tasks/items get attached, if not via Route?** — Still
unresolved, but partially constrained: `Order` already has a direct `FK_StoreId`
(`Distribution/Models/Order.cs`) independent of Route, so a future attachment mechanism can key
off Store directly without reintroducing Route anywhere. The exact mechanism (manual dispatcher
assignment, an implicit link at task-creation time, or something else) is still a separate design
effort, not part of slimming down the Ride record.
2. ~~**What happens to `GetBreakdownAssistAsync`'s route-scoped order listing?**~~ — **Resolved:**
removed outright, not redesigned. See [Impact on Dependent Features](#impact-on-dependent-features)
item 3.
3. **`04_loading_event.md` / `05_delivery_ride.md` rewrite** — scheduled as follow-up work once (1)
is resolved; explicitly out of scope for this document.
4. ~~**How is a ride's current workflow stage tracked without a stored `Status` column?**~~ —
**Resolved:** the workflow collapses to three stages (Open, Cancelled, Closed — see
[State Machine](#state-machine)), simple enough that `Status` is kept as a stored `RideStatus`
column again.
5. **What replaces stock-return enforcement at Close/Cancel without `FK_SourceWarehouseId`?** — See
[Impact on Dependent Features](#impact-on-dependent-features) item 4. Not designed here.
6. **Does `RideType` (Delivery vs Sale) need to exist somewhere else** — e.g. on whatever
loading-event or order-linkage mechanism eventually replaces the route-derived flow — even though
it's removed from `Ride` itself? Not decided here.
---
## Migration Notes (for when this is implemented)
Not part of this docs-only pass, but captured for the eventual implementation step:
- Drop the `FK_RouteId`, `RideType`, `ActivatedAt`, and `FK_SourceWarehouseId` columns from
`DeliveryRun`/`Ride` and their Fluent/EF configuration. `Status` stays, re-typed to the narrowed
`RideStatus` (`Open`/`Cancelled`/`Closed`). Add `CancelTypeCode`, `CancelReason`, `CancelledAt`
columns to `DeliveryRun`.
- Drop the `RideCancelRequest` table, its Fluent/EF configuration, and
`RideCancelRequestRepository`/`IRideCancelRequestRepository` — the request/approve/reject flow is
gone.
- Remove `RouteId`, `RideType`, and `SourceWarehouseId`/`SourceWarehouseName` from
`CreateRideRequest`, `RideResponse`, and `BreakdownAssistRideResponse` in `RideDTOs.cs`. Remove the
open-orders list from `BreakdownAssistResponse`. Remove the `RaiseCancelRequest`/cancel-request
response DTOs; add a simple `CancelRideRequest { TypeCode, Reason }`.
- Remove the `.Include(r => r.Route)`/`.Include(r => r.SourceWarehouse)` calls and
`GetActiveOrdersByRouteIdAsync` usage in `RideRepository.cs`/`RideService.cs` — the breakdown-assist
order listing is removed, not replaced.
- Remove the `RaiseCancelRequestAsync`, `GetCancelRequestsAsync`, `ApproveCancelAsync`,
`RejectCancelAsync`, `GetCancelRequestAsync` methods from `RideService.cs` and the corresponding
`/cancel-request`, `/cancel-requests`, `/cancel-requests/{id}/approve`,
`/cancel-requests/{id}/reject`, `/{id}/cancel-request` routes from `RideController.cs`; add a
single `CancelAsync`/`POST /api/rides/{id}/cancel` in their place.
- Remove the `/open` and `/activate` transition endpoints/methods — ride creation sets `Status = Open`
directly.
- Remove the required `Route` and `Source Warehouse` `<Select>` fields (and the `RideType` picker)
from `ride-sheet.tsx` (frontend create form).
- `DeliveryTaskService.GenerateAsync` and `LoadingSheetService`'s route-based aggregation need a
redesign or removal before this ships — this is the load-bearing blocker, not a cleanup item.
- Resolve Open Question 5 (stock-return enforcement without a source warehouse) before touching
`Close`/`Cancel` logic in `RideService.cs`.
+55 -14
View File
@@ -6,20 +6,9 @@ Running log of backend API failures found during testing and the corresponding f
| # | Area | Backend API Failure | Frontend Change Needed | Date |
|---|------|----------------------|--------------------------|------|
| 1 | Operations → Rides (Create Ride) | On create (not save) | Add a View icon to the action column in Operations → Rides table | 2026-09-01 |
| 2 | Operations → Loading Sheets | | Add a View icon to the action column in Operations → Loading Sheets table | 2026-09-01 |
| 3 | Sales (Create Sale) | Sale create not working | Update the Create Sale modal UI |Sale summary calculation not display | Add view icon to the action | 2026-09-01 |
| 4 | Orders | | Add a View icon to the action column in Orders table | 2026-09-01 |
| 5 | Payments (Record Payment) | Cannot add new payment | Add a View icon to the action column in Payments table | 2026-09-01 |
| 6 | Sales → Visits (Route dropdown) | Selecting a specific route in the route dropdown does not filter route sales — all routes still show; check-in also not working | Fix route filter and check-in on Sales Visits page | 2026-09-02 |
| 7 | Catalogue → Categories (Subcategories) | Added subcategories don't display in the FE | Fix subcategory list rendering | Add a View icon to the action column in Categories table | 2026-09-02 |
| 8 | Stock Balances | | Add a View icon to the action column in Stock Balances table; allow Edit | 2026-09-02 |
| 9 | Stock Movements | Some rows missing From/To locations — every movement should always have both | Add a View icon and an Edit icon to the action column in Stock Movements table | 2026-09-02 |
| 10 | Procurement → Orders (Place Order) | Placing a new order throws: `var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();` | Add action icons to the action column in Procurement Orders table | 2026-09-02 |
| 11 | Procurement → GRN | | Full module CRUD check pending (CRUD + action icons) | 2026-09-02 |
| 12 | Pricing | | Full module CRUD check pending (frontend + CRUD + action icons) | 2026-09-02 |
| 11 | Procurement → GRN | Record Receipt throws `var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();` (same #28 pattern, not yet given a temp workaround) | Added a View icon (see Resolved #44). No Edit/Delete — a GRN has no backend update/delete endpoint by design, since it already wrote stock balance + a `PurchaseReceipt` movement + the Po's `ReceivedQuantity` on create; reversing one safely needs a dedicated endpoint, not a plain CRUD delete. Still pending: the broken-auth throw on Record Receipt | 2026-09-02 |
| 13 | Pricing → Schemes (Add Scheme modal) | | Add Scheme modal should show a rule-type-specific "value" input field that changes based on the selected Scheme Name/Rule Type (currently no dynamic value field for the chosen rule type) | 2026-09-02 |
| 14 | Suppliers | | Add a View icon and a Cancel icon to the action column in Suppliers table | 2026-09-02 |
| 15 | Driver Profiles | Delete doesn't work | Add a View icon to the action column in Driver Profiles table | 2026-09-02 |
| 16 | Users | Delete action not working | Add View icon | 2026-09-02 |
| 17 | Users → Roles | | For now, hide the Add Role icon and the Edit/Cancel (deactivate) icons in the action column; keep only the View icon | 2026-09-02 |
@@ -28,12 +17,64 @@ Running log of backend API failures found during testing and the corresponding f
| 20 | Network → Routes | Inactive status doesn't work properly | Check Manage Owner and Manage Stores actions; add View icon to the action column; check Cancel action too | 2026-09-02 |
| 21 | Network → Vehicles | | Add a View icon to the action column; check Cancel action | 2026-09-02 |
| 22 | Network → Warehouses | Delete doesn't work | Add a View icon to the action column; check Inactive status | 2026-09-02 |
| 25 | Orders (CRUD gap) | Create/Update/Delete endpoints exist and work | Orders page is read-only in the frontend — only list/get are wired up; add Create/Update/Delete UI | 2026-09-02 |
| 26 | Pricing → Schemes (unused capability) | Eligibility check, spend/ROI analytics, and apply-scheme-to-order endpoints all exist | None of these are wired into the frontend — only basic scheme CRUD is; add UI for eligibility check, spend/ROI view, and applying a scheme to an order | 2026-09-02 |
| 27 | Misc missing Delete actions | Delete/soft-delete endpoints exist for Users, Stores, Price Lists, and Suppliers | No Delete UI wired up for these on their respective pages | 2026-09-02 |
| 28 | Auth (root cause of #10 and likely others) | The actually-registered middleware is `Distribution/Middleware/AuthMiddleware.cs` (`app.UseMiddleware<AuthMiddleware>()` in Program.cs) — a plain, symmetric-key JWT check, NOT the RSA-based `Infra/Authorization/Middleware/AuthMiddleware.cs`/`JwtMiddleware.cs`, which despite their doc comments claiming to be "reference-only," are actually unregistered/dead. The active middleware reads `config["Jwt:Key"]`, but `appsettings.json` only defines `Jwt:PublicKey` (RSA XML, used by AuthHex-issued tokens) — so `Jwt:Key` is always null, the whole validation block is skipped, and `context.User` is never set for ANY request, valid token or not. It also never attaches `role_id`/`role_level`/`username` claims the way the dead Infra middleware does. Net effect: `ICurrentUserService.UserId`/`RoleId` are always null, so every `currentUser.UserId ?? throw new UnauthorizedAccessException()` check fails (this is the actual cause of bug #10, Procurement → Orders) and every permission/role-level check silently fails too | Decision: keep `Distribution/Middleware/AuthMiddleware.cs` (outside Infra) as the one true auth flow going forward; delete the dead unregistered duplicates `Infra/Authorization/Middleware/AuthMiddleware.cs` and `JwtMiddleware.cs`. Then fix the active middleware to validate against `Jwt:PublicKey` (RSA), reject (not silently swallow) invalid/missing tokens, and attach `role_id`/`role_level`/`username` claims from the local User/Role row after validation | 2026-09-02 |
## Resolved
| # | Area | Backend API Failure | Frontend Change Needed | Fixed In | Date |
|---|------|----------------------|--------------------------|----------|------|
| | | | | | |
| 1 | Operations → Rides (Create Ride) | Create Ride failed with "Vehicle not found" | — | Fixed DTO field naming mismatch | 2026-09-02 |
| 29 | Operations → Rides | Cancel-request API routes mismatched | Added View, Edit, and Cancel icons to Rides table | Fixed API routes + added action icons | 2026-09-02 |
| 30 | Operations → Delivery Tasks | Delivery task status update API mismatched | Added View, Confirm Delivery, and Mark as Failed icons | Fixed API routes + added action icons | 2026-09-02 |
| 32 | Sales (Create Sale, van sale) | If any item was short on the vehicle, the whole sale was rejected instead of selling what was available | — | Van sale now sells whatever quantity is on the vehicle per item and is always a clean Completed sale (no partial status) | 2026-09-03 |
| 33 | Operations → Rides (Close Ride) | No validation that meter end >= meter start | Close Ride dialog now enforces meter end >= meter start | Added backend + frontend validation | 2026-09-03 |
| 34 | Sales | — | No action icons on the Sales table | Added View and Cancel icons | 2026-09-03 |
| 2 | Operations → Loading Sheets | — | Missing View icon | Added View icon + details modal (status, dispatch/receipt confirmer + timestamps, item-level dispatched/received/resolved breakdown) | 2026-09-02 |
| 35 | Sales/Orders/Stores (scope-denied paths) | `Forbid()` crashed with `InvalidOperationException: No authenticationScheme was specified...` since no auth scheme is registered in Program.cs | — | Replaced all 6 `Forbid()` calls (SaleController, OrderController, StoreController) with `StatusCode(403)` | 2026-09-03 |
| 36 | Sales (Create Sale) — TEMPORARY workaround | Store-scope check and `currentUser.UserId` were always unresolved due to the broken auth middleware (#28), blocking every sale create | Added a required "Created By" user dropdown to the Create Sale modal | TEMP: bypassed store-scope check and added `CreatedByUserId` to `CreateSaleRequest` so the caller supplies it explicitly. Revert once auth (#28) is fixed | 2026-09-03 |
| 37 | Sales (Create Sale) | Every business-rule failure (store inactive, KYC threshold, price resolution failed, ride not found) silently returned a bare 403 with no explanation | — | Each failure now throws a specific `InvalidOperationException`, surfaced as a clear 400 error message in the create-sale toast | 2026-09-03 |
| 38 | Sales (Create Sale modal) | — | "Sale Summary" total box was frozen at Rs. 0 — `form.watch('items')` called inline doesn't subscribe the component to re-render on field changes | Replaced with `useWatch({ control, name: 'items' })`, which properly re-renders the total as items are entered | 2026-09-03 |
| 39 | Sales (Create Sale) | — | Moved Create Sale from a cramped modal to a dedicated `/sales/new` page: Sale Details and Items are each full-width, Items shown as an invoice-style table, and a compact Sale Summary card is right-aligned below the items | 2026-09-03 |
| 40 | Sales (Cancel Sale) — TEMPORARY workaround | `CancelAsync` still had the same broken store-scope check as Create (#36) — `HasStoreScopeAsync` always false due to broken auth middleware (#28), so cancel silently 404'd | — | TEMP: bypassed store-scope check in `CancelAsync`, same as Create. Revert once auth (#28) is fixed | 2026-09-03 |
| 3 | Sales (Create Sale) | Sale create always failed — `saleDate` was never sent, defaulted to year 0001, no price list ever active that date | Added a required Sale Date field to the Create Sale modal | Sent `saleDate` in the create payload; summary total was already computing correctly, just never reached the backend | 2026-09-03 |
| 6 | Sales → Visits (Route dropdown) | Selecting a specific route in the route dropdown does not filter route sales — all routes still show; check-in also not working | Route filter and check-in now work on the Sales Visits page | Fixed route filter and check-in (see TEMP `CheckedInByUserId` workaround, #40-style, pending real auth) | 2026-09-08 |
| 7 | Catalogue → Categories (Subcategories) | Added subcategories don't display in the FE | Added a View icon to the action column in Categories table | Fixed subcategory list rendering — root cause was `GetAllAsync` returning `CategorySummaryResponse`, which had no `subCategories` field at all | 2026-09-08 |
| 42 | Procurement → Orders (Place Order UI) | — | Moved Create Purchase Order from the `PurchaseOrderSheet` modal to a dedicated `/procurement/orders/new` page, matching the `/sales/new` layout: Order Details full-width card, Items as an invoice-style table, and a right-aligned Order Summary card below | Created `procurement/orders/new/page.tsx`, updated `PurchaseOrdersClient`'s "New Order" button to navigate there, removed the now-unused `purchase-order-sheet.tsx` | 2026-09-08 |
| 43 | Procurement → Orders (action icons) | Cancel API call was broken: frontend sent `PATCH /purchase-orders/{id}/cancel` with body `{cancelReason}`, but the backend only registers `POST .../cancel` and expects `{reason}` — the wrong HTTP verb alone would 405 | Added View (details modal, showing all lines with received/cancelled quantities) and Edit (a `/procurement/orders/[id]/edit` page, enabled only while the order is Issued and matching backend's `UpdatePoRequest` rule that already-received lines are locked). Delete was already present as the Cancel action (soft-cancel with a reason, per the no-hard-delete Implementation Guide rule) — relabeled "Delete order" | Fixed the cancel API client to `POST` with `{reason}` matching `CancelPoRequest`; added `purchaseOrders.update()`; created `PurchaseOrderViewSheet` and the Edit page | 2026-09-08 |
| 44 | Procurement → GRN (action icons) | — | Added a View icon (details modal showing warehouse, received date, and each line's accepted/rejected quantity). No Delete — deliberate, not a gap: a goods receipt already applied its effect (stock balance + `PurchaseReceipt` movement + the Po's `ReceivedQuantity`) at create time, so a generic delete would silently desync stock; reversing one needs a dedicated, explicit endpoint, which hasn't been requested/built | Created `GrnViewSheet`, wired a View row action into `GrnClient` | 2026-09-08 |
| 10 | Procurement (Create Purchase Order + Record Goods Receipt) | Both `POST /purchase-orders` and `POST /goods-receipts` (and the Po-scoped goods-receipts route) had `var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();` — with auth broken (#28) and no global exception handler registered, every Create Order / Record Receipt attempt crashed with an unhandled 500, not even a clean 401. This was a genuine, currently-broken business flow, not just a cosmetic gap | Added a "Created By" dropdown to `/procurement/orders/new` and a "Received By" dropdown to the Record Receipt (GRN) sheet | TEMP: added `CreatedByUserId` to `CreatePoRequest` and `ReceivedByUserId` to `CreateGrnRequest`/`CreateFlatGrnRequest`, same `currentUser.UserId ?? request.XxxUserId` fallback pattern as #36/#40/#8, with a clean `BadRequest` instead of the unhandled crash | 2026-09-08 |
| 23 | Returns & Claims Management | `ReturnController`, `ClaimController`, `SupplierClaimController` all exist and are fully implemented, but `Create`/`Settle`/`Approve`/`Reject` on all three had the same unhandled `?? throw new UnauthorizedAccessException()` crash as #10; `ClaimController` and `ReturnController` also had no `GetAll` endpoint at all, only per-order/per-agency lookups, so there was no way to list existing records for a page. None of the three had an Update/edit endpoint either | No frontend integration existed at all — built a new "Returns & Claims" nav group with 3 dedicated pages: **Order Returns** (`/returns-claims/order-returns` — list + View + Edit + a Record Return dialog that only allows returning items the selected order actually has, mode CreditDeduction/ItemExchange), **Agency Claims** (`/returns-claims/claims` — list + View + Edit (while Open) + Raise Claim + Settle), **Supplier Claims** (`/returns-claims/supplier-claims` — list + View + Edit (while Open) + Raise Claim scoped to a PO's received items + Approve/Reject). Edit scope per module: Order Return quantities/mode are applied to stock and the order total immediately on create with no approval step, so only each line's **reason code** is editable, not quantity/mode/items (same immutable-effect principle as Stock Movements, #9); Claims and Supplier Claims have no side effect until Settle/Approve, so their Details/Items are fully editable while still Open, locked out afterward. Purchase Returns (#24) already lives in-context inside the Purchase Order View sheet and was left as-is | Backend: added `GetAllAsync` to `IReturnRepository`/`IClaimRepository`/`ISupplierClaimRepository` + a `GET` list endpoint on each controller; fixed all 5 broken-auth crash sites (`ReturnController.Create`, `ClaimController.Settle`, `SupplierClaimController.Create`/`Approve`/`Reject`) with the same `CreatedByUserId`/`SettledByUserId`/`RaisedByUserId`/`ApprovedByUserId`/`RejectedByUserId` fallback pattern; added `PATCH /returns/{id}/reasons`, `PUT /claims/{id}` (blocked once Settled), `PUT /supplier-claims/{id}` (blocked once resolved). Frontend: added `saleReturns`/`claims`/`supplierClaims` to a new `lib/api/returns-claims.ts`, plus types in `types/api.ts` | 2026-09-08 |
| 24 | Procurement → Purchase Returns | `PurchaseReturnController.Create` had the same unhandled `?? throw new UnauthorizedAccessException()` crash as #10; also no `GetAll` endpoint exists — a return only ever makes sense in the context of one purchase order (must have a prior GoodsReceipt against it, and quantities are capped per-item at received-minus-already-returned), so there's no global "all returns" listing to build a standalone page around | Built the module in-context: the Purchase Order View sheet now shows a "Purchase Returns" section (reason, date, line items for every return already recorded against that order) plus a "Return Items" button (shown only once the order has any received quantity) that opens a Return dialog — item dropdown capped to each item's real receivable-and-not-already-returned quantity, reason, and a temporary "Created By" user dropdown | TEMP: added `CreatedByUserId` to `CreatePurchaseReturnRequest`, same fallback pattern; added `purchaseReturns` API client (`get`, `listByPurchaseOrder`, `create`); created `PurchaseReturnDialog`, wired into `PurchaseOrderViewSheet` | 2026-09-08 |
| 45 | Pricing → Price Lists (action icons) | `DeleteAsync` did a hard delete (`repo.Remove` → EF `Remove`), violating the no-hard-delete Implementation Guide rule (the repo method itself was already misleadingly named `Remove` but did set `IsActive = false` — the actual bug was `GetAllAsync` filtering `.Where(pl => pl.IsActive)`, hiding a deleted price list from the list entirely instead of showing it as Inactive, and `GetByIdAsync`/`UpdateAsync` blocking view/edit of an inactive one, matching the same recurring pattern as Stores/Items/Categories/Brands/Orders). Also `GetAll` returns a plain `List<PriceListResponse>` but the frontend was configured `serverSide: true` expecting a `PaginatedResponse`, so pagination silently did nothing | Added a View icon (details modal — fetches the full record incl. items via `GET /price-lists/{id}` since the list endpoint returns rows with an empty `items` array) and a Delete icon (soft-deactivate, reusing the existing "Active" checkbox in the Edit sheet to reactivate). Edit was already wired up and already sent `isActive` in its request body — just needed the backend to actually accept it | Removed the `IsActive` filters from `GetAllAsync`/`GetByIdAsync`/`UpdateAsync`; added `IsActive` to `UpdatePriceListRequest`; switched the frontend to client-side pagination (same fix as Orders/Brands) since the backend has no real paging; added `priceLists.remove()`; created `PriceListViewSheet` | 2026-09-08 |
| 46 | Pricing → Price Lists (item pricing) | `PUT /price-lists/{id}/items/{sku}` and `DELETE /price-lists/{id}/items/{sku}` were fully implemented and correctly validated (MinPrice ≤ Price, overlap check against other active lists) but **no frontend code called either endpoint** — the Create/Edit sheet only ever sent `items: []`. So every price list created through the UI was permanently empty, meaning `SaleService.ResolveUnitPriceAsync` could never resolve a real price-list price and always fell back to the flat `Item.SalePrice` — the whole point of the Price Lists module (region/date-scoped item pricing) was unreachable end-to-end | Added an "Items" section to the Edit sheet: a table of already-priced items (inline edit price/min-price, remove) plus an "Add item" row (item picker + price + min price). Since the backend upserts one SKU at a time (not a bulk array), each row change is its own immediate API call, same as the existing GRN/PO-style line-item tables but per-row-saved instead of submitted together. Creating a new price list now keeps the dialog open afterward (switching in place to "edit" mode) so items can be added immediately instead of requiring a reopen | Created `PriceListItemsSection`, embedded in `PriceListSheet` (edit mode only); added `priceLists.upsertItem()`/`priceLists.removeItem()` to the API client | 2026-09-09 |
| 47 | Sales (Create Sale) — wrong price prefilled | Even with #46 fixed, `/sales/new` still prefilled `unitPrice` straight from `item.salePrice` (the flat catalogue price) — no frontend code ever called price-list resolution, so a rep would see the wrong default price for any item actually covered by an active region/date-scoped price list, and the sale would only succeed if they happened to retype the exact resolved price manually. `PriceListService.ResolvePriceAsync` (region price list → global price list → `Item.SalePrice`) existed but was only ever called from inside `SaleService`, with no public endpoint to query it ahead of submission | Selecting an item now calls the new resolution endpoint (store → its route's region, so the exact same lookup `SaleService` uses) and prefills the real resolved price; changing the Store or Sale Date afterward re-resolves every already-selected line, since the applicable price list depends on both. When the resolved price has a `MinPrice` band, a "Negotiable down to Rs. X" hint shows under the field so the rep knows how far they can discount without the submit failing | Added `PriceListService.ResolveByStoreAsync(itemId, storeId, saleDate)` (looks up the store's region via `IStoreRepository.GetByIdWithRegionAsync`, then reuses `ResolvePriceAsync`) and `GET /price-lists/resolve?itemId=&storeId=&saleDate=`; added `priceLists.resolve()` to the frontend API client and a `PriceResolution` type | 2026-09-09 |
| 12 | Pricing → Schemes (action icons) | Same recurring pattern as Price Lists (#45): `DeleteAsync`'s `repo.Remove` already soft-deleted (`IsActive = false`), but `GetAllAsync`/`GetByIdAsync`/`UpdateAsync` filtered/blocked on `IsActive`, hiding a deleted scheme from the list entirely and blocking viewing/editing/reactivating it. `EvaluateEligibilityAsync` relied on that same repo-level filter to only ever consider active schemes as redemption candidates — removing the filter without care would have silently let inactive/deleted schemes be evaluated as eligible. Also `GetAll` returns a plain `List<SchemeResponse>` but the frontend was configured `serverSide: true` expecting a `PaginatedResponse`, so pagination silently did nothing | Added View (details modal — rule type, region, segment, date range, trigger/free-goods payout, bundle components) and Delete (soft-deactivate, reuses the existing "Active" checkbox in the Edit form to reactivate) icons | Removed the `IsActive` filter from `SchemeRepository.GetAllAsync`; moved the active-only filter into `SchemeService.EvaluateEligibilityAsync` itself (`.Where(s => s.IsActive)` on the fetched candidates) so eligibility evaluation is unaffected; removed the `IsActive` blocks from `GetByIdAsync`/`UpdateAsync`; added `IsActive` to `UpdateSchemeRequest`; switched the frontend to client-side pagination (same fix as Price Lists/Orders/Brands); added `schemes.remove()`; created `SchemeViewSheet` | 2026-09-09 |
| 14 | Suppliers (action icons) | Exact same recurring pattern again: `SupplierRepository.GetAllAsync` filtered `.Where(s => s.IsActive)` (hiding a deleted supplier from the list instead of showing it Inactive), `GetByIdAsync`/`UpdateAsync` blocked viewing/editing an inactive one, and `UpdateSupplierRequest` had no `IsActive` field so the Edit form's existing "Active" checkbox couldn't reactivate one. Also `suppliers.list()` was typed as `PaginatedResponse` against a backend `GetAll` that returns a plain array, same pagination-shape bug as Price Lists/Schemes/Orders/Brands | Added View (details modal — name, contact, status, performance score, created date) and Delete (soft-deactivate, reuses the existing "Active" checkbox in Edit to reactivate) icons | Removed the `IsActive` filter from `SupplierRepository.GetAllAsync`; removed the `IsActive` blocks from `GetByIdAsync`/`UpdateAsync` (kept on `EvaluateAsync`, a real business guard — no point recomputing a performance score for a deleted supplier); added `IsActive` to `UpdateSupplierRequest`; switched the frontend to client-side pagination; added `suppliers.remove()`; created `SupplierViewSheet` | 2026-09-09 |
| 48 | Pricing → Price Lists (new fields) | — | Added `RetailPrice` and `AgencyMarginPercent` to `PriceListItem`, alongside the existing `Price`/`MinPrice` band — see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) for the full business rules. `RetailPrice` is the MRP the store is expected to charge the end consumer (must be `>= Price`); `AgencyMarginPercent` is the regional agency's commission `%` of `Price` (must be `0-100`). Both are informational only — carried through `PriceResolution` for display/reporting, never enforced on order pricing | Added both columns to the Items table in the Price List Edit/View pages (add-row + inline edit + view-only table), and a "Retail price: Rs. X" hint under the unit price on Create Sale when the resolved price came from a real price list | Added `RetailPrice`/`AgencyMarginPercent` to `PriceListItem`, migration `AddRetailPriceAndAgencyMargin`; extended `UpsertPriceListItemRequest`/`PriceListItemResponse`/`PriceResolution` with validation (`RetailPrice >= Price`, `AgencyMarginPercent` in `[0,100]`); updated `PriceListItemsSection`, the View page's items table, and `priceLists.upsertItem()`; updated `docs/14_pricing_and_suppliers.md` | 2026-09-09 |
| 8 | Stock Balances | No way to correct a balance — stock is architecturally never edited directly, only through movement transactions (load, sale, return, damage, adjustment); the `POST /stock/adjustment` endpoint existed but its controller action always returned `Unauthorized()` because `currentUser.UserId` is unset (#28) | Added a View icon (details modal) and an Edit icon that opens an "Adjust Stock" dialog (signed quantity delta + reason + Adjusted By) which posts to `/stock/adjustment` — this is the correct way to change a balance, not a direct field edit | TEMP: added `AdjustedByUserId` fallback to `AdjustmentRequest`, same pattern as #36/#40; wired `StockBalanceViewSheet` + `StockAdjustDialog` into Stock Balances table | 2026-09-08 |
| 9 | Stock Movements | Some rows missing From/To locations — every movement should always have both | Added View and Edit icons. Movements are an immutable audit-trail ledger by design — quantity, locations, and type can never change once recorded (the running balance is the sum of all movements), so Edit only lets you correct the Reason/note via a new `PATCH /stock/movements/{id}/reason` endpoint; a wrong quantity/location must be corrected with a new Adjustment (see #8), not by editing history | Wired `StockMovementViewSheet` + `StockMovementEditDialog` into Stock Movements table | 2026-09-08 |
| 41 | Stock Balances / Stock Movements | Balances and Movements tables/view-sheets showed raw location GUIDs instead of names; Movements also had a genuinely missing side — Sale/PurchaseReceipt/PurchaseReturn/CustomerReturn/Damage/Adjustment each only ever wrote one of From/To, since the other side (customer, supplier, write-off, adjustment) has no real Warehouse/Vehicle record. Existing seeded movement rows still showed blank From/To after the code fix since it only affects newly-created movements | — | Added `LocationName` to `StockBalanceResponse` and `FromLocationName`/`ToLocationName` to `StockMovementResponse`, resolved server-side (`StockService`) by looking up the real Warehouse/Vehicle. Added a new `LocationType.External` plus a fixed `Guid.Empty` sentinel location so every movement's missing side is now always populated and labeled contextually ("Customer", "Supplier", "Written Off", "Stock Adjustment") instead of being left null. Updated all 6 movement-creation call sites (StockService Damage/CustomerReturn/Adjustment, SaleService, DeliveryTaskService, GrnService, PurchaseReturnService, ReturnService), and added migration `FixSeedStockMovementExternalLocations` to backfill the 2 affected seeded rows | 2026-09-08 |
## Future Fixes — Pending Real Auth
Everything below is a stopgap that only exists because `Distribution/Middleware/AuthMiddleware.cs` (see #28) never actually authenticates a request — `currentUser.UserId`/`RoleId`/`RoleLevel` are always unset. Once #28 is properly fixed (validate against `Jwt:PublicKey` via RSA, reject bad/missing tokens instead of silently skipping validation, and attach `role_id`/`role_level`/`username` claims from the local User/Role row), all of the following must be reverted/re-enabled:
1. **`SaleService.CreateAsync`** — restore the `HasStoreScopeAsync(request.StoreId)` check that was bypassed in #36.
2. **`SaleService.CancelAsync`** — restore the `HasStoreScopeAsync(sale.FK_StoreId)` check that was bypassed in #40.
3. **`CreateSaleRequest.CreatedByUserId`** (`SaleDTOs.cs`) — remove this field; `CreateAsync` should go back to using `currentUser.UserId` only (drop the `?? request.CreatedByUserId` fallback and the `InvalidOperationException` guard around it).
4. **Create Sale page** (`/sales/new`) — remove the "Created By" user dropdown and its form field/validation entirely; the acting user should come from the session, not be picked manually.
5. **Sweep for the same pattern elsewhere** — `HasStoreScopeAsync`/`HasGlobalScope`-style checks exist in other services too (Order, Store, etc., per #35's affected controllers) and likely have the same silent-fail behavior; audit and fix once auth is real, not just the Sales module.
6. **Re-verify `StatusCode(403)` usages from #35** — once real authentication exists, confirm whether any of those 6 call sites should become genuine ASP.NET `[Authorize]`-driven `403`/`401` responses instead of manual scope checks.
7. **`AdjustmentRequest.AdjustedByUserId`** (`StockDTOs.cs`) and the "Adjusted By" dropdown in the Adjust Stock dialog (#8) — remove once `StockController.Adjust` can resolve `currentUser.UserId` on its own.
8. **`CreatePoRequest.CreatedByUserId`** (`PoDTOs.cs`) and the "Created By" dropdown on `/procurement/orders/new` — remove once `PoController.Create` can resolve `currentUser.UserId` on its own.
9. **`CreateGrnRequest.ReceivedByUserId`/`CreateFlatGrnRequest.ReceivedByUserId`** (`GrnDTOs.cs`) and the "Received By" dropdown in the Record Receipt sheet — remove once `GrnController.Create`/`CreateFlat` can resolve `currentUser.UserId` on its own.
10. **`CreatePurchaseReturnRequest.CreatedByUserId`** (`PurchaseReturnDTOs.cs`) and the "Created By" dropdown in the Return Items dialog (#24) — remove once `PurchaseReturnController.Create` can resolve `currentUser.UserId` on its own.
11. **`CreateSaleReturnRequest.CreatedByUserId`** (`ReturnDTOs.cs`) and the "Created By" dropdown in the Record Order Return dialog (#23) — remove once `ReturnController.Create` can resolve `currentUser.UserId` on its own.
12. **`SettleClaimRequest.SettledByUserId`** (`ClaimDTOs.cs`) and the "Settled By" dropdown in the Settle Claim dialog (#23) — remove once `ClaimController.Settle` can resolve `currentUser.UserId` on its own.
13. **`CreateSupplierClaimRequest.RaisedByUserId`/`ApproveSupplierClaimRequest.ApprovedByUserId`/`RejectSupplierClaimRequest.RejectedByUserId`** (`SupplierClaimDTOs.cs`) and the corresponding user dropdowns in the Raise/Approve/Reject Supplier Claim dialogs (#23) — remove once `SupplierClaimController.Create`/`Approve`/`Reject` can resolve `currentUser.UserId` on their own.
## Implementation Guide