Feat/promotions & trade #7
@@ -1,10 +1,11 @@
|
||||
using Distribution.Features.Scheme;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Store;
|
||||
|
||||
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
|
||||
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService)
|
||||
{
|
||||
private const int GlobalScopeRoleLevel = 50;
|
||||
private const int CreditApproverRoleLevel = 20;
|
||||
@@ -217,8 +218,10 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
||||
var store = await repo.GetByIdAsync(id);
|
||||
if (store is null || !store.IsActive) return null;
|
||||
if (!await HasStoreScopeAsync(id)) return null;
|
||||
// Wired to Step 17 (Promotions & Schemes) once scheme rules exist.
|
||||
return new SchemeEligibilityResponse(true, null);
|
||||
|
||||
var result = await schemeService.CheckStoreScopeEligibilityAsync(id, schemeId);
|
||||
if (result is null) return null;
|
||||
return new SchemeEligibilityResponse(result.Value.Eligible, result.Value.Reason);
|
||||
}
|
||||
|
||||
public async Task<StoreDashboardResponse?> GetDashboardAsync(Guid id, string period)
|
||||
|
||||
@@ -21,12 +21,18 @@ gap #8 in [00_existing_gaps.md](./00_existing_gaps.md). Credit fields exist info
|
||||
`OneTimeAmount` / `CreditAmount`, which stay as-is; this step adds the real credit-limit +
|
||||
outstanding-balance + KYC-gated-ordering flow on top.
|
||||
|
||||
`getStoreVisitHistory` and `checkStoreSchemeEligibility` are read-only integration points into
|
||||
modules that don't exist yet ([11_route_sales_management.md](./11_route_sales_management.md) for
|
||||
visits, [17_promotions_schemes.md](./17_promotions_schemes.md) for schemes). Implement them now
|
||||
with the query shape defined below; wire the actual join once those modules land — return an empty
|
||||
list / `eligible: true` (no active schemes to check) until then, guarded by a `NotImplementedYet`
|
||||
style short-circuit, not a broken query.
|
||||
`getStoreVisitHistory` and `checkStoreSchemeEligibility` were originally read-only integration
|
||||
points into modules that didn't exist yet ([11_route_sales_management.md](./11_route_sales_management.md)
|
||||
for visits, [17_promotions_schemes.md](./17_promotions_schemes.md) for schemes) — both shipped as
|
||||
placeholders (empty list / `eligible: true`) guarded by a `NotImplementedYet`-style short-circuit.
|
||||
|
||||
**Update (2026-08-21, after 17_promotions_schemes.md landed):** `checkStoreSchemeEligibility` is now
|
||||
wired for real. `StoreService.CheckSchemeEligibilityAsync` calls
|
||||
`SchemeService.CheckStoreScopeEligibilityAsync(storeId, schemeId)`, which checks the scheme's
|
||||
region/segment/date-range scope against the store (no order context, since this endpoint takes no
|
||||
`orderId` — that's a *scope* eligibility check, not the full order-line rule evaluation that
|
||||
`GET /api/schemes/eligible?storeId=...&orderId=...` does). `getStoreVisitHistory` is still an empty
|
||||
list — [11_route_sales_management.md](./11_route_sales_management.md) remains Not Started.
|
||||
|
||||
---
|
||||
|
||||
@@ -102,7 +108,7 @@ One migration needed:
|
||||
| GET | /api/stores/{id}/outstanding-balance | stores.view | Returns cached value + CreditLimit + available credit |
|
||||
| PUT | /api/stores/{id}/segment | stores.update | Body: `{ "segment": "Gold" }` |
|
||||
| GET | /api/stores/{id}/visit-history | stores.view | Paginated. Empty list until Step 11 wiring lands |
|
||||
| GET | /api/stores/{id}/schemes/{schemeId}/eligibility | stores.view | Returns `{ eligible: true, reason: null }` until Step 17 wiring lands |
|
||||
| GET | /api/stores/{id}/schemes/{schemeId}/eligibility | stores.view | Wired to Step 17 — returns real region/segment/date-range eligibility for the store against that scheme |
|
||||
| GET | /api/stores/{id}/dashboard?period=... | stores.view | Aggregates order count/value for the period from existing Order tables |
|
||||
|
||||
---
|
||||
@@ -206,7 +212,9 @@ Features/
|
||||
StoreController.cs (extended — credit-limit, balance, kyc/verify, segment,
|
||||
visit-history, scheme-eligibility, dashboard endpoints)
|
||||
StoreDTOs.cs (extended — new request/response records)
|
||||
StoreService.cs (extended — new methods, RoleLevel >= 20 credit-approver check)
|
||||
StoreService.cs (extended — new methods, RoleLevel >= 20 credit-approver check;
|
||||
CheckSchemeEligibilityAsync now depends on Features/Scheme's
|
||||
SchemeService for the real region/segment/date-range check)
|
||||
IStoreRepository.cs (extended — GetOrdersForDashboardAsync)
|
||||
StoreRepository.cs (extended)
|
||||
Kyc/
|
||||
|
||||
@@ -1,70 +1,378 @@
|
||||
# 17 — Promotions & Trade Scheme Management
|
||||
|
||||
**Status**: Not Started
|
||||
**Depends On**: 01 — Item Catalog, 14 — Pricing & Supplier Master Data
|
||||
**Status**: Done — migrations applied to the database
|
||||
**Depends On**: 01 — Item Catalog (Done), 14 — Pricing & Supplier Master Data (Done), 02 — Stock Ledger (Done)
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Covers [mainPlan.md §11](./mainPlan.md#11-promotions--trade-scheme-management)
|
||||
in full — an entirely new subsystem. Once built, `applyPricingAndDiscounts` (§1) and
|
||||
in full — an entirely new subsystem. `applyPricingAndDiscounts` (§1) and
|
||||
`checkStoreSchemeEligibility` (§4) both read from it.
|
||||
|
||||
---
|
||||
|
||||
## Background / Context
|
||||
|
||||
No scheme/promotion entity exists today. Every reference to "eligible discount" elsewhere in this
|
||||
doc set (order pricing, field invoices, agent discount limits) currently has no scheme data source
|
||||
— it only enforces the flat per-role discount ceiling from
|
||||
No scheme/promotion entity existed before this step. Every reference to "eligible discount"
|
||||
elsewhere in this doc set (order pricing, field invoices, agent discount limits) had no scheme data
|
||||
source — it only enforced the flat per-role discount ceiling from
|
||||
[08_sales_agent_controls.md](./08_sales_agent_controls.md).
|
||||
|
||||
For an enterprise distributor, free-goods/bundle promotions ("buy 10 get 1 free", "buy A + B get C
|
||||
free") are operationally different from discount schemes because they move physical stock at zero
|
||||
revenue, not just reduce a price. That distinction shaped the data model below: `BuyXGetY` and
|
||||
`FreeBundle` rules are structured columns/child rows (with their own CRUD), not a JSON blob, so a
|
||||
free-goods rule can be validated (item exists, positive qty, stock available) the same way any other
|
||||
order line is. `SlabDiscount`/`FlatPercent` stay JSON in `RuleConfig` since they're pure
|
||||
percentage-off rules with no inventory or fulfilment side effects.
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Scheme
|
||||
```
|
||||
Scheme
|
||||
├── Id (Guid) — PK, from Template
|
||||
├── Name (string)
|
||||
├── RuleType (enum) — BuyXGetY, SlabDiscount, FlatPercent, FreeBundle
|
||||
├── RuleConfig (string?) — JSON, only used by SlabDiscount/FlatPercent (see below); null
|
||||
│ for BuyXGetY/FreeBundle
|
||||
├── FK_TriggerItemId (Guid?) — BuyXGetY only: the item the store must buy
|
||||
├── TriggerQty (int?) — BuyXGetY only: qty of TriggerItem required to earn one payout
|
||||
├── FK_FreeItemId (Guid?) — shared payout for BuyXGetY and FreeBundle: item given free
|
||||
├── FreeQty (decimal?) — shared payout: qty of FreeItem given free
|
||||
├── FK_RegionId (Guid?) — nullable, null = all regions
|
||||
├── EligibleSegment (string?) — Store.Segment match (Gold/Silver/Key Account/...); null = all segments
|
||||
├── DateRangeFrom (DateTime)
|
||||
├── DateRangeTo (DateTime)
|
||||
├── AgentLimitExempt (bool) — if true, this scheme's discount stacks on top of the per-role
|
||||
│ discount ceiling (08_sales_agent_controls.md) unconditionally
|
||||
├── BaselineExpectedSales (decimal?) — manual baseline used by getSchemeROI (see Open Questions)
|
||||
├── IsActive, CreatedAt — from Template
|
||||
```
|
||||
|
||||
`Name`, `RuleType` (`BuyXGetY`, `SlabDiscount`, `FlatPercent`), `RuleConfig` (JSON), `Eligibility`
|
||||
(store segment / region scope), `DateRangeFrom`, `DateRangeTo`.
|
||||
`RuleConfig` JSON shape (SlabDiscount/FlatPercent only):
|
||||
- `FlatPercent` — `{ "discountPercent": 10 }`
|
||||
- `SlabDiscount` — `{ "tiers": [{ "minOrderAmount": 5000, "discountPercent": 5 }, ...] }`, the
|
||||
highest tier whose `minOrderAmount` the order total meets or exceeds wins.
|
||||
|
||||
### Scheme Redemption
|
||||
### SchemeBundleComponent
|
||||
```
|
||||
SchemeBundleComponent
|
||||
├── Id (Guid) — PK
|
||||
├── FK_SchemeId (Guid) — required, cascade delete with parent Scheme
|
||||
├── FK_ItemId (Guid) — required
|
||||
├── Qty (int) — required qty of this item for the FreeBundle rule to trigger
|
||||
```
|
||||
Unique constraint on `(SchemeId, ItemId)`. Only meaningful when `Scheme.RuleType = FreeBundle` — the
|
||||
order must contain every component in at least its listed `Qty` before the scheme's
|
||||
`FK_FreeItemId`/`FreeQty` payout is granted.
|
||||
|
||||
`SchemeId`, `OrderId` (or `FieldInvoiceId`), `DiscountAmount`, `RedeemedAt` — feeds
|
||||
`trackSchemeSpend` and `getSchemeROI` without re-scanning every order.
|
||||
### SchemeRedemption
|
||||
```
|
||||
SchemeRedemption
|
||||
├── Id (Guid) — PK
|
||||
├── FK_SchemeId (Guid) — required
|
||||
├── FK_OrderId (Guid?) — nullable (see FieldInvoiceId below)
|
||||
├── FieldInvoiceId (Guid?) — placeholder for the future field-invoice module; no FieldInvoice
|
||||
│ entity exists yet, so this is an unconstrained Guid for now
|
||||
├── DiscountAmount (decimal?) — set for SlabDiscount/FlatPercent redemptions
|
||||
├── FK_FreeItemId (Guid?) — set for BuyXGetY/FreeBundle redemptions
|
||||
├── FreeQty (decimal?)
|
||||
├── FreeItemCostValue (decimal?) — free item's Item.CostPrice snapshotted at redemption time, so
|
||||
│ getSchemeROI/trackSchemeSpend stay accurate after CostPrice
|
||||
│ later changes
|
||||
├── RedeemedAt (DateTime)
|
||||
```
|
||||
At least one of `DiscountAmount` or `FreeQty` is set per redemption; `Scheme`/`Order` restrict-delete
|
||||
(a scheme or order with redemptions can't be hard-deleted, and soft-delete leaves history intact).
|
||||
|
||||
### Item (extended)
|
||||
```
|
||||
Item
|
||||
├── ...
|
||||
├── CostPrice (decimal?) — new: cost/purchase price, used to value free-goods redemptions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migrations
|
||||
|
||||
Two migrations:
|
||||
1. **AddPromotionsSchemes** (applied) — creates `Schemes`, `SchemeRedemptions`; adds `Item.CostPrice`.
|
||||
2. **AddSchemeFreeGoodsRuleAndBundleComponents** (applied) — makes `Scheme.RuleConfig` nullable, adds
|
||||
`Scheme.FK_TriggerItemId`/`TriggerQty`/`FK_FreeItemId`/`FreeQty`, creates `SchemeBundleComponents`.
|
||||
|
||||
(Split into two because the free-goods columns were promoted from a JSON blob to structured
|
||||
columns/child table after the first migration had already been applied to the dev database.)
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Function reference | Endpoint intent |
|
||||
|---|---|
|
||||
| `createScheme(schemeRules, eligibility, dateRange)` | `POST /api/schemes` |
|
||||
| `evaluateSchemeEligibility(storeId, orderId)` | `GET /api/schemes/eligible?storeId=...&orderId=...` |
|
||||
| `applySchemeDiscount(orderId, schemeId)` | `POST /api/orders/{id}/apply-scheme` |
|
||||
| `trackSchemeSpend(schemeId)` | `GET /api/schemes/{id}/spend` |
|
||||
| `getSchemeROI(schemeId)` | `GET /api/schemes/{id}/roi` |
|
||||
| Function reference | Endpoint | Notes |
|
||||
|---|---|---|
|
||||
| `createScheme(schemeRules, eligibility, dateRange)` | `POST /api/schemes` | `RuleConfig` required only for SlabDiscount/FlatPercent; `TriggerItemId`/`TriggerQty`/`FreeItemId`/`FreeQty`/`BundleComponents` can be supplied inline to fully create a BuyXGetY/FreeBundle scheme in one call |
|
||||
| — | `GET /api/schemes` | Filterable by region |
|
||||
| — | `GET /api/schemes/{id}` | Includes trigger/free item, bundle components |
|
||||
| — | `PUT /api/schemes/{id}` | Updates name/eligibility/date range/`RuleConfig` |
|
||||
| — | `DELETE /api/schemes/{id}` | Soft delete |
|
||||
| — | `PUT /api/schemes/{id}/free-goods-rule` | Sets the BuyXGetY trigger + shared free-goods payout (also the payout for FreeBundle) |
|
||||
| — | `DELETE /api/schemes/{id}/free-goods-rule` | Clears it |
|
||||
| — | `PUT /api/schemes/{id}/bundle-components/{itemId}` | Upserts one FreeBundle required-item row |
|
||||
| — | `DELETE /api/schemes/{id}/bundle-components/{itemId}` | Removes one FreeBundle required-item row |
|
||||
| `evaluateSchemeEligibility(storeId, orderId)` | `GET /api/schemes/eligible?storeId=...&orderId=...` | Returns eligibility for every in-scope scheme, not just one |
|
||||
| `applySchemeDiscount(orderId, schemeId)` | `POST /api/orders/{id}/apply-scheme` | Adds a zero-price order line for free-goods payouts |
|
||||
| `trackSchemeSpend(schemeId)` | `GET /api/schemes/{id}/spend` | Discount spend + free-goods cost spend |
|
||||
| `getSchemeROI(schemeId)` | `GET /api/schemes/{id}/roi` | Uses `BaselineExpectedSales` |
|
||||
|
||||
---
|
||||
|
||||
## Request / Response Shapes
|
||||
|
||||
### POST `/api/schemes` (BuyXGetY — created fully populated in one call)
|
||||
```json
|
||||
{
|
||||
"name": "Soap Buy10Get1",
|
||||
"ruleType": "BuyXGetY",
|
||||
"ruleConfig": null,
|
||||
"regionId": null,
|
||||
"eligibleSegment": "Gold",
|
||||
"dateRangeFrom": "2026-08-01T00:00:00",
|
||||
"dateRangeTo": "2026-08-31T23:59:59",
|
||||
"agentLimitExempt": false,
|
||||
"baselineExpectedSales": 50000,
|
||||
"triggerItemId": "<soapItemId>",
|
||||
"triggerQty": 10,
|
||||
"freeItemId": "<soapItemId>",
|
||||
"freeQty": 1
|
||||
}
|
||||
```
|
||||
`triggerItemId`/`triggerQty`/`freeItemId`/`freeQty` are optional on create — omit them for a plain
|
||||
SlabDiscount/FlatPercent scheme, or omit and set them later via `PUT .../free-goods-rule` if you want
|
||||
to create the shell first and configure the payout afterwards.
|
||||
|
||||
### POST `/api/schemes` (FreeBundle — created fully populated in one call)
|
||||
```json
|
||||
{
|
||||
"name": "Soap+Shampoo -> Free Toothbrush",
|
||||
"ruleType": "FreeBundle",
|
||||
"ruleConfig": null,
|
||||
"regionId": null,
|
||||
"eligibleSegment": null,
|
||||
"dateRangeFrom": "2026-08-01T00:00:00",
|
||||
"dateRangeTo": "2026-08-31T23:59:59",
|
||||
"agentLimitExempt": false,
|
||||
"baselineExpectedSales": null,
|
||||
"freeItemId": "<toothbrushId>",
|
||||
"freeQty": 1,
|
||||
"bundleComponents": [ { "itemId": "<soapId>", "qty": 1 }, { "itemId": "<shampooId>", "qty": 1 } ]
|
||||
}
|
||||
```
|
||||
`triggerItemId`/`triggerQty` are ignored for FreeBundle — the required items come from
|
||||
`bundleComponents` instead.
|
||||
|
||||
### PUT `/api/schemes/{id}/free-goods-rule`
|
||||
```json
|
||||
{ "triggerItemId": "<soapItemId>", "triggerQty": 10, "freeItemId": "<soapItemId>", "freeQty": 1 }
|
||||
```
|
||||
Same shape as the inline create fields — use this to add or change the free-goods payout on a
|
||||
scheme that was created without one.
|
||||
|
||||
### PUT `/api/schemes/{id}/bundle-components/{itemId}` (FreeBundle)
|
||||
```json
|
||||
{ "qty": 1 }
|
||||
```
|
||||
Use this to add/edit one bundle component after creation.
|
||||
|
||||
### POST `/api/schemes` (SlabDiscount)
|
||||
```json
|
||||
{
|
||||
"name": "Volume Discount",
|
||||
"ruleType": "SlabDiscount",
|
||||
"ruleConfig": "{\"tiers\":[{\"minOrderAmount\":5000,\"discountPercent\":5},{\"minOrderAmount\":10000,\"discountPercent\":8}]}",
|
||||
"regionId": null,
|
||||
"eligibleSegment": null,
|
||||
"dateRangeFrom": "2026-08-01T00:00:00",
|
||||
"dateRangeTo": "2026-08-31T23:59:59",
|
||||
"agentLimitExempt": false,
|
||||
"baselineExpectedSales": null
|
||||
}
|
||||
```
|
||||
|
||||
### POST `/api/orders/{id}/apply-scheme`
|
||||
```json
|
||||
{ "schemeId": "..." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Business Rules
|
||||
|
||||
- A scheme discount stacks with, but does not bypass, the per-role discount ceiling in
|
||||
[08_sales_agent_controls.md](./08_sales_agent_controls.md) unless explicitly flagged as an
|
||||
agent-limit-exempt scheme.
|
||||
- `getSchemeROI` requires a baseline (pre-scheme expected sales) to compare against — confirm data
|
||||
source before implementing (see Open Questions).
|
||||
1. A scheme discount stacks with, but does not bypass, the per-role discount ceiling in
|
||||
[08_sales_agent_controls.md](./08_sales_agent_controls.md) unless `AgentLimitExempt = true`.
|
||||
(Enforcement against the actual ceiling is deferred — 08_sales_agent_controls.md's discount
|
||||
ceiling isn't implemented in code yet; this is recorded as the target behavior.)
|
||||
2. `evaluateSchemeEligibility` matches a scheme to a store/order only if: the scheme's `RegionId` is
|
||||
null or matches the store's region; `EligibleSegment` is null or matches `Store.Segment`; and the
|
||||
order's date falls within `[DateRangeFrom, DateRangeTo]`.
|
||||
3. `BuyXGetY` eligibility: the order must contain at least `TriggerQty` of `TriggerItem`; the payout
|
||||
scales with how many multiples of `TriggerQty` were bought (`FreeQty × floor(boughtQty / TriggerQty)`).
|
||||
4. `FreeBundle` eligibility: the order must contain every `SchemeBundleComponent` in at least its
|
||||
listed `Qty`; payout is the flat `FreeQty` of `FreeItem` (no multiplier).
|
||||
5. `applySchemeDiscount` adds a zero-price `OrderItem` line for the free-goods payout (`UnitPrice = 0`,
|
||||
`Quantity = FreeQty`), rather than only recording a discount amount — the free line still needs to
|
||||
be picked, delivered, and stock-tracked like any other sold line.
|
||||
6. A free-goods redemption is blocked if the free item's available `StockBalance` (summed across all
|
||||
locations) is less than the payout `FreeQty` — same out-of-stock guard as a normal sold line.
|
||||
7. A scheme can only be redeemed once per order (`HasRedemptionAsync` check in `applySchemeDiscount`).
|
||||
8. `SchemeRedemption.FreeItemCostValue` snapshots `Item.CostPrice` at redemption time, so
|
||||
`trackSchemeSpend`/`getSchemeROI` stay accurate even after the item's cost price later changes.
|
||||
9. Free goods are typically still a taxable "deemed supply" at zero net value under GST/VAT-style
|
||||
regimes — not yet wired into a tax calculation, since no tax subsystem exists in this codebase
|
||||
yet. Flagged for whoever builds tax/invoicing.
|
||||
10. `trackSchemeSpend` = sum(`DiscountAmount`) + sum(`FreeItemCostValue × FreeQty`) across all
|
||||
redemptions. `getSchemeROI` = `(actualSales − BaselineExpectedSales) / totalSpend`, where
|
||||
`actualSales` is the sum of `Order.TotalAmount` for every distinct order that redeemed the scheme.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
- What counts as "incremental sales" for `getSchemeROI` — a manual baseline entered when the scheme
|
||||
is created, or a trailing-period average computed automatically?
|
||||
- What counts as "incremental sales" for `getSchemeROI` — **resolved for v1**: a manual
|
||||
`BaselineExpectedSales` entered at scheme creation, not a trailing-period average. Revisit if
|
||||
historical order data volume later makes an automatic trailing average preferable.
|
||||
- Per-role discount ceiling stacking (`AgentLimitExempt`) is unenforced pending
|
||||
[08_sales_agent_controls.md](./08_sales_agent_controls.md) having an actual ceiling implementation
|
||||
to stack against.
|
||||
- Deemed-supply tax treatment for free goods — deferred until a tax/invoicing subsystem exists.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
> Fill this section in during planning discussion before writing any code.
|
||||
### File structure created / touched
|
||||
```
|
||||
Features/
|
||||
Scheme/
|
||||
SchemeController.cs (also hosts SchemeOrderController for POST /api/orders/{id}/apply-scheme)
|
||||
SchemeDTOs.cs
|
||||
SchemeService.cs (EvaluateRule — the core eligibility/payout calculator; ApplySchemeAsync
|
||||
— the free-goods order-line + stock-check + redemption write path)
|
||||
ISchemeRepository.cs
|
||||
SchemeRepository.cs
|
||||
|
||||
Models/
|
||||
Scheme.cs (new — includes SchemeRuleType enum)
|
||||
SchemeBundleComponent.cs (new)
|
||||
SchemeRedemption.cs (new)
|
||||
Item.cs (extended — CostPrice)
|
||||
|
||||
Data/AppDBContext.cs
|
||||
add Schemes, SchemeRedemptions, SchemeBundleComponents DbSets; relationships (Scheme→TriggerItem/
|
||||
FreeItem restrict-delete, SchemeBundleComponent→Scheme cascade-delete, →Item restrict-delete,
|
||||
SchemeRedemption→Scheme/Order/FreeItem restrict-delete); unique index on
|
||||
SchemeBundleComponent(FK_SchemeId, FK_ItemId)
|
||||
|
||||
Migrations/
|
||||
AddPromotionsSchemes (applied)
|
||||
AddSchemeFreeGoodsRuleAndBundleComponents (applied)
|
||||
|
||||
Program.cs
|
||||
register ISchemeRepository/SchemeRepository, SchemeService
|
||||
```
|
||||
|
||||
### Eligibility + payout logic (pseudo)
|
||||
```
|
||||
EvaluateRule(scheme, orderItems):
|
||||
switch scheme.RuleType:
|
||||
BuyXGetY:
|
||||
boughtQty = sum(orderItems where ItemId = scheme.TriggerItemId).Quantity
|
||||
if boughtQty < scheme.TriggerQty: not eligible
|
||||
multiplier = boughtQty / scheme.TriggerQty // integer division
|
||||
→ eligible, FreeItemId = scheme.FreeItemId, FreeQty = scheme.FreeQty * multiplier
|
||||
|
||||
FreeBundle:
|
||||
missing = scheme.BundleComponents where sum(orderItems qty for that item) < component.Qty
|
||||
if missing.any(): not eligible
|
||||
→ eligible, FreeItemId = scheme.FreeItemId, FreeQty = scheme.FreeQty
|
||||
|
||||
FlatPercent / SlabDiscount:
|
||||
parse RuleConfig JSON, compute DiscountAmount off orderItems total
|
||||
```
|
||||
|
||||
### applySchemeDiscount flow (pseudo)
|
||||
```
|
||||
ApplySchemeAsync(orderId, schemeId):
|
||||
reject if scheme already redeemed against this order
|
||||
eligibility = EvaluateRule(scheme, order's items)
|
||||
reject if not eligible
|
||||
|
||||
if eligibility has a free-goods payout:
|
||||
check StockBalance across all locations >= FreeQty, else reject
|
||||
add OrderItem { ItemId = FreeItemId, Quantity = FreeQty, UnitPrice = 0 }
|
||||
redemption.FreeItemCostValue = freeItem.CostPrice ?? 0
|
||||
|
||||
save SchemeRedemption { DiscountAmount, FreeItemId, FreeQty, FreeItemCostValue }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Worked Example
|
||||
|
||||
Item `Soap-500ml` has `CostPrice = 12`. Store is in the "Gold" segment.
|
||||
|
||||
**Create the scheme (one call, fully populated):**
|
||||
```
|
||||
POST /api/schemes
|
||||
{ "name": "Soap Buy10Get1", "ruleType": "BuyXGetY", "ruleConfig": null,
|
||||
"regionId": null, "eligibleSegment": "Gold",
|
||||
"dateRangeFrom": "2026-08-01", "dateRangeTo": "2026-08-31",
|
||||
"agentLimitExempt": false, "baselineExpectedSales": 50000,
|
||||
"triggerItemId": "<soapId>", "triggerQty": 10, "freeItemId": "<soapId>", "freeQty": 1 }
|
||||
→ Scheme { Id, ... }
|
||||
```
|
||||
|
||||
**Store places an order** for 20 units of `Soap-500ml` → `Order { Id, TotalAmount }`.
|
||||
|
||||
**Check eligibility:**
|
||||
```
|
||||
GET /api/schemes/eligible?storeId=...&orderId=...
|
||||
→ [{ "schemeId": "...", "eligible": true, "freeItemId": "<soapId>", "freeQty": 2 }]
|
||||
```
|
||||
20 ÷ 10 = 2 multiples → 2 free units.
|
||||
|
||||
**Apply it:**
|
||||
```
|
||||
POST /api/orders/{orderId}/apply-scheme
|
||||
{ "schemeId": "..." }
|
||||
```
|
||||
Adds an `OrderItem` line (`Soap-500ml`, qty 2, `UnitPrice = 0`) and a `SchemeRedemption` with
|
||||
`FreeQty = 2`, `FreeItemCostValue = 12`.
|
||||
|
||||
**Spend/ROI:**
|
||||
```
|
||||
GET /api/schemes/{id}/spend
|
||||
→ { "totalDiscountSpend": 0, "totalFreeGoodsCostSpend": 24, "totalSpend": 24, "redemptionCount": 1 }
|
||||
|
||||
GET /api/schemes/{id}/roi
|
||||
→ { "baselineExpectedSales": 50000, "actualSales": 62000, "incrementalSales": 12000,
|
||||
"totalSpend": 24, "roi": 500.0 }
|
||||
```
|
||||
|
||||
**FreeBundle variant** ("buy Soap + Shampoo, get a free Toothbrush") — also one call:
|
||||
```
|
||||
POST /api/schemes
|
||||
{ "name": "Soap+Shampoo -> Free Toothbrush", "ruleType": "FreeBundle", "ruleConfig": null,
|
||||
"regionId": null, "eligibleSegment": null,
|
||||
"dateRangeFrom": "2026-08-01", "dateRangeTo": "2026-08-31",
|
||||
"agentLimitExempt": false, "baselineExpectedSales": null,
|
||||
"freeItemId": "<toothbrushId>", "freeQty": 1,
|
||||
"bundleComponents": [ { "itemId": "<soapId>", "qty": 1 }, { "itemId": "<shampooId>", "qty": 1 } ] }
|
||||
```
|
||||
Eligibility now requires both Soap ≥1 and Shampoo ≥1 in the order; payout is 1 free toothbrush,
|
||||
flat (no multiplier). Components/payout can still be edited afterwards via
|
||||
`PUT .../bundle-components/{itemId}` and `PUT .../free-goods-rule`.
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
| Role Management | Done (read-only) | 6 roles seeded, no create/update via API |
|
||||
| Region / Route / Vehicle CRUD | Done | Full API surface with permission checks |
|
||||
| Store / Warehouse CRUD | Done | Full API surface with permission checks |
|
||||
| Store Management (Beyond CRUD) | Done | Credit limit, KYC, segment, dashboard, scheme eligibility; see [12_store_management.md](./12_store_management.md) |
|
||||
| Order CRUD | Done | Scope-aware (user only sees their assigned stores) |
|
||||
| Unit of Work + Repositories | Done | Transaction support throughout |
|
||||
| Soft Deletes | Done | IsActive pattern on all entities |
|
||||
| Price Lists / Suppliers | Done | Region-scoped price lists + supplier master; see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) |
|
||||
| Promotions & Trade Schemes | Done | BuyXGetY/FreeBundle/SlabDiscount/FlatPercent schemes, free-goods fulfillment, spend/ROI tracking; see [17_promotions_schemes.md](./17_promotions_schemes.md) |
|
||||
|
||||
## Known Gaps in Existing System (Pre-Work)
|
||||
|
||||
@@ -47,12 +49,12 @@ They must be resolved before new feature steps begin because later steps depend
|
||||
| 09 | Fraud Controls & Audit Trail | Not Started | 08 | [09_fraud_controls.md](./09_fraud_controls.md) |
|
||||
| 10 | Region Management (Beyond CRUD) | Not Started | 00 | [10_region_management.md](./10_region_management.md) |
|
||||
| 11 | Route & Sales Visit Management | Not Started | 00 | [11_route_sales_management.md](./11_route_sales_management.md) |
|
||||
| 12 | Store Management (Beyond CRUD) | In Progress | 00 | [12_store_management.md](./12_store_management.md) |
|
||||
| 12 | Store Management (Beyond CRUD) | Done | 00 | [12_store_management.md](./12_store_management.md) |
|
||||
| 13 | Fleet Management (Beyond CRUD) | Not Started | 00 | [13_fleet_management.md](./13_fleet_management.md) |
|
||||
| 14 | Pricing & Supplier Master Data | Done | 01 | [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) |
|
||||
| 15 | Procurement / Purchasing | Not Started | 01, 14 | [15_procurement.md](./15_procurement.md) |
|
||||
| 16 | Sales Force Automation / CRM | Not Started | 12 | [16_sfa_crm.md](./16_sfa_crm.md) |
|
||||
| 17 | Promotions & Trade Scheme Management | Not Started | 01, 14 | [17_promotions_schemes.md](./17_promotions_schemes.md) |
|
||||
| 17 | Promotions & Trade Scheme Management | Done | 01, 14 | [17_promotions_schemes.md](./17_promotions_schemes.md) |
|
||||
| 18 | Returns & Claims Management | Not Started | 05, 06 | [18_returns_claims.md](./18_returns_claims.md) |
|
||||
| 19 | Finance & Accounting | Not Started | 05, 06, 18 | [19_finance_accounting.md](./19_finance_accounting.md) |
|
||||
| 20 | Reporting, Analytics & BI | Not Started | all prior | [20_reporting_analytics.md](./20_reporting_analytics.md) |
|
||||
@@ -83,7 +85,7 @@ here — see `SRS_User_Management_Module.pdf`.
|
||||
| `createOrder(storeId, items[], salesRepId)` | Validates stock/credit, creates a new order in `pending` state. | Partially implemented - `OrderService.CreateAsync` exists, enforces store scope, blocks inactive stores, and applies the unverified-KYC order threshold from config. It still has no stock validation, credit-limit validation, approval flow, or order state machine. |
|
||||
| `validateCreditLimit(storeId, orderAmount)` | Checks store's outstanding balance + new order value against its credit limit. | Not implemented - `Store` now has `CreditLimit` and `OutstandingBalance`, but there is no dedicated validator or order-time credit-limit enforcement yet. |
|
||||
| `checkStockAvailability(items[])` | Confirms requested SKUs/quantities are available at the fulfilling warehouse. | Not implemented — depends on [02_stock_ledger.md](./02_stock_ledger.md). |
|
||||
| `applyPricingAndDiscounts(orderId)` | Runs the active price list and eligible scheme rules against order lines. | Partially implemented — `OrderService.CreateAsync` resolves the price-list half via `PriceListService.ResolveUnitPriceAsync` (region-scoped → global → `Item.SalePrice`, or a caller-supplied `UnitPrice`); the scheme-discount half still depends on §11 Promotions below. |
|
||||
| `applyPricingAndDiscounts(orderId)` | Runs the active price list and eligible scheme rules against order lines. | Partially implemented — `OrderService.CreateAsync` resolves the price-list half via `PriceListService.ResolveUnitPriceAsync` (region-scoped → global → `Item.SalePrice`, or a caller-supplied `UnitPrice`). The scheme-discount half now exists as a separate call — `SchemeService.EvaluateEligibilityAsync`/`ApplySchemeAsync` (§11) — but is not yet auto-invoked from `OrderService.CreateAsync`; the caller applies a scheme explicitly via `POST /api/orders/{id}/apply-scheme` after order creation. |
|
||||
| `submitOrderForApproval(orderId)` | Routes the order to the correct approver based on its value/discount threshold. | Not implemented — depends on [08_sales_agent_controls.md](./08_sales_agent_controls.md). |
|
||||
| `approveOrder(orderId, approverId)` / `rejectOrder(orderId, reason)` | Moves order to `approved` or `rejected`, logging the actor. | Not implemented. |
|
||||
| `updateOrderStatus(orderId, status)` | Transitions order through `pending → approved → picked → dispatched → delivered → invoiced`. | Not implemented — `Order` has no status field or state machine today. |
|
||||
@@ -132,7 +134,7 @@ here — see `SRS_User_Management_Module.pdf`.
|
||||
| `classifyStore(storeId, segment)` | Sets/updates the store's segment (key account, general trade, etc.). | Implemented - `PUT /api/stores/{id}/segment` updates the free-text `Store.Segment` field. |
|
||||
| `deactivateStore(storeId, reason)` | Soft-deletes a store, blocking new orders against it. | Partially implemented — `DELETE /api/stores/{id}` soft-deletes via `IsActive`, no reason code captured. |
|
||||
| `getStoreVisitHistory(storeId)` | Returns visit compliance history from Route Management. | Placeholder only - endpoint exists (`GET /api/stores/{id}/visit-history`) but currently returns an empty list until Route/Sales Visit Management lands. |
|
||||
| `checkStoreSchemeEligibility(storeId, schemeId)` | Evaluates whether a store qualifies for a given promotion. | Placeholder only - endpoint exists (`GET /api/stores/{id}/schemes/{schemeId}/eligibility`) but currently returns a default eligible response until Promotions is implemented. |
|
||||
| `checkStoreSchemeEligibility(storeId, schemeId)` | Evaluates whether a store qualifies for a given promotion. | Implemented — `GET /api/stores/{id}/schemes/{schemeId}/eligibility` now calls `SchemeService.CheckStoreScopeEligibilityAsync` (region/segment/date-range scope check, no order context). For full order-line rule evaluation against a specific order, use `evaluateSchemeEligibility(storeId, orderId)` instead (`GET /api/schemes/eligible?storeId=...&orderId=...`, §11). |
|
||||
| `getStoreSalesDashboard(storeId, period)` | Store-level sales and outstanding summary. | Partially implemented - `GET /api/stores/{id}/dashboard` aggregates order count, total order value, and last order date for the selected month. It does not yet include full sales/outstanding analytics from Finance. |
|
||||
|
||||
### 5. Vehicle / Fleet Management
|
||||
@@ -212,11 +214,11 @@ here — see `SRS_User_Management_Module.pdf`.
|
||||
|
||||
| Function | What it does | Status |
|
||||
|---|---|---|
|
||||
| `createScheme(schemeRules, eligibility, dateRange)` | Defines a promotion (buy-X-get-Y, slab discount, etc.). | Not implemented — no scheme entity exists. |
|
||||
| `evaluateSchemeEligibility(storeId, orderId)` | Checks whether an order qualifies for a scheme. | Not implemented. |
|
||||
| `applySchemeDiscount(orderId, schemeId)` | Applies the scheme's discount to order pricing. | Not implemented. |
|
||||
| `trackSchemeSpend(schemeId)` | Aggregates total discount value given under a scheme. | Not implemented. |
|
||||
| `getSchemeROI(schemeId)` | Compares scheme spend against incremental sales generated. | Not implemented. |
|
||||
| `createScheme(schemeRules, eligibility, dateRange)` | Defines a promotion (buy-X-get-Y, slab discount, etc.). | Implemented — `POST /api/schemes`. Supports `BuyXGetY`, `FreeBundle`, `SlabDiscount`, `FlatPercent`; free-goods trigger/payout and bundle components can be supplied inline in the same call. See [17_promotions_schemes.md](./17_promotions_schemes.md). |
|
||||
| `evaluateSchemeEligibility(storeId, orderId)` | Checks whether an order qualifies for a scheme. | Implemented — `GET /api/schemes/eligible?storeId=...&orderId=...`, returns eligibility (and computed discount/free-goods payout) for every in-scope scheme, not just one. |
|
||||
| `applySchemeDiscount(orderId, schemeId)` | Applies the scheme's discount to order pricing. | Implemented — `POST /api/orders/{id}/apply-scheme`. For free-goods schemes this adds a zero-price `OrderItem` line and checks `StockBalance` availability first; not yet auto-invoked from `createOrder`/`applyPricingAndDiscounts` (§1) — caller applies it explicitly after order creation. |
|
||||
| `trackSchemeSpend(schemeId)` | Aggregates total discount value given under a scheme. | Implemented — `GET /api/schemes/{id}/spend`, sums `DiscountAmount` plus free-goods cost (`FreeItemCostValue × FreeQty`) across redemptions. |
|
||||
| `getSchemeROI(schemeId)` | Compares scheme spend against incremental sales generated. | Implemented — `GET /api/schemes/{id}/roi`, using a manually-entered `BaselineExpectedSales` per scheme (no trailing-period auto-baseline yet). |
|
||||
|
||||
### 12. Returns & Claims Management
|
||||
|
||||
|
||||
Reference in New Issue
Block a user