Add PriceList functionality with RetailPrice and AgencyMarginPercent
This commit is contained in:
@@ -50,6 +50,36 @@ default — the same negotiation-boundary pattern already used for discount limi
|
||||
(400, via a null resolution) if the requested price falls outside the band, or if no price can be
|
||||
resolved at all.
|
||||
|
||||
### Retail price and agency margin (informational, not enforced on sales)
|
||||
|
||||
Two more numbers can be tracked per `(PriceListItem)` beyond the wholesale `Price`/`MinPrice` band,
|
||||
covering the rest of the chain from distributor down to the end consumer:
|
||||
|
||||
- **`RetailPrice` (decimal?)** — the MRP the store is expected to charge the end consumer. Not
|
||||
enforced anywhere (a store can charge whatever it wants at the till) — it exists so the
|
||||
wholesale `Price` can be validated against it (a store must always have a positive margin) and so
|
||||
the sales rep can see it while negotiating with a store.
|
||||
- **`AgencyMarginPercent` (decimal?)** — the percentage of `Price` the regional agency operating
|
||||
that territory retains as commission for running local distribution (sales force, vehicles,
|
||||
warehousing). Purely informational/reporting — not deducted from anything automatically; it feeds
|
||||
agency settlement/payout reporting, the same `RegionalAgency` role that raises Agency Claims
|
||||
([18_returns_claims.md](./18_returns_claims.md)).
|
||||
|
||||
Neither field participates in `ResolveUnitPriceAsync`'s pricing logic or the `[MinPrice, Price]`
|
||||
negotiation band — they ride along in the same `PriceListItem` row and `PriceResolution` response
|
||||
purely for display and reporting.
|
||||
|
||||
- `PriceListItem.RetailPrice (decimal?)` — must be `>= Price` when set (409 if violated); `null` =
|
||||
not tracked for this item/list.
|
||||
- `PriceListItem.AgencyMarginPercent (decimal?)` — must be between 0 and 100 when set (409 if
|
||||
violated); `null` = not tracked for this item/list.
|
||||
- `PUT /api/price-lists/{id}/items/{sku}` accepts the extended body
|
||||
`{ "price": ..., "minPrice": ..., "retailPrice": ..., "agencyMarginPercent": ... }` — both new
|
||||
fields are optional and independent of each other and of `minPrice`.
|
||||
- `PriceResolution` (returned by `ResolvePriceAsync`/`ResolveByStoreAsync`) carries both fields
|
||||
through so the frontend can display them without a second lookup; both are `null` on the
|
||||
`Item.SalePrice` fallback path, since neither is tracked outside an actual `PriceListItem`.
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
@@ -75,6 +105,10 @@ PriceListItem
|
||||
│ the parent PriceList is active and applicable to the order's region
|
||||
├── MinPrice (decimal?) — nullable floor for negotiated pricing; null = Price is fixed.
|
||||
│ Must be <= Price when set.
|
||||
├── RetailPrice (decimal?) — nullable MRP the store charges the end consumer; informational,
|
||||
│ not enforced on sales. Must be >= Price when set.
|
||||
├── AgencyMarginPercent (decimal?) — nullable, 0-100, the regional agency's commission % of Price;
|
||||
│ informational only, feeds agency settlement reporting.
|
||||
├── IsActive, CreatedAt — from Template
|
||||
```
|
||||
Unique constraint on `(PriceListId, ItemId)` — one price per item per price list.
|
||||
@@ -138,10 +172,11 @@ One migration needed:
|
||||
|
||||
### PUT `/api/price-lists/{id}/items/{sku}`
|
||||
```json
|
||||
{ "price": 125.50, "minPrice": 115.00 }
|
||||
{ "price": 125.50, "minPrice": 115.00, "retailPrice": 150.00, "agencyMarginPercent": 15 }
|
||||
```
|
||||
Resolves `sku` to `ItemId`, upserts the `PriceListItem` row. `minPrice` is optional — omit or send
|
||||
`null` for a fixed, non-negotiable price. Rejected with 409 if `minPrice > price`.
|
||||
Resolves `sku` to `ItemId`, upserts the `PriceListItem` row. `minPrice`, `retailPrice`, and
|
||||
`agencyMarginPercent` are all optional — omit or send `null` for none. Rejected with 409 if
|
||||
`minPrice > price`, `retailPrice < price`, or `agencyMarginPercent` is outside `[0, 100]`.
|
||||
|
||||
### POST `/api/suppliers`
|
||||
```json
|
||||
@@ -178,6 +213,14 @@ Resolves `sku` to `ItemId`, upserts the `PriceListItem` row. `minPrice` is optio
|
||||
`[MinPrice ?? Price, Price]` for the item's resolved `PriceListItem` (region-scoped, then
|
||||
global). The `Item.SalePrice` fallback has no `MinPrice` — always fixed, no negotiation. An
|
||||
out-of-band price, or no resolvable price at all, rejects the whole order (400).
|
||||
8. `PriceListItem.RetailPrice`, when set, must be `>= Price` — enforced on every write via
|
||||
`PUT /api/price-lists/{id}/items/{sku}` (409 if violated); a store must never be expected to
|
||||
sell below what it paid.
|
||||
9. `PriceListItem.AgencyMarginPercent`, when set, must be within `[0, 100]` — enforced the same
|
||||
way (409 if violated).
|
||||
10. Neither `RetailPrice` nor `AgencyMarginPercent` participates in order pricing/validation
|
||||
(rule 7) — both are informational only, carried through `PriceResolution` for display and
|
||||
reporting, and are `null` on the `Item.SalePrice` fallback path.
|
||||
|
||||
---
|
||||
|
||||
@@ -311,9 +354,14 @@ POST /api/price-lists
|
||||
→ PriceList A (RegionId = North)
|
||||
|
||||
PUT /api/price-lists/{A.Id}/items/COKE-330
|
||||
{ "price": 90, "minPrice": 80 }
|
||||
{ "price": 90, "minPrice": 80, "retailPrice": 120, "agencyMarginPercent": 15 }
|
||||
```
|
||||
This publishes a band for `COKE-330` in the North region: ceiling 90, floor 80.
|
||||
This publishes a band for `COKE-330` in the North region: ceiling 90, floor 80. It also records that
|
||||
the store is expected to retail it at 120 (so the store's own margin is 90–120, or up to 40 if the
|
||||
rep negotiated down to 80), and that the North region's agency retains 15% of whatever wholesale
|
||||
price was actually charged (e.g. 15% of 90 = 13.50 if sold at the ceiling) as their distribution
|
||||
commission — informational only, used for agency settlement reporting, not deducted automatically
|
||||
from anything.
|
||||
|
||||
**Order at the default (no negotiation):**
|
||||
```
|
||||
|
||||
@@ -6,15 +6,9 @@ Running log of backend API failures found during testing and the corresponding f
|
||||
|
||||
| # | Area | Backend API Failure | Frontend Change Needed | Date |
|
||||
|---|------|----------------------|--------------------------|------|
|
||||
| 6 | Sales → Visits (Route dropdown) | Selecting a specific route in the route dropdown does not filter route sales — all routes still show; check-in also not working | Fix route filter and check-in on Sales Visits page. **Partial fix landed (#49)**: `GET /stores?routeId=...` now actually filters server-side, so the store list for a selected route should now be correct — check-in itself still needs investigating | 2026-09-02 |
|
||||
| 7 | Catalogue → Categories (Subcategories) | Added subcategories don't display in the FE | Fix subcategory list rendering | Add a View icon to the action column in Categories table | 2026-09-02 |
|
||||
| 8 | Stock Balances | | Add a View icon to the action column in Stock Balances table; allow Edit | 2026-09-02 |
|
||||
| 9 | Stock Movements | Some rows missing From/To locations — every movement should always have both | Add a View icon and an Edit icon to the action column in Stock Movements table | 2026-09-02 |
|
||||
| 10 | Procurement → Orders (Place Order) | Placing a new order throws: `var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();` | Add action icons to the action column in Procurement Orders table | 2026-09-02 |
|
||||
| 11 | Procurement → GRN | | Full module CRUD check pending (CRUD + action icons) | 2026-09-02 |
|
||||
| 12 | Pricing | | Full module CRUD check pending (frontend + CRUD + action icons) | 2026-09-02 |
|
||||
|
||||
| 11 | Procurement → GRN | Record Receipt throws `var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();` (same #28 pattern, not yet given a temp workaround) | Added a View icon (see Resolved #44). No Edit/Delete — a GRN has no backend update/delete endpoint by design, since it already wrote stock balance + a `PurchaseReceipt` movement + the Po's `ReceivedQuantity` on create; reversing one safely needs a dedicated endpoint, not a plain CRUD delete. Still pending: the broken-auth throw on Record Receipt | 2026-09-02 |
|
||||
| 13 | Pricing → Schemes (Add Scheme modal) | | Add Scheme modal should show a rule-type-specific "value" input field that changes based on the selected Scheme Name/Rule Type (currently no dynamic value field for the chosen rule type) | 2026-09-02 |
|
||||
| 14 | Suppliers | | Add a View icon and a Cancel icon to the action column in Suppliers table | 2026-09-02 |
|
||||
| 15 | Driver Profiles | Delete doesn't work | Add a View icon to the action column in Driver Profiles table | 2026-09-02 |
|
||||
| 16 | Users | Delete action not working | Add View icon | 2026-09-02 |
|
||||
| 17 | Users → Roles | | For now, hide the Add Role icon and the Edit/Cancel (deactivate) icons in the action column; keep only the View icon | 2026-09-02 |
|
||||
@@ -23,8 +17,6 @@ Running log of backend API failures found during testing and the corresponding f
|
||||
| 20 | Network → Routes | Inactive status doesn't work properly | Check Manage Owner and Manage Stores actions; add View icon to the action column; check Cancel action too | 2026-09-02 |
|
||||
| 21 | Network → Vehicles | | Add a View icon to the action column; check Cancel action | 2026-09-02 |
|
||||
| 22 | Network → Warehouses | Delete doesn't work | Add a View icon to the action column; check Inactive status | 2026-09-02 |
|
||||
| 23 | Returns & Claims Management | `ReturnController`, `ClaimController`, `SupplierClaimController` all exist and are fully implemented | No frontend integration at all — no API client, no pages/UI for order returns, claims, or supplier claims. Build the module end-to-end | 2026-09-02 |
|
||||
| 24 | Procurement → Purchase Returns | `PurchaseReturn` CRUD fully implemented | No frontend API client and no UI at all for purchase returns. Build the module end-to-end | 2026-09-02 |
|
||||
| 25 | Orders (CRUD gap) | Create/Update/Delete endpoints exist and work | Orders page is read-only in the frontend — only list/get are wired up; add Create/Update/Delete UI | 2026-09-02 |
|
||||
| 26 | Pricing → Schemes (unused capability) | Eligibility check, spend/ROI analytics, and apply-scheme-to-order endpoints all exist | None of these are wired into the frontend — only basic scheme CRUD is; add UI for eligibility check, spend/ROI view, and applying a scheme to an order | 2026-09-02 |
|
||||
| 27 | Misc missing Delete actions | Delete/soft-delete endpoints exist for Users, Stores, Price Lists, and Suppliers | No Delete UI wired up for these on their respective pages | 2026-09-02 |
|
||||
@@ -48,17 +40,23 @@ Running log of backend API failures found during testing and the corresponding f
|
||||
| 39 | Sales (Create Sale) | — | Moved Create Sale from a cramped modal to a dedicated `/sales/new` page: Sale Details and Items are each full-width, Items shown as an invoice-style table, and a compact Sale Summary card is right-aligned below the items | 2026-09-03 |
|
||||
| 40 | Sales (Cancel Sale) — TEMPORARY workaround | `CancelAsync` still had the same broken store-scope check as Create (#36) — `HasStoreScopeAsync` always false due to broken auth middleware (#28), so cancel silently 404'd | — | TEMP: bypassed store-scope check in `CancelAsync`, same as Create. Revert once auth (#28) is fixed | 2026-09-03 |
|
||||
| 3 | Sales (Create Sale) | Sale create always failed — `saleDate` was never sent, defaulted to year 0001, no price list ever active that date | Added a required Sale Date field to the Create Sale modal | Sent `saleDate` in the create payload; summary total was already computing correctly, just never reached the backend | 2026-09-03 |
|
||||
| 4 | Orders | `GET /orders` returns a plain array with no pagination support, but the frontend client treated it as a paginated response (same bug class as delivery tasks, #30) | No action icons on the Orders table; store shown as a raw ID | Fixed API client to match the real response shape; added a View icon + details modal; store name now resolved and shown instead of the raw store ID | 2026-09-03 |
|
||||
| 41 | Orders — TEMPORARY workaround | `UpdateAsync` had the same broken store-scope check as Delete/Sales (#28/#40) — always false, so status edits silently 404'd | Added Edit (status dropdown) and Delete icons to the Orders table, matching backend capability | TEMP: bypassed store-scope check in `OrderService.UpdateAsync`, same as `DeleteAsync`. Revert once auth (#28) is fixed | 2026-09-03 |
|
||||
| 42 | Orders (Delete) | `GetAllAsync` filtered `.Where(o => o.IsActive)` — Delete sets `IsActive=false`, so a deleted order vanished from the list entirely (violated the Implementation Guide's own rule) instead of staying visible as Cancelled, and any further `PUT` on it correctly 404'd since `UpdateAsync` also checks `IsActive` | — | Removed the `IsActive` filter from `GetAllAsync` — deleted orders now stay visible in the table with a Cancelled badge | 2026-09-03 |
|
||||
| 43 | Payments | — | Table and View modal showed raw Store ID and Collected By user ID instead of names | Resolved and now show store name and collected-by user's full name (fetched from `/stores` and `/users`) | 2026-09-03 |
|
||||
| 5 | Payments (Record Payment) — TEMPORARY workaround, resolved for now | `PaymentService.CreateAsync` threw an uncaught `UnauthorizedAccessException` (`currentUser.UserId` always unset, #28) — controller only catches `InvalidOperationException`, so this was an unhandled 500, not a clean error; separately, `CreateFlatPaymentRequest` never accepted `StoreId`/`CollectedAt` at all, yet the form required typing a raw Store ID and picking a date, both silently discarded | Rebuilt Record Payment form: Sale picked from a dropdown (auto-derives store), removed the dead Store ID/Collected-At fields, added a required "Collected By" user dropdown; added a View icon + details modal to the Payments table | TEMP: added `CollectedByUserId` fallback to `CreatePaymentRequest`/`CreateFlatPaymentRequest`, same pattern as Sale (#36). **Payment creation now works end-to-end** with the user picking themselves manually. **Still to do once real auth (#28) lands**: drop the `CollectedByUserId` fallback and go back to `currentUser.UserId` only, and remove the "Collected By" dropdown from the form — see Future Fixes #7–#8 below | 2026-09-03 |
|
||||
| 44 | Stores | — | Route column showed the raw Route ID instead of the route name (table and the Store detail page) | Resolved and now shown as the route name (fetched from `/routes`) on both the Stores table and `/stores/{id}` detail page | 2026-09-04 |
|
||||
| 45 | Stores (map, dev only) | — | Any store page with the map (create/edit/view) crashed with "Cannot read properties of undefined (reading 'appendChild')" — react-leaflet's `MapContainer` doesn't support React Strict Mode's dev-only double-invoke of mount effects | Disabled `reactStrictMode` in `next.config.ts`. Dev-only issue; production builds never double-invoke effects so this was never a prod bug — restart the dev server to pick up the change | 2026-09-04 |
|
||||
| 46 | Stores (Edit / Activate-Deactivate) | `UpdateStoreRequest` had no `IsActive` field at all — unchecking "Active" on the Edit Store page, and the Activate/Deactivate row action on the Stores table (both use `PUT /stores/{id}`), silently had no effect since the backend DTO dropped the field entirely | — | Added `IsActive` to `UpdateStoreRequest` and applied it in `StoreService.UpdateAsync` | 2026-09-04 |
|
||||
| 47 | Stores (list/detail hid inactive) | `GetAllAsync` filtered `.Where(s => s.IsActive)` (same violation as Orders, #42) — after #46's fix actually started persisting the deactivation, the store just vanished from the list instead of showing Inactive; `GetByIdAsync`/`UpdateAsync` also blocked viewing or re-editing any already-inactive store, so it couldn't even be reactivated | — | Removed the `IsActive` filter from `GetAllAsync`; removed the `!IsActive` guards from `GetByIdAsync`/`UpdateAsync` so a deactivated store stays visible and can still be viewed/reactivated | 2026-09-04 |
|
||||
| 48 | Stores (Activate/Deactivate row action, View visit history) | The table's Activate/Deactivate action sent only `{ isActive }` to `PUT /stores/{id}`, which requires the FULL record (RouteId, OwnerName, StoreName, etc. all required/non-nullable) — missing fields bound to C# defaults (`RouteId` → `Guid.Empty`), corrupting the store or failing an FK constraint; `GetVisitHistoryAsync` also still had the stale `!store.IsActive` guard, so any store corrupted/deactivated by the above would 404 on View Visit History too | — | Added a dedicated `PATCH /stores/{id}/status` endpoint (`StoreService.SetActiveStatusAsync`) that only touches `IsActive`; wired the row action to it instead of the full update; removed the stale `IsActive` guard from `GetVisitHistoryAsync` | 2026-09-04 |
|
||||
| 49 | Stores (list pagination/filtering) | `GET /stores` accepted no query params at all and returned a plain array, while the frontend `serverSide: true` table + `stores.list({ routeId, kycStatus, search, page, pageSize })` callers (Stores table, Orders/Payments/Sale-new/Route-manage/Sales-Visits store pickers) expected real server-side filtering and a paginated response — pagination totals were broken and filters (including the route filter behind #6) were silently ignored | — | Implemented real filtering + pagination in `StoreRepository`/`StoreService`/`StoreController` (`routeId`, `kycStatus`, `search`, `page`, `pageSize`), returning a proper `PagedResponse<StoreResponse>` | 2026-09-04 |
|
||||
| 6 | Sales → Visits (Route dropdown) | Selecting a specific route in the route dropdown does not filter route sales — all routes still show; check-in also not working | Route filter and check-in now work on the Sales Visits page | Fixed route filter and check-in (see TEMP `CheckedInByUserId` workaround, #40-style, pending real auth) | 2026-09-08 |
|
||||
| 7 | Catalogue → Categories (Subcategories) | Added subcategories don't display in the FE | Added a View icon to the action column in Categories table | Fixed subcategory list rendering — root cause was `GetAllAsync` returning `CategorySummaryResponse`, which had no `subCategories` field at all | 2026-09-08 |
|
||||
| 42 | Procurement → Orders (Place Order UI) | — | Moved Create Purchase Order from the `PurchaseOrderSheet` modal to a dedicated `/procurement/orders/new` page, matching the `/sales/new` layout: Order Details full-width card, Items as an invoice-style table, and a right-aligned Order Summary card below | Created `procurement/orders/new/page.tsx`, updated `PurchaseOrdersClient`'s "New Order" button to navigate there, removed the now-unused `purchase-order-sheet.tsx` | 2026-09-08 |
|
||||
| 43 | Procurement → Orders (action icons) | Cancel API call was broken: frontend sent `PATCH /purchase-orders/{id}/cancel` with body `{cancelReason}`, but the backend only registers `POST .../cancel` and expects `{reason}` — the wrong HTTP verb alone would 405 | Added View (details modal, showing all lines with received/cancelled quantities) and Edit (a `/procurement/orders/[id]/edit` page, enabled only while the order is Issued and matching backend's `UpdatePoRequest` rule that already-received lines are locked). Delete was already present as the Cancel action (soft-cancel with a reason, per the no-hard-delete Implementation Guide rule) — relabeled "Delete order" | Fixed the cancel API client to `POST` with `{reason}` matching `CancelPoRequest`; added `purchaseOrders.update()`; created `PurchaseOrderViewSheet` and the Edit page | 2026-09-08 |
|
||||
| 44 | Procurement → GRN (action icons) | — | Added a View icon (details modal showing warehouse, received date, and each line's accepted/rejected quantity). No Delete — deliberate, not a gap: a goods receipt already applied its effect (stock balance + `PurchaseReceipt` movement + the Po's `ReceivedQuantity`) at create time, so a generic delete would silently desync stock; reversing one needs a dedicated, explicit endpoint, which hasn't been requested/built | Created `GrnViewSheet`, wired a View row action into `GrnClient` | 2026-09-08 |
|
||||
| 10 | Procurement (Create Purchase Order + Record Goods Receipt) | Both `POST /purchase-orders` and `POST /goods-receipts` (and the Po-scoped goods-receipts route) had `var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();` — with auth broken (#28) and no global exception handler registered, every Create Order / Record Receipt attempt crashed with an unhandled 500, not even a clean 401. This was a genuine, currently-broken business flow, not just a cosmetic gap | Added a "Created By" dropdown to `/procurement/orders/new` and a "Received By" dropdown to the Record Receipt (GRN) sheet | TEMP: added `CreatedByUserId` to `CreatePoRequest` and `ReceivedByUserId` to `CreateGrnRequest`/`CreateFlatGrnRequest`, same `currentUser.UserId ?? request.XxxUserId` fallback pattern as #36/#40/#8, with a clean `BadRequest` instead of the unhandled crash | 2026-09-08 |
|
||||
| 23 | Returns & Claims Management | `ReturnController`, `ClaimController`, `SupplierClaimController` all exist and are fully implemented, but `Create`/`Settle`/`Approve`/`Reject` on all three had the same unhandled `?? throw new UnauthorizedAccessException()` crash as #10; `ClaimController` and `ReturnController` also had no `GetAll` endpoint at all, only per-order/per-agency lookups, so there was no way to list existing records for a page. None of the three had an Update/edit endpoint either | No frontend integration existed at all — built a new "Returns & Claims" nav group with 3 dedicated pages: **Order Returns** (`/returns-claims/order-returns` — list + View + Edit + a Record Return dialog that only allows returning items the selected order actually has, mode CreditDeduction/ItemExchange), **Agency Claims** (`/returns-claims/claims` — list + View + Edit (while Open) + Raise Claim + Settle), **Supplier Claims** (`/returns-claims/supplier-claims` — list + View + Edit (while Open) + Raise Claim scoped to a PO's received items + Approve/Reject). Edit scope per module: Order Return quantities/mode are applied to stock and the order total immediately on create with no approval step, so only each line's **reason code** is editable, not quantity/mode/items (same immutable-effect principle as Stock Movements, #9); Claims and Supplier Claims have no side effect until Settle/Approve, so their Details/Items are fully editable while still Open, locked out afterward. Purchase Returns (#24) already lives in-context inside the Purchase Order View sheet and was left as-is | Backend: added `GetAllAsync` to `IReturnRepository`/`IClaimRepository`/`ISupplierClaimRepository` + a `GET` list endpoint on each controller; fixed all 5 broken-auth crash sites (`ReturnController.Create`, `ClaimController.Settle`, `SupplierClaimController.Create`/`Approve`/`Reject`) with the same `CreatedByUserId`/`SettledByUserId`/`RaisedByUserId`/`ApprovedByUserId`/`RejectedByUserId` fallback pattern; added `PATCH /returns/{id}/reasons`, `PUT /claims/{id}` (blocked once Settled), `PUT /supplier-claims/{id}` (blocked once resolved). Frontend: added `saleReturns`/`claims`/`supplierClaims` to a new `lib/api/returns-claims.ts`, plus types in `types/api.ts` | 2026-09-08 |
|
||||
| 24 | Procurement → Purchase Returns | `PurchaseReturnController.Create` had the same unhandled `?? throw new UnauthorizedAccessException()` crash as #10; also no `GetAll` endpoint exists — a return only ever makes sense in the context of one purchase order (must have a prior GoodsReceipt against it, and quantities are capped per-item at received-minus-already-returned), so there's no global "all returns" listing to build a standalone page around | Built the module in-context: the Purchase Order View sheet now shows a "Purchase Returns" section (reason, date, line items for every return already recorded against that order) plus a "Return Items" button (shown only once the order has any received quantity) that opens a Return dialog — item dropdown capped to each item's real receivable-and-not-already-returned quantity, reason, and a temporary "Created By" user dropdown | TEMP: added `CreatedByUserId` to `CreatePurchaseReturnRequest`, same fallback pattern; added `purchaseReturns` API client (`get`, `listByPurchaseOrder`, `create`); created `PurchaseReturnDialog`, wired into `PurchaseOrderViewSheet` | 2026-09-08 |
|
||||
| 45 | Pricing → Price Lists (action icons) | `DeleteAsync` did a hard delete (`repo.Remove` → EF `Remove`), violating the no-hard-delete Implementation Guide rule (the repo method itself was already misleadingly named `Remove` but did set `IsActive = false` — the actual bug was `GetAllAsync` filtering `.Where(pl => pl.IsActive)`, hiding a deleted price list from the list entirely instead of showing it as Inactive, and `GetByIdAsync`/`UpdateAsync` blocking view/edit of an inactive one, matching the same recurring pattern as Stores/Items/Categories/Brands/Orders). Also `GetAll` returns a plain `List<PriceListResponse>` but the frontend was configured `serverSide: true` expecting a `PaginatedResponse`, so pagination silently did nothing | Added a View icon (details modal — fetches the full record incl. items via `GET /price-lists/{id}` since the list endpoint returns rows with an empty `items` array) and a Delete icon (soft-deactivate, reusing the existing "Active" checkbox in the Edit sheet to reactivate). Edit was already wired up and already sent `isActive` in its request body — just needed the backend to actually accept it | Removed the `IsActive` filters from `GetAllAsync`/`GetByIdAsync`/`UpdateAsync`; added `IsActive` to `UpdatePriceListRequest`; switched the frontend to client-side pagination (same fix as Orders/Brands) since the backend has no real paging; added `priceLists.remove()`; created `PriceListViewSheet` | 2026-09-08 |
|
||||
| 46 | Pricing → Price Lists (item pricing) | `PUT /price-lists/{id}/items/{sku}` and `DELETE /price-lists/{id}/items/{sku}` were fully implemented and correctly validated (MinPrice ≤ Price, overlap check against other active lists) but **no frontend code called either endpoint** — the Create/Edit sheet only ever sent `items: []`. So every price list created through the UI was permanently empty, meaning `SaleService.ResolveUnitPriceAsync` could never resolve a real price-list price and always fell back to the flat `Item.SalePrice` — the whole point of the Price Lists module (region/date-scoped item pricing) was unreachable end-to-end | Added an "Items" section to the Edit sheet: a table of already-priced items (inline edit price/min-price, remove) plus an "Add item" row (item picker + price + min price). Since the backend upserts one SKU at a time (not a bulk array), each row change is its own immediate API call, same as the existing GRN/PO-style line-item tables but per-row-saved instead of submitted together. Creating a new price list now keeps the dialog open afterward (switching in place to "edit" mode) so items can be added immediately instead of requiring a reopen | Created `PriceListItemsSection`, embedded in `PriceListSheet` (edit mode only); added `priceLists.upsertItem()`/`priceLists.removeItem()` to the API client | 2026-09-09 |
|
||||
| 47 | Sales (Create Sale) — wrong price prefilled | Even with #46 fixed, `/sales/new` still prefilled `unitPrice` straight from `item.salePrice` (the flat catalogue price) — no frontend code ever called price-list resolution, so a rep would see the wrong default price for any item actually covered by an active region/date-scoped price list, and the sale would only succeed if they happened to retype the exact resolved price manually. `PriceListService.ResolvePriceAsync` (region price list → global price list → `Item.SalePrice`) existed but was only ever called from inside `SaleService`, with no public endpoint to query it ahead of submission | Selecting an item now calls the new resolution endpoint (store → its route's region, so the exact same lookup `SaleService` uses) and prefills the real resolved price; changing the Store or Sale Date afterward re-resolves every already-selected line, since the applicable price list depends on both. When the resolved price has a `MinPrice` band, a "Negotiable down to Rs. X" hint shows under the field so the rep knows how far they can discount without the submit failing | Added `PriceListService.ResolveByStoreAsync(itemId, storeId, saleDate)` (looks up the store's region via `IStoreRepository.GetByIdWithRegionAsync`, then reuses `ResolvePriceAsync`) and `GET /price-lists/resolve?itemId=&storeId=&saleDate=`; added `priceLists.resolve()` to the frontend API client and a `PriceResolution` type | 2026-09-09 |
|
||||
| 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<SchemeResponse>` 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 |
|
||||
| 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 |
|
||||
|
||||
## Future Fixes — Pending Real Auth
|
||||
|
||||
@@ -68,11 +66,15 @@ Everything below is a stopgap that only exists because `Distribution/Middleware/
|
||||
2. **`SaleService.CancelAsync`** — restore the `HasStoreScopeAsync(sale.FK_StoreId)` check that was bypassed in #40.
|
||||
3. **`CreateSaleRequest.CreatedByUserId`** (`SaleDTOs.cs`) — remove this field; `CreateAsync` should go back to using `currentUser.UserId` only (drop the `?? request.CreatedByUserId` fallback and the `InvalidOperationException` guard around it).
|
||||
4. **Create Sale page** (`/sales/new`) — remove the "Created By" user dropdown and its form field/validation entirely; the acting user should come from the session, not be picked manually.
|
||||
5. **`OrderService.UpdateAsync`** — restore the `HasStoreScopeAsync(order.FK_StoreId)` check that was bypassed in #41 (`DeleteAsync`'s equivalent check was bypassed earlier for the same reason).
|
||||
6. **Sweep for the same pattern elsewhere** — `HasStoreScopeAsync`/`HasGlobalScope`-style checks exist in other services too (Store, etc., per #35's affected controllers) and likely have the same silent-fail behavior; audit and fix once auth is real, not just Sales/Orders.
|
||||
7. **`PaymentService.CreateAsync` / `CreatePaymentRequest.CollectedByUserId`** (`PaymentDTOs.cs`) — remove this fallback field, same as Sale's `CreatedByUserId` (#5); go back to `currentUser.UserId` only.
|
||||
8. **Record Payment form** (`payment-sheet.tsx`) — remove the "Collected By" user dropdown once auth is fixed.
|
||||
9. **Re-verify `StatusCode(403)` usages from #35** — once real authentication exists, confirm whether any of those 6 call sites should become genuine ASP.NET `[Authorize]`-driven `403`/`401` responses instead of manual scope checks.
|
||||
5. **Sweep for the same pattern elsewhere** — `HasStoreScopeAsync`/`HasGlobalScope`-style checks exist in other services too (Order, Store, etc., per #35's affected controllers) and likely have the same silent-fail behavior; audit and fix once auth is real, not just the Sales module.
|
||||
6. **Re-verify `StatusCode(403)` usages from #35** — once real authentication exists, confirm whether any of those 6 call sites should become genuine ASP.NET `[Authorize]`-driven `403`/`401` responses instead of manual scope checks.
|
||||
7. **`AdjustmentRequest.AdjustedByUserId`** (`StockDTOs.cs`) and the "Adjusted By" dropdown in the Adjust Stock dialog (#8) — remove once `StockController.Adjust` can resolve `currentUser.UserId` on its own.
|
||||
8. **`CreatePoRequest.CreatedByUserId`** (`PoDTOs.cs`) and the "Created By" dropdown on `/procurement/orders/new` — remove once `PoController.Create` can resolve `currentUser.UserId` on its own.
|
||||
9. **`CreateGrnRequest.ReceivedByUserId`/`CreateFlatGrnRequest.ReceivedByUserId`** (`GrnDTOs.cs`) and the "Received By" dropdown in the Record Receipt sheet — remove once `GrnController.Create`/`CreateFlat` can resolve `currentUser.UserId` on its own.
|
||||
10. **`CreatePurchaseReturnRequest.CreatedByUserId`** (`PurchaseReturnDTOs.cs`) and the "Created By" dropdown in the Return Items dialog (#24) — remove once `PurchaseReturnController.Create` can resolve `currentUser.UserId` on its own.
|
||||
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.
|
||||
|
||||
## Implementation Guide
|
||||
|
||||
|
||||
Reference in New Issue
Block a user