refactor invoice and po life cycle

This commit is contained in:
Kalana B. Thilakarathna
2026-09-16 10:39:42 +05:30
parent 85a16396aa
commit 308be2f5ce
4 changed files with 748 additions and 0 deletions
@@ -0,0 +1,195 @@
# [Refactor] Dispatch Pipeline — Shop PO to Invoice Finalization
**Status**: Proposed (design only — no code changes yet)
**Depends On**: [order_invoice_lifecycle.md](./order_invoice_lifecycle.md), [loading_event.md](./loading_event.md), [ride_lifecycle.md](./ride_lifecycle.md)
---
## Goal
Define the end-to-end order processing pipeline from a shop's initial PO through to invoice
completion. This replaces the separate Delivery ride and Sale ride flows
([05_delivery_ride.md](../05_delivery_ride.md), [06_sale_ride.md](../06_sale_ride.md)) with a
single unified pipeline.
---
## Pipeline Overview
```
[Stage 1] Shop PO created (editable)
↓
[Stage 2] PO Confirmed + Invoice Created → Draft (Mode A: combined / Mode B: separate)
↓
[Stage 3] Invoice Printed → agent takes to field
↓
[Stage 4] Loading Sheet Population (dispatcher-triggered, separate flow)
↓
[Stage 5] Delivery (TBD)
↓
[Stage 6] Agent returns printed invoice → office worker enters edits → Invoice Finalized
```
---
## Stage 1 — Shop PO
**Business term**: Shop PO
**System state**: `Order` (no Invoice yet)
The entry point to the pipeline. A sales agent creates a PO for a shop capturing line items and
quantities.
**Key rules:**
- Items editable — every edit soft-deletes current `OrderItem` rows (`IsActive = false`) and writes
a fresh set. `IsActive = true` = latest version; `IsActive = false` = history.
- Editing allowed only while Shop PO (before confirmation).
- No Invoice exists yet at this stage.
**API:**
| Method | Route | Description |
|---|---|---|
| POST | `/api/orders` | Create Shop PO for a store. |
| PUT | `/api/orders/{id}/items` | Rewrite line items (versioned). |
| GET | `/api/orders/{id}` | Get PO with current active items. |
---
## Stage 2 — Confirmed PO + Invoice Created
**Business term**: Confirmed PO
**System state**: `Order` (items locked) + `Invoice` at `Draft`
PO items are locked and an Invoice is created at `Draft`. How this happens depends on the active
mode — controlled by the system-level `InvoiceCreationMode` config flag.
**Mode A — combined action (one button):**
- `POST /api/orders/{id}/confirm-and-create-invoice`
- Locks PO items and creates Invoice at `Draft` atomically.
**Mode B — separate actions:**
- `POST /api/orders/{id}/confirm` — locks PO items only.
- Office user then browses Confirmed POs (filtered by `agentId`, `routeId`, `shopId`) and creates
the invoice: `POST /api/invoices { orderId }`.
**Key rules:**
- Confirmation requires no signature or OTP.
- `OrderItem` lines are permanently locked after confirmation.
- `Order.Status` (fulfillment tracking) is untouched — separate axis.
---
## Stage 3 — Invoice Printed
**Trigger**: `POST /api/invoices/{id}/print`
Office user prints the invoice. The agent takes the physical copy to the field.
**Key rules:**
- Agent may make handwritten edits on the printed copy during the ride (quantity adjustments,
returns, damage, discounts).
- Invoice is locked from system edits between `Printed` and `Finalized`.
---
## Stage 4 — Loading Sheet Population
A dispatcher manually triggers population via `POST /api/loading-sheets/populate` with one or more
filters. The system finds all matching Confirmed POs and aggregates them into **one loading sheet**.
**Filters (at least one required):**
- `routeId` — all Confirmed POs for shops on this route
- `agentId` — all Confirmed POs created by this agent
- `shopId` — all Confirmed POs for this specific shop
**What happens:**
1. System finds all `Order` records matching the filter where items are locked (Confirmed PO)
2. One `LoadingSheet` created at `Planned` status
3. `LoadingSheetItem` rows populated from the active `OrderItem` lines of those POs
4. Each source PO recorded in `LoadingSheetOrder` linking table
5. Each item carries `FK_OrderItemId` pointing back to its source `OrderItem`
**After population — editing:**
- Dispatcher can edit item quantities while the sheet is `Planned` or `Assigned`
- Every edit soft-deletes current `LoadingSheetItem` rows (`IsActive = false`) and writes a fresh
set — same versioning as Shop PO items
**Confirmation and stock movement:**
- Ride is provided at populate time — no separate assign step
- Each item has its own `FK_WarehouseId` — one sheet can load from multiple warehouses
- Dispatcher confirms the sheet (`Planned → Confirmed`) — items frozen
- Stock movements: one debit per item's warehouse, one credit to the ride's vehicle
- Timing: open question (Path A at `Confirmed` vs Path B at `ReceiptConfirmed`)
See [loading_event.md](./loading_event.md) for the full state machine.
---
## Stage 5 — Delivery
**Status**: TBD — to be designed in a subsequent pass.
---
## Stage 6 — Invoice Finalized
**Trigger**: `POST /api/invoices/{id}/complete`
After the ride or day-end, the agent returns the printed invoice to the office. The office worker
enters any edits the agent made into the system and submits.
**What happens:**
- Office worker enters adjustments (quantity changes, returns, damage, discounts) from the paper copy
- `Invoice.TotalAmount` is recomputed and locked
- `Invoice.Status` → `Finalized`
- `FinalizedAt` / `FinalizedBy` set
---
## Stage 3 — Loading Sheet Population
A dispatcher manually triggers population via `POST /api/loading-sheets/populate` with one or more
filters. The system finds all matching Confirmed POs and aggregates them into **one loading sheet**.
**Filters (at least one required):**
- `routeId` — all Confirmed POs for shops on this route
- `agentId` — all Confirmed POs created by this agent
- `shopId` — all Confirmed POs for this specific shop
**What happens:**
1. System finds all `Order` records matching the filter where `Invoice.Status = Draft`
2. One `LoadingSheet` created at `Planned` status
3. `LoadingSheetItem` rows populated from the active `OrderItem` lines of those POs
4. Each source PO recorded in `LoadingSheetOrder` linking table
5. Each item carries `FK_OrderItemId` pointing back to its source `OrderItem`
**After population — editing:**
- Dispatcher can edit item quantities while the sheet is `Planned` or `Assigned`
- Every edit soft-deletes all current `LoadingSheetItem` rows (`IsActive = false`) and writes a
fresh set — same versioning mechanism as Shop PO items
- Active rows (`IsActive = true`) = latest version; soft-deleted = history
**Confirmation and stock movement:**
- Dispatcher assigns a ride + source warehouse (`Planned → Assigned`)
- Dispatcher confirms the sheet (`Assigned → Confirmed`) — items frozen
- Stock movement timing is an open question — see [loading_event.md](./loading_event.md) Open Question 1
See [loading_event.md](./loading_event.md) for the full loading sheet state machine and dispatch/receipt flow.
---
## Stage 4+ — Delivery and Invoice Finalization
**Status**: TBD — to be designed in a subsequent pass.
---
## Open Questions
1. **Who can confirm a Shop PO?** — the creating sales agent, a supervisor, or either?
2. **Can a Confirmed PO be reverted to Shop PO?** — no un-confirm path designed here.
3. **Cancellation path for a Shop PO or Confirmed PO?** — not addressed here.
4. **Exact adjustment payload for `POST /complete`** — the fields the office worker can edit
(returns, damage, discounts) are assumed but not fully specified.
5. **Does Stage 4 (loading sheet) depend on Stage 3 (invoice printed), or can they happen in
parallel?** — not decided here.
@@ -0,0 +1,289 @@
# [Refactor] Loading Event — PO-Generated, Route-Free, No Ride Activation
**Status**: Proposed (design only — no code changes yet)
**Refactors**: [../04_loading_event.md](../04_loading_event.md) (kept as-is, this is the target design)
**Depends On**: [ride_lifecycle.md](./ride_lifecycle.md), [order_invoice_lifecycle.md](./order_invoice_lifecycle.md)
---
## Goal
Bring `04_loading_event.md` in line with the other two refactors:
1. **No more `Open -> Active`.** [ride_lifecycle.md](./ride_lifecycle.md) collapses `Ride.Status` to
`Open`/`Cancelled`/`Closed` — there is no `Active` stage left to auto-trigger. Confirming a
loading event no longer changes `Ride.Status` at all; the ride simply stays `Open` through
loading, delivery, and everything else, until it's explicitly `Closed` or `Cancelled`.
`LoadingSheet.Status` tracks loading progress entirely on its own.
2. **No more Route or `RideType`-based item population.** `04_loading_event.md`'s "Delivery rides
auto-populate from the ride's route" no longer applies — `Ride` no longer carries a route
([ride_lifecycle.md](./ride_lifecycle.md)) and no longer distinguishes Delivery/Sale. Instead, per
[order_invoice_lifecycle.md](./order_invoice_lifecycle.md), a `LoadingSheet` is generated
automatically from a single PO's items the moment that PO is confirmed (`Invoice` Pending →
Draft) — before any ride exists.
3. **A `LoadingSheet` now starts ride-less.** It's created PO-scoped
(`FK_OrderId`), not ride-scoped, and gets attached to an actual `Ride` + source warehouse later,
via a new explicit action, once a vehicle/trip is planned for it. The rest of the
dispatch/receipt/discrepancy workflow is unchanged in shape — it just now runs per PO-generated
sheet instead of per ride-created sheet.
---
## Background / Context
Same physical mechanics as `04_loading_event.md`: a vehicle is a location with
`LocationType.Vehicle` in the stock ledger, and confirming receipt (or resolving a discrepancy)
creates the same two stock movements per item — debit source warehouse, credit vehicle. None of that
changes here. What changes is *when a `LoadingSheet` comes into existence* and *what it takes to
start the dispatch/receipt workflow on it*.
---
## Data Model Changes
### Updated Entity — `LoadingSheet`
| 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. |
| 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 |
### 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
}
```
`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.
---
## State Machine
```text
Planned -> Confirmed -> [Path A: stock moves here]
\-> [Path B: 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. |
---
## Item Population
Replaces `04_loading_event.md`'s "Item Population by Ride Type" section entirely.
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.
---
## 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`
---
## API Endpoints
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`. |
---
## 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.
---
## Impact on Dependent Features
1. **`ride_lifecycle.md`'s Impact item 2** (loading sheet auto-population without a route) and
**`order_invoice_lifecycle.md`'s Impact item 6** are addressed by this document: population now
comes from the PO directly, and the ride-attachment gap (`order_invoice_lifecycle.md`'s
[Open Question 7](../refactor_docs/order_invoice_lifecycle.md#open-questions)) is resolved by the
new `Planned -> Assigned` step and `POST /{id}/assign-ride` endpoint.
2. **Dispatcher role/permission is new** — `04_loading_event.md`'s permission table has no
"dispatcher" concept; `assign-ride` needs a new permission (see below), and who is authorized to
perform it (a route/trip planner role?) is not fully specified here.
3. **Multiple sheets per ride** changes how a driver/warehouse experiences dispatch — instead of one
confirm-dispatch per ride, there may now be several, one per assigned PO. UI/UX and any
"ride loading summary" view built against the old one-sheet-per-ride assumption needs rework —
not designed here.
4. **`order_invoice_lifecycle.md`'s Open Question 8** (does editing PO items post-confirm re-sync the
`LoadingSheet`?) is still open here too — this document doesn't resolve what happens to an
`Assigned` or later-stage sheet if its source PO's items change afterward.
---
## Permissions to Seed
| 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 |
---
## Fraud / Control Considerations
Unchanged from `04_loading_event.md`:
- Discrepancies are immediately visible and block further progress.
- `QuantityResolved` is permanently stored per line for audit purposes.
Additionally: `AssignedAt`/`AssignedBy` gives an audit trail for who committed a planned PO load to
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.
---
## Migration Notes (for when this is implemented)
Not part of this docs-only pass, but captured for the eventual implementation step:
- Add `FK_OrderId` (required), `AssignedAt`, `AssignedBy` to `Models/LoadingSheet.cs`; make
`FK_RideId` and `FK_SourceWarehouseId` nullable.
- Replace `LoadingSheetStatus.Pending` with `Planned` and `Assigned`; update all status checks in
`LoadingSheetService`/`LoadingSheetRepository` accordingly.
- Remove `POST /api/loading-sheets` (manual create); wire automatic `LoadingSheet` creation into the
PO-confirm action from [order_invoice_lifecycle.md](./order_invoice_lifecycle.md)'s migration
notes.
- Add `POST /{id}/assign-ride` endpoint/service method, validating the target ride's `Status == Open`
(per [ride_lifecycle.md](./ride_lifecycle.md)'s narrowed `RideStatus`).
- Remove the `confirm-receipt`/`resolve` code paths' calls into ride-activation logic — there is no
`Ride.Status` transition to trigger anymore.
- Remove the ride-route-based item population path entirely (`Order.Store.FK_RouteId` aggregation);
replace with direct `FK_OrderId`-based population at sheet-creation time.
- Seed the new `loading.assign` permission.
- Resolve Open Questions 1 and 4 before finalizing the `assign-ride` endpoint's authorization rules
and the one-sheet-per-PO assumption, respectively.
@@ -0,0 +1,264 @@
# [Refactor] Order/Invoice Lifecycle — Unified PO → Invoice Pipeline
**Status**: Proposed (design only — no code changes yet)
**Refactors**: [../05_delivery_ride.md](../05_delivery_ride.md) and
[../06_sale_ride.md](../06_sale_ride.md) (both kept as-is, this is the target design)
**Related but independent**: [ride_lifecycle.md](./ride_lifecycle.md) — that refactor removes
`RideType`/`Route`/`Status` from `Ride`; this document does not attempt to resolve most of its open
questions, and this document's design does not depend on it landing first. One exception: this
document's automatic `LoadingSheet` creation on PO confirm (see below) partially addresses
`ride_lifecycle.md`'s "loading sheet auto-population" gap — the `LoadingSheet` is now generated from
the PO/Invoice directly, not from a Route. How that `LoadingSheet` later attaches to an actual Ride
(vehicle) is resolved in [loading_event.md](./loading_event.md).
---
## 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.
### PO lifecycle (on `Order`)
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.
---
## Data Model Changes
### Reshaped Entity — `Order` (business term: "PO")
The existing `Order` entity ([Order.cs](../../Models)) is reshaped into this role, keeping its class
name as `Order` in code.
> **Naming note**: `Distribution/Models/PurchaseOrder.cs` and `Distribution/Features/PurchaseOrder/`
> already exist in the codebase — that's the *supplier-facing* procurement flow (the distributor
> buying from its own suppliers), completely unrelated to this store-facing PO. To avoid confusion,
> this document deliberately keeps the class name `Order`, using "PO" only as the business-facing
> term for what the store ordered. Do **not** name the reshaped entity/class `PurchaseOrder`.
```
Order ("PO")
├── Id (Guid) — PK, from Template
├── 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.
│ This tracks fulfillment progress, a separate axis from the new
│ InvoiceStatus below — not merged in this pass.
├── FulfilledAt (DateTime?) — unchanged
├── IsActive, CreatedAt — from Template
```
`OrderItem` (the PO's line items) is unchanged in shape (`FK_ItemId`, `Quantity`,
`QuantityFulfilled`, `UnitPrice`) — editability is now gated by the linked `Invoice`'s status (see
below), not by `Order.Status` directly.
### New Entity — `Invoice`
```
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
├── IsActive, CreatedAt — from Template
```
### New Enum — `InvoiceStatus`
```csharp
public enum InvoiceStatus { Draft, Printed, Finalized } // Finalized is the terminal state
```
### 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.
---
## State Machine
### PO (`Order`) transitions
```
(create) → Shop PO → Confirmed PO
```
| 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. |
---
## 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).
---
## Impact on Dependent Features
Reshaping `Order` and introducing `Invoice` touches several existing behaviors. Each is flagged here
as **known-affected, deferred follow-up work**, not solved in this pass:
1. **`SaleService.CreateAsync`** — currently the only path that creates an `Order`, and it always
sets a non-null `FK_SaleId`. With `FK_SaleId` now optional, a new PO-creation path (not
necessarily originating from a `Sale`) needs to exist for the take-order-now/deliver-later
scenario. Any other code that assumes `Order.Sale` is always non-null also needs auditing —
not enumerated exhaustively here.
2. **`Payment` attachment** — per `06_sale_ride.md`, `Payment` currently attaches to `Sale`. Once
`Invoice` exists as the real billing document, `Payment` more naturally attaches to the `Invoice`
instead. Flagged as a design question for [19_finance_accounting.md](../19_finance_accounting.md)
to reconcile — not decided here.
3. **`19_finance_accounting.md`'s proposed Invoice status** (`Issued, PartiallyPaid, Paid`) needs
reconciling with this document's `Pending/Draft/Finalized`. These are likely two different axes
on the same `Invoice` — document lifecycle status (this doc) vs. payment-collection status (the
finance doc) — but that reconciliation is not attempted here.
4. **`DeliveryTask.CustomerConfirmed`** (boolean, from `05_delivery_ride.md`) overlaps conceptually
with the new Pending → Draft transition (both represent "the customer confirmed this"). Whether
they merge into one confirmation event, or stay deliberately separate (delivery confirmation vs.
invoice sign-off, which could happen at different times), is not decided here.
5. **`05_delivery_ride.md` and `06_sale_ride.md`** both go stale the moment this design ships and
need their own rewrite/retirement — tracked as follow-up, not attempted in this pass.
6. **`LoadingSheet` generation moves from route-scoped to PO-scoped.** `LoadingSheetRepository`'s
existing `GetAggregatedOrderItemsByRouteIdAsync` (aggregating by `Order.Store.FK_RouteId`) is
superseded by generating one `LoadingSheet` per PO at confirm time. This directly answers part of
`ride_lifecycle.md`'s Impact item 2 (loading sheet auto-population without a route) — but raises a
new gap: nothing in this design says how/when a PO-scoped `LoadingSheet` gets matched to an actual
Ride/vehicle for physical loading, since `ride_lifecycle.md` also removed `FK_SourceWarehouseId`
and any route/store linkage from `Ride`. Flagged as [Open Question 7](#open-questions), not solved
here.
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. ~~**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.
---
## Migration Notes (for when this is implemented)
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`).
- 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.