diff --git a/Distribution/docs/15_procurement.md b/Distribution/docs/15_procurement.md index 274d8b9..cb9b576 100644 --- a/Distribution/docs/15_procurement.md +++ b/Distribution/docs/15_procurement.md @@ -97,11 +97,18 @@ PurchaseOrderItem ├── IsCancelled (bool) — true once this line is individually cancelled, default false ``` +> **Update (GRN rework):** a goods receipt no longer requires a PO — see "Direct goods receipt" and +> the costing / free-issue / tax additions in +> [refactor_docs/grn_costing_and_taxes.md](./refactor_docs/grn_costing_and_taxes.md). The tree +> below shows the original design plus those changes, marked *(later)*. + ### GoodsReceipt ``` GoodsReceipt ├── Id (Guid) — PK, from Template -├── FK_PurchaseOrderId (Guid) — required +├── FK_PurchaseOrderId (Guid?) — nullable *(later)*: null = direct receipt (no PO) +├── FK_SupplierId (Guid?) — *(later)* set on a direct receipt; a PO receipt reaches the supplier via the PO +├── DiscountPercent, VatPercent, NbtPercent (decimal 5,2) — *(later)* GRN-wide defaults for lines ├── FK_ReceivedByUserId (Guid) — required ├── FK_WarehouseId (Guid) — required; the warehouse the accepted quantities are received into. │ Not anticipated in the original design — added because @@ -116,9 +123,13 @@ GoodsReceipt GoodsReceiptItem ├── Id (Guid) — PK, from Template ├── FK_GoodsReceiptId (Guid) — required -├── FK_PurchaseOrderItemId (Guid) — required, ties the receipt line back to its ordered line +├── FK_PurchaseOrderItemId (Guid?) — ties the receipt line back to its ordered line; null on a direct receipt +├── FK_ItemId (Guid?) — *(later)* the item, on a direct receipt line ├── AcceptedQuantity (int) — added to warehouse stock ├── RejectedQuantity (int) — damaged/short at receipt, default 0, not added to stock +├── FreeQuantity (int) — *(later)* free-issue units: added to stock, not costed, not counted against the PO +├── UnitCost (decimal 18,2) — *(later)* cost per unit; PO receipts default to the PO line's UnitCost +├── DiscountPercent, NbtPercent, VatPercent (decimal 5,2) — *(later)* per-line rates ``` ### PurchaseReturn @@ -154,11 +165,16 @@ gives one receiving warehouse per PO in practice; see Open Questions for the mul ## Migrations -One migration, applied: 1. **AddProcurement** (`20260824110925_AddProcurement`) — created `PurchaseOrders`, `PurchaseOrderItems`, `GoodsReceipts`, `GoodsReceiptItems`, `PurchaseReturns`, `PurchaseReturnItems`, with FKs to `Suppliers`/`Items`/`Warehouses` and indexes on every FK column. Applied via `dotnet ef database update`. +2. **AddDirectGoodsReceipt** (`20261006070042`) — made the PO links nullable and added the supplier / + item links for direct receipts. +3. **AddGrnPricing**, **AddGrnFreeQuantity**, **AddGrnNbt** (`20261007…`, `20261008…`) — GRN costing, + free issues and NBT. Pricing and free quantity are applied; **AddGrnNbt was still pending** when + last checked, and GRN reads/writes fail until it is applied — see + [refactor_docs/grn_costing_and_taxes.md](./refactor_docs/grn_costing_and_taxes.md) §7. --- @@ -182,11 +198,17 @@ One migration, applied: | `resolveInvoiceVariance(poId, variance, resolution)` | `POST /api/purchase-orders/{id}/resolve-variance` | ### Goods Receipt (`Features/Grn`) +> A PO is **not mandatory** for a goods receipt: `POST /api/goods-receipts/direct` receives stock +> with no PO (no ordered-quantity limit, no PO status, no purchase returns / invoice matching for +> it). See [refactor_docs/grn_costing_and_taxes.md](./refactor_docs/grn_costing_and_taxes.md) §1 for +> when to use which. | Function reference | Endpoint | |---|---| -| `createGoodsReceipt(poId, warehouseId, items[])` | `POST /api/purchase-orders/{id}/goods-receipts` | +| `createGoodsReceipt(poId, warehouseId, items[])` | `POST /api/purchase-orders/{id}/goods-receipts` (also `POST /api/goods-receipts` with `purchaseOrderId` in the body) | +| `createDirectGoodsReceipt(warehouseId, supplierId?, items[])` | `POST /api/goods-receipts/direct` | | `getGoodsReceipt(grnId)` | `GET /api/goods-receipts/{id}` | | `listGoodsReceipts(poId)` | `GET /api/purchase-orders/{id}/goods-receipts` | +| `listGoodsReceipts(page)` | `GET /api/goods-receipts` | ### Purchase Returns (`Features/PurchaseReturn`) | Function reference | Endpoint | @@ -215,8 +237,12 @@ One migration, applied: { "purchaseOrderItemId": "...", "acceptedQuantity": 95, "rejectedQuantity": 5 } ] } ``` -`rejectedQuantity` defaults to 0 if omitted; only `acceptedQuantity` is credited to -`StockBalance`. +`rejectedQuantity` defaults to 0 if omitted. `acceptedQuantity` **plus `freeQuantity`** (free-issue +units, default 0) is credited to `StockBalance`; only accepted units are costed and only accepted +units advance the PO line's `ReceivedQuantity`. Optional pricing fields (`unitCost`, +`discountPercent`, `vatPercent`, `nbtPercent` per line; `discountPercent`, `vatPercent`, +`nbtPercent` for the whole GRN) are described in +[refactor_docs/grn_costing_and_taxes.md](./refactor_docs/grn_costing_and_taxes.md). ### POST `/api/purchase-orders/{id}/returns` ```json @@ -297,9 +323,10 @@ above) per 14's existing target design. - Where should cost-vs-sale-price margin reporting (`PurchaseOrderItem.UnitCost` vs `PriceListItem.Price`/`Item.SalePrice`) live — a read-only endpoint/report in this step, or a later analytics/reporting step? Not a blocker for the core PO→GRN flow. -- Landed cost (freight, duty, taxes allocated onto received stock) is out of scope — - `PurchaseOrderItem.UnitCost` is the only cost basis captured. Defer unless accurate per-unit cost - is needed sooner. +- Landed cost (freight, duty allocated onto received stock) is out of scope. The GRN now records + unit cost, discount, NBT and VAT per line (see + [refactor_docs/grn_costing_and_taxes.md](./refactor_docs/grn_costing_and_taxes.md)), but these do + not feed stock valuation or `Item.CostPrice`, and the PO itself carries no discount or tax. - Reorder triggers (`setReorderPoint`/`sendLowStockAlert`) are not wired to procurement — restocking stays fully manual until Notifications & Alerts exists. - `createPurchaseReturn` assumes a single receiving warehouse per PO (uses the most recent diff --git a/Distribution/docs/shop_po_invoice_operations_flow.md b/Distribution/docs/shop_po_invoice_operations_flow.md new file mode 100644 index 0000000..5b7c6bb --- /dev/null +++ b/Distribution/docs/shop_po_invoice_operations_flow.md @@ -0,0 +1,105 @@ +# Shop PO → Invoice → Operations — Actual Flow (as implemented) + +**Status**: Reference — describes current code behavior, not a plan +**Related docs**: 03_ride_lifecycle.md, 04_loading_event.md, 05_delivery_ride.md, 06_sale_ride.md (original specs — some details below diverge from those specs; this doc reflects what the code actually does today) + +--- + +## Goal + +Trace the end-to-end lifecycle of a customer order from the moment a Shop PO is raised through +billing (Invoice) and physical fulfillment (Ride → Loading Sheet → Delivery Task), including every +status transition and stock movement along the way. + +--- + +## 1. Shop PO (raised by a sales agent, on behalf of a store) + +**Pages**: `/sales` (list), `/sales/new` (create), `/sales/[id]` (detail) +**Backend**: `Features/ShopPO` + +- **Create** (`ShopPOService.CreateAsync`): agent picks a store + items. Unit price resolves from + the active `PriceList` for that store's region, falling back to `Item.SalePrice` if none is + active. Status starts `Pending`. +- **Confirm** (`ConfirmAsync`): sets `ConfirmedAt`/`ConfirmedBy`. Status **stays `Pending`** — + confirming does not change `Status`, it only marks the PO as ready to act on (load/invoice). +- **Cancel**: only allowed while `Status == Pending` (not `PartiallyFulfilled`/`Fulfilled`). +- A confirmed PO with no invoice yet (`ConfirmedAt != null && InvoiceId == null`) is the exact set + `GetConfirmedWithoutInvoiceAsync` and the Loading Sheet picker (`GET /shop-pos?confirmed=true`) + query for. + +## 2. Invoice (billing document, one per Shop PO) + +**Page**: `/sales/orders` (labeled "Orders" in the nav, but it is actually the Invoices list) +**Backend**: `Features/Invoice` + +- Created via `ShopPOService.ConfirmAndCreateInvoiceAsync` — confirms the PO **and** creates an + `Invoice` (`Status: Draft`, `PaymentStatus: Pending`) in one step, linking `ShopPO.InvoiceId`. + (No frontend UI currently calls this endpoint — only the plain `Confirm` action is wired up.) +- Lifecycle: `Draft → Printed → Finalized`. + - Finalizing can adjust the billed quantity per line (capped between `0` and the ordered + quantity) and recomputes `TotalAmount` from the adjusted lines. +- Payments (`payments` API) apply against the invoice, moving `PaymentStatus`: + `Pending → PartiallyPaid → Paid`. + +## 3. Operations: Ride → Loading Sheet → Confirm → Delivery Tasks + +**Pages**: `/operations/rides`, `/operations/loading`, `/operations/loading/new` +**Backend**: `Features/Ride`, `Features/LoadingEvent`, `Features/DeliveryRide` + +1. **Ride** created with `Status: Open`. A Ride is just a Vehicle + Driver pairing — it has **no** + Route field in the current data model (unlike the original 03/06 specs' "ride type" framing). +2. **Loading Sheet** created against an Open ride (`/operations/loading/new`): + - Pick a **Route** (required, first) → confirmed Shop POs for stores on that route load + (`ConfirmedAt != null`, status `Pending`/`PartiallyFulfilled`, remaining quantity > 0). + - Optionally narrow by **Shop** (store) within that route. + - Pick items/quantities to load, a **Warehouse** (single warehouse per sheet), and finally the + **Ride** to load onto. + - Sheet starts `Status: Planned`. +3. **Set items** (`PUT /loading-sheets/{id}/items`) — editable only while `Planned`. Behavior is + **additive/upsert**, keyed by `(FK_ItemId, FK_WarehouseId, FK_ShopPOItemId)`: a matching row's + `QuantityDispatched` is updated in place, a new combination is added, and rows for combinations + not present in the request are left untouched. This lets a shortfall in one warehouse be topped + up from a second warehouse in a later save without losing the first line. +4. **Confirm sheet** (`LoadingSheetService.ConfirmAsync`): for every line, deducts stock from its + Warehouse and credits it to the Ride's Vehicle (`StockMovement.Type = Load`, + `Warehouse → Vehicle`). Sheet → `Status: Confirmed`. This is the only point stock actually + leaves the warehouse. (Note: unlike the 04_loading_event.md spec, confirming a sheet does **not** + transition the Ride's own status — `RideStatus` only has `Open`/`Cancelled`/`Closed`, no + `Active`.) +5. **Generate delivery tasks** (`DeliveryTaskService.GenerateAsync`): once a ride has at least one + `Confirmed` loading sheet, one `DeliveryTask` (`Status: Pending`) is created per distinct Shop PO + that sheet's `LoadingSheetOrder` rows reference. +6. **Set quantities** on each task (`SetQuantitiesAsync`) — how much of each `ShopPOItem` was + actually delivered, capped at the item's ordered quantity. Only allowed while the task is + `Pending`. +7. **Confirm task** (`DeliveryTaskService.ConfirmAsync`) — closes the loop back to the Shop PO: + - Moves stock **off the vehicle** (`StockMovement.Type = Sale`, `Vehicle → External`) for + whatever was fulfilled, failing if the vehicle doesn't actually have enough stock. + - Task → `Status: Delivered` (all items fully fulfilled) or `PartiallyDelivered`. + - Writes back to the Shop PO: `Status = Fulfilled`/`PartiallyFulfilled`, `FulfilledAt` set, and + each `ShopPOItem.QuantityFulfilled` bumped. This is exactly the field the Loading Sheet + picker's "remaining quantity" (`quantity - quantityFulfilled`) depends on — a fully fulfilled + PO drops out of the picker entirely. +8. **Fail task**: marks `Status: Failed`. No stock movement, no Shop PO status change. + +--- + +## Stock ledger across the whole flow + +``` +Warehouse --(Loading Sheet confirm, Type=Load)--> Vehicle --(Delivery Task confirm, Type=Sale)--> External/Customer +``` + +Every step writes a `StockMovement` row, so `StockBalance` for each Warehouse/Vehicle stays +reconciled from Shop PO creation all the way through delivery. + +--- + +## Known gaps at time of writing + +- No frontend UI calls `ConfirmAndCreateInvoiceAsync` — only the plain `Confirm` action (no + invoice) is wired up on the Shop PO screens. +- `ShopPOController`/`LoadingSheetController` Create/Confirm endpoints, like most other + create/confirm endpoints in this codebase, rely on the TEMPORARY `currentUser.UserId ?? request.XxxUserId` + fallback pattern documented in `testbug.md` (#28) until the real auth middleware is fixed. diff --git a/Distribution/docs/testbug.md b/Distribution/docs/testbug.md index 987afb2..0c89138 100644 --- a/Distribution/docs/testbug.md +++ b/Distribution/docs/testbug.md @@ -54,6 +54,7 @@ Running log of backend API failures found during testing and the corresponding f | 12 | Pricing → Schemes (action icons) | Same recurring pattern as Price Lists (#45): `DeleteAsync`'s `repo.Remove` already soft-deleted (`IsActive = false`), but `GetAllAsync`/`GetByIdAsync`/`UpdateAsync` filtered/blocked on `IsActive`, hiding a deleted scheme from the list entirely and blocking viewing/editing/reactivating it. `EvaluateEligibilityAsync` relied on that same repo-level filter to only ever consider active schemes as redemption candidates — removing the filter without care would have silently let inactive/deleted schemes be evaluated as eligible. Also `GetAll` returns a plain `List` but the frontend was configured `serverSide: true` expecting a `PaginatedResponse`, so pagination silently did nothing | Added View (details modal — rule type, region, segment, date range, trigger/free-goods payout, bundle components) and Delete (soft-deactivate, reuses the existing "Active" checkbox in the Edit form to reactivate) icons | Removed the `IsActive` filter from `SchemeRepository.GetAllAsync`; moved the active-only filter into `SchemeService.EvaluateEligibilityAsync` itself (`.Where(s => s.IsActive)` on the fetched candidates) so eligibility evaluation is unaffected; removed the `IsActive` blocks from `GetByIdAsync`/`UpdateAsync`; added `IsActive` to `UpdateSchemeRequest`; switched the frontend to client-side pagination (same fix as Price Lists/Orders/Brands); added `schemes.remove()`; created `SchemeViewSheet` | 2026-09-09 | | 14 | Suppliers (action icons) | Exact same recurring pattern again: `SupplierRepository.GetAllAsync` filtered `.Where(s => s.IsActive)` (hiding a deleted supplier from the list instead of showing it Inactive), `GetByIdAsync`/`UpdateAsync` blocked viewing/editing an inactive one, and `UpdateSupplierRequest` had no `IsActive` field so the Edit form's existing "Active" checkbox couldn't reactivate one. Also `suppliers.list()` was typed as `PaginatedResponse` against a backend `GetAll` that returns a plain array, same pagination-shape bug as Price Lists/Schemes/Orders/Brands | Added View (details modal — name, contact, status, performance score, created date) and Delete (soft-deactivate, reuses the existing "Active" checkbox in Edit to reactivate) icons | Removed the `IsActive` filter from `SupplierRepository.GetAllAsync`; removed the `IsActive` blocks from `GetByIdAsync`/`UpdateAsync` (kept on `EvaluateAsync`, a real business guard — no point recomputing a performance score for a deleted supplier); added `IsActive` to `UpdateSupplierRequest`; switched the frontend to client-side pagination; added `suppliers.remove()`; created `SupplierViewSheet` | 2026-09-09 | | 48 | Pricing → Price Lists (new fields) | — | Added `RetailPrice` and `AgencyMarginPercent` to `PriceListItem`, alongside the existing `Price`/`MinPrice` band — see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) for the full business rules. `RetailPrice` is the MRP the store is expected to charge the end consumer (must be `>= Price`); `AgencyMarginPercent` is the regional agency's commission `%` of `Price` (must be `0-100`). Both are informational only — carried through `PriceResolution` for display/reporting, never enforced on order pricing | Added both columns to the Items table in the Price List Edit/View pages (add-row + inline edit + view-only table), and a "Retail price: Rs. X" hint under the unit price on Create Sale when the resolved price came from a real price list | Added `RetailPrice`/`AgencyMarginPercent` to `PriceListItem`, migration `AddRetailPriceAndAgencyMargin`; extended `UpsertPriceListItemRequest`/`PriceListItemResponse`/`PriceResolution` with validation (`RetailPrice >= Price`, `AgencyMarginPercent` in `[0,100]`); updated `PriceListItemsSection`, the View page's items table, and `priceLists.upsertItem()`; updated `docs/14_pricing_and_suppliers.md` | 2026-09-09 | +| 49 | Configuration → Products (Product Capabilities toggles) — TEMPORARY workaround | `PUT /api/sys-config/{key}` had the same unhandled `var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();` crash as #10/#36/#40/#8, surfaced the moment a toggle tried to save. A first attempt added `Guid? UserId = null` as a record constructor default, which broke `System.Text.Json` deserialization entirely (`NotSupportedException` — a record constructor parameter with a default value reads to STJ as an ambiguous/parameterless-like constructor) | Rebuilt the Configuration area end to end: sidebar "Configuration" is now a standalone link (no sub-nav/collapsible) straight to `/configuration`, a card-grid landing page; its "Products" card opens `/configuration/products`, a redesigned "Product Capabilities" card (two-column toggle grid, ON/OFF `Switch`, no "Enable…" wording). Removed the page's "Save Changes" button entirely — each toggle (and the default-warranty-days input, on blur) now calls the API immediately on change, optimistically updating and reverting with a toast on failure. Also renamed "Items" → "Products" in the Catalogue nav label, page heading, and the Add/Edit/View dialogs (left every other module's "Item" line-item wording — Sales/Procurement/GRN/Loading Sheets/Returns — untouched, since those are document line items, not the catalogue) | TEMP: added `UserId` (no default value, to keep STJ happy) to `SetSysConfigRequest`; `SysConfigController.Set` now does `currentUser.UserId ?? request.UserId`, returning a clean `BadRequest` instead of throwing when both are null. Frontend `sysConfig.set()` takes an optional `userId` and the Product Configuration page passes `useAuth().user.id` (the mock-auth user) on every toggle/save call. Revert once auth (#28) is fixed | 2026-10-02 | | 8 | Stock Balances | No way to correct a balance — stock is architecturally never edited directly, only through movement transactions (load, sale, return, damage, adjustment); the `POST /stock/adjustment` endpoint existed but its controller action always returned `Unauthorized()` because `currentUser.UserId` is unset (#28) | Added a View icon (details modal) and an Edit icon that opens an "Adjust Stock" dialog (signed quantity delta + reason + Adjusted By) which posts to `/stock/adjustment` — this is the correct way to change a balance, not a direct field edit | TEMP: added `AdjustedByUserId` fallback to `AdjustmentRequest`, same pattern as #36/#40; wired `StockBalanceViewSheet` + `StockAdjustDialog` into Stock Balances table | 2026-09-08 | | 9 | Stock Movements | Some rows missing From/To locations — every movement should always have both | Added View and Edit icons. Movements are an immutable audit-trail ledger by design — quantity, locations, and type can never change once recorded (the running balance is the sum of all movements), so Edit only lets you correct the Reason/note via a new `PATCH /stock/movements/{id}/reason` endpoint; a wrong quantity/location must be corrected with a new Adjustment (see #8), not by editing history | Wired `StockMovementViewSheet` + `StockMovementEditDialog` into Stock Movements table | 2026-09-08 | | 41 | Stock Balances / Stock Movements | Balances and Movements tables/view-sheets showed raw location GUIDs instead of names; Movements also had a genuinely missing side — Sale/PurchaseReceipt/PurchaseReturn/CustomerReturn/Damage/Adjustment each only ever wrote one of From/To, since the other side (customer, supplier, write-off, adjustment) has no real Warehouse/Vehicle record. Existing seeded movement rows still showed blank From/To after the code fix since it only affects newly-created movements | — | Added `LocationName` to `StockBalanceResponse` and `FromLocationName`/`ToLocationName` to `StockMovementResponse`, resolved server-side (`StockService`) by looking up the real Warehouse/Vehicle. Added a new `LocationType.External` plus a fixed `Guid.Empty` sentinel location so every movement's missing side is now always populated and labeled contextually ("Customer", "Supplier", "Written Off", "Stock Adjustment") instead of being left null. Updated all 6 movement-creation call sites (StockService Damage/CustomerReturn/Adjustment, SaleService, DeliveryTaskService, GrnService, PurchaseReturnService, ReturnService), and added migration `FixSeedStockMovementExternalLocations` to backfill the 2 affected seeded rows | 2026-09-08 | @@ -75,6 +76,7 @@ Everything below is a stopgap that only exists because `Distribution/Middleware/ 11. **`CreateSaleReturnRequest.CreatedByUserId`** (`ReturnDTOs.cs`) and the "Created By" dropdown in the Record Order Return dialog (#23) — remove once `ReturnController.Create` can resolve `currentUser.UserId` on its own. 12. **`SettleClaimRequest.SettledByUserId`** (`ClaimDTOs.cs`) and the "Settled By" dropdown in the Settle Claim dialog (#23) — remove once `ClaimController.Settle` can resolve `currentUser.UserId` on its own. 13. **`CreateSupplierClaimRequest.RaisedByUserId`/`ApproveSupplierClaimRequest.ApprovedByUserId`/`RejectSupplierClaimRequest.RejectedByUserId`** (`SupplierClaimDTOs.cs`) and the corresponding user dropdowns in the Raise/Approve/Reject Supplier Claim dialogs (#23) — remove once `SupplierClaimController.Create`/`Approve`/`Reject` can resolve `currentUser.UserId` on their own. +14. **`SetSysConfigRequest.UserId`** (`SysConfigDTOs.cs`) and the `userId` argument threaded through `sysConfig.set()` from the Product Configuration toggles page (`app/(dashboard)/configuration/products/page.tsx`, #49) — remove once `SysConfigController.Set` can resolve `currentUser.UserId` on its own; drop the `?? request.UserId` fallback and the `BadRequest` guard around it. ## Implementation Guide