16 KiB
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
not already handled by 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) 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) 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 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) 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.
PriceListItem.MinPrice (decimal?)—null= no negotiation, the item is fixed atPrice.PUT /api/price-lists/{id}/items/{sku}accepts{ "price": ..., "minPrice": ... }; rejected with 409 ifMinPrice > Price.OrderService.CreateAsyncvalidates any caller-suppliedOrderItemRequest.UnitPriceagainst[MinPrice ?? Price, Price]for that item's resolved price (region-scoped list → global list; theItem.SalePricefallback has noMinPriceand 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:
- AddPricingAndSuppliers — creates
PriceLists,PriceListItems,Supplierstables.
API Endpoints
Permission column names the target policy for each route; enforcement is not implemented yet — see 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
{ "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}
{ "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
{ "name": "Acme Distributors", "contactInfo": "acme@example.com, +91-98765-43210" }
Business Rules
applyPricingAndDiscountsresolves 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).- "Active" means
EffectiveFrom <= orderDate <= EffectiveToandIsActive = true. - "Region-scoped" means
PriceList.RegionIdequals 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.
- "Active" means
- Two active price lists cannot have overlapping
EffectiveFrom/EffectiveToranges for the same(ItemId, RegionId)pair — checked at thePriceListItemlevel (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 sameRegionIdvalue, including both being null). - A
PriceListcannot be deleted (soft delete) while anyPriceListItemunder 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 itsPriceListItemrows in the same unit-of-work). Supplier.PerformanceScoreis null until 15_procurement.md'sevaluateSuppliersets it; no endpoint in this step writes a non-null value.- A
Suppliercannot be hard-deleted, ever — soft delete only, matching theTemplate.IsActiveconvention used across the codebase. PriceListItem.MinPrice, when set, must be<= Price— enforced on every write viaPUT /api/price-lists/{id}/items/{sku}(409 if violated).- At order time, a caller-supplied
OrderItemRequest.UnitPricemust fall within[MinPrice ?? Price, Price]for the item's resolvedPriceListItem(region-scoped, then global). TheItem.SalePricefallback has noMinPrice— 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 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.Segmentscoping); 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": "<northRegionId>",
"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's evaluateSupplier
runs against this supplier.