Merge branch 'dev' into production
This commit is contained in:
@@ -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.
|
||||||
@@ -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`.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user