Merge branch 'dev' into production

This commit is contained in:
Kalana B. Thilakarathna
2026-09-16 10:57:55 +05:30
4 changed files with 524 additions and 364 deletions
@@ -133,7 +133,7 @@ See [loading_event.md](./loading_event.md) for the full state machine.
## Stage 6 — Invoice Finalized ## Stage 6 — Invoice Finalized
**Trigger**: `POST /api/invoices/{id}/complete` **Trigger**: `POST /api/invoices/{id}/finalize`
After the ride or day-end, the agent returns the printed invoice to the office. The office worker 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. enters any edits the agent made into the system and submits.
@@ -146,44 +146,6 @@ enters any edits the agent made into the system and submits.
--- ---
## 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 ## Open Questions
1. **Who can confirm a Shop PO?** — the creating sales agent, a supervisor, or either? 1. **Who can confirm a Shop PO?** — the creating sales agent, a supervisor, or either?
@@ -0,0 +1,224 @@
# [Refactor] Invoice Collection — Cash/Cheque/Credit Collection After Finalize
**Status**: Proposed (design only — no code changes yet)
**Depends On**: [order_invoice_lifecycle.md](./order_invoice_lifecycle.md) — this document only applies
once an `Invoice` reaches `Finalized`; it does not touch Pending/Draft.
**Refactors**: `Models/Payment.cs`, `Models/Enums.cs` (`PaymentMethod`, `PaymentStatus`),
`Sale.PaymentStatus`, and the payment recording flow in
[06_sale_ride.md](../06_sale_ride.md) (`POST /api/sales/{saleId}/payments`). Also resolves
[19_finance_accounting.md](../19_finance_accounting.md)'s open question of whether field and
warehouse invoices share one `Payment` shape, and [order_invoice_lifecycle.md](./order_invoice_lifecycle.md)'s
Impact item 2 / Open Question 4 ("how does `Payment` attach once `Invoice` exists?").
---
## Goal
Once an `Invoice` is `Finalized` (locked amount — see `order_invoice_lifecycle.md`), someone still has
to physically collect that money from the shop and get it recorded. Today `Payment` attaches to
`Sale` and only distinguishes `Cash`/`Credit` (`Models/Payment.cs`, `Models/Enums.cs:28`). This
document re-points `Payment` at `Invoice` (the real billing document, not the Sale), adds `Cheque` as
a method, and — the core problem this document solves — accounts for the fact that the money doesn't
always arrive in one clean, connected transaction:
- **Real-time**: whoever is at the shop (the same rep, a dedicated collector, or anyone authorized —
no role restriction) enters the collection on the spot as it happens, one method per entry.
- **Day-end / offline**: when there's no real-time connectivity or no live tracking at the point of
collection, office staff enter the same details after the fact, at day-end, from whatever
paper/verbal record the field brought back.
Both paths produce the exact same `Payment` row shape — `EntryMode` only records *how* it was
captured, it does not change what's captured or how it's totaled.
---
## Background / Context
- `order_invoice_lifecycle.md` deliberately left `Invoice.TotalAmount` as "the amount the shop
actually received and paid" once `Finalized`, but designed no collection/payment mechanism against
it — it only flagged the gap (Impact item 2, Open Question 4).
- `19_finance_accounting.md` independently sketched a `Payment` (`InvoiceId`, `Amount`, `Method`,
`CollectedAt`) and asked whether it should unify with the existing `Sale`-attached `Payment` from
`06_sale_ride.md`. This document answers that: yes, one `Payment` table, moved to key off
`Invoice`, not `Sale`.
- Any *reduction* in the amount owed (returns, damage, short-delivery, discounts) is **not** a new
concept introduced here — it is the same deduction mechanism already described at Finalize time in
`order_invoice_lifecycle.md`, reusing the `CreditNote` shape from
[18_returns_claims.md](../18_returns_claims.md). By the time a `Payment` is recorded, `Invoice.TotalAmount`
already reflects every reduction — collection only ever records money actually received against
that already-net amount. This document does not add a second reduction path.
- A single `Payment` uses exactly one `Method` — a shop paying part cash and part cheque on the same
visit is recorded as two `Payment` rows, not one row with a split. Multiple `Payment` rows against
the same `Invoice` over time are expected and normal (e.g. partial cash today, the remainder by
cheque next week, or the balance written off to credit) — collection is not a single lump action.
---
## Data Model Changes
### `Payment` — re-pointed from `Sale` to `Invoice`
```
Payment
├── Id (Guid) — PK, from Template
├── FK_InvoiceId (Guid) — required. WAS FK_SaleId — see Migration Notes for the cutover.
│ Invoice replaces Sale as the thing being paid down, per
│ order_invoice_lifecycle.md.
├── FK_StoreId (Guid) — unchanged, still denormalized for reporting/scope filtering
├── Amount (decimal) — unchanged
├── Method (PaymentMethod) — Cash, Cheque, Credit (Cheque is new — see below)
├── EntryMode (EntryMode) — NEW: RealTime, DayEndManual (see below)
├── CollectedAt (DateTime) — unchanged: when the money actually changed hands at the shop, not
│ when the row was saved (see EntryMode below for why these differ)
├── CollectedBy (Guid) — unchanged: UserId of whoever collected it. No role constraint — can
│ be the rep, a dedicated collector, or office staff entering on
│ someone else's behalf at day-end.
├── RecordedBy (Guid?) — NEW, nullable. Only set when EntryMode = DayEndManual and the person
│ keying it in is not the same person who physically collected it
│ (CollectedBy). Null under RealTime, where the collector is the one
│ entering it.
├── ChequeNumber (string?) — NEW, only meaningful when Method = Cheque
├── ChequeDate (DateTime?) — NEW, only meaningful when Method = Cheque (the date on the cheque,
│ not CollectedAt)
├── IsActive, CreatedAt — from Template
```
### `PaymentMethod` — add `Cheque`
```csharp
public enum PaymentMethod { Cash, Cheque, Credit }
```
`Credit` records that the shop was not charged now — the amount is carried as store debt (see
`Store.OutstandingBalance`, `19_finance_accounting.md`) rather than settled; no money moves.
### New Enum — `EntryMode`
```csharp
public enum EntryMode { RealTime, DayEndManual }
```
Purely a provenance flag on how the row was captured — it does not affect totaling, `PaymentStatus`
computation, or any business rule below. It exists so a day-end reconciliation view can distinguish
"confirmed live at the shop" from "keyed in later from a paper/verbal record," which matters for
audit and for spotting collectors who never enter anything in real time.
### `Invoice.PaymentStatus` (new field on `Invoice`, from `order_invoice_lifecycle.md`)
```
Invoice
├── ... (existing fields from order_invoice_lifecycle.md: Status, ConfirmedAt, FinalizedAt, ...)
├── PaymentStatus (PaymentStatus) — NEW: Pending, PartiallyPaid, Paid — mirrors the existing
│ Sale.PaymentStatus pattern from 06_sale_ride.md, recomputed on
│ every Payment insert (see Business Rules)
```
This is a deliberate second axis alongside `Invoice.Status` (`Pending/Draft/Finalized`, the document
lifecycle) — `PaymentStatus` only starts being meaningful once `Status = Finalized`, and answers
`order_invoice_lifecycle.md`'s Open Question 3 (whether the two axes should merge): they stay
separate. `PaymentStatus` is the same axis `19_finance_accounting.md` proposed baking into
`Invoice.Status` (`Issued/PartiallyPaid/Paid`) — this document keeps it as its own field instead of
folding it into `Invoice.Status`, so `Invoice.Status` stays purely about the PO→Invoice document
pipeline.
`Sale.PaymentStatus` (`06_sale_ride.md`) is superseded by `Invoice.PaymentStatus` for any Sale that
has a linked Order/Invoice — see Impact section.
---
## Business Rules
1. A `Payment` can only be recorded against an `Invoice` whose `Status = Finalized` — matches
`order_invoice_lifecycle.md`'s framing that Finalize is "the record used for day-end calculations."
Attempting to record a `Payment` against a Pending/Draft invoice is rejected (409).
2. Multiple `Payment` rows are allowed per `Invoice` — collection is not required to happen in a
single action. Each row has exactly one `Method`; a mixed cash+cheque collection on one visit is
two rows with the same `CollectedAt`/visit context, not one split row.
3. On every `Payment` insert, sum all `Payment.Amount` for the `Invoice` and recompute
`Invoice.PaymentStatus` in the same transaction — identical logic to the existing
`Sale.PaymentStatus` computation in `06_sale_ride.md`:
- `sum == 0` → `Pending`
- `sum < Invoice.TotalAmount` → `PartiallyPaid`
- `sum >= Invoice.TotalAmount` → `Paid`
4. Overpayment is allowed (`sum > Invoice.TotalAmount`), same as the existing Sale-level rule —
`PaymentStatus` stays `Paid`. No refund/credit-back mechanism is designed here.
5. `Method = Credit` still inserts a `Payment` row (so it's part of the same total and audit trail)
but represents no money movement — it exists so the collector/office can record "this portion
isn't being collected now, it's going on the shop's account" in the same flow as an actual cash or
cheque collection, rather than needing a separate mechanism.
6. `ChequeNumber`/`ChequeDate` are required when `Method = Cheque`, ignored (nullable, left unset) for
`Cash`/`Credit`.
7. `EntryMode` does not gate or delay anything — a `RealTime` and a `DayEndManual` `Payment` are
processed identically once submitted; the only difference is which field is populated
(`RecordedBy` vs. not) and what the UI shows for provenance.
8. `RecordedBy` is only set when `EntryMode = DayEndManual` and the person entering the row is not
`CollectedBy` (e.g. office staff transcribing a rep's paper collection sheet at day-end). If the
same person both collected and enters it, `RecordedBy` is left null even under `DayEndManual`.
9. No reduction/deduction is recorded on `Payment` itself — any amount not being collected in full is
either already netted into `Invoice.TotalAmount` via the Finalize-time deduction (returns/damage/
discount, per `order_invoice_lifecycle.md`) or is recorded as `Method = Credit` (rule 5) for the
uncollected remainder. There is no third path.
---
## Impact on Dependent Features
1. **`06_sale_ride.md`'s `POST /api/sales/{saleId}/payments`** — this endpoint and
`Sale.PaymentStatus` become the field-sale-specific legacy path. Once a Sale has a linked
Order/Invoice (per `order_invoice_lifecycle.md`), payments should go through
`POST /api/invoices/{id}/payments` instead and `Sale.PaymentStatus` stops being authoritative.
Whether `Sale.PaymentStatus` is removed outright or kept in sync (mirrored from
`Invoice.PaymentStatus`) for backward compatibility is not decided here — see Open Questions.
2. **`Store.OutstandingBalance`** (`12_store_management.md`, referenced in `19_finance_accounting.md`)
— needs to account for `Method = Credit` payments (rule 5) increasing the balance, same as it
already should for any unpaid `Invoice.TotalAmount` minus `PaymentStatus`-tracked collections. Not
redesigned here, just flagged as touched.
3. **`19_finance_accounting.md`'s GL posting** (`postToGeneralLedger`) should trigger off `Payment`
insert events the same way it already anticipates triggering off invoice/payment/credit-note
events — no new trigger type, just confirms `Payment` (now Invoice-keyed) is the event source.
4. **Existing `Payment` rows** (keyed by `FK_SaleId` today) need a migration path once `Order`/
`Invoice` exist for the sales they belong to — see Migration Notes.
---
## Open Questions
1. **Does `Sale.PaymentStatus` get removed, or mirrored?** — see Impact item 1. Not decided here;
removing it outright is cleaner but touches whatever UI/reporting still reads it directly.
2. **Multi-visit collection reminders/scheduling** — this document only covers *recording* a
collection, not scheduling or reminding a collector to go back for the remainder of a
`PartiallyPaid` invoice. Out of scope here.
3. **Cheque bounce / dishonor handling** — a `Cheque` `Payment` is recorded as collected the moment
it's received; there's no designed path for reversing a `Payment` (and its effect on
`Invoice.PaymentStatus`) if the cheque later bounces. Flagged as a gap, not solved.
4. **Does `EntryMode = DayEndManual` need a batch/reconciliation view** (e.g. "these 12 collections
were keyed in for yesterday's rides") rather than being entered one `Payment` at a time through the
same endpoint as real-time? Not designed here — this document only specifies the data shape and
business rules, not the day-end UI workflow.
5. **Should a `Payment` be linkable to a specific `Ride`/visit** (beyond `CollectedAt`/`CollectedBy`),
so a day-end reconciliation can group collections by the ride that generated them? Not modeled
here — `Payment` only links to `Invoice` and `Store`.
---
## Migration Notes (for when this is implemented)
Not part of this docs-only pass, but captured for the eventual implementation step:
- Add `Cheque` to `PaymentMethod` (`Models/Enums.cs:28`); add `EntryMode` enum (`RealTime`,
`DayEndManual`).
- Add `FK_InvoiceId`, `EntryMode`, `RecordedBy`, `ChequeNumber`, `ChequeDate` to `Models/Payment.cs`.
- `FK_SaleId` on `Payment` is superseded by `FK_InvoiceId` — do not drop it immediately; keep it
nullable through a transition period so existing field-sale payments (which predate any
Order/Invoice) aren't orphaned, and backfill `FK_InvoiceId` where a Sale's Order has a linked
Invoice. Full removal of `FK_SaleId` is a follow-up once backfill is verified complete.
- Add `PaymentStatus` to `Models/Invoice.cs` (from `order_invoice_lifecycle.md`'s new `Invoice`
entity), defaulting to `Pending`.
- New `POST /api/invoices/{id}/payments` endpoint (`Features/Payment/`, extending the existing
feature rather than duplicating it) — validates `Invoice.Status == Finalized`, inserts `Payment`,
recomputes `Invoice.PaymentStatus` in the same transaction as the existing
`POST /api/sales/{saleId}/payments` logic in `06_sale_ride.md`.
- `GET /api/invoices/{id}/payments` — list collections against an invoice (needed for both the
real-time collector view and the day-end reconciliation view).
- Resolve Open Question 1 (Sale.PaymentStatus fate) before removing or repointing the existing
`POST /api/sales/{saleId}/payments` endpoint.
+159 -155
View File
@@ -1,41 +1,36 @@
# [Refactor] Loading Event — PO-Generated, Route-Free, No Ride Activation # [Refactor] Loading Event — Dispatcher-Populated, Multi-PO, Multi-Warehouse
**Status**: Proposed (design only — no code changes yet) **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) **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) **Depends On**: [ride_lifecycle.md](./ride_lifecycle.md), [order_invoice_lifecycle.md](./order_invoice_lifecycle.md), [dispatch_pipeline.md](./dispatch_pipeline.md)
--- ---
## Goal ## Goal
Bring `04_loading_event.md` in line with the other two refactors: Replace the previous loading event design with a dispatcher-driven, multi-PO model:
1. **No more `Open -> Active`.** [ride_lifecycle.md](./ride_lifecycle.md) collapses `Ride.Status` to 1. **No more auto-creation.** Loading sheets are no longer generated automatically when a PO is
`Open`/`Cancelled`/`Closed` — there is no `Active` stage left to auto-trigger. Confirming a confirmed. A dispatcher manually triggers population.
loading event no longer changes `Ride.Status` at all; the ride simply stays `Open` through 2. **No more `Open → Active`.** `Ride.Status` collapses to `Open`/`Cancelled`/`Closed` — no
loading, delivery, and everything else, until it's explicitly `Closed` or `Cancelled`. `Active` stage. Loading sheet confirmation no longer changes `Ride.Status` at all.
`LoadingSheet.Status` tracks loading progress entirely on its own. 3. **One sheet aggregates multiple POs.** A single loading sheet is created from all Confirmed POs
2. **No more Route or `RideType`-based item population.** `04_loading_event.md`'s "Delivery rides matching a dispatcher's filter (route/agent/shop). No longer one sheet per PO.
auto-populate from the ride's route" no longer applies — `Ride` no longer carries a route 4. **Ride set at populate time.** The ride is provided when the dispatcher triggers population —
([ride_lifecycle.md](./ride_lifecycle.md)) and no longer distinguishes Delivery/Sale. Instead, per no separate assign-ride step.
[order_invoice_lifecycle.md](./order_invoice_lifecycle.md), a `LoadingSheet` is generated 5. **Warehouse is per item, not per sheet.** Different items on the same loading sheet can be
automatically from a single PO's items the moment that PO is confirmed (`Invoice` Pending → loaded from different warehouses. Stock movements are generated per item's warehouse.
Draft) — before any ride exists. 6. **Item versioning.** Loading sheet items are versioned the same way as Shop PO items — every
3. **A `LoadingSheet` now starts ride-less.** It's created PO-scoped edit soft-deletes current rows (`IsActive = false`) and writes a fresh set.
(`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 ## Background / Context
Same physical mechanics as `04_loading_event.md`: a vehicle is a location with 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) `LocationType.Vehicle` in the stock ledger. Confirming receipt (or resolving a discrepancy)
creates the same two stock movements per item — debit source warehouse, credit vehicle. None of that creates stock movements — debit source warehouse(s), credit vehicle. What changes is *when and how
changes here. What changes is *when a `LoadingSheet` comes into existence* and *what it takes to a `LoadingSheet` comes into existence*, *which POs it covers*, and *how warehouse selection works*.
start the dispatch/receipt workflow on it*.
--- ---
@@ -46,80 +41,122 @@ start the dispatch/receipt workflow on it*.
| Column | Type | Notes | | Column | Type | Notes |
|---|---|---| |---|---|---|
| Id | Guid | PK | | Id | Guid | PK |
| **`FK_OrderId`** | Guid | **New, required.** FK → `Order` (PO). Set at creation; never changes. | | ~~`FK_OrderId`~~ | — | **Removed.** One sheet now aggregates multiple POs — replaced by `LoadingSheetOrder` linking table. |
| **`FK_RideId`** | Guid? | **Now nullable.** Null while `Planned`; set by the new assign-ride action. | | **`FK_RideId`** | Guid | **Required. Set at population time** — dispatcher provides the ride when triggering populate. |
| **`FK_SourceWarehouseId`** | Guid? | **Now nullable**, for the same reason — set together with `FK_RideId` at assign time, not at creation. | | ~~`FK_SourceWarehouseId`~~ | — | **Removed.** Warehouse is tracked per item, not per sheet — a single sheet can be fulfilled from multiple warehouses. |
| Status | LoadingSheetStatus | State machine below — gains a `Planned`/`Assigned` prefix. | | Status | LoadingSheetStatus | See state machine below. |
| **`AssignedAt`** | DateTime? | **New.** Set when a Ride + source warehouse are assigned. | | **`PopulatedBy`** | Guid | **New.** UserId of dispatcher who triggered population. |
| **`AssignedBy`** | Guid? | **New.** UserId of whoever assigned the ride. | | **`PopulatedAt`** | DateTime | **New.** When population was triggered. |
| DispatchConfirmedAt | DateTime? | Unchanged — set when warehouse confirms dispatch. | | ~~`AssignedAt`~~ | — | **Removed.** No separate assign step. |
| DispatchConfirmedBy | Guid? | Unchanged. | | ~~`AssignedBy`~~ | — | **Removed.** Same reason. |
| ReceiptConfirmedAt | DateTime? | Unchanged — set when driver confirms receipt. | | ConfirmedAt | DateTime? | Set when loading sheet is confirmed (items locked). |
| ReceiptConfirmedBy | Guid? | Unchanged. | | ConfirmedBy | Guid? | UserId of whoever confirmed the sheet. |
| CreatedAt | DateTime | Unchanged. | | 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 | From Template. |
`LoadingSheetItem` is **unchanged** (`FK_ItemId`, `QuantityDispatched`, `QuantityReceived`, ### New Entity — `LoadingSheetOrder` (linking table)
`QuantityResolved`) — still populated the same way, just from a different source (see
[Item Population](#item-population) below). 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`
| 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` ### Updated Enum — `LoadingSheetStatus`
```csharp ```csharp
public enum LoadingSheetStatus public enum LoadingSheetStatus
{ {
Planned, // new: created from a confirmed PO, no ride yet Planned, // populated by dispatcher (ride set, items editable/versioned)
Assigned, // new: ride + source warehouse attached, ready to dispatch Confirmed, // items locked — stock movement path TBD (see Open Questions)
DispatchConfirmed, DispatchConfirmed, // Path B only: warehouse confirms dispatch
ReceiptConfirmed, ReceiptConfirmed, // Path B only: driver confirms receipt, stock moves
DiscrepancyFlagged, DiscrepancyFlagged, // Path B only: received qty mismatch
Resolved Resolved // Path B only: supervisor resolves discrepancy, stock moves
} }
``` ```
`Pending` from `04_loading_event.md` is replaced by two states (`Planned`, `Assigned`) to make the `Pending` from `04_loading_event.md` is replaced by `Planned` and `Confirmed`. `Assigned` is
new ride-less creation step explicit. `DispatchConfirmed`, `ReceiptConfirmed`, removed — ride is set at populate time, no separate assign step.
`DiscrepancyFlagged`, and `Resolved` keep their existing meaning and stock-movement behavior
unchanged.
--- ---
## State Machine ## State Machine
```text ```text
Planned -> Assigned -> DispatchConfirmed -> ReceiptConfirmed Planned -> Confirmed -> [Path A: stock moves here]
\-> DiscrepancyFlagged -> Resolved \-> [Path B: DispatchConfirmed -> ReceiptConfirmed]
\-> DiscrepancyFlagged -> Resolved
``` ```
| Transition | Trigger | What it does | | Transition | Trigger | What it does |
|---|---|---| |---|---|---|
| (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. | | (create) → Planned | `POST /api/loading-sheets/populate` | Dispatcher provides `rideId` + filters. System finds all matching Confirmed POs, creates one `LoadingSheet` with `FK_RideId` set, populates `LoadingSheetItem` rows from active `OrderItem` lines, records PO links in `LoadingSheetOrder`. `PopulatedBy`/`PopulatedAt` set. Items editable (versioned via `IsActive`). Dispatcher sets `FK_WarehouseId` per item. |
| 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. | | Planned → Confirmed | `POST /{id}/confirm` | Locks the sheet — `LoadingSheetItem` rows frozen. Sets `ConfirmedAt`/`ConfirmedBy`. Stock movement path TBD (see Open Questions). |
| 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). | | Confirmed → DispatchConfirmed | `POST /{id}/confirm-dispatch` | **Path B only.** Warehouse manager confirms dispatch quantities. |
| 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 → 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` | Unchanged — any line mismatch. No stock movements created. | | DispatchConfirmed → DiscrepancyFlagged | `POST /{id}/confirm-receipt` | **Path B only.** Any line mismatch. No stock movements yet. |
| DiscrepancyFlagged -> Resolved | `POST /{id}/resolve` | Unchanged mechanics — supervisor enters `QuantityResolved` per line, creates stock movements using resolved quantities. **No longer triggers any ride transition.** | | DiscrepancyFlagged → Resolved | `POST /{id}/resolve` | **Path B only.** Supervisor enters `QuantityResolved` per line. Stock movements created using resolved quantities. |
--- ---
## Item Population ## Item Population
Replaces `04_loading_event.md`'s "Item Population by Ride Type" section — there is no more A dispatcher manually triggers population via `POST /api/loading-sheets/populate` providing:
Delivery/Sale split:
Every `LoadingSheet` is auto-populated on creation (at `Planned`) from the aggregate of its own | Input | Notes |
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 | `rideId` | **Required.** The ride this loading sheet is for. Must be an `Open` ride. |
confirmed PO regardless of how that PO originated. `PUT /{id}/items` remains available to override | `routeId` | Filter — all Confirmed POs for shops on this route |
quantities before dispatch is confirmed (through `Planned` and `Assigned`). | `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 ## Stock Movements
**Unchanged** from `04_loading_event.md`. Still created exactly once, at `ReceiptConfirmed` or One stock movement pair per `LoadingSheetItem` (active rows only). Created exactly once.
`Resolved`, still one pair per line item (debit `FK_SourceWarehouseId`, credit the assigned ride's
vehicle), still using `QuantityFinal` = `QuantityReceived` or `QuantityResolved`, still - **Debit**: `LoadingSheetItem.FK_WarehouseId` (the warehouse that item loads from)
`Type = Load`. - **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`
--- ---
@@ -129,63 +166,37 @@ Base route: `/api/loading-sheets`
| Method | Route | Who | Description | | Method | Route | Who | Description |
|---|---|---|---| |---|---|---|---|
| ~~POST `/`~~ | — | — | **Removed.** Sheets are no longer manually created — they're generated automatically when a PO is confirmed. | | POST | `/populate` | Dispatcher | Trigger population: `{ rideId, routeId?, agentId?, shopId? }` — `rideId` required, at least one filter required. Creates one `LoadingSheet` at `Planned` with `FK_RideId` set. |
| GET | `/` | Any | List sheets. Filter by `orderId`, `rideId`, `status`. Paginated. | | GET | `/` | Any | List sheets. Filter by `routeId`, `agentId`, `shopId`, `rideId`, `status`. Paginated. `routeId` resolves via `Store.FK_RouteId → Order.FK_StoreId → LoadingSheetOrder.FK_OrderId` server-side. |
| GET | `/{id}` | Any | Get sheet detail with all line items. | | GET | `/{id}` | Any | Get sheet detail. Active items only by default; `?history=true` includes soft-deleted versions. |
| PUT | `/{id}/items` | Warehouse mgr | Set or override item quantities — allowed through `Planned` and `Assigned`. | | 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}/assign-ride`** | **Dispatcher** | **New.** `{ rideId, sourceWarehouseId }`. Requires the ride to be `Open`. `Planned -> Assigned`. | | POST | `/{id}/confirm` | Dispatcher | Lock the sheet. `Planned → Confirmed`. Items frozen. |
| POST | `/{id}/confirm-dispatch` | Warehouse mgr | `Assigned -> DispatchConfirmed`. | | POST | `/{id}/confirm-dispatch` | Warehouse mgr | **Path B only.** `Confirmed → DispatchConfirmed`. |
| PUT | `/{id}/receipt-items` | Driver | Set `QuantityReceived` per line. Unchanged. | | PUT | `/{id}/receipt-items` | Driver | **Path B only.** Set `QuantityReceived` per line. |
| POST | `/{id}/confirm-receipt` | Driver | `-> ReceiptConfirmed` or `DiscrepancyFlagged`. No ride-status side effect. | | POST | `/{id}/confirm-receipt` | Driver | **Path B only.** `→ ReceiptConfirmed` or `DiscrepancyFlagged`. |
| POST | `/{id}/resolve` | Supervisor | Enter `QuantityResolved` per line `-> Resolved`. No ride-status side effect. | | POST | `/{id}/resolve` | Supervisor | **Path B only.** Enter `QuantityResolved` per line `→ Resolved`. |
--- ---
## Business Rules ## Business Rules
1. A `LoadingSheet` is created automatically, exactly once per PO, when that PO is confirmed — there 1. A `LoadingSheet` is created manually by a dispatcher — not automatically. `rideId` and at least
is no manual "create sheet" action and no `Open`-ride precondition at creation time (replaces one PO filter (routeId, agentId, or shopId) are required.
`04_loading_event.md` rule 1). 2. The ride (`FK_RideId`) is set at populate time — there is no separate assign-ride step.
2. **A ride may now have more than one `LoadingSheet` assigned to it** — one per PO whose sheet was 3. Only Confirmed POs (locked items) are included in population — Shop POs are excluded.
assigned there. `04_loading_event.md`'s "a ride can only have one active loading sheet at a time" 4. One `LoadingSheet` aggregates all matching Confirmed POs — not one sheet per PO.
no longer holds, since a single ride/trip can carry stock for several stores' POs at once. Each 5. A ride may have more than one `LoadingSheet` — each progresses independently.
assigned sheet still progresses through dispatch/receipt/discrepancy independently. 6. The target ride must be `Open` at populate time.
3. `assign-ride` requires the target ride to be `Open`; a `Cancelled` or `Closed` ride cannot receive 7. Each `LoadingSheetItem` carries its own `FK_WarehouseId` — items on the same sheet can come
a new assignment. from different warehouses. There is no sheet-level source warehouse.
4. `QuantityDispatched != QuantityReceived` on any line -> `DiscrepancyFlagged`. No partial receipt 8. Items are editable (versioned via `IsActive`) while `Planned` — frozen at `Confirmed`.
confirmation (unchanged from `04_loading_event.md`, minus the "activation" language since there's 9. Every item edit soft-deletes all current `LoadingSheetItem` rows (`IsActive = false`) and writes
no `Active` status anymore). a fresh set. Active rows = latest version; soft-deleted = history.
5. Stock movements are created only once — at `ReceiptConfirmed` or `Resolved`. Never on dispatch or 10. Stock movements are created exactly once — one debit per item warehouse, one credit to the
on assignment. Unchanged. vehicle. Timing depends on path chosen (see Open Questions).
6. Discrepancies must be resolved per line item with an explicit `QuantityResolved` value. Unchanged. 11. No `Ride.Status` transition is triggered at any point in this flow.
7. All list endpoints return paginated responses. Unchanged. 12. All list endpoints return paginated responses.
8. Vehicle capacity check is still not enforced in this step. Unchanged. 13. Vehicle capacity check is not enforced in this step.
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.
---
## 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.
--- ---
@@ -194,57 +205,50 @@ regardless of how quickly it moves through Pending → Draft → Finalized.
| Code | Who | | Code | Who |
|---|---| |---|---|
| `loading.view` | All roles | | `loading.view` | All roles |
| **`loading.assign`** | **New.** Dispatcher and above | | **`loading.populate`** | **New.** Dispatcher — trigger population, edit items, confirm sheet |
| `loading.dispatch` | Warehouse manager and above | | `loading.dispatch` | Warehouse manager — Path B dispatch confirmation |
| `loading.receive` | Driver | | `loading.receive` | Driver — Path B receipt confirmation |
| `loading.resolve` | Supervisor and above | | `loading.resolve` | Supervisor — Path B discrepancy resolution |
--- ---
## Fraud / Control Considerations ## Fraud / Control Considerations
Unchanged from `04_loading_event.md`:
- Discrepancies are immediately visible and block further progress. - Discrepancies are immediately visible and block further progress.
- `QuantityResolved` is permanently stored per line for audit purposes. - `QuantityResolved` is permanently stored per line for audit purposes.
- `PopulatedBy`/`PopulatedAt` gives an audit trail for who triggered population and when.
Additionally: `AssignedAt`/`AssignedBy` gives an audit trail for who committed a planned PO load to - `FK_OrderItemId` on each item traces the exact source PO line for full audit.
a specific ride/vehicle, which didn't exist as a separate step before.
--- ---
## Open Questions ## Open Questions
1. **Who is authorized to assign a ride to a Planned sheet?** — a new "dispatcher" role/permission is 1. **Path A vs Path B — when do stock movements happen?** Stock can move at `Confirmed` (Path A)
introduced (`loading.assign`) but not otherwise defined in this pass. or at `ReceiptConfirmed`/`Resolved` (Path B). Both paths retained until decided.
2. **What happens to a `Planned` sheet if its PO is never assigned to a ride?** — no timeout, retry, 2. **Who confirms the loading sheet?** — the dispatcher who populated it, the warehouse manager,
or cancellation path is designed here. or either?
3. **Does editing a PO's items after its sheet is `Planned`/`Assigned` re-sync the sheet?** — carried 3. **What happens to a `Planned` sheet if it is never confirmed?** — no cancellation or timeout
over from `order_invoice_lifecycle.md`'s Open Question 8, still unresolved here. path is designed here.
4. **Should multiple `Planned` sheets ever merge into one `Assigned` sheet** (e.g. one dispatch 4. Vehicle capacity check — still deferred.
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`.
--- ---
## Migration Notes (for when this is implemented) ## Migration Notes (for when this is implemented)
Not part of this docs-only pass, but captured for the eventual implementation step: - Remove `FK_OrderId` and `FK_SourceWarehouseId` from `Models/LoadingSheet.cs`. Make `FK_RideId`
required (non-nullable). Remove `AssignedAt`/`AssignedBy`. Add `PopulatedBy`, `PopulatedAt`,
- Add `FK_OrderId` (required), `AssignedAt`, `AssignedBy` to `Models/LoadingSheet.cs`; make `ConfirmedAt`, `ConfirmedBy`.
`FK_RideId` and `FK_SourceWarehouseId` nullable. - Add `Models/LoadingSheetOrder.cs` (`FK_LoadingSheetId`, `FK_OrderId`) with composite PK.
- Replace `LoadingSheetStatus.Pending` with `Planned` and `Assigned`; update all status checks in - Add `FK_WarehouseId` (required), `FK_OrderItemId` (nullable), `IsActive` to
`LoadingSheetService`/`LoadingSheetRepository` accordingly. `Models/LoadingSheetItem.cs`.
- Remove `POST /api/loading-sheets` (manual create); wire automatic `LoadingSheet` creation into the - Replace `LoadingSheetStatus.Pending` and `Assigned` with `Planned` and `Confirmed`; update all
PO-confirm action from [order_invoice_lifecycle.md](./order_invoice_lifecycle.md)'s migration status checks in `LoadingSheetService`/`LoadingSheetRepository`.
notes. - Remove `POST /api/loading-sheets` (old manual create) and `POST /{id}/assign-ride`; add
- Add `POST /{id}/assign-ride` endpoint/service method, validating the target ride's `Status == Open` `POST /api/loading-sheets/populate` and `POST /{id}/confirm`.
(per [ride_lifecycle.md](./ride_lifecycle.md)'s narrowed `RideStatus`). - Remove ride-route-based item population (`GetAggregatedOrderItemsByRouteIdAsync`); replace with
- Remove the `confirm-receipt`/`resolve` code paths' calls into ride-activation logic — there is no filter-based multi-PO aggregation in `LoadingSheetService`.
`Ride.Status` transition to trigger anymore. - Remove all calls to ride-activation logic from `confirm-receipt`/`resolve` — no `Ride.Status`
- Remove the ride-route-based item population path entirely (`Order.Store.FK_RouteId` aggregation); transition exists anymore.
replace with direct `FK_OrderId`-based population at sheet-creation time. - Seed the new `loading.populate` permission.
- Seed the new `loading.assign` permission. - Resolve Open Question 1 (stock movement timing) before implementing `POST /{id}/confirm` and
- Resolve Open Questions 1 and 4 before finalizing the `assign-ride` endpoint's authorization rules `POST /{id}/confirm-receipt` stock movement logic.
and the one-sheet-per-PO assumption, respectively.
@@ -3,37 +3,34 @@
**Status**: Proposed (design only — no code changes yet) **Status**: Proposed (design only — no code changes yet)
**Refactors**: [../05_delivery_ride.md](../05_delivery_ride.md) and **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) [../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 **Related but independent**: [ride_lifecycle.md](./ride_lifecycle.md), [dispatch_pipeline.md](./dispatch_pipeline.md)
`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 ## Goal
Replace the Delivery-vs-Sale ride-type split as the organizing concept for how a store's order gets Replace the Delivery-vs-Sale ride-type split with a single unified pipeline. The PO and Invoice are
turned into money. Today `05_delivery_ride.md` (rep takes an order now, delivers days later) and two separate but linked concerns — the PO tracks what the shop ordered; the Invoice is the billing
`06_sale_ride.md` (rep+driver visit together and sell on the spot) are two separate flows. Underneath, document that goes with the delivery.
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:
1. **Pending** — invoice just created from the PO; the PO's line items are freely editable. ### PO lifecycle (on `Order`)
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.
The two ride scenarios differ only in *timing* through this one pipeline, not in structure — no 1. **Shop PO** — PO created by sales agent, line items freely editable (versioned via `IsActive`).
ride-type-specific branching is needed in the PO/Invoice model itself. 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.
--- ---
@@ -54,198 +51,171 @@ name as `Order` in code.
Order ("PO") Order ("PO")
├── Id (Guid) — PK, from Template ├── Id (Guid) — PK, from Template
├── FK_StoreId (Guid) — required, unchanged ├── FK_StoreId (Guid) — required, unchanged
├── FK_SaleId (Guid?) — now OPTIONAL (was required). See Impact section — a PO can now ├── FK_SaleId (Guid?) — now OPTIONAL (was required). A PO can exist before any Sale.
│ exist before any Sale/payment event completes. ├── InvoiceId (Guid?) — NOT a FK constraint. Set when the Invoice is created for this PO
├── TotalAmount (decimal) — unchanged, though only authoritative once the linked Invoice is │ (Mode A or B). Null until then. No referential enforcement at DB
│ Finalized (see below) │ level — configured explicitly in Fluent API.
├── TotalAmount (decimal) — only authoritative once the linked Invoice is Finalized
├── Status (OrderStatus) — unchanged: Pending, PartiallyFulfilled, Fulfilled, Cancelled. ├── Status (OrderStatus) — unchanged: Pending, PartiallyFulfilled, Fulfilled, Cancelled.
│ This tracks fulfillment progress, a separate axis from the new │ Tracks fulfillment progress — separate axis from InvoiceStatus.
│ InvoiceStatus below — not merged in this pass.
├── FulfilledAt (DateTime?) — unchanged ├── FulfilledAt (DateTime?) — unchanged
├── IsActive, CreatedAt — from Template ├── IsActive, CreatedAt — from Template
``` ```
`OrderItem` (the PO's line items) is unchanged in shape (`FK_ItemId`, `Quantity`, `OrderItem` (`FK_ItemId`, `Quantity`, `QuantityFulfilled`, `UnitPrice`, `IsActive`, `CreatedAt`) —
`QuantityFulfilled`, `UnitPrice`) — editability is now gated by the linked `Invoice`'s status (see editability gated by whether the PO is confirmed. Every edit soft-deletes current rows
below), not by `Order.Status` directly. (`IsActive = false`) and writes a fresh set. Active rows = latest version; soft-deleted = history.
### New Entity — `Invoice` ### New Entity — `Invoice`
``` ```
Invoice Invoice
├── Id (Guid) — PK, from Template ├── Id (Guid) — PK, from Template
├── FK_OrderId (Guid) — required. One PO → one Invoice for v1 (see Open Questions). ├── FK_OrderId (Guid) — required. One PO → one Invoice (1:1).
├── Status (InvoiceStatus) — Pending, Draft, Finalized ├── Status (InvoiceStatus) — Draft, Printed, Finalized
├── ConfirmedAt (DateTime?) — set on Pending → Draft, when the PO is confirmed ├── CreatedBy (Guid) — UserId of office user who created the invoice
├── FinalizedAt (DateTime?) — set on Draft → Finalized ├── PrintedAt (DateTime?) — set on Draft → Printed
├── FinalizedBy (Guid?) — UserId of whoever finalized it ├── PrintedBy (Guid?) — UserId of whoever triggered the print
├── TotalAmount (decimal) — mirrors the PO total while Pending/Draft; becomes the ├── FinalizedAt (DateTime?) — set on Printed → Finalized
│ authoritative, locked amount once Finalized ├── FinalizedBy (Guid?) — UserId of office worker who entered agent edits and submitted
├── TotalAmount (decimal) — reflects current line items through Draft/Printed; locked at Finalized
├── IsActive, CreatedAt — from Template ├── 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` ### New Enum — `InvoiceStatus`
```csharp ```csharp
public enum InvoiceStatus { Pending, Draft, Finalized } public enum InvoiceStatus { Draft, Printed, Finalized }
``` ```
An `Invoice` is created automatically the moment a PO (`Order`) is created, starting at `Pending` — ### Invoice Creation Modes
there is no separate "create invoice" action.
In a given system instance, **exactly one** of the following modes is active. 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 atomic action.
- `Order.InvoiceId` set to the new Invoice's Id in the same transaction.
**Mode B — Create Invoice from Confirmed POs (separate action)**
- PO confirmation is a separate step: `POST /api/orders/{id}/confirm` — locks items only.
- `Order.InvoiceId` remains null until invoice is created.
- Office user browses Confirmed POs filtered by `agentId`, `routeId`, or `shopId`, then creates
the invoice: `POST /api/invoices { orderId }`.
- `Order.InvoiceId` set to the new Invoice's Id at creation.
### `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 creation 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 ## State Machine
``` ### PO (`Order`) transitions
Pending → Draft → Finalized
```
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. (create) → Shop PO → Confirmed PO
```
| Transition | Trigger | What it does | | Transition | Trigger | What it does |
|---|---|---| |---|---|---|
| (implicit) → Pending | PO (`Order`) created | Invoice is created automatically, `Status = Pending`. `OrderItem` lines fully editable. | | (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. No Invoice yet. |
| 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). | | Shop PO → Confirmed PO | **Mode A:** `POST /api/orders/{id}/confirm-and-create-invoice` | Confirms PO and creates Invoice at `Draft` atomically. `OrderItem` lines locked. `Order.InvoiceId` set. |
| 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. | | Shop PO → Confirmed PO | **Mode B:** `POST /api/orders/{id}/confirm` | Confirms PO only. `OrderItem` lines locked. `Order.InvoiceId` remains null until invoice 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`. `Order.InvoiceId` set. |
| (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. |
| Draft → Printed | `POST /api/invoices/{id}/print` | Invoice is physically printed. `PrintedAt`/`PrintedBy` set. Agent takes the printed copy to the field and makes handwritten edits during the ride. |
| Printed → Finalized | `POST /api/invoices/{id}/finalize` | 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 ## Business Rules
1. `OrderItem` add/edit/remove is allowed while the linked `Invoice.Status` is `Pending` **or** 1. `OrderItem` editing is allowed **only** while the PO is a Shop PO (before confirmation). Every
`Draft` — both are working-document states. edit soft-deletes all current `OrderItem` rows (`IsActive = false`) and creates a fresh set —
2. An `Invoice` cannot be `Finalized` without first passing through `Draft` — `Pending → Finalized` active rows are the latest version, soft-deleted rows are history. Once confirmed, items are
directly is not a valid transition. locked permanently.
3. `Order.TotalAmount`/`Invoice.TotalAmount` is only authoritative once `Finalized`; while 2. Invoice is **not** auto-created at PO creation — it is always a deliberate office user action.
Pending/Draft it reflects the current (still-changeable) line items. 3. Exactly one Invoice Creation Mode (A or B) is active per system instance — controlled by
4. `Order.Status` (fulfillment tracking: Pending/PartiallyFulfilled/Fulfilled/Cancelled) is `InvoiceCreationMode` config flag. Both modes cannot be active simultaneously.
untouched and operates on a separate axis from `Invoice.Status` (billing/document lifecycle) — 4. Mode A only: confirming a PO and creating its Invoice are atomic — one cannot happen without
whether these two should eventually merge is an open question, not resolved here. the other.
5. `Order.FK_SaleId` is now optional — a PO/Invoice can exist and progress through Pending/Draft 5. Mode B only: a PO can be confirmed without an Invoice existing; the Invoice is created later as
before any `Sale` is recorded, covering the take-order-now/deliver-later scenario. See Impact a separate action.
section for what this touches. 6. `Order.InvoiceId` is set (non-null) once the Invoice is created — in the same transaction as
6. Both ride scenarios (take-order-now-deliver-later vs. on-the-spot sale) create a PO the same way Invoice creation for both modes. It is stored as a plain column with no DB-level FK constraint.
and pass through the same three Invoice statuses — the only difference is how quickly they move 7. An Invoice cannot skip `Printed` — `Draft → Finalized` directly is not a valid transition.
through Pending → Draft → Finalized. No `RideType`-specific branching applies to the PO/Invoice 8. `Invoice.TotalAmount` is only authoritative once `Finalized`; through `Draft`/`Printed` it
model. reflects the current line items.
7. Confirming a PO (Pending → Draft) requires no customer signature or OTP — it is a plain action 9. `Order.Status` (fulfillment tracking: Pending/PartiallyFulfilled/Fulfilled/Cancelled) is a
available to whoever is authorized to confirm a PO (e.g. the rep/dispatcher), not a separate axis from `Invoice.Status` — not merged in this pass.
customer-facing sign-off step. 10. `Order.FK_SaleId` is optional — a PO can exist and be confirmed before any `Sale` is recorded.
8. A `LoadingSheet` is generated exactly once per PO, automatically, at PO-confirm time — there is no 11. Loading sheet population is a separate dispatcher-triggered flow — not triggered by PO
separate manual "create loading sheet" action. confirmation or invoice creation. See [dispatch_pipeline.md](./dispatch_pipeline.md).
--- ---
## Impact on Dependent Features ## Impact on Dependent Features
Reshaping `Order` and introducing `Invoice` touches several existing behaviors. Each is flagged here 1. **`SaleService.CreateAsync`** — currently the only path that creates an `Order`, always with a
as **known-affected, deferred follow-up work**, not solved in this pass: non-null `FK_SaleId`. With `FK_SaleId` now optional, a new PO-creation path needs to exist.
All code that assumes `Order.Sale` is non-null needs auditing.
1. **`SaleService.CreateAsync`** — currently the only path that creates an `Order`, and it always 2. **`Payment` attachment** — `Payment` currently attaches to `Sale`. Once `Invoice` exists as the
sets a non-null `FK_SaleId`. With `FK_SaleId` now optional, a new PO-creation path (not real billing document, `Payment` more naturally attaches to `Invoice`. Deferred to
necessarily originating from a `Sale`) needs to exist for the take-order-now/deliver-later [19_finance_accounting.md](../19_finance_accounting.md).
scenario. Any other code that assumes `Order.Sale` is always non-null also needs auditing — 3. **`05_delivery_ride.md` and `06_sale_ride.md`** go stale when this ships — need rewrite/retirement.
not enumerated exhaustively here. 4. **`LoadingSheet` population** moves from route-scoped to dispatcher-triggered, multi-PO
2. **`Payment` attachment** — per `06_sale_ride.md`, `Payment` currently attaches to `Sale`. Once aggregation. See [loading_event.md](./loading_event.md).
`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 ## Open Questions
1. ~~**Signature/OTP mechanism for the Pending → Draft transition**~~ — **Resolved:** dropped 1. ~~**Signature/OTP on PO confirmation**~~ — **Resolved:** dropped. Plain action, no OTP.
entirely. Pending → Draft is driven by a plain PO-confirm action (`POST /api/orders/{id}/confirm`), 2. ~~**One Invoice per PO or multiple?**~~ — **Resolved:** 1:1 for v1.
not a customer signature or OTP. See [State Machine](#state-machine). 3. **Should `Order.Status` and `Invoice.Status` eventually merge?** — kept separate; not decided.
2. **One Invoice per PO, or multiple?** — this document assumes a 1:1 `Order`→`Invoice` relationship 4. **How does `Payment` attach to `Invoice`?** — see Impact item 2. Not designed here.
for v1. Whether a PO could ever need multiple invoices (e.g. split billing) is not designed here. 5. **Cancellation/void path for a Shop PO, Confirmed PO, or Invoice?** — not addressed here.
3. **Should `Order.Status` and `Invoice.Status` eventually merge into one status axis?** — kept 6. **Exact adjustment payload for `POST /finalize`** — quantity adjustments, returns, damage, and
deliberately separate in this pass (fulfillment progress vs. document/billing lifecycle); not discounts are assumed but the payload shape is not fully specified here.
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.
--- ---
## Migration Notes (for when this is implemented) ## 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 non-null.
- Add `Order.InvoiceId` (Guid?, no FK constraint) to `Models/Order.cs` — configure in Fluent API
- Make `Order.FK_SaleId` nullable in `Models/Order.cs` and its EF/Fluent configuration; audit all to ensure no referential constraint is generated.
code paths that currently assume it's non-null. - Add `Models/Invoice.cs` (`FK_OrderId`, `Status`, `CreatedBy`, `PrintedAt`, `PrintedBy`,
- Add `Models/Invoice.cs` (`FK_OrderId`, `Status`, `ConfirmedAt`, `FinalizedAt`, `FinalizedBy`, `FinalizedAt`, `FinalizedBy`, `TotalAmount`, plus `Template` base fields) and the `InvoiceStatus`
`TotalAmount`, plus `Template` base fields) and the `InvoiceStatus` enum. enum (`Draft`, `Printed`, `Finalized`).
- New `Features/Invoice/` folder (`InvoiceController`, `InvoiceService`, `IInvoiceRepository`, - New `Features/Invoice/` folder (`InvoiceController`, `InvoiceService`, `IInvoiceRepository`,
`InvoiceRepository`, `InvoiceDTOs`) — do not place this under `Features/PurchaseOrder/` or reuse `InvoiceRepository`, `InvoiceDTOs`) — do not place under `Features/PurchaseOrder/`.
any of its DTOs/names, to avoid the supplier-PO naming collision flagged above. - Add system configuration flag `InvoiceCreationMode` (`"A"` or `"B"`) — gate Mode A and Mode B
- Add `POST /api/orders/{id}/confirm` on the `Order`/PO feature (not on `Invoice`) — it sets endpoints accordingly so only the active mode's endpoints are reachable.
`Invoice.ConfirmedAt`/`Status = Draft` and creates the `LoadingSheet` in one action; no - **Mode A endpoint:** `POST /api/orders/{id}/confirm-and-create-invoice` — atomic: locks
`POST /api/invoices/{id}/draft` endpoint and no signature/OTP capture are needed. `OrderItem` rows, creates `Invoice` at `Draft`, sets `Order.InvoiceId` in one transaction.
- `POST /api/invoices/{id}/finalize` endpoint; the finalize endpoint's adjustment/deduction payload - **Mode B endpoints:** `POST /api/orders/{id}/confirm` (locks items only) + `POST /api/invoices`
should reuse the `CreditNote` shape from [18_returns_claims.md](../18_returns_claims.md) where `{ orderId }` (creates Invoice at `Draft`, sets `Order.InvoiceId`). Add `GET /api/orders` filter
applicable rather than inventing a parallel mechanism. params `agentId`, `routeId`, `shopId` to support the office user's PO selection UI.
- Add `FK_OrderId` to `Models/LoadingSheet.cs` (or equivalent) and change - `POST /api/invoices/{id}/print` — sets `PrintedAt`/`PrintedBy`, transitions to `Printed`.
`LoadingSheetService`/`LoadingSheetRepository` to generate a sheet from a PO's `OrderItem` lines - `POST /api/invoices/{id}/finalize` — accepts adjustment payload; locks `TotalAmount`; sets
directly, replacing `GetAggregatedOrderItemsByRouteIdAsync`'s route-based aggregation — wire this `FinalizedAt`/`FinalizedBy`. Resolve Open Question 6 before specifying the exact payload shape.
call into the new PO-confirm action. - `LoadingSheet` population is a separate flow — see [dispatch_pipeline.md](./dispatch_pipeline.md).
- A new PO-creation path independent of `SaleService.CreateAsync`, to support creating an - A new PO-creation path independent of `SaleService.CreateAsync` is needed — not specified here.
`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`.