Add documentation for GRN costing, taxes, and item tracking configuration

This commit is contained in:
2026-10-08 12:54:38 +05:30
parent b12f3c8d08
commit 0451a0f418
4 changed files with 547 additions and 6 deletions
@@ -0,0 +1,166 @@
# GRN: single entry screen, costing, free issues and configurable taxes
**Status**: Implemented in code. Three migrations are written but must be applied with
`dotnet ef database update` before GRNs can be saved or listed (see Migrations).
**Touches**: `Features/Grn`, `Features/SysConfig`, `Models/GoodsReceipt*.cs`, the Configuration page,
`distribution_fe/app/(dashboard)/procurement/grn/*`.
**Related**: [15_procurement.md](../15_procurement.md) (PO → GRN flow),
[item_tracking_config.md](./item_tracking_config.md) (serial / batch / expiry capture).
---
## 1. One screen for both kinds of receipt
**Is a purchase order mandatory? No.** A PO is the order placed with a supplier *before* goods
arrive; a GRN records what *actually* arrived and increases stock. Stock posting is identical either
way. What a PO adds: auto-filled lines and costs, a hard stop on over-receiving, PO status tracking
(Issued → Partially Received → Received), and the features that hang off a PO (purchase returns,
invoice matching, supplier evaluation). A Direct GRN has none of these controls. Use a PO for normal
ordering; use Direct GRN for unplanned deliveries, walk-in purchases or opening stock. Nothing
currently restricts Direct GRN (no permission, no switch) — add one if every receipt must have an order.
The old two pages (`/procurement/grn/po/new`, `/procurement/grn/direct/new`) are replaced by
**`/procurement/grn/new`**. The GRN list has a single **New GRN** button.
A **Receipt Type** switch at the top picks the behaviour:
| | Against PO (default) | Direct GRN |
|---|---|---|
| Purchase order | required dropdown (Issued / PartiallyReceived only) | none |
| Supplier | read-only, taken from the PO | optional dropdown |
| Received date | shown | not shown (server stamps the time) |
| Lines | prefilled from the PO: remaining qty, PO unit cost | any catalogue item, added by hand |
| **Add Item** | adds a line chosen from **that PO's items** (e.g. a second batch of the same PO line) | adds a line with any item |
| Endpoint | `POST /api/goods-receipts` | `POST /api/goods-receipts/direct` |
| Service method | `GrnService.CreateAsync` | `GrnService.CreateDirectAsync` |
Switching the type resets the lines. A receipt against a PO can only contain items that are on the PO;
anything else must be a Direct GRN. Several lines may point at the same PO line; the over-receipt
check runs against the running total as each line is applied.
## 2. The items table
Columns: **Item · Batch No · Accepted · Rejected · Free Qty · Unit Cost · Disc % · NBT % · VAT % ·
Line Total** (NBT % / VAT % only when enabled in Configuration), plus a delete icon.
- **Batch No** summarises the lots (`B1 ×5, B2 ×3`), a serial count, or an expiry-lot count. The
existing panel under the row still edits the serials / batches.
- **Totals card** under the table: Subtotal, Discount, Taxable amount, NBT, VAT, Grand total.
- **GRN Discount %, NBT %, VAT %** at the top are *defaults*: picking or typing a value (0–100,
decimals allowed) fills every line; any line can still be edited afterwards.
## 3. Costing rules (server is the source of truth)
Per line, rounded to 2 dp (half away from zero), in `Features/Grn/GrnPricing.cs`:
```
gross = acceptedQty x unitCost
discount = gross x disc%
net = gross - discount
NBT = net x NBT%
VAT = (net + NBT) x VAT% // Sri Lanka order: VAT is charged on the NBT-inclusive amount
total = net + NBT + VAT
```
- Only **accepted** units are costed. Rejected units never are; **free** units are stocked but not
costed.
- Unit cost: the request's value, else the PO line's `UnitCost` (PO receipts), else 0 (direct).
- Disc % / NBT % / VAT %: the line's value, else the GRN-wide value.
- Percentages must be 0–100, cost cannot be negative; violations are `409` with a message.
- GRN totals are **not stored**. They are recomputed from the stored line percentages whenever a GRN
is read, so they cannot drift. The frontend mirrors the same formula (`grn-costing.tsx`).
## 4. Free issues
`GoodsReceiptItem.FreeQuantity`: units the supplier gave at no charge.
- Added to stock together with accepted units (`StockBalance` / `StockMovement`).
- Not costed and **not** counted against the PO line's ordered quantity (`ReceivedQuantity` grows by
accepted units only).
- Serial / batch entries must cover **accepted + free** (10 accepted + 2 free = 12 serials, or batch
quantities summing to 12).
- A line may be entirely free. A direct line is rejected only if accepted + rejected + free is 0.
## 5. Configurable GRN taxes (Configuration → GRN Taxes)
Stored in the existing `SysConfig` key/value table (no table change):
| Key | Values | Unconfigured default |
|---|---|---|
| `Grn.Tax.Vat.Mode` | `Disabled` / `Optional` / `Mandatory` | `Optional` (how GRNs behaved before) |
| `Grn.Tax.Vat.DefaultRate` | 0–100 | 0 |
| `Grn.Tax.Nbt.Mode` | `Disabled` / `Optional` / `Mandatory` | `Disabled` |
| `Grn.Tax.Nbt.DefaultRate` | 0–100 | 0 |
Set through the generic `PUT /api/sys-config/{key}`; the service validates modes and rates.
`SysConfigService.GetGrnTaxSettingsAsync()` reads them. The GRN screen reads the same keys from
`GET /api/sys-config`.
| Mode | GRN form | Server |
|---|---|---|
| Disabled ("Not applied") | field and column hidden | percentage forced to 0, client values ignored |
| Optional | shown; may stay at 0 | missing value = 0 |
| Mandatory | shown, red `*`; the GRN-wide field cannot be left blank (0% is allowed); Save is disabled while blank | request rejected (`409`, "… is mandatory") if neither the line nor the GRN supplies the value |
Applies to **GRNs only**. The default rate seeds new GRNs (header and lines); users can change it.
## 6. Data model changes
| Table | New columns |
|---|---|
| `GoodsReceipts` | `DiscountPercent`, `VatPercent`, `NbtPercent` (`decimal(5,2)`, default 0) |
| `GoodsReceiptItems` | `UnitCost` (`decimal(18,2)`), `DiscountPercent`, `VatPercent`, `NbtPercent` (`decimal(5,2)`), `FreeQuantity` (`int`), all default 0 |
New enum `TaxMode { Disabled, Optional, Mandatory }`.
### API surface
Requests (`POST /api/goods-receipts`, `/api/goods-receipts/direct`, `/api/purchase-orders/{id}/goods-receipts`):
header `discountPercent`, `vatPercent`, `nbtPercent`; per line `unitCost`, `discountPercent`,
`vatPercent`, `nbtPercent`, `freeQuantity` (all optional).
Responses add the same fields plus per-line `grossAmount`, `discountAmount`, `nbtAmount`,
`vatAmount`, `lineTotal` and GRN-level `subTotal`, `discountAmount`, `taxableAmount`, `nbtAmount`,
`vatAmount`, `totalAmount`. See `sharedDocs/BACKEND_API_REFERENCE.md` §6.19.
## 7. Migrations
1. `20261007063956_AddGrnPricing` — header + line discount / VAT, line unit cost. *Applied.*
2. `20261007065602_AddGrnFreeQuantity` — line free quantity. *Applied.*
3. `20261008060840_AddGrnNbt` — header + line NBT. ***Pending** when last checked
(`dotnet ef migrations list`).*
All only add columns with default 0 (existing GRNs are unaffected). Run
`dotnet ef database update` from `Distribution/` (stop a running API first, it locks the build).
The app does not migrate on startup, so until the NBT migration is applied every call that reads or
writes GRNs fails with `Unknown column 'g.NbtPercent'` (surfacing on save as a `DbUpdateException`).
Re-check with `dotnet ef migrations list` before assuming a database is current.
## 8. Auto-generated serial numbers continue from what is saved
The panel's **Auto-generate** used to start at 1 each time, so a second receipt of the same item
regenerated `BEV-Fanta-100-0001…` and the server rejected it ("Serial number … already exists for
item …"). Now:
- `GET /api/stock/serials/next-sequence?itemId=&prefix=` returns `{ "next": n }`: one past the highest
numeric suffix among the item's saved serials that look like `<prefix><digits>` (1 when none).
Serials that don't fit the pattern (e.g. a supplier's own numbers) are ignored.
(`StockRepository.GetNextSerialSequenceAsync`.)
- Auto-generate calls it, then starts at `max(start field, next)`: first 5 units → `0001–0005`, next
receipt → `0006–0010`. The "start" field is updated after each run.
- It also skips serials already in the same list and serials on **other lines of the same GRN for the
same item**, so two lines of one item cannot collide.
- If the lookup fails, it warns and falls back to the start field; the server still rejects any
duplicate at save.
- Serials already sitting in an open form are not rewritten: clear the list and generate again.
## 9. Known limits / follow-ups
- The PO receipt request carries no received date; the server stamps `DateTime.Now`. The date field on
the screen is sent but ignored.
- In the over-receipt check, a PO line's *rejected* units from earlier lines / receipts are not
carried forward (only accepted units advance `ReceivedQuantity`). Pre-existing behaviour.
- The GRN list table does not show costs or totals; the details dialog does (NBT only when present).
- Mandatory means "must be filled in"; there is no rule forcing a rate above 0.
- Tests: `Distribution.Tests/Services/GrnPricingTests.cs` covers the arithmetic and validation. The
server-side tax-mode handling, free-quantity stock posting and serial numbering have no automated
tests yet.
@@ -188,8 +188,10 @@ an item that doesn't track batches, or a missing expiry date on an item that req
warranty-period number input, and (only when `Feature.CustomWarrantyOverride` is on) an warranty-period number input, and (only when `Feature.CustomWarrantyOverride` is on) an
`Allow custom warranty period` checkbox. Checkboxes for a disabled global feature are hidden `Allow custom warranty period` checkbox. Checkboxes for a disabled global feature are hidden
entirely, not just disabled, to avoid confusing item editors. entirely, not just disabled, to avoid confusing item editors.
- **GRN entry** — built as two full pages (against a PO: `/procurement/grn/po/new`; direct: - **GRN entry** — now **one page**, `/procurement/grn/new`, with an *Against PO / Direct GRN* switch
`/procurement/grn/direct/new`). Tracked lines show a status chip that opens a panel for serial (the former `/procurement/grn/po/new` and `/procurement/grn/direct/new` pages were merged; see
[grn_costing_and_taxes.md](./grn_costing_and_taxes.md)). Serial / batch counts on a line must cover
*accepted + free* units. Tracked lines show a status chip that opens a panel for serial
entry (scan / auto-generate / type-paste-edit) or batch rows (batch no. with Auto, expiry, qty). entry (scan / auto-generate / type-paste-edit) or batch rows (batch no. with Auto, expiry, qty).
Save is disabled until every tracked line is complete. Details in the plan, Phases 10–12. Save is disabled until every tracked line is complete. Details in the plan, Phases 10–12.
- **configuration page**: a single Products card on `/configuration` holds the switches and warranty - **configuration page**: a single Products card on `/configuration` holds the switches and warranty
@@ -233,13 +235,17 @@ Rejected quantity never touches stock. Everything posts in one unit of work and
failure. A serial that already exists for the item, or a mismatch with the item's flags, is rejected. failure. A serial that already exists for the item, or a mismatch with the item's flags, is rejected.
**Two ways to receive**: *against a PO* (lines come from the PO, PO received-quantities and status **Two ways to receive**: *against a PO* (lines come from the PO, PO received-quantities and status
update) or *direct* (no PO; item chosen per line, optional supplier). The UI exposes them as two update) or *direct* (no PO; item chosen per line, optional supplier). The UI exposes them as one
pages and both share the same serial/batch entry panel. page with a receipt-type switch, sharing the same serial/batch entry panel. Free-issue units are
stocked like accepted ones, so the serial / batch totals include them.
### 3. Entering serials / batches in the UI ### 3. Entering serials / batches in the UI
- Each tracked line shows a chip (amber until complete, green ✓ when complete) → opens the panel. - Each tracked line shows a chip (amber until complete, green ✓ when complete) → opens the panel.
- **Serials**: scan (Enter adds one), auto-generate (prefix + start number, fills only missing units), - **Serials**: scan (Enter adds one), auto-generate (prefix + start number, fills only missing units;
or type/paste one per line; every entry stays editable; duplicates are flagged. numbering continues after the highest `<prefix><digits>` already saved for the item, so a later
receipt gets `0006–0010` rather than repeating `0001–0005` — see
[grn_costing_and_taxes.md](./grn_costing_and_taxes.md) §8), or type/paste one per line; every
entry stays editable; duplicates are flagged.
- **Batches**: add rows; batch number can be typed, scanned or auto-generated; expiry date warns if - **Batches**: add rows; batch number can be typed, scanned or auto-generated; expiry date warns if
expired or within 30 days; quantities must add up to the accepted quantity. expired or within 30 days; quantities must add up to the accepted quantity.
- Save is disabled until all tracked lines are complete; the server re-validates. - Save is disabled until all tracked lines are complete; the server re-validates.
@@ -0,0 +1,142 @@
# Plan: Product Report
## Status — implemented (Phases 1–3)
Built as `Features/ProductReport` (`api/reports/products`: `stock`, `sales`, `returns`, `summary`)
and the page `/reports/products` (Stock / Sales / Returns tabs, summary cards). Not built: Excel/PDF
export, a `reports.view` permission and region/route/store scoping (the page reuses
`warehouses.view`), purchase-return figures, the slow-stock / expiry phase (4).
Decisions taken for the open questions below: **sales** = fulfilled quantity on non-cancelled shop
POs whose invoice is `Finalized`, dated by `Invoice.FinalizedAt`; **margin** uses `Item.CostPrice`.
Implementation notes (the database is **MySQL** via Pomelo, which constrains the queries):
- EF's `GroupBy` + `Distinct().Count()` over a navigation (distinct stores per item) generates a
derived table that references the outer query, which MySQL rejects (`Unknown column … in 'WHERE'`).
Distinct stores are therefore counted in a second small query for the items on the current page.
- Grouped queries project to an anonymous type, are ordered and paged on that, then mapped to the
response records. The stock report counts warehouse / vehicle quantity with per-item subqueries
instead of a `GroupBy` + `DefaultIfEmpty` join.
- Limits: `pageSize` ≤ 200, date range ≤ 366 days.
---
## Context
There is no reporting module yet. "Product" in this codebase is `Item`
(`Distribution/Models/Item.cs`). A product report is a read-only aggregation over data that
already exists, so **no new tables or migrations** are needed for the first phases.
Source data (verified in the models):
| Area | Entity | Useful fields |
|---|---|---|
| Master | `Item` | `Sku`, `Name`, `FK_CategoryId`, `FK_SubCategoryId`, `FK_BrandId`, `FK_BaseUomId`, `SalePrice`, `CostPrice`, `ReorderLevel`, tracking flags, `IsActive` (via `Template`) |
| Stock | `StockBalance` | `FK_ItemId`, `LocationId` + `LocationType` (Warehouse/Vehicle/External), batch/serial, `Quantity` |
| Stock history | `StockMovement` | `Type` (Sale, CustomerReturn, Transfer, Damage, Adjustment, Load, PurchaseReceipt, PurchaseReturn), `Quantity`, `CreatedAt` |
| Sales | `ShopPOItem` | `Quantity`, `QuantityFulfilled`, `UnitPrice`; parent `ShopPO` (Status, ConfirmedAt, store) |
| Invoice | `Invoice` | Has **no line items**. Links to `ShopPO` via `FK_ShopPOId`; `Status` (Draft/Printed/Finalized), `TotalAmount` |
| Purchase | `PurchaseOrderItem`, `GoodsReceiptItem` | `UnitCost`, ordered qty; `AcceptedQuantity`, `RejectedQuantity` |
| Returns | `SaleReturnItem`, `PurchaseReturnItem` | `Quantity`, `ReasonCode` (Expired/Damaged/OrderError/Other), `UnitPrice` |
Important consequence: because `Invoice` has no line items, **product-level sales must come from
`ShopPOItem` joined to a finalized `Invoice`** (via `ShopPO`), not from invoice rows.
## Scope (phased)
Each phase is independently shippable. Build in order.
### Phase 1: Product Stock Report (no new data)
One row per item (optionally per warehouse):
- Sku, name, brand, category, sub-category, base UOM
- Sale price, cost price, stock value (`Quantity * CostPrice`)
- On-hand qty (sum of `StockBalance` for `LocationType.Warehouse`), on-vehicle qty
(`LocationType.Vehicle`), total
- Low-stock flag (`Quantity <= ReorderLevel`), reuses the logic behind `GET /api/stock/low-stock`
- Active/inactive
Filters: brand, category, sub-category, warehouse, active only, low-stock only, search (sku/name).
### Phase 2: Product Sales Report
Per item for a date range:
- Qty ordered, qty fulfilled, gross sales (`QuantityFulfilled * UnitPrice`)
- Number of distinct shops/orders
- Rank (top/bottom N)
- Breakdown by region/route/store (grouping option)
Rules:
- Count only `ShopPO` rows where `Status != Cancelled`.
- **Decision needed:** define "sales" once. Recommended: fulfilled quantity on shop POs whose
invoice is `Finalized` (matches the pipeline in `shoppo_invoice_pipeline.md`).
- Date basis: `Invoice.FinalizedAt`.
### Phase 3: Returns and Margin
- Sales returns per item: qty, value, reason breakdown (`ReturnReasonCode`), return % of sold qty
- Purchase returns per item
- Gross margin: `(avg selling price - CostPrice) * qty`. Use `Item.CostPrice` first; later switch
to latest `PurchaseOrderItem.UnitCost` if cost history matters.
### Phase 4: Movement / ageing (optional)
- Slow/dead stock: items with no `StockMovement` of type `Sale`/`Load` in N days
- Movement ledger per item (reuse `GET /api/stock/movements`)
- Batch/expiry report for items with `TracksBatches`/`TracksExpiryDate`
## Backend design
Follow the existing feature layout (`Controller`, `Service`, `IRepository`, `Repository`, `DTOs`).
New feature folder: `Distribution/Features/ProductReport/`
- `ProductReportController.cs`, route `api/reports/products`
- `GET /stock` (Phase 1)
- `GET /sales` (Phase 2)
- `GET /returns` (Phase 3)
- `GET /summary`: top-line totals for the report header
- `GET /export?format=xlsx` (last step)
- `IProductReportRepository` / `ProductReportRepository`: EF Core grouped queries
(`GroupBy` on `FK_ItemId`, `AsNoTracking`, projection straight into DTOs). No in-memory loops.
- `ProductReportService`: applies filters, scope and paging; computes derived fields
(stock value, return %, margin).
- `ProductReportDTOs.cs`: `ProductReportQuery` (filters, `From`, `To`, `Page`, `PageSize`, `SortBy`),
row DTOs per report, `PagedResult<T>` (reuse whatever the other features already use).
- Register in `Program.cs` the same way as the other feature services.
Cross-cutting:
- **Authorization:** add a `reports.view` permission (see `Permission.cs`/`RolePermission.cs`) and
honour the existing `UserRegionScope`/`UserRouteScope`/`UserStoreScope` so users only see data
in their scope.
- **Paging and limits:** default page size 50; cap date range (e.g. 366 days).
- **Indexes:** check `ShopPOItem(FK_ItemId)`, `StockMovement(FK_ItemId, CreatedAt)`,
`StockBalance(FK_ItemId, LocationId)`; add only if the query plans need them.
## Frontend design
- Page: `distribution_fe/app/(dashboard)/reports/products/page.tsx`
(tabs: Stock, Sales, Returns)
- API client: `distribution_fe/lib/api/reports.ts`, exported from `lib/api/index.ts`
(same pattern as `stock.ts`, `sales.ts`)
- Filter bar (date range, brand, category, warehouse, region/route), summary tiles, table with
server-side paging and sorting, Export button.
- Add a "Reports" entry to the dashboard nav.
## Open questions
1. Who is the primary reader (management, warehouse, sales)? This sets Phase 1 vs 2 priority.
2. Definition of "sales": fulfilled qty on finalized invoices, or all confirmed shop POs?
3. Cost basis for margin: `Item.CostPrice` or latest PO cost?
4. Export format: Excel only, or PDF too?
5. Do reports need to respect region/route/store scope for all roles?
## Verification
- Unit tests in `Distribution.Tests` for the service (filter, paging, derived fields) with seeded data.
- Cross-check: Phase 1 on-hand totals equal `GET /api/stock/balances/{itemId}/total`.
- Cross-check: Phase 2 sales total for a date range equals the sum of finalized `Invoice.TotalAmount`
for the same range (excluding any invoice-level adjustments).
- Run with a large seed and check query time and that exports match the on-screen table.
@@ -0,0 +1,227 @@
# Plan: Close the Ride → Loading Sheet → Shop PO → Delivery → Invoice → Payment pipeline
## Context
The goal was a step-by-step frontend flow for Operations (Rides/Loading Sheets/Delivery Tasks)
and for Shop POs/Invoices, grounded in what the backend actually implements.
Investigation found the pipeline was **not end-to-end**, even though most of the frontend UI for it
already existed:
- **Ride** (`RideController`) and **LoadingSheet** (`LoadingSheetController`) were fully implemented,
backend and frontend, and already worked (create → set items → confirm → stock moves warehouse→vehicle).
- **ShopPO** (`ShopPOController`) was **GET-only** on the backend — no create/update-items/confirm/
confirm-and-create-invoice/cancel logic existed — despite the frontend
(`distribution_fe/app/(dashboard)/sales/new/page.tsx`, `lib/api/sales.ts`'s `shopPos` client)
already calling all five of those routes. Those calls previously 404'd.
- **Invoice** (`InvoiceController`) was likewise **GET-only** — no print/finalize logic — despite the
frontend (`sales/orders/_components/orders-client.tsx`) already calling `invoices.print()` /
`invoices.finalize()`.
- **DeliveryTask** generation (`DeliveryTaskService.GenerateAsync`) was stubbed:
`throw new NotImplementedException(...)`. No frontend trigger for it existed either.
- **LoadingSheetOrder** (the link table between a `LoadingSheet` and the `ShopPO`s it's carrying) was
never populated anywhere in code — only by seed data — even though `LoadingSheetItem.FK_ShopPOItemId`
(optional, already wired end-to-end) was exactly the data needed to derive it.
- **Payment** (`PaymentController`/`PaymentService`) was fully implemented on both ends already —
no changes needed there.
The DTOs for the missing ShopPO endpoints (`CreateShopPORequest`, `UpdateShopPOItemsRequest`,
`ShopPOItemRequest`) already existed in `Distribution/Features/ShopPO/ShopPODTOs.cs`, unused — the
frontend and DTOs were clearly built ahead of the service/controller logic. This plan closes that
gap: implement the missing backend pieces, wire the one still-missing frontend action (delivery task
generation trigger), and document the resulting step-by-step flow.
## Backend work
### 1. `ShopPOService` — Create / UpdateItems / Confirm / ConfirmAndCreateInvoice / Cancel
File: `Distribution/Features/ShopPO/ShopPOService.cs` (previously only had `GetAllAsync`/`GetByIdAsync`).
New endpoints on `ShopPOController.cs` (previously GET-only), matching the routes the frontend
already calls:
- `POST /api/shop-pos` → `CreateAsync(CreateShopPORequest, Guid raisedById, ct)`
- Creates `ShopPO` with `Status = Pending`, `TotalAmount` computed from line items
(`Quantity * UnitPrice`, resolving `UnitPrice` server-side via `PriceListService.ResolveByStoreAsync`
when not supplied — the same resolution `sales/new/page.tsx` calls client-side at
`priceListsApi.resolve`).
- Creates `ShopPOItem` rows, `QuantityFulfilled = 0`.
- `PUT /api/shop-pos/{id}/items` → `UpdateItemsAsync(id, UpdateShopPOItemsRequest, ct)`
- Guard: only allowed while `ConfirmedAt is null` (mirrors `LoadingSheetService.SetItemsAsync`'s
Planned-only guard) — same replace-all-items pattern (`ReplaceItemsAsync`).
- Recomputes `TotalAmount`.
- `POST /api/shop-pos/{id}/confirm` → `ConfirmAsync(id, Guid confirmedBy, ct)`
- Guard: `ConfirmedAt is null` and `Status != Cancelled`.
- Sets `ConfirmedAt = DateTime.Now`, `ConfirmedBy = confirmedBy`. Status stays `Pending`
(fulfillment tracking is a separate axis, same pattern as `DeliveryTaskService.ConfirmAsync`
already updating `ShopPO.Status` independently of `ConfirmedAt`).
- Does **not** create an Invoice (delivery-later path — invoice creation trigger for that path
remains an open question, see below).
- `POST /api/shop-pos/{id}/confirm-and-create-invoice` → `ConfirmAndCreateInvoiceAsync(id, Guid userId, ct)`
- Does everything `ConfirmAsync` does, plus creates an `Invoice` row (`FK_ShopPOId`, `Status = Draft`,
`PaymentStatus = Pending`, `CreatedBy = userId`, `TotalAmount = po.TotalAmount`) and sets
`po.InvoiceId`. This is the on-the-spot sale path (rep+driver together).
- `POST /api/shop-pos/{id}/cancel` → `CancelAsync(id, ct)`
- Guard: `Status != Cancelled` and not already `PartiallyFulfilled`/`Fulfilled`.
- Sets `Status = Cancelled`.
Follows the existing service pattern used by `RideService.CloseAsync`/`CancelAsync` and
`LoadingSheetService.ConfirmAsync`: `uow.BeginAsync` → mutate → `repo.Update` →
`uow.CommitAsync`/`RollbackAsync` on exception; controller catches `KeyNotFoundException` → 404,
`InvalidOperationException` → 400.
### 2. `LoadingSheetService.SetItemsAsync` — derive `LoadingSheetOrder` rows
File: `Distribution/Features/LoadingEvent/LoadingSheetService.cs`. After replacing items, the
distinct `FK_ShopPOId` values are looked up from each item's `FK_ShopPOItemId` (via the new
`ILoadingSheetRepository.GetShopPOIdsByItemIdsAsync`), and `LoadingSheetOrder` rows for the sheet are
replaced accordingly (`ReplaceOrdersAsync` — same replace-all-on-each-call approach as the items
themselves). This makes `LoadingSheetResponse.Orders` (already returned, previously always empty
outside seed data) actually populate, and is the data `DeliveryTaskService.GenerateAsync` needs.
### 3. `DeliveryTaskService.GenerateAsync` — implemented
File: `Distribution/Features/DeliveryRide/DeliveryTaskService.cs`. For the given `rideId`: finds the
ride's confirmed `LoadingSheet`(s), reads their `LoadingSheetOrder` rows, and creates one
`DeliveryTask` per distinct `ShopPO` referenced — `Status = Pending`, `Sequence` assigned in creation
order, `CustomerConfirmed = false`. Skips POs that already have a `DeliveryTask` for this ride
(idempotent, via `IDeliveryTaskRepository.ExistsForOrderAndRideAsync`). Guard: throws if the ride has
no confirmed loading sheet.
### 4. `InvoiceService` — Print / Finalize
File: `Distribution/Features/Invoice/InvoiceService.cs` (previously only Get/GetAll). Adds:
- `POST /api/invoices/{id}/print` → `PrintAsync(id, Guid printedBy, ct)`
- Guard: `Status == Draft`. Sets `Status = Printed`, `PrintedAt = Now`, `PrintedBy = printedBy`.
- `POST /api/invoices/{id}/finalize` → `FinalizeAsync(id, FinalizeInvoiceRequest, Guid finalizedBy, ct)`
- Guard: `Status == Printed`. Sets `Status = Finalized`, `FinalizedAt = Now`, `FinalizedBy = finalizedBy`.
- Uses the pre-existing `FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments)` DTO
(was already defined, unused, in `InvoiceDTOs.cs` — `Adjustments` was made optional/nullable so
the current no-body frontend call still binds). When adjustments are supplied, each
`ShopPOItemId`'s billed quantity is overridden (validated to be between 0 and the item's ordered
`Quantity`) and `Invoice.TotalAmount` is recomputed as `Σ billedQuantity × UnitPrice`; with no
adjustments, `TotalAmount` is left as-is. This does **not** touch `ShopPOItem.QuantityFulfilled`
(that field is owned by the delivery-confirmation flow) — adjustments are billing-only. The
deeper CreditNote-based deduction design from `order_invoice_lifecycle.md` remains unbuilt; this
is a minimal, non-persisted (no adjustment audit trail) implementation.
- `InvoiceRepository.GetByIdAsync` was extended to also include `ShopPO.Items` (previously only
`ShopPO.Store`), needed to compute the adjustment total.
- Matches the frontend's existing `visible` guards in `orders-client.tsx` (`print` shown only when
`Draft`, `finalize` only when `Printed`) — no frontend change needed there, since the frontend
currently calls `finalize` with no body/adjustments.
`PaymentService.CreateAsync` already blocks payments once `Status == Finalized` — consistent with
this state machine.
## Frontend work
Almost everything needed already existed and was correctly wired to the (previously missing) backend
routes above — once the backend work lands, `sales/new`, `sales/orders`, `sales/payments`,
`operations/rides`, `operations/loading` all start working without changes. The one gap:
- **Trigger for `DeliveryTaskService.GenerateAsync`**: no frontend call existed anywhere. Added a
`generate: (rideId: string) => api.post<DeliveryTaskResponse[]>(\`/rides/${rideId}/tasks/generate\`)`
method to `deliveryTasks` in `distribution_fe/lib/api/operations.ts` (route already defined in
`DeliveryTaskController.cs`), and a "Generate delivery tasks" **row action** on `Confirmed` sheets
in the Loading Sheets table (`operations/loading/_components/loading-sheets-client.tsx`) — not the
read-only `loading-sheet-view-sheet.tsx`, which stayed untouched — since that's where every other
sheet action (Set items, Confirm) already lives as a row action, and that's the point at which
`LoadingSheetOrder` data becomes available. Also required adding `'generate'` to the row-action
icon map, which turned out to be duplicated in two places
(`components/shared/row-actions.tsx`'s `ACTION_ICONS` and `components/shared/data-table/types.ts`'s
`RowActionKind` union) — both needed updating.
## End-to-end flow (once the above lands)
1. **Sales rep**: `/sales/new` → creates a `ShopPO` (`Pending`, unconfirmed) for a store with line items.
2. **Confirm PO**: either `confirm` (delivery-later — no invoice yet) or `confirm-and-create-invoice`
(on-the-spot sale — `Invoice` created immediately, `Draft`).
3. **Operations**: `/operations/rides` → create a `Ride` (`Open`) for a vehicle+driver.
4. `/operations/loading` → create a `LoadingSheet` for that ride, set items (optionally tagging each
with the `ShopPOItem` it's fulfilling via `FK_ShopPOItemId`), confirm → stock moves warehouse→vehicle,
`LoadingSheetOrder` rows derived.
5. Trigger **Generate delivery tasks** for the ride → one `DeliveryTask` per PO on the sheet.
6. `/operations/deliveries` → for each task, set delivered quantities, confirm (stock moves
vehicle→external, `ShopPO.Status` synced to `Fulfilled`/`PartiallyFulfilled`) or fail.
7. If not already created in step 2, an `Invoice` should exist by now for delivery-later POs —
**open question, not resolved by this plan**: no endpoint currently creates an Invoice for the
delivery-later path after fulfillment; flagged as a follow-up.
8. `/sales/orders` (Invoices list) → `print` (`Draft → Printed`) → `finalize` (`Printed → Finalized`).
9. `/sales/payments` → record payments against the invoice until `PaymentStatus = Paid` (blocked once
`Finalized`).
10. Back in `/operations/rides` → **close** the ride (requires all vehicle stock cleared, i.e. every
loaded item delivered or otherwise moved off) or **cancel** it.
## Addendum: Loading Sheet "Set Items" screen (region/route-filtered PO picker)
While implementing the above, a further gap was found: the frontend never had any UI to actually set
items on a `LoadingSheet` — `LoadingSheetCreateDialog` only picks a ride and creates an empty
`Planned` sheet; `loadingSheets.updateItems()` existed in the API client but nothing called it.
Practically, a loading sheet should be built *from* confirmed Shop POs (what to load = what's been
ordered), narrowed by Region/Route so the rep isn't picking through every PO in the system.
`Ride`/`Vehicle`/`DriverProfile` carry no route assignment in this app (rides/drivers/vehicles are
not fixed to a route — confirmed as intentional, so a "derive route from the driver" auto-filter was
rejected in favor of a manual filter). The resolution:
- **Backend**: `ShopPOController`/`ShopPOService`/`ShopPORepository` `GetAll`/`GetCount` gained
`regionId` and `status` query filters (previously only `agentId`/`routeId`/`shopId`/`confirmed`),
filtering on `Store.Route.FK_RegionId` and `ShopPO.Status`. `GetAllAsync` now also includes each
PO's active `ShopPOItem`s (with `Item`) so the picker can render line items without N+1 calls.
- **Frontend**: new `LoadingSheetItemsDialog`
(`operations/loading/_components/loading-sheet-items-dialog.tsx`), opened via a "Set items" row
action on `Planned` sheets in `loading-sheets-client.tsx`. It has manual Region and Route filter
dropdowns (Route list narrows to the selected Region via the existing `routes.list({ regionId })`),
fetches confirmed `Pending`/`PartiallyFulfilled` POs matching the filter, lists their outstanding
line items (`Quantity - QuantityFulfilled`) with a checkbox and quantity input per line, a single
Warehouse selector applied to all picked lines, and saves via the existing
`loadingSheets.updateItems()` call — which already tags each `LoadingSheetItem` with its source
`FK_ShopPOItemId`, so `LoadingSheetService.SetItemsAsync`'s `LoadingSheetOrder` derivation (see
above) picks it up automatically.
- The "Confirm & commit stock" row action now also requires `sheet.items.length > 0`, since
confirming an item-less sheet was possible before but meaningless.
## Addendum: Loading Sheets list filterable by Region/Route
The Loading Sheets list (`loading-sheets-client.tsx`) previously only filtered by Ride and Status.
Since a `LoadingSheet` has no direct region/route of its own, filtering is derived through
`LoadingSheetOrder → ShopPO → Store → Route(.FK_RegionId)` — i.e. "sheets carrying at least one PO
for a store on this route/region." `ILoadingSheetRepository`/`LoadingSheetService`/
`LoadingSheetController` `GetAll`/`GetCount` gained `routeId`/`regionId` query params
(`s.Orders.Any(o => o.ShopPO.Store.FK_RouteId == routeId)`, same pattern for region). Frontend added
matching Region/Route filter dropdowns (Route list narrows to the selected Region), consistent with
the `LoadingSheetItemsDialog` filter UX added above.
## Open questions (not resolved by this pass)
- No endpoint creates an `Invoice` for the delivery-later path (`ConfirmAsync`, not
`ConfirmAndCreateInvoiceAsync`) once fulfillment completes. `IShopPORepository.GetConfirmedWithoutInvoiceAsync`
already exists and looks purpose-built for this — likely intended for a future
"generate invoices for confirmed-but-unbilled POs" job or endpoint, not built in this pass.
- Multi-invoice-per-PO, partial invoicing, and Invoice/OrderStatus axis merging remain unresolved,
consistent with `order_invoice_lifecycle.md`.
## Known environment issue (unrelated to this pipeline's code)
While verifying this pipeline live, every non-GET request (`POST`/`OPTIONS`, with or without a body,
including to nonexistent routes and endpoints untouched by this work like `/api/auth/login`) hung
indefinitely against the local backend, while every `GET` responded instantly. Since even a `POST` to
a route that doesn't exist hangs before reaching routing/controller code, this rules out an
application bug and points to something intercepting/filtering non-GET loopback HTTP traffic on the
machine (antivirus/EDR web/HTTP inspection, or a proxy/VPN). Restarting the backend process did not
resolve it. Not a defect in the code documented above — flagged here so it isn't mistaken for one
during future verification.
## Verification
- `dotnet build Distribution/Distribution.csproj` clean after each service/controller addition.
- Manually exercise the flow above via the existing frontend screens end-to-end against the local
backend (fresh `ShopPO` through to `Payment`), confirming each previously-404ing action (`create`,
`updateItems`, `confirm`, `confirmAndCreateInvoice`, `cancel`, `print`, `finalize`, `generate`) now
succeeds and the UI's existing `visible` guards match the state machine.
- `npx tsc --noEmit -p tsconfig.json` in `distribution_fe` after the frontend addition (generate
trigger + api client method).