Files

19 KiB
Raw Permalink Blame History

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 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.

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).

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

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.
├── 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.

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. 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, "retailPrice": 150.00, "agencyMarginPercent": 15 }

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

{ "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).
    • "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'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).
  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.

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.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": "<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, "retailPrice": 120, "agencyMarginPercent": 15 }

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):

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.