Add documentation for GRN costing, taxes, and item tracking configuration
This commit is contained in:
@@ -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
|
||||
`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 `<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
|
||||
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.
|
||||
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user