Update pricing and store implementation plans
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# 14 — Pricing & Supplier Master Data
|
||||
|
||||
**Status**: Not Started
|
||||
**Depends On**: 01 — Item Catalog
|
||||
**Status**: Done — migration applied to the database
|
||||
**Depends On**: 01 — Item Catalog (Done)
|
||||
|
||||
---
|
||||
|
||||
@@ -15,56 +15,339 @@ records. `createProduct`/`deactivateSku` live in 01; this step is everything els
|
||||
|
||||
## Background / Context
|
||||
|
||||
`Item.BasePrice` (from [01_item_catalog.md](./01_item_catalog.md)) is a single flat price. There is
|
||||
no concept of a region- or date-scoped price list, and no `Supplier` entity anywhere in the schema.
|
||||
`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
|
||||
|
||||
### Price List
|
||||
### 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
|
||||
```
|
||||
|
||||
`Name`, `RegionId` (nullable — null means global), `EffectiveFrom`, `EffectiveTo`.
|
||||
|
||||
### Price List Item
|
||||
|
||||
`PriceListId`, `ItemId`, `Price` — overrides `Item.BasePrice` when an applicable, currently-
|
||||
effective price list exists for the order's region.
|
||||
### 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
|
||||
```
|
||||
|
||||
`Name`, `ContactInfo`, `PerformanceScore` (nullable, populated by
|
||||
[15_procurement.md](./15_procurement.md)'s `evaluateSupplier`).
|
||||
---
|
||||
|
||||
## Migrations
|
||||
|
||||
One migration needed:
|
||||
1. **AddPricingAndSuppliers** — creates `PriceLists`, `PriceListItems`, `Suppliers` tables.
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Function reference | Endpoint intent |
|
||||
|---|---|
|
||||
| `createPriceList(name, region, effectiveDates)` | `POST /api/price-lists` |
|
||||
| `updateProductPricing(sku, priceListId, price)` | `PUT /api/price-lists/{id}/items/{sku}` |
|
||||
| `createSupplierMaster(supplierData)` | `POST /api/suppliers` |
|
||||
> 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
|
||||
|
||||
- `applyPricingAndDiscounts` (§1, used in [01_item_catalog.md](./01_item_catalog.md) and
|
||||
[06_sale_ride.md](./06_sale_ride.md)) resolves price in this order: region-scoped active price
|
||||
list → global active price list → `Item.BasePrice`.
|
||||
- Two price lists cannot have overlapping `EffectiveFrom`/`EffectiveTo` ranges for the same
|
||||
`(ItemId, RegionId)` pair.
|
||||
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
|
||||
|
||||
- Are price lists customer/segment-specific as well as region-specific (ties into `Store.Segment`
|
||||
from [12_store_management.md](./12_store_management.md)), or region-only in v1?
|
||||
- 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
|
||||
|
||||
> Fill this section in during planning discussion before writing any code.
|
||||
### 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](./15_procurement.md)'s `evaluateSupplier`
|
||||
runs against this supplier.
|
||||
|
||||
Reference in New Issue
Block a user