# 14 — Pricing & Supplier Master Data **Status**: Done — migration applied to the database **Depends On**: 01 — Item Catalog (Done) --- ## Goal Covers the remainder of [mainPlan.md §7](./mainPlan.md#7-master-data-management) not already handled by [01_item_catalog.md](./01_item_catalog.md): price lists and supplier master records. `createProduct`/`deactivateSku` live in 01; this step is everything else in §7. --- ## Background / Context `Item.SalePrice` (from [01_item_catalog.md](./01_item_catalog.md)) is a single flat, optional price — `OrderItem.UnitPrice` is captured from it at order-creation time, or supplied manually if `SalePrice` is null. There is no concept of a region- or date-scoped price list, and no `Supplier` entity anywhere in the schema. `applyPricingAndDiscounts` ([mainPlan.md §1](./mainPlan.md#1-order-management-lifecycle)) is the function that resolves the actual price for an order line; this step implements the price-list half of it (region + date scoping). The discount/scheme half stays out of scope until [17_promotions_schemes.md](./17_promotions_schemes.md) lands — until then `applyPricingAndDiscounts` only walks price lists, never scheme discounts. Resolved in planning discussion (2026-08-20): price lists are **region-only in v1**, not segment-scoped. `Store.Segment` ([12_store_management.md](./12_store_management.md)) is not consulted by pricing. Segment-based pricing can be added as a later step without breaking this model, since it would only add an optional new scoping column. ### Negotiable price range (Min/Max) An item's price within a price list can float in a band instead of being fixed: the same actor who creates the price list (`pricing.create`/`pricing.update`, AreaManager and above) sets both a **ceiling** (`Price` — the list/default price) and an optional **floor** (`MinPrice`) below which no order for that item may be priced. The sales rep/driver placing the order can then supply any `UnitPrice` within `[MinPrice, Price]` on `OrderItemRequest` instead of always taking the resolved default — the same negotiation-boundary pattern already used for discount limits in [08_sales_agent_controls.md](./08_sales_agent_controls.md). - `PriceListItem.MinPrice (decimal?)` — `null` = no negotiation, the item is fixed at `Price`. - `PUT /api/price-lists/{id}/items/{sku}` accepts `{ "price": ..., "minPrice": ... }`; rejected with 409 if `MinPrice > Price`. - `OrderService.CreateAsync` validates any caller-supplied `OrderItemRequest.UnitPrice` against `[MinPrice ?? Price, Price]` for that item's resolved price (region-scoped list → global list; the `Item.SalePrice` fallback has no `MinPrice` and is always fixed). The order is rejected (400, via a null resolution) if the requested price falls outside the band, or if no price can be resolved at all. --- ## Data Model Changes ### PriceList ``` PriceList ├── Id (Guid) — PK, from Template ├── Name (string) ├── FK_RegionId (Guid?) — nullable, null = global (applies to all regions) ├── EffectiveFrom (DateTime) ├── EffectiveTo (DateTime) ├── IsActive, CreatedAt — from Template ``` ### PriceListItem ``` PriceListItem ├── Id (Guid) — PK, from Template ├── FK_PriceListId (Guid) — required ├── FK_ItemId (Guid) — required ├── Price (decimal) — ceiling/list price, overrides Item.SalePrice for this item while │ 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. ├── IsActive, CreatedAt — from Template ``` Unique constraint on `(PriceListId, ItemId)` — one price per item per price list. ### Supplier ``` Supplier ├── Id (Guid) — PK, from Template ├── Name (string) ├── ContactInfo (string?) — free text (phone/email/address), matches ContactInfo pattern │ used elsewhere rather than splitting into separate columns ├── PerformanceScore (decimal?) — nullable, populated by 15_procurement.md's evaluateSupplier; │ this step only defines the column and leaves it null-only ├── IsActive, CreatedAt — from Template ``` --- ## Migrations One migration needed: 1. **AddPricingAndSuppliers** — creates `PriceLists`, `PriceListItems`, `Suppliers` tables. --- ## API Endpoints > Permission column names the target policy for each route; enforcement is **not implemented yet** > — see [Permissions to Add to Seed](#permissions-to-add-to-seed). Endpoints require > authentication only until seeding is turned on. ### Price Lists | Method | Route | Permission | Notes | |---|---|---|---| | GET | /api/price-lists | pricing.view | Filterable by region, active-on-date | | GET | /api/price-lists/{id} | pricing.view | Includes items | | POST | /api/price-lists | pricing.create | `createPriceList(name, region, effectiveDates)` | | PUT | /api/price-lists/{id} | pricing.update | Rejects if new dates overlap another price list for any shared `(ItemId, RegionId)` pair | | DELETE | /api/price-lists/{id} | pricing.delete | Soft delete | | PUT | /api/price-lists/{id}/items/{sku} | pricing.update | `updateProductPricing(sku, priceListId, price)` — upserts the `PriceListItem` row | | DELETE | /api/price-lists/{id}/items/{sku} | pricing.update | Removes an item's override from the price list | ### Suppliers | Method | Route | Permission | Notes | |---|---|---|---| | GET | /api/suppliers | suppliers.view | | | GET | /api/suppliers/{id} | suppliers.view | | | POST | /api/suppliers | suppliers.create | `createSupplierMaster(supplierData)` | | PUT | /api/suppliers/{id} | suppliers.update | Cannot set `PerformanceScore` directly — that field is read-only via this endpoint, only 15_procurement.md's `evaluateSupplier` writes it | | DELETE | /api/suppliers/{id} | suppliers.delete | Soft delete, blocked if any PurchaseOrder references it (enforced once 15_procurement.md exists; no-op check until then) | --- ## Request / Response Shapes ### POST `/api/price-lists` ```json { "name": "North Region Q3", "regionId": "...", "effectiveFrom": "2026-09-01", "effectiveTo": "2026-09-30" } ``` `regionId` omitted or null = global price list. ### PUT `/api/price-lists/{id}/items/{sku}` ```json { "price": 125.50, "minPrice": 115.00 } ``` 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`. ### POST `/api/suppliers` ```json { "name": "Acme Distributors", "contactInfo": "acme@example.com, +91-98765-43210" } ``` --- ## Business Rules 1. `applyPricingAndDiscounts` resolves the unit price for an order line in this order: region-scoped active price list → global active price list → `Item.SalePrice` → reject if all three are absent (order creation requires an explicit price, per rule 10 of [01_item_catalog.md](./01_item_catalog.md)). - "Active" means `EffectiveFrom <= orderDate <= EffectiveTo` and `IsActive = true`. - "Region-scoped" means `PriceList.RegionId` equals the order's store's region. - If more than one region-scoped price list is active for the same region (shouldn't happen given rule 2, but defensively), the most recently created one wins. 2. Two active price lists cannot have overlapping `EffectiveFrom`/`EffectiveTo` ranges for the same `(ItemId, RegionId)` pair — checked at the `PriceListItem` level (a global list and a region list with the same item may coexist since region-scoped always wins in resolution, so the overlap check only compares price lists with the *same* `RegionId` value, including both being null). 3. A `PriceList` cannot be deleted (soft delete) while any `PriceListItem` under it is still active — delete/deactivate the items first, or cascade-deactivate them in the same transaction (cascade chosen: deleting a price list deactivates all its `PriceListItem` rows in the same unit-of-work). 4. `Supplier.PerformanceScore` is null until [15_procurement.md](./15_procurement.md)'s `evaluateSupplier` sets it; no endpoint in this step writes a non-null value. 5. A `Supplier` cannot be hard-deleted, ever — soft delete only, matching the `Template.IsActive` convention used across the codebase. 6. `PriceListItem.MinPrice`, when set, must be `<= Price` — enforced on every write via `PUT /api/price-lists/{id}/items/{sku}` (409 if violated). 7. At order time, a caller-supplied `OrderItemRequest.UnitPrice` must fall within `[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). --- ## Permissions to Add to Seed **Status: Not implemented for now.** Endpoints in this step are built without permission enforcement (no `[Authorize(Policy = ...)]` gate) until explicitly requested. The codes and role assignments below are the target design to wire in later — keep them here as the reference, not as something to seed yet. Target permission codes (not yet in `RoleAndPermissionSeed.cs`): ``` pricing.view, pricing.create, pricing.update, pricing.delete suppliers.view, suppliers.create, suppliers.update, suppliers.delete ``` Target role assignments (mirrors the [01_item_catalog.md](./01_item_catalog.md) items/categories pattern), for when seeding is turned on: - **SalesRep (10)**: pricing.view - **Supervisor (20)**: pricing.view - **AreaManager (30)**: pricing.view, pricing.create, pricing.update, suppliers.view - **AreaSupervisor (40)**: all pricing + supplier permissions - **RegionalManager (50)**: all pricing + supplier permissions - **RegionalAgency (60)**: all pricing + supplier permissions --- ## Open Questions - None. Resolved in planning discussion (2026-08-20): price lists are region-only in v1 (no `Store.Segment` scoping); deleting a price list cascades to deactivate its items rather than being blocked by them. --- ## Implementation Notes ### File structure created / touched ``` Features/ PriceList/ PriceListController.cs PriceListDTOs.cs PriceListService.cs (ResolveUnitPriceAsync — the price-list half of applyPricingAndDiscounts) IPriceListRepository.cs PriceListRepository.cs Supplier/ SupplierController.cs SupplierDTOs.cs SupplierService.cs ISupplierRepository.cs SupplierRepository.cs Order/ OrderDTOs.cs (extended — OrderItemRequest gained an optional UnitPrice override for the SalePrice-is-null / no-price-list-match case; OrderItemResponse now echoes the captured UnitPrice) OrderService.cs (extended — CreateAsync resolves each line's price via a private ResolveUnitPriceAsync that calls PriceListService, using the request's UnitPrice override if supplied; rejects the order (returns null) if no price can be resolved at all) Item/ IItemRepository.cs / ItemRepository.cs (extended — GetBySkuAsync, needed to resolve the {sku} route segment on the price-list item endpoints) Store/ IStoreRepository.cs / StoreRepository.cs (extended — GetByIdWithRegionAsync, includes Store.Route.Region so OrderService can read the store's RegionId for price resolution) Models/ PriceList.cs (new) PriceListItem.cs (new) Supplier.cs (new) Data/AppDBContext.cs add PriceLists, PriceListItems, Suppliers DbSets; unique index on PriceListItem(FK_PriceListId, FK_ItemId) Migrations/ AddPricingAndSuppliers (applied) Program.cs register IPriceListRepository/PriceListRepository, ISupplierRepository/SupplierRepository, PriceListService, SupplierService Data/Seed/ RoleAndPermissionSeed.cs — untouched. Permission enforcement is intentionally deferred (see Permissions to Add to Seed above); controllers use plain [Authorize] with no policy for now. ``` ### Price resolution logic (pseudo) ``` ResolveUnitPriceAsync(itemId, storeId, orderDate): regionId = store.RegionId regionList = active PriceListItem where ItemId = itemId and PriceList.RegionId = regionId and PriceList.EffectiveFrom <= orderDate <= PriceList.EffectiveTo and PriceList.IsActive and PriceListItem.IsActive order by PriceList.CreatedAt desc, take 1 if regionList found: return regionList.Price globalList = same query with PriceList.RegionId = null if globalList found: return globalList.Price if item.SalePrice != null: return item.SalePrice return null // caller (OrderService) rejects with 400 if no explicit price was supplied ``` ### Overlap validation on price list write (pseudo) ``` On PUT /api/price-lists/{id}/items/{sku} or POST /api/price-lists: for each ItemId affected, check existing PriceListItem rows joined to PriceList where PriceList.RegionId = this.RegionId (both null counts as a match) and PriceList.Id != this.Id and date ranges overlap (existing.EffectiveFrom <= new.EffectiveTo and existing.EffectiveTo >= new.EffectiveFrom) → reject with 400 if any match found ``` --- ## Worked Example Item `COKE-330` has `SalePrice = 100`. Store `S1` is in the North region. **Set up a negotiable region price list:** ``` POST /api/price-lists { "name": "North Region September", "regionId": "", "effectiveFrom": "2026-09-01", "effectiveTo": "2026-09-30" } → PriceList A (RegionId = North) PUT /api/price-lists/{A.Id}/items/COKE-330 { "price": 90, "minPrice": 80 } ``` This publishes a band for `COKE-330` in the North region: ceiling 90, floor 80. **Order at the default (no negotiation):** ``` POST /api/orders { "storeId": "S1", "orderDate": "2026-09-15", "items": [{ "itemId": "COKE-330", "quantity": 10 }] } ``` No `unitPrice` supplied → resolves to the ceiling, **unit price = 90**. **Order with a negotiated price inside the band:** ``` POST /api/orders { "storeId": "S1", "orderDate": "2026-09-15", "items": [{ "itemId": "COKE-330", "quantity": 10, "unitPrice": 83 }] } ``` 83 is within `[80, 90]` → **accepted, unit price = 83**. **Order with a price outside the band:** ``` POST /api/orders { "storeId": "S1", "orderDate": "2026-09-15", "items": [{ "itemId": "COKE-330", "quantity": 10, "unitPrice": 75 }] } ``` 75 is below `MinPrice = 80` → **the whole order is rejected (400)**, same as if no price could be resolved at all. A store in the South region (no region price list, so it falls through to the global list or `Item.SalePrice = 100`) has no `MinPrice` unless a global `PriceListItem` also sets one — an override there is rejected unless it equals the fixed price exactly. **Supplier example** (unrelated to pricing resolution, same step): ``` POST /api/suppliers { "name": "Acme Distributors", "contactInfo": "acme@example.com, +91-98765-43210" } → Supplier { Id, Name: "Acme Distributors", PerformanceScore: null } ``` `PerformanceScore` stays null until [15_procurement.md](./15_procurement.md)'s `evaluateSupplier` runs against this supplier.