diff --git a/Distribution/docs/refactor_docs/grn_costing_and_taxes.md b/Distribution/docs/refactor_docs/grn_costing_and_taxes.md new file mode 100644 index 0000000..5e3e91c --- /dev/null +++ b/Distribution/docs/refactor_docs/grn_costing_and_taxes.md @@ -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 `` (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. diff --git a/Distribution/docs/refactor_docs/item_tracking_config.md b/Distribution/docs/refactor_docs/item_tracking_config.md index 63b322f..9f0aa75 100644 --- a/Distribution/docs/refactor_docs/item_tracking_config.md +++ b/Distribution/docs/refactor_docs/item_tracking_config.md @@ -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 `Allow custom warranty period` checkbox. Checkboxes for a disabled global feature are hidden entirely, not just disabled, to avoid confusing item editors. -- **GRN entry** — built as two full pages (against a PO: `/procurement/grn/po/new`; direct: - `/procurement/grn/direct/new`). Tracked lines show a status chip that opens a panel for serial +- **GRN entry** — now **one page**, `/procurement/grn/new`, with an *Against PO / Direct GRN* switch + (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). 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 @@ -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. **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 -pages and both share the same serial/batch entry panel. +update) or *direct* (no PO; item chosen per line, optional supplier). The UI exposes them as one +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 - 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), - or type/paste one per line; every entry stays editable; duplicates are flagged. +- **Serials**: scan (Enter adds one), auto-generate (prefix + start number, fills only missing units; + numbering continues after the highest `` 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 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. diff --git a/Distribution/docs/refactor_docs/product_report_plan.md b/Distribution/docs/refactor_docs/product_report_plan.md new file mode 100644 index 0000000..2361c27 --- /dev/null +++ b/Distribution/docs/refactor_docs/product_report_plan.md @@ -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` (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. diff --git a/Distribution/docs/refactor_docs/shoppo_invoice_pipeline.md b/Distribution/docs/refactor_docs/shoppo_invoice_pipeline.md new file mode 100644 index 0000000..df649de --- /dev/null +++ b/Distribution/docs/refactor_docs/shoppo_invoice_pipeline.md @@ -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? 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(\`/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).