Add PriceList functionality with RetailPrice and AgencyMarginPercent

This commit is contained in:
2026-09-09 16:28:57 +05:30
parent 58d67495d2
commit 7dbeaa722a
6 changed files with 168 additions and 51 deletions
+28 -26
View File
@@ -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