diff --git a/Distribution/Features/PriceList/PriceListController.cs b/Distribution/Features/PriceList/PriceListController.cs index 41aebc1..b54b9d9 100644 --- a/Distribution/Features/PriceList/PriceListController.cs +++ b/Distribution/Features/PriceList/PriceListController.cs @@ -10,6 +10,13 @@ public class PriceListController(PriceListService service) : ControllerBase public async Task GetAll([FromQuery] Guid? regionId) => Ok(await service.GetAllAsync(regionId)); + [HttpGet("resolve")] + public async Task Resolve([FromQuery] Guid itemId, [FromQuery] Guid storeId, [FromQuery] DateTime saleDate) + { + var result = await service.ResolveByStoreAsync(itemId, storeId, saleDate); + return result is null ? NotFound() : Ok(result); + } + [HttpGet("{id:guid}")] public async Task GetById(Guid id) { @@ -62,7 +69,14 @@ public class PriceListController(PriceListService service) : ControllerBase [HttpDelete("{id:guid}/items/{sku}")] public async Task RemoveItem(Guid id, string sku, CancellationToken ct) { - var result = await service.RemoveItemAsync(id, sku, ct); - return result is null ? NotFound() : Ok(result); + try + { + var result = await service.RemoveItemAsync(id, sku, ct); + return result is null ? NotFound() : Ok(result); + } + catch (InvalidOperationException ex) + { + return Conflict(new { error = ex.Message }); + } } } diff --git a/Distribution/Features/PriceList/PriceListDTOs.cs b/Distribution/Features/PriceList/PriceListDTOs.cs index 2e24595..2d4bccf 100644 --- a/Distribution/Features/PriceList/PriceListDTOs.cs +++ b/Distribution/Features/PriceList/PriceListDTOs.cs @@ -2,16 +2,30 @@ namespace Distribution.Features.PriceList; public record CreatePriceListRequest(string Name, Guid? RegionId, DateTime EffectiveFrom, DateTime EffectiveTo); -public record UpdatePriceListRequest(string Name, Guid? RegionId, DateTime EffectiveFrom, DateTime EffectiveTo); +public record UpdatePriceListRequest(string Name, Guid? RegionId, DateTime EffectiveFrom, DateTime EffectiveTo, bool IsActive); -public record UpsertPriceListItemRequest(decimal Price, decimal? MinPrice = null); +public record UpsertPriceListItemRequest( + decimal Price, + decimal? MinPrice = null, + decimal? RetailPrice = null, + decimal? AgencyMarginPercent = null); -public record PriceListItemResponse(Guid Id, Guid ItemId, string Sku, string ItemName, decimal Price, decimal? MinPrice); +public record PriceListItemResponse( + Guid Id, + Guid ItemId, + string Sku, + string ItemName, + decimal Price, + decimal? MinPrice, + decimal? RetailPrice, + decimal? AgencyMarginPercent); /// -/// A resolved price with its negotiable floor. MinPrice null = no negotiation, Price is fixed. +/// A resolved price with its negotiable floor, plus the informational retail price and agency +/// margin when the resolution came from an actual PriceListItem (both null on the Item.SalePrice +/// fallback, since neither is tracked at the item level). /// -public record PriceResolution(decimal Price, decimal? MinPrice); +public record PriceResolution(decimal Price, decimal? MinPrice, decimal? RetailPrice, decimal? AgencyMarginPercent); public record PriceListResponse( Guid Id, diff --git a/Distribution/Features/PriceList/PriceListRepository.cs b/Distribution/Features/PriceList/PriceListRepository.cs index d02e5b2..feb8277 100644 --- a/Distribution/Features/PriceList/PriceListRepository.cs +++ b/Distribution/Features/PriceList/PriceListRepository.cs @@ -13,9 +13,7 @@ public class PriceListRepository(AppDBContext context) : IPriceListRepository public async Task> GetAllAsync(Guid? regionId = null) { - var query = context.PriceLists - .Include(pl => pl.Region) - .Where(pl => pl.IsActive); + var query = context.PriceLists.Include(pl => pl.Region).AsQueryable(); if (regionId.HasValue) query = query.Where(pl => pl.FK_RegionId == regionId); diff --git a/Distribution/Features/PriceList/PriceListService.cs b/Distribution/Features/PriceList/PriceListService.cs index 01c7f4b..21e312b 100644 --- a/Distribution/Features/PriceList/PriceListService.cs +++ b/Distribution/Features/PriceList/PriceListService.cs @@ -1,12 +1,13 @@ using Distribution.Features.Item; +using Distribution.Features.Store; using Distribution.Infra.UoW; namespace Distribution.Features.PriceList; -public class PriceListService(IPriceListRepository repo, IItemRepository itemRepo, IUnitOfWork uow) +public class PriceListService(IPriceListRepository repo, IItemRepository itemRepo, IStoreRepository storeRepo, IUnitOfWork uow) { private static PriceListItemResponse MapItem(Models.PriceListItem i) => - new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Price, i.MinPrice); + new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Price, i.MinPrice, i.RetailPrice, i.AgencyMarginPercent); private static PriceListResponse MapDetail(Models.PriceList pl) => new(pl.Id, pl.Name, pl.FK_RegionId, pl.Region?.Name, @@ -24,7 +25,7 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep public async Task GetByIdAsync(Guid id) { var pl = await repo.GetByIdAsync(id); - if (pl is null || !pl.IsActive) return null; + if (pl is null) return null; return MapDetail(pl); } @@ -48,7 +49,7 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep public async Task UpdateAsync(Guid id, UpdatePriceListRequest request, CancellationToken ct = default) { var priceList = await repo.GetByIdAsync(id); - if (priceList is null || !priceList.IsActive) return null; + if (priceList is null) return null; foreach (var item in priceList.Items.Where(i => i.IsActive)) { @@ -61,6 +62,7 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep priceList.FK_RegionId = request.RegionId; priceList.EffectiveFrom = request.EffectiveFrom; priceList.EffectiveTo = request.EffectiveTo; + priceList.IsActive = request.IsActive; await uow.BeginAsync(ct); try { repo.Update(priceList); await uow.CommitAsync(ct); } @@ -90,7 +92,9 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep public async Task UpsertItemAsync(Guid priceListId, string sku, UpsertPriceListItemRequest request, CancellationToken ct = default) { var priceList = await repo.GetByIdAsync(priceListId); - if (priceList is null || !priceList.IsActive) return null; + if (priceList is null) return null; + if (!priceList.IsActive) + throw new InvalidOperationException("Cannot price items on an inactive price list. Reactivate it first."); var item = await itemRepo.GetBySkuAsync(sku); if (item is null) return null; @@ -99,6 +103,13 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep throw new InvalidOperationException( $"MinPrice ({request.MinPrice}) cannot exceed Price ({request.Price}) for item '{sku}'."); + if (request.RetailPrice.HasValue && request.RetailPrice.Value < request.Price) + throw new InvalidOperationException( + $"RetailPrice ({request.RetailPrice}) cannot be less than Price ({request.Price}) for item '{sku}' — the store would sell at a loss."); + + if (request.AgencyMarginPercent is < 0 or > 100) + throw new InvalidOperationException("AgencyMarginPercent must be between 0 and 100."); + if (await repo.HasOverlapAsync(item.Id, priceList.FK_RegionId, priceList.EffectiveFrom, priceList.EffectiveTo, priceListId)) throw new InvalidOperationException( $"Overlapping price list already exists for item '{sku}' in the same region for this date range."); @@ -115,13 +126,17 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep FK_PriceListId = priceListId, FK_ItemId = item.Id, Price = request.Price, - MinPrice = request.MinPrice + MinPrice = request.MinPrice, + RetailPrice = request.RetailPrice, + AgencyMarginPercent = request.AgencyMarginPercent }); } else { existing.Price = request.Price; existing.MinPrice = request.MinPrice; + existing.RetailPrice = request.RetailPrice; + existing.AgencyMarginPercent = request.AgencyMarginPercent; repo.UpdateItem(existing); } await uow.CommitAsync(ct); @@ -134,7 +149,9 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep public async Task RemoveItemAsync(Guid priceListId, string sku, CancellationToken ct = default) { var priceList = await repo.GetByIdAsync(priceListId); - if (priceList is null || !priceList.IsActive) return null; + if (priceList is null) return null; + if (!priceList.IsActive) + throw new InvalidOperationException("Cannot remove items from an inactive price list. Reactivate it first."); var item = await itemRepo.GetBySkuAsync(sku); if (item is null) return null; @@ -160,12 +177,36 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep if (regionId.HasValue) { var regionMatches = await repo.GetActiveItemsAsync(itemId, regionId, orderDate); - if (regionMatches.Count > 0) return new PriceResolution(regionMatches[0].Price, regionMatches[0].MinPrice); + if (regionMatches.Count > 0) + { + var m = regionMatches[0]; + return new PriceResolution(m.Price, m.MinPrice, m.RetailPrice, m.AgencyMarginPercent); + } } var globalMatches = await repo.GetActiveItemsAsync(itemId, null, orderDate); - if (globalMatches.Count > 0) return new PriceResolution(globalMatches[0].Price, globalMatches[0].MinPrice); + if (globalMatches.Count > 0) + { + var m = globalMatches[0]; + return new PriceResolution(m.Price, m.MinPrice, m.RetailPrice, m.AgencyMarginPercent); + } - return itemSalePrice.HasValue ? new PriceResolution(itemSalePrice.Value, null) : null; + return itemSalePrice.HasValue ? new PriceResolution(itemSalePrice.Value, null, null, null) : null; + } + + /// + /// Same resolution `SaleService.ResolveUnitPriceAsync` uses internally, exposed read-only so + /// the frontend can show the real price (region/date-scoped price list, else Item.SalePrice) + /// before a sale is ever submitted, instead of guessing from the flat catalogue price. + /// + public async Task ResolveByStoreAsync(Guid itemId, Guid storeId, DateTime saleDate) + { + var item = await itemRepo.GetByIdAsync(itemId); + if (item is null) return null; + + var store = await storeRepo.GetByIdWithRegionAsync(storeId); + if (store is null) return null; + + return await ResolvePriceAsync(itemId, store.Route?.FK_RegionId, item.SalePrice, saleDate); } } diff --git a/Distribution/docs/14_pricing_and_suppliers.md b/Distribution/docs/14_pricing_and_suppliers.md index 4719ee1..462b7f2 100644 --- a/Distribution/docs/14_pricing_and_suppliers.md +++ b/Distribution/docs/14_pricing_and_suppliers.md @@ -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):** ``` diff --git a/Distribution/docs/testbug.md b/Distribution/docs/testbug.md index 53cf28c..987afb2 100644 --- a/Distribution/docs/testbug.md +++ b/Distribution/docs/testbug.md @@ -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` | 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` 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` 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