Add PriceList functionality with RetailPrice and AgencyMarginPercent

This commit is contained in:
2026-09-09 16:28:57 +05:30
parent 58d67495d2
commit 7dbeaa722a
6 changed files with 168 additions and 51 deletions
+53 -5
View File
@@ -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):**
```