Document order invoice lifecycle
This commit is contained in:
@@ -1,12 +1,89 @@
|
|||||||
# [Refactor] Order/Invoice Lifecycle — Unified PO → Invoice Pipeline
|
# [Refactor] Order/Invoice Lifecycle — Unified PO → Invoice Pipeline
|
||||||
|
|
||||||
**Status**: Proposed (design only — no code changes yet)
|
**Status**: Partially implemented — schema, API, and DB migration live on branch
|
||||||
|
`feat/order-invoice-lifecycle`. Frontend, `LoadingSheet` wiring, and the cancellation cascade
|
||||||
|
(Open Question 5) are not done. See [Implementation Status](#implementation-status) below for the
|
||||||
|
as-built schema, which differs in a few small ways from the original design further down this
|
||||||
|
document.
|
||||||
**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), [dispatch_pipeline.md](./dispatch_pipeline.md)
|
**Related but independent**: [ride_lifecycle.md](./ride_lifecycle.md), [dispatch_pipeline.md](./dispatch_pipeline.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Implementation Status
|
||||||
|
|
||||||
|
Everything below reflects what is actually in the database and codebase right now, not just the
|
||||||
|
plan. The rest of this document (Data Model Changes onward) is kept as the original design record —
|
||||||
|
where the two disagree, this section wins.
|
||||||
|
|
||||||
|
### `Orders` (existing table, altered)
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `FK_SaleId` | `char(36)`, nullable | Changed from required to nullable, as designed. |
|
||||||
|
| `CreatedByUserId` | `char(36)`, required | **Added — not in the original diagram below.** The
|
||||||
|
original design had no way to know who created a PO or to answer the `agentId` filter Mode B's own
|
||||||
|
endpoints require once a PO can exist without a Sale. Backfilled for every pre-existing row from its
|
||||||
|
linked Sale's `CreatedByUserId`. FK → `Users`. |
|
||||||
|
| `ConfirmedAt` | `datetime`, nullable | **Added — not in the original diagram below.** The design
|
||||||
|
never named a field for "is this PO confirmed" independent of an Invoice existing (Mode B confirms
|
||||||
|
with no Invoice at all), so this was added to actually gate item-editing per Business Rule 1. Null =
|
||||||
|
Shop PO, set = Confirmed PO. |
|
||||||
|
| `ConfirmedByUserId` | `char(36)`, nullable | **Added**, alongside `ConfirmedAt`. FK → `Users`. |
|
||||||
|
| `InvoiceId` | `char(36)`, nullable | As designed — plain column, no DB-level FK constraint. |
|
||||||
|
|
||||||
|
### `OrderItems` (existing table, altered)
|
||||||
|
|
||||||
|
| Change | Notes |
|
||||||
|
|---|---|
|
||||||
|
| Primary key | Changed from the composite `(FK_OrderId, FK_ItemId)` to the single inherited `Id`
|
||||||
|
column. **Required for versioning to work at all** — the original composite key made it impossible
|
||||||
|
for two rows to exist for the same PO+item (one soft-deleted, one active), which item-editing needs.
|
||||||
|
Not called out explicitly in the original design below. |
|
||||||
|
| `IsActive` | Already existed via the `Template` base class; now actually used to mark old versions
|
||||||
|
inactive on every edit. |
|
||||||
|
| New index | `IX_OrderItems_FK_OrderId` (standalone — previously implied by the composite key). |
|
||||||
|
|
||||||
|
### `Invoices` (new table)
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `Id` | `char(36)` | PK |
|
||||||
|
| `FK_OrderId` | `char(36)`, required | FK → `Orders` |
|
||||||
|
| `Status` | `int` (`InvoiceStatus`) | `Draft` / `Printed` / `Finalized` |
|
||||||
|
| `CreatedByUserId` | `char(36)`, required | FK → `Users`. Named `CreatedByUserId` in code, not
|
||||||
|
`CreatedBy` as in the diagram below — matches this codebase's existing naming convention
|
||||||
|
(`Sale.CreatedByUserId`, etc.). |
|
||||||
|
| `PrintedAt` | `datetime`, nullable | |
|
||||||
|
| `PrintedByUserId` | `char(36)`, nullable | FK → `Users`. Same naming note as above. |
|
||||||
|
| `FinalizedAt` | `datetime`, nullable | |
|
||||||
|
| `FinalizedByUserId` | `char(36)`, nullable | FK → `Users`. Same naming note as above. |
|
||||||
|
| `TotalAmount` | `decimal` | |
|
||||||
|
| `IsActive`, `CreatedAt` | from `Template` | |
|
||||||
|
|
||||||
|
### API endpoints implemented
|
||||||
|
|
||||||
|
`POST /api/orders` (independent PO creation, replacing the old "created from Sale" behaviour),
|
||||||
|
`PUT /api/orders/{id}/items` (versioned rewrite), `POST /api/orders/{id}/confirm` (Mode B),
|
||||||
|
`POST /api/orders/{id}/confirm-and-create-invoice` (Mode A), `GET /api/orders/confirmed`
|
||||||
|
(filtered by `agentId`/`routeId`/`shopId`), `POST /api/orders/{id}/cancel`; `POST /api/invoices`
|
||||||
|
(Mode B), `POST /api/invoices/{id}/print`, `POST /api/invoices/{id}/finalize` (using the resolved
|
||||||
|
payload from Open Question 6 below). Both modes exist side by side, gated at runtime by the
|
||||||
|
`OrderInvoice:InvoiceCreationMode` config flag (`appsettings.json`, defaulted to `"B"`).
|
||||||
|
|
||||||
|
### Not implemented yet
|
||||||
|
|
||||||
|
- Frontend (no UI changes made).
|
||||||
|
- `LoadingSheet` population still needs its own wiring per [loading_event.md](./loading_event.md) —
|
||||||
|
untouched by this pass.
|
||||||
|
- Cancellation cascade to `Invoice`/`LoadingSheet` (Open Question 5) — still unresolved; cancelling a
|
||||||
|
Confirmed PO with a linked Invoice today leaves the Invoice untouched.
|
||||||
|
- `Payment`/`Invoice` wiring from [invoice_collection.md](./invoice_collection.md) — separate doc,
|
||||||
|
not started.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Replace the Delivery-vs-Sale ride-type split with a single unified pipeline. The PO and Invoice are
|
Replace the Delivery-vs-Sale ride-type split with a single unified pipeline. The PO and Invoice are
|
||||||
@@ -201,9 +278,38 @@ Draft → Printed → Finalized
|
|||||||
already has a linked Invoice, what happens to that Invoice (voided? stays Draft?) is unresolved.
|
already has a linked Invoice, what happens to that Invoice (voided? stays Draft?) is unresolved.
|
||||||
If the PO's items were pulled into a loading sheet, what happens to those loading sheet items is
|
If the PO's items were pulled into a loading sheet, what happens to those loading sheet items is
|
||||||
also unresolved. Both cases need explicit cascade rules before implementation.
|
also unresolved. Both cases need explicit cascade rules before implementation.
|
||||||
6. **Exact adjustment payload for `POST /finalize`** — quantity adjustments, returns, damage, and
|
6. ~~**Exact adjustment payload for `POST /finalize`**~~ — **Resolved:**
|
||||||
discounts are assumed but the payload shape is not fully specified. "Free issues" handling (items
|
|
||||||
given to the shop at no charge) is a known case that needs its own field(s) in the payload.
|
```
|
||||||
|
POST /api/invoices/{id}/finalize
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{ "orderItemId": Guid, "deliveredQuantity": decimal, "freeQuantity": decimal }
|
||||||
|
],
|
||||||
|
"returns": [
|
||||||
|
{ "itemId": Guid, "quantity": decimal, "reasonCode": ReturnReasonCode }
|
||||||
|
],
|
||||||
|
"discount": { "type": "Amount" | "Percentage", "value": decimal, "reason": string? } | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `items[].deliveredQuantity` — what the shop actually received per line, replacing the ordered
|
||||||
|
`OrderItem.Quantity` for billing purposes. Captures shortages/quantity edits the agent
|
||||||
|
handwrote on the printed copy.
|
||||||
|
- `items[].freeQuantity` — **the free-issue field this document previously flagged as missing.**
|
||||||
|
Units given to the shop at no charge on top of `deliveredQuantity`. Contributes `0` to
|
||||||
|
`Invoice.TotalAmount` but is still recorded per line for stock/audit — it moves stock the same
|
||||||
|
as a sold unit, it just isn't billed.
|
||||||
|
- `returns` — **reuses the existing `SaleReturn`/`SaleReturnItem`/`CreditNote` shape from
|
||||||
|
[18_returns_claims.md](../18_returns_claims.md) instead of inventing a parallel mechanism.**
|
||||||
|
Finalize creates a `SaleReturn` (`Mode = CreditDeduction`) keyed to the PO's `Order` with one
|
||||||
|
`SaleReturnItem` per entry, which produces a `CreditNote` the normal way. "Damage" is not a
|
||||||
|
separate field — it's `ReasonCode = Damaged` on a return line, since `ReturnReasonCode` already
|
||||||
|
covers it (`Expired, Damaged, OrderError, Other`).
|
||||||
|
- `discount` — a single amount-or-percentage adjustment applied to the invoice subtotal, with an
|
||||||
|
optional free-text reason for audit. Not per-line; one discount per invoice for v1.
|
||||||
|
- `Invoice.TotalAmount` at Finalize = `Σ(items[].deliveredQuantity × OrderItem.UnitPrice)`
|
||||||
|
− discount − `Σ(CreditNote.Amount from returns)`. `freeQuantity` never enters this sum.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user