Merge pull request 'Dev' (#6) from dev into production

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-08-21 08:52:16 +00:00
18 changed files with 1915 additions and 46 deletions
+49
View File
@@ -36,6 +36,9 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<DriverProfile> DriverProfiles { get; set; } public DbSet<DriverProfile> DriverProfiles { get; set; }
public DbSet<DriverComplianceConfig> DriverComplianceConfigs { get; set; } public DbSet<DriverComplianceConfig> DriverComplianceConfigs { get; set; }
public DbSet<RideCancelRequest> RideCancelRequests { get; set; } public DbSet<RideCancelRequest> RideCancelRequests { get; set; }
public DbSet<Scheme> Schemes { get; set; }
public DbSet<SchemeRedemption> SchemeRedemptions { get; set; }
public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -90,6 +93,52 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(r => r.FK_DriverProfileId) .HasForeignKey(r => r.FK_DriverProfileId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.Scheme)
.WithMany(s => s.Redemptions)
.HasForeignKey(r => r.FK_SchemeId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.Order)
.WithMany()
.HasForeignKey(r => r.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.FreeItem)
.WithMany()
.HasForeignKey(r => r.FK_FreeItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Scheme>()
.HasOne(s => s.TriggerItem)
.WithMany()
.HasForeignKey(s => s.FK_TriggerItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Scheme>()
.HasOne(s => s.FreeItem)
.WithMany()
.HasForeignKey(s => s.FK_FreeItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeBundleComponent>()
.HasOne(c => c.Scheme)
.WithMany(s => s.BundleComponents)
.HasForeignKey(c => c.FK_SchemeId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<SchemeBundleComponent>()
.HasOne(c => c.Item)
.WithMany()
.HasForeignKey(c => c.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeBundleComponent>()
.HasIndex(c => new { c.FK_SchemeId, c.FK_ItemId })
.IsUnique();
Seed.RoleAndPermissionSeed.Apply(modelBuilder); Seed.RoleAndPermissionSeed.Apply(modelBuilder);
} }
} }
@@ -0,0 +1,19 @@
namespace Distribution.Features.Scheme;
public interface ISchemeRepository
{
Task<Models.Scheme?> GetByIdAsync(Guid id);
Task<List<Models.Scheme>> GetAllAsync(Guid? regionId = null);
Task AddAsync(Models.Scheme entity);
void Update(Models.Scheme entity);
void Remove(Models.Scheme entity);
Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId);
Task AddRedemptionAsync(Models.SchemeRedemption entity);
Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId);
Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId);
Task AddBundleComponentAsync(Models.SchemeBundleComponent entity);
void UpdateBundleComponent(Models.SchemeBundleComponent entity);
void RemoveBundleComponent(Models.SchemeBundleComponent entity);
}
@@ -0,0 +1,139 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Scheme;
[ApiController]
[Route("api/schemes")]
[Authorize]
public class SchemeController(SchemeService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] Guid? regionId) =>
Ok(await service.GetAllAsync(regionId));
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateSchemeRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, UpdateSchemeRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
[HttpPut("{id:guid}/free-goods-rule")]
public async Task<IActionResult> SetFreeGoodsRule(Guid id, SetFreeGoodsRuleRequest request, CancellationToken ct)
{
try
{
var result = await service.SetFreeGoodsRuleAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex)
{
return Conflict(new { error = ex.Message });
}
}
[HttpDelete("{id:guid}/free-goods-rule")]
public async Task<IActionResult> ClearFreeGoodsRule(Guid id, CancellationToken ct)
{
var result = await service.ClearFreeGoodsRuleAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/bundle-components/{itemId:guid}")]
public async Task<IActionResult> UpsertBundleComponent(Guid id, Guid itemId, UpsertBundleComponentRequest request, CancellationToken ct)
{
try
{
var result = await service.UpsertBundleComponentAsync(id, itemId, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex)
{
return Conflict(new { error = ex.Message });
}
}
[HttpDelete("{id:guid}/bundle-components/{itemId:guid}")]
public async Task<IActionResult> RemoveBundleComponent(Guid id, Guid itemId, CancellationToken ct)
{
var result = await service.RemoveBundleComponentAsync(id, itemId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("eligible")]
public async Task<IActionResult> GetEligible([FromQuery] Guid storeId, [FromQuery] Guid orderId)
{
var result = await service.EvaluateEligibilityAsync(storeId, orderId);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id:guid}/spend")]
public async Task<IActionResult> GetSpend(Guid id)
{
var result = await service.GetSpendAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id:guid}/roi")]
public async Task<IActionResult> GetROI(Guid id)
{
var result = await service.GetROIAsync(id);
return result is null ? NotFound() : Ok(result);
}
}
[ApiController]
[Route("api/orders")]
[Authorize]
public class SchemeOrderController(SchemeService service) : ControllerBase
{
[HttpPost("{id:guid}/apply-scheme")]
public async Task<IActionResult> ApplyScheme(Guid id, ApplySchemeRequest request, CancellationToken ct)
{
try
{
var result = await service.ApplySchemeAsync(id, request.SchemeId, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex)
{
return Conflict(new { error = ex.Message });
}
}
}
+111
View File
@@ -0,0 +1,111 @@
using Distribution.Models;
namespace Distribution.Features.Scheme;
// ── Rule config payloads for percentage-based rules (serialized into Scheme.RuleConfig) ──
public record SlabDiscountConfig(List<SlabTier> Tiers);
public record SlabTier(decimal MinOrderAmount, decimal DiscountPercent);
public record FlatPercentConfig(decimal DiscountPercent);
// ── Requests ────────────────────────────────────────────────────────────
public record BundleComponentRequest(Guid ItemId, int Qty);
/// <summary>
/// TriggerItemId/TriggerQty/FreeItemId/FreeQty/BundleComponents are optional so plain discount
/// schemes (SlabDiscount/FlatPercent) can omit them entirely. When RuleType is BuyXGetY, supplying
/// TriggerItemId+TriggerQty+FreeItemId+FreeQty here creates the scheme fully populated in one call
/// instead of a separate PUT .../free-goods-rule. When RuleType is FreeBundle, supplying
/// FreeItemId+FreeQty+BundleComponents does the same. These can still be added/edited later via
/// PUT .../free-goods-rule and PUT .../bundle-components/{itemId}.
/// </summary>
public record CreateSchemeRequest(
string Name,
SchemeRuleType RuleType,
string? RuleConfig,
Guid? RegionId,
string? EligibleSegment,
DateTime DateRangeFrom,
DateTime DateRangeTo,
bool AgentLimitExempt,
decimal? BaselineExpectedSales,
Guid? TriggerItemId = null,
int? TriggerQty = null,
Guid? FreeItemId = null,
decimal? FreeQty = null,
List<BundleComponentRequest>? BundleComponents = null);
public record UpdateSchemeRequest(
string Name,
string? RuleConfig,
Guid? RegionId,
string? EligibleSegment,
DateTime DateRangeFrom,
DateTime DateRangeTo,
bool AgentLimitExempt,
decimal? BaselineExpectedSales);
public record ApplySchemeRequest(Guid SchemeId);
/// <summary>
/// Sets the free-goods payout for a scheme. For BuyXGetY, TriggerItemId/TriggerQty are required
/// (what the store must buy); for FreeBundle they are ignored — the required items instead come
/// from the scheme's bundle-components CRUD. FreeItemId/FreeQty is the payout in both cases.
/// </summary>
public record SetFreeGoodsRuleRequest(Guid? TriggerItemId, int? TriggerQty, Guid FreeItemId, decimal FreeQty);
public record UpsertBundleComponentRequest(int Qty);
// ── Responses ───────────────────────────────────────────────────────────
public record BundleComponentResponse(Guid ItemId, string Sku, string ItemName, int Qty);
public record SchemeResponse(
Guid Id,
string Name,
SchemeRuleType RuleType,
string? RuleConfig,
Guid? TriggerItemId,
string? TriggerItemName,
int? TriggerQty,
Guid? FreeItemId,
string? FreeItemName,
decimal? FreeQty,
List<BundleComponentResponse> BundleComponents,
Guid? RegionId,
string? RegionName,
string? EligibleSegment,
DateTime DateRangeFrom,
DateTime DateRangeTo,
bool AgentLimitExempt,
decimal? BaselineExpectedSales,
bool IsActive,
DateTime CreatedAt);
public record SchemeEligibilityResponse(
Guid SchemeId,
string SchemeName,
SchemeRuleType RuleType,
bool Eligible,
string? Reason,
decimal? DiscountAmount,
Guid? FreeItemId,
decimal? FreeQty);
public record SchemeRedemptionResponse(
Guid Id,
Guid SchemeId,
Guid? OrderId,
decimal? DiscountAmount,
Guid? FreeItemId,
string? FreeItemName,
decimal? FreeQty,
decimal? FreeItemCostValue,
DateTime RedeemedAt);
public record SchemeSpendResponse(Guid SchemeId, decimal TotalDiscountSpend, decimal TotalFreeGoodsCostSpend, decimal TotalSpend, int RedemptionCount);
public record SchemeROIResponse(Guid SchemeId, decimal BaselineExpectedSales, decimal ActualSales, decimal IncrementalSales, decimal TotalSpend, decimal? ROI);
@@ -0,0 +1,67 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Scheme;
public class SchemeRepository(AppDBContext context) : ISchemeRepository
{
public async Task<Models.Scheme?> GetByIdAsync(Guid id) =>
await context.Schemes
.Include(s => s.Region)
.Include(s => s.TriggerItem)
.Include(s => s.FreeItem)
.Include(s => s.BundleComponents).ThenInclude(c => c.Item)
.FirstOrDefaultAsync(s => s.Id == id);
public async Task<List<Models.Scheme>> GetAllAsync(Guid? regionId = null)
{
var query = context.Schemes
.Include(s => s.Region)
.Include(s => s.TriggerItem)
.Include(s => s.FreeItem)
.Include(s => s.BundleComponents).ThenInclude(c => c.Item)
.Where(s => s.IsActive);
if (regionId.HasValue) query = query.Where(s => s.FK_RegionId == regionId);
return await query.ToListAsync();
}
public async Task AddAsync(Models.Scheme entity) =>
await context.Schemes.AddAsync(entity);
public void Update(Models.Scheme entity) =>
context.Schemes.Update(entity);
public void Remove(Models.Scheme entity)
{
entity.IsActive = false;
context.Schemes.Update(entity);
}
public async Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId) =>
await context.SchemeRedemptions
.Include(r => r.FreeItem)
.Include(r => r.Order)
.Where(r => r.FK_SchemeId == schemeId)
.ToListAsync();
public async Task AddRedemptionAsync(Models.SchemeRedemption entity) =>
await context.SchemeRedemptions.AddAsync(entity);
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId) =>
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_OrderId == orderId);
public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
await context.SchemeBundleComponents
.FirstOrDefaultAsync(c => c.FK_SchemeId == schemeId && c.FK_ItemId == itemId);
public async Task AddBundleComponentAsync(Models.SchemeBundleComponent entity) =>
await context.SchemeBundleComponents.AddAsync(entity);
public void UpdateBundleComponent(Models.SchemeBundleComponent entity) =>
context.SchemeBundleComponents.Update(entity);
public void RemoveBundleComponent(Models.SchemeBundleComponent entity) =>
context.SchemeBundleComponents.Remove(entity);
}
@@ -0,0 +1,515 @@
using System.Text.Json;
using Distribution.Features.Item;
using Distribution.Features.Order;
using Distribution.Features.Stock;
using Distribution.Features.Store;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Scheme;
public class SchemeService(
ISchemeRepository repo,
IOrderRepository orderRepo,
IItemRepository itemRepo,
IStoreRepository storeRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static SchemeResponse ToResponse(Models.Scheme s) =>
new(s.Id, s.Name, s.RuleType, s.RuleConfig,
s.FK_TriggerItemId, s.TriggerItem?.Name, s.TriggerQty,
s.FK_FreeItemId, s.FreeItem?.Name, s.FreeQty,
s.BundleComponents.Select(c => new BundleComponentResponse(c.FK_ItemId, c.Item.Sku, c.Item.Name, c.Qty)).ToList(),
s.FK_RegionId, s.Region?.Name, s.EligibleSegment,
s.DateRangeFrom, s.DateRangeTo, s.AgentLimitExempt, s.BaselineExpectedSales, s.IsActive, s.CreatedAt);
private static SchemeRedemptionResponse ToRedemptionResponse(SchemeRedemption r) =>
new(r.Id, r.FK_SchemeId, r.FK_OrderId, r.DiscountAmount, r.FK_FreeItemId, r.FreeItem?.Name,
r.FreeQty, r.FreeItemCostValue, r.RedeemedAt);
// ── CRUD ───────────────────────────────────────────────────────────────
public async Task<List<SchemeResponse>> GetAllAsync(Guid? regionId = null)
{
var schemes = await repo.GetAllAsync(regionId);
return schemes.Select(ToResponse).ToList();
}
public async Task<SchemeResponse?> GetByIdAsync(Guid id)
{
var scheme = await repo.GetByIdAsync(id);
return scheme is null || !scheme.IsActive ? null : ToResponse(scheme);
}
/// <summary>
/// createScheme(schemeRules, eligibility, dateRange). RuleConfig is only meaningful for
/// SlabDiscount/FlatPercent. For BuyXGetY/FreeBundle, the free-goods payout (and bundle
/// components) can be supplied inline here to create a fully-populated scheme in one call, or
/// added afterwards via SetFreeGoodsRuleAsync / UpsertBundleComponentAsync.
/// </summary>
public async Task<SchemeResponse> CreateAsync(CreateSchemeRequest request, CancellationToken ct = default)
{
ValidatePercentRuleConfig(request.RuleType, request.RuleConfig);
if (request.DateRangeFrom > request.DateRangeTo)
throw new InvalidOperationException("DateRangeFrom must not be after DateRangeTo.");
var hasFreeGoodsRule = request.FreeItemId.HasValue || request.FreeQty.HasValue ||
request.TriggerItemId.HasValue || request.TriggerQty.HasValue;
if (hasFreeGoodsRule)
await ValidateFreeGoodsRuleAsync(request.RuleType, request.TriggerItemId, request.TriggerQty, request.FreeItemId, request.FreeQty);
if (request.BundleComponents is { Count: > 0 })
{
if (request.RuleType != SchemeRuleType.FreeBundle)
throw new InvalidOperationException("BundleComponents only applies to FreeBundle schemes.");
foreach (var c in request.BundleComponents)
{
if (c.Qty <= 0)
throw new InvalidOperationException("Bundle component Qty must be positive.");
if (await itemRepo.GetByIdAsync(c.ItemId) is null)
throw new InvalidOperationException($"Bundle component item '{c.ItemId}' not found.");
}
}
var scheme = new Models.Scheme
{
Name = request.Name,
RuleType = request.RuleType,
RuleConfig = request.RuleConfig,
FK_RegionId = request.RegionId,
EligibleSegment = request.EligibleSegment,
DateRangeFrom = request.DateRangeFrom,
DateRangeTo = request.DateRangeTo,
AgentLimitExempt = request.AgentLimitExempt,
BaselineExpectedSales = request.BaselineExpectedSales
};
if (hasFreeGoodsRule)
{
scheme.FK_TriggerItemId = request.RuleType == SchemeRuleType.BuyXGetY ? request.TriggerItemId : null;
scheme.TriggerQty = request.RuleType == SchemeRuleType.BuyXGetY ? request.TriggerQty : null;
scheme.FK_FreeItemId = request.FreeItemId;
scheme.FreeQty = request.FreeQty;
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(scheme);
if (request.BundleComponents is { Count: > 0 })
{
foreach (var c in request.BundleComponents)
await repo.AddBundleComponentAsync(new SchemeBundleComponent { FK_SchemeId = scheme.Id, FK_ItemId = c.ItemId, Qty = c.Qty });
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(scheme.Id))!;
}
public async Task<SchemeResponse?> UpdateAsync(Guid id, UpdateSchemeRequest request, CancellationToken ct = default)
{
var scheme = await repo.GetByIdAsync(id);
if (scheme is null || !scheme.IsActive) return null;
ValidatePercentRuleConfig(scheme.RuleType, request.RuleConfig);
if (request.DateRangeFrom > request.DateRangeTo)
throw new InvalidOperationException("DateRangeFrom must not be after DateRangeTo.");
scheme.Name = request.Name;
scheme.RuleConfig = request.RuleConfig;
scheme.FK_RegionId = request.RegionId;
scheme.EligibleSegment = request.EligibleSegment;
scheme.DateRangeFrom = request.DateRangeFrom;
scheme.DateRangeTo = request.DateRangeTo;
scheme.AgentLimitExempt = request.AgentLimitExempt;
scheme.BaselineExpectedSales = request.BaselineExpectedSales;
await uow.BeginAsync(ct);
try { repo.Update(scheme); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(scheme);
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
var scheme = await repo.GetByIdAsync(id);
if (scheme is null || !scheme.IsActive) return false;
await uow.BeginAsync(ct);
try { repo.Remove(scheme); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return true;
}
// ── Free-goods rule CRUD (BuyXGetY trigger + shared payout) ──────────────
public async Task<SchemeResponse?> SetFreeGoodsRuleAsync(Guid schemeId, SetFreeGoodsRuleRequest request, CancellationToken ct = default)
{
var scheme = await repo.GetByIdAsync(schemeId);
if (scheme is null || !scheme.IsActive) return null;
await ValidateFreeGoodsRuleAsync(scheme.RuleType, request.TriggerItemId, request.TriggerQty, request.FreeItemId, request.FreeQty);
scheme.FK_TriggerItemId = scheme.RuleType == SchemeRuleType.BuyXGetY ? request.TriggerItemId : null;
scheme.TriggerQty = scheme.RuleType == SchemeRuleType.BuyXGetY ? request.TriggerQty : null;
scheme.FK_FreeItemId = request.FreeItemId;
scheme.FreeQty = request.FreeQty;
await uow.BeginAsync(ct);
try { repo.Update(scheme); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(schemeId))!;
}
public async Task<SchemeResponse?> ClearFreeGoodsRuleAsync(Guid schemeId, CancellationToken ct = default)
{
var scheme = await repo.GetByIdAsync(schemeId);
if (scheme is null || !scheme.IsActive) return null;
scheme.FK_TriggerItemId = null;
scheme.TriggerQty = null;
scheme.FK_FreeItemId = null;
scheme.FreeQty = null;
await uow.BeginAsync(ct);
try { repo.Update(scheme); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(schemeId))!;
}
// ── Bundle component CRUD (FreeBundle required items) ────────────────────
public async Task<SchemeResponse?> UpsertBundleComponentAsync(Guid schemeId, Guid itemId, UpsertBundleComponentRequest request, CancellationToken ct = default)
{
var scheme = await repo.GetByIdAsync(schemeId);
if (scheme is null || !scheme.IsActive) return null;
if (scheme.RuleType != SchemeRuleType.FreeBundle)
throw new InvalidOperationException("Bundle components only apply to FreeBundle schemes.");
if (request.Qty <= 0)
throw new InvalidOperationException("Qty must be positive.");
var item = await itemRepo.GetByIdAsync(itemId)
?? throw new InvalidOperationException("Item not found.");
var existing = await repo.GetBundleComponentAsync(schemeId, itemId);
await uow.BeginAsync(ct);
try
{
if (existing is null)
{
await repo.AddBundleComponentAsync(new SchemeBundleComponent
{
FK_SchemeId = schemeId,
FK_ItemId = item.Id,
Qty = request.Qty
});
}
else
{
existing.Qty = request.Qty;
repo.UpdateBundleComponent(existing);
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(schemeId))!;
}
public async Task<SchemeResponse?> RemoveBundleComponentAsync(Guid schemeId, Guid itemId, CancellationToken ct = default)
{
var scheme = await repo.GetByIdAsync(schemeId);
if (scheme is null || !scheme.IsActive) return null;
var existing = await repo.GetBundleComponentAsync(schemeId, itemId);
if (existing is null) return null;
await uow.BeginAsync(ct);
try { repo.RemoveBundleComponent(existing); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(schemeId))!;
}
// ── evaluateSchemeEligibility(storeId, orderId) ──────────────────────────
public async Task<List<SchemeEligibilityResponse>?> EvaluateEligibilityAsync(Guid storeId, Guid orderId)
{
var store = await storeRepo.GetByIdWithRegionAsync(storeId);
if (store is null || !store.IsActive) return null;
var order = await orderRepo.GetByIdAsync(orderId);
if (order is null || !order.IsActive) return null;
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
var regionId = store.Route?.FK_RegionId;
var candidates = await repo.GetAllAsync();
var result = new List<SchemeEligibilityResponse>();
foreach (var scheme in candidates)
{
if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != regionId)
continue;
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
!string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase))
continue;
if (order.OrderDate < scheme.DateRangeFrom || order.OrderDate > scheme.DateRangeTo)
continue;
result.Add(EvaluateRule(scheme, orderItems));
}
return result;
}
/// <summary>
/// Scope-only check (region, segment, date range as of today) with no order to evaluate line
/// items against — used by Store Management's `checkStoreSchemeEligibility(storeId, schemeId)`
/// (12_store_management.md), which has no order context. Returns null if the store or scheme
/// doesn't exist; otherwise (Eligible, Reason) — Eligible here means "in scope for this store",
/// not "this order's items satisfy the rule" (use EvaluateEligibilityAsync for that).
/// </summary>
public async Task<(bool Eligible, string? Reason)?> CheckStoreScopeEligibilityAsync(Guid storeId, Guid schemeId)
{
var store = await storeRepo.GetByIdWithRegionAsync(storeId);
if (store is null || !store.IsActive) return null;
var scheme = await repo.GetByIdAsync(schemeId);
if (scheme is null || !scheme.IsActive) return null;
var regionId = store.Route?.FK_RegionId;
if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != regionId)
return (false, "Scheme is not available in the store's region.");
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
!string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase))
return (false, $"Scheme is only available to the '{scheme.EligibleSegment}' segment.");
var today = DateTime.Now;
if (today < scheme.DateRangeFrom || today > scheme.DateRangeTo)
return (false, "Scheme is not currently active (outside its date range).");
return (true, null);
}
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<OrderItem> orderItems)
{
switch (scheme.RuleType)
{
case SchemeRuleType.BuyXGetY:
{
if (scheme.FK_TriggerItemId is null || scheme.TriggerQty is null or <= 0 ||
scheme.FK_FreeItemId is null || scheme.FreeQty is null or <= 0)
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
"Scheme has no free-goods rule configured.", null, null, null);
var boughtQty = orderItems.Where(i => i.FK_ItemId == scheme.FK_TriggerItemId).Sum(i => i.Quantity);
if (boughtQty < scheme.TriggerQty)
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
$"Requires {scheme.TriggerQty} of the trigger item; order has {boughtQty}.", null, null, null);
var multiplier = boughtQty / scheme.TriggerQty.Value;
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
null, scheme.FK_FreeItemId, scheme.FreeQty * multiplier);
}
case SchemeRuleType.FreeBundle:
{
if (scheme.BundleComponents.Count == 0 || scheme.FK_FreeItemId is null || scheme.FreeQty is null or <= 0)
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
"Scheme has no bundle components or free-goods payout configured.", null, null, null);
var missing = scheme.BundleComponents
.Where(c => orderItems.Where(i => i.FK_ItemId == c.FK_ItemId).Sum(i => i.Quantity) < c.Qty)
.ToList();
if (missing.Count > 0)
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
"Order is missing required bundle components.", null, null, null);
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
null, scheme.FK_FreeItemId, scheme.FreeQty);
}
case SchemeRuleType.FlatPercent:
{
if (string.IsNullOrEmpty(scheme.RuleConfig))
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Scheme has no RuleConfig.", null, null, null);
var cfg = JsonSerializer.Deserialize<FlatPercentConfig>(scheme.RuleConfig, JsonOptions)!;
var orderTotal = orderItems.Sum(i => i.Quantity * i.UnitPrice);
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
Math.Round(orderTotal * cfg.DiscountPercent / 100m, 2), null, null);
}
case SchemeRuleType.SlabDiscount:
{
if (string.IsNullOrEmpty(scheme.RuleConfig))
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Scheme has no RuleConfig.", null, null, null);
var cfg = JsonSerializer.Deserialize<SlabDiscountConfig>(scheme.RuleConfig, JsonOptions)!;
var orderTotal = orderItems.Sum(i => i.Quantity * i.UnitPrice);
var tier = cfg.Tiers.Where(t => orderTotal >= t.MinOrderAmount)
.OrderByDescending(t => t.MinOrderAmount).FirstOrDefault();
if (tier is null)
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
"Order total does not meet any slab threshold.", null, null, null);
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
Math.Round(orderTotal * tier.DiscountPercent / 100m, 2), null, null);
}
default:
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Unknown rule type.", null, null, null);
}
}
// ── applySchemeDiscount(orderId, schemeId) ───────────────────────────────
public async Task<SchemeRedemptionResponse?> ApplySchemeAsync(Guid orderId, Guid schemeId, CancellationToken ct = default)
{
var scheme = await repo.GetByIdAsync(schemeId);
if (scheme is null || !scheme.IsActive) return null;
var order = await orderRepo.GetByIdAsync(orderId);
if (order is null || !order.IsActive) return null;
if (await repo.HasRedemptionAsync(schemeId, orderId))
throw new InvalidOperationException("This scheme has already been redeemed against this order.");
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
var eligibility = EvaluateRule(scheme, orderItems);
if (!eligibility.Eligible)
throw new InvalidOperationException(eligibility.Reason ?? "Order is not eligible for this scheme.");
var redemption = new SchemeRedemption
{
FK_SchemeId = schemeId,
FK_OrderId = orderId,
DiscountAmount = eligibility.DiscountAmount
};
var newOrderItems = new List<OrderItem>();
if (eligibility.FreeItemId.HasValue && eligibility.FreeQty is > 0)
{
var freeItem = await itemRepo.GetByIdAsync(eligibility.FreeItemId.Value)
?? throw new InvalidOperationException("Free item not found.");
var availableQty = (await stockRepo.GetBalancesByItemAsync(freeItem.Id)).Sum(b => b.Quantity);
if (availableQty < eligibility.FreeQty.Value)
throw new InvalidOperationException($"Insufficient stock of '{freeItem.Sku}' to fulfil the free-goods scheme.");
newOrderItems.Add(new OrderItem
{
FK_OrderId = orderId,
FK_ItemId = freeItem.Id,
Quantity = (int)eligibility.FreeQty.Value,
UnitPrice = 0m
});
redemption.FK_FreeItemId = freeItem.Id;
redemption.FreeQty = eligibility.FreeQty;
redemption.FreeItemCostValue = freeItem.CostPrice ?? 0m;
}
await uow.BeginAsync(ct);
try
{
if (newOrderItems.Count > 0) await orderRepo.AddItemsAsync(newOrderItems);
await repo.AddRedemptionAsync(redemption);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var reloaded = (await repo.GetRedemptionsAsync(schemeId)).First(r => r.Id == redemption.Id);
return ToRedemptionResponse(reloaded);
}
// ── trackSchemeSpend(schemeId) ───────────────────────────────────────────
public async Task<SchemeSpendResponse?> GetSpendAsync(Guid schemeId)
{
var scheme = await repo.GetByIdAsync(schemeId);
if (scheme is null) return null;
var redemptions = await repo.GetRedemptionsAsync(schemeId);
var discountSpend = redemptions.Sum(r => r.DiscountAmount ?? 0m);
var freeGoodsSpend = redemptions.Sum(r => (r.FreeItemCostValue ?? 0m) * (r.FreeQty ?? 0m));
return new SchemeSpendResponse(schemeId, discountSpend, freeGoodsSpend, discountSpend + freeGoodsSpend, redemptions.Count);
}
// ── getSchemeROI(schemeId) ───────────────────────────────────────────────
public async Task<SchemeROIResponse?> GetROIAsync(Guid schemeId)
{
var scheme = await repo.GetByIdAsync(schemeId);
if (scheme is null) return null;
var redemptions = await repo.GetRedemptionsAsync(schemeId);
var spend = await GetSpendAsync(schemeId);
var actualSales = redemptions
.Where(r => r.Order is not null)
.Select(r => r.Order!)
.Distinct()
.Sum(o => o.TotalAmount);
var baseline = scheme.BaselineExpectedSales ?? 0m;
var incremental = actualSales - baseline;
decimal? roi = spend!.TotalSpend > 0 ? incremental / spend.TotalSpend : null;
return new SchemeROIResponse(schemeId, baseline, actualSales, incremental, spend.TotalSpend, roi);
}
private async Task ValidateFreeGoodsRuleAsync(SchemeRuleType ruleType, Guid? triggerItemId, int? triggerQty, Guid? freeItemId, decimal? freeQty)
{
if (ruleType is not (SchemeRuleType.BuyXGetY or SchemeRuleType.FreeBundle))
throw new InvalidOperationException($"Free-goods rule only applies to BuyXGetY or FreeBundle schemes, not '{ruleType}'.");
if (ruleType == SchemeRuleType.BuyXGetY)
{
if (triggerItemId is null || triggerQty is null or <= 0)
throw new InvalidOperationException("BuyXGetY requires TriggerItemId and a positive TriggerQty.");
if (await itemRepo.GetByIdAsync(triggerItemId.Value) is null)
throw new InvalidOperationException("Trigger item not found.");
}
if (freeItemId is null || freeQty is null or <= 0)
throw new InvalidOperationException("FreeItemId and a positive FreeQty are required.");
if (await itemRepo.GetByIdAsync(freeItemId.Value) is null)
throw new InvalidOperationException("Free item not found.");
}
private static void ValidatePercentRuleConfig(SchemeRuleType ruleType, string? ruleConfig)
{
if (ruleType is not (SchemeRuleType.SlabDiscount or SchemeRuleType.FlatPercent)) return;
if (string.IsNullOrWhiteSpace(ruleConfig))
throw new InvalidOperationException($"RuleConfig is required for RuleType '{ruleType}'.");
try
{
if (ruleType == SchemeRuleType.SlabDiscount)
{
var slab = JsonSerializer.Deserialize<SlabDiscountConfig>(ruleConfig, JsonOptions) ?? throw new JsonException();
if (slab.Tiers.Count == 0)
throw new InvalidOperationException("SlabDiscount scheme requires at least one tier.");
}
else
{
_ = JsonSerializer.Deserialize<FlatPercentConfig>(ruleConfig, JsonOptions) ?? throw new JsonException();
}
}
catch (JsonException)
{
throw new InvalidOperationException($"RuleConfig is not valid JSON for RuleType '{ruleType}'.");
}
}
}
+6 -3
View File
@@ -1,10 +1,11 @@
using Distribution.Features.Scheme;
using Distribution.Infra.Authorization; using Distribution.Infra.Authorization;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
using Distribution.Models; using Distribution.Models;
namespace Distribution.Features.Store; 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 GlobalScopeRoleLevel = 50;
private const int CreditApproverRoleLevel = 20; private const int CreditApproverRoleLevel = 20;
@@ -217,8 +218,10 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
var store = await repo.GetByIdAsync(id); var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null; if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) 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) public async Task<StoreDashboardResponse?> GetDashboardAsync(Guid id, string period)
@@ -0,0 +1,129 @@
using System;
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
[DbContext(typeof(AppDBContext))]
[Migration("20260821120000_AddPromotionsSchemes")]
/// <inheritdoc />
public partial class AddPromotionsSchemes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "CostPrice",
table: "Items",
type: "decimal(65,30)",
nullable: true);
migrationBuilder.CreateTable(
name: "Schemes",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Name = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
RuleType = table.Column<int>(type: "int", nullable: false),
RuleConfig = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
EligibleSegment = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
DateRangeFrom = table.Column<DateTime>(type: "datetime(6)", nullable: false),
DateRangeTo = table.Column<DateTime>(type: "datetime(6)", nullable: false),
AgentLimitExempt = table.Column<bool>(type: "tinyint(1)", nullable: false),
BaselineExpectedSales = table.Column<decimal>(type: "decimal(65,30)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Schemes", x => x.Id);
table.ForeignKey(
name: "FK_Schemes_Regions_FK_RegionId",
column: x => x.FK_RegionId,
principalTable: "Regions",
principalColumn: "Id");
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "SchemeRedemptions",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_SchemeId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_OrderId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
FieldInvoiceId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
DiscountAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: true),
FK_FreeItemId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
FreeQty = table.Column<decimal>(type: "decimal(65,30)", nullable: true),
FreeItemCostValue = table.Column<decimal>(type: "decimal(65,30)", nullable: true),
RedeemedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SchemeRedemptions", x => x.Id);
table.ForeignKey(
name: "FK_SchemeRedemptions_Schemes_FK_SchemeId",
column: x => x.FK_SchemeId,
principalTable: "Schemes",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_SchemeRedemptions_Orders_FK_OrderId",
column: x => x.FK_OrderId,
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_SchemeRedemptions_Items_FK_FreeItemId",
column: x => x.FK_FreeItemId,
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Schemes_FK_RegionId",
table: "Schemes",
column: "FK_RegionId");
migrationBuilder.CreateIndex(
name: "IX_SchemeRedemptions_FK_SchemeId",
table: "SchemeRedemptions",
column: "FK_SchemeId");
migrationBuilder.CreateIndex(
name: "IX_SchemeRedemptions_FK_OrderId",
table: "SchemeRedemptions",
column: "FK_OrderId");
migrationBuilder.CreateIndex(
name: "IX_SchemeRedemptions_FK_FreeItemId",
table: "SchemeRedemptions",
column: "FK_FreeItemId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SchemeRedemptions");
migrationBuilder.DropTable(
name: "Schemes");
migrationBuilder.DropColumn(
name: "CostPrice",
table: "Items");
}
}
}
@@ -0,0 +1,170 @@
using System;
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
[DbContext(typeof(AppDBContext))]
[Migration("20260821130000_AddSchemeFreeGoodsRuleAndBundleComponents")]
/// <inheritdoc />
public partial class AddSchemeFreeGoodsRuleAndBundleComponents : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "RuleConfig",
table: "Schemes",
type: "longtext",
nullable: true,
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<Guid>(
name: "FK_TriggerItemId",
table: "Schemes",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.AddColumn<int>(
name: "TriggerQty",
table: "Schemes",
type: "int",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "FK_FreeItemId",
table: "Schemes",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.AddColumn<decimal>(
name: "FreeQty",
table: "Schemes",
type: "decimal(65,30)",
nullable: true);
migrationBuilder.CreateTable(
name: "SchemeBundleComponents",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_SchemeId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Qty = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SchemeBundleComponents", x => x.Id);
table.ForeignKey(
name: "FK_SchemeBundleComponents_Schemes_FK_SchemeId",
column: x => x.FK_SchemeId,
principalTable: "Schemes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SchemeBundleComponents_Items_FK_ItemId",
column: x => x.FK_ItemId,
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Schemes_FK_TriggerItemId",
table: "Schemes",
column: "FK_TriggerItemId");
migrationBuilder.CreateIndex(
name: "IX_Schemes_FK_FreeItemId",
table: "Schemes",
column: "FK_FreeItemId");
migrationBuilder.CreateIndex(
name: "IX_SchemeBundleComponents_FK_SchemeId_FK_ItemId",
table: "SchemeBundleComponents",
columns: new[] { "FK_SchemeId", "FK_ItemId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_SchemeBundleComponents_FK_ItemId",
table: "SchemeBundleComponents",
column: "FK_ItemId");
migrationBuilder.AddForeignKey(
name: "FK_Schemes_Items_FK_TriggerItemId",
table: "Schemes",
column: "FK_TriggerItemId",
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Schemes_Items_FK_FreeItemId",
table: "Schemes",
column: "FK_FreeItemId",
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SchemeBundleComponents");
migrationBuilder.DropForeignKey(
name: "FK_Schemes_Items_FK_TriggerItemId",
table: "Schemes");
migrationBuilder.DropForeignKey(
name: "FK_Schemes_Items_FK_FreeItemId",
table: "Schemes");
migrationBuilder.DropIndex(
name: "IX_Schemes_FK_TriggerItemId",
table: "Schemes");
migrationBuilder.DropIndex(
name: "IX_Schemes_FK_FreeItemId",
table: "Schemes");
migrationBuilder.DropColumn(
name: "FK_TriggerItemId",
table: "Schemes");
migrationBuilder.DropColumn(
name: "TriggerQty",
table: "Schemes");
migrationBuilder.DropColumn(
name: "FK_FreeItemId",
table: "Schemes");
migrationBuilder.DropColumn(
name: "FreeQty",
table: "Schemes");
migrationBuilder.AlterColumn<string>(
name: "RuleConfig",
table: "Schemes",
type: "longtext",
nullable: false,
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
}
}
}
@@ -196,6 +196,9 @@ namespace Distribution.Migrations
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
b.Property<decimal?>("CostPrice")
.HasColumnType("decimal(65,30)");
b.Property<string>("Description") b.Property<string>("Description")
.HasColumnType("longtext"); .HasColumnType("longtext");
@@ -819,6 +822,135 @@ namespace Distribution.Migrations
b.ToTable("Regions"); b.ToTable("Regions");
}); });
modelBuilder.Entity("Distribution.Models.Scheme", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<bool>("AgentLimitExempt")
.HasColumnType("tinyint(1)");
b.Property<decimal?>("BaselineExpectedSales")
.HasColumnType("decimal(65,30)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("DateRangeFrom")
.HasColumnType("datetime(6)");
b.Property<DateTime>("DateRangeTo")
.HasColumnType("datetime(6)");
b.Property<string>("EligibleSegment")
.HasColumnType("longtext");
b.Property<Guid?>("FK_FreeItemId")
.HasColumnType("char(36)");
b.Property<Guid?>("FK_RegionId")
.HasColumnType("char(36)");
b.Property<Guid?>("FK_TriggerItemId")
.HasColumnType("char(36)");
b.Property<decimal?>("FreeQty")
.HasColumnType("decimal(65,30)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("RuleConfig")
.HasColumnType("longtext");
b.Property<int>("RuleType")
.HasColumnType("int");
b.Property<int?>("TriggerQty")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("FK_FreeItemId");
b.HasIndex("FK_RegionId");
b.HasIndex("FK_TriggerItemId");
b.ToTable("Schemes");
});
modelBuilder.Entity("Distribution.Models.SchemeBundleComponent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("FK_ItemId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_SchemeId")
.HasColumnType("char(36)");
b.Property<int>("Qty")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("FK_ItemId");
b.HasIndex("FK_SchemeId", "FK_ItemId")
.IsUnique();
b.ToTable("SchemeBundleComponents");
});
modelBuilder.Entity("Distribution.Models.SchemeRedemption", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<decimal?>("DiscountAmount")
.HasColumnType("decimal(65,30)");
b.Property<Guid?>("FK_FreeItemId")
.HasColumnType("char(36)");
b.Property<Guid?>("FK_OrderId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_SchemeId")
.HasColumnType("char(36)");
b.Property<Guid?>("FieldInvoiceId")
.HasColumnType("char(36)");
b.Property<decimal?>("FreeItemCostValue")
.HasColumnType("decimal(65,30)");
b.Property<decimal?>("FreeQty")
.HasColumnType("decimal(65,30)");
b.Property<DateTime>("RedeemedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("FK_FreeItemId");
b.HasIndex("FK_OrderId");
b.HasIndex("FK_SchemeId");
b.ToTable("SchemeRedemptions");
});
modelBuilder.Entity("Distribution.Models.RideCancelRequest", b => modelBuilder.Entity("Distribution.Models.RideCancelRequest", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -2939,6 +3071,25 @@ namespace Distribution.Migrations
b.Navigation("Role"); b.Navigation("Role");
}); });
modelBuilder.Entity("Distribution.Models.PriceListItem", b =>
{
b.HasOne("Distribution.Models.Item", "Item")
.WithMany()
.HasForeignKey("FK_ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Distribution.Models.PriceList", "PriceList")
.WithMany("Items")
.HasForeignKey("FK_PriceListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Item");
b.Navigation("PriceList");
});
modelBuilder.Entity("Distribution.Models.Route", b => modelBuilder.Entity("Distribution.Models.Route", b =>
{ {
b.HasOne("Distribution.Models.Region", "Region") b.HasOne("Distribution.Models.Region", "Region")
@@ -2950,6 +3101,73 @@ namespace Distribution.Migrations
b.Navigation("Region"); b.Navigation("Region");
}); });
modelBuilder.Entity("Distribution.Models.Scheme", b =>
{
b.HasOne("Distribution.Models.Item", "FreeItem")
.WithMany()
.HasForeignKey("FK_FreeItemId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Distribution.Models.Region", "Region")
.WithMany()
.HasForeignKey("FK_RegionId");
b.HasOne("Distribution.Models.Item", "TriggerItem")
.WithMany()
.HasForeignKey("FK_TriggerItemId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("FreeItem");
b.Navigation("Region");
b.Navigation("TriggerItem");
});
modelBuilder.Entity("Distribution.Models.SchemeBundleComponent", b =>
{
b.HasOne("Distribution.Models.Item", "Item")
.WithMany()
.HasForeignKey("FK_ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.Scheme", "Scheme")
.WithMany("BundleComponents")
.HasForeignKey("FK_SchemeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Item");
b.Navigation("Scheme");
});
modelBuilder.Entity("Distribution.Models.SchemeRedemption", b =>
{
b.HasOne("Distribution.Models.Item", "FreeItem")
.WithMany()
.HasForeignKey("FK_FreeItemId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Distribution.Models.Order", "Order")
.WithMany()
.HasForeignKey("FK_OrderId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Distribution.Models.Scheme", "Scheme")
.WithMany("Redemptions")
.HasForeignKey("FK_SchemeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("FreeItem");
b.Navigation("Order");
b.Navigation("Scheme");
});
modelBuilder.Entity("Distribution.Models.StockBalance", b => modelBuilder.Entity("Distribution.Models.StockBalance", b =>
{ {
b.HasOne("Distribution.Models.Item", "Item") b.HasOne("Distribution.Models.Item", "Item")
@@ -3135,6 +3353,13 @@ namespace Distribution.Migrations
{ {
b.Navigation("Items"); b.Navigation("Items");
}); });
modelBuilder.Entity("Distribution.Models.Scheme", b =>
{
b.Navigation("BundleComponents");
b.Navigation("Redemptions");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }
+3
View File
@@ -26,6 +26,9 @@ namespace Distribution.Models
public decimal? SalePrice { get; set; } public decimal? SalePrice { get; set; }
/// <summary>Cost/purchase price, used to value free-goods scheme redemptions for getSchemeROI.</summary>
public decimal? CostPrice { get; set; }
public SyncSource SyncSource { get; set; } = SyncSource.Manual; public SyncSource SyncSource { get; set; } = SyncSource.Manual;
public int? ExternalErpItemId { get; set; } public int? ExternalErpItemId { get; set; }
public DateTime? LastSyncedAt { get; set; } public DateTime? LastSyncedAt { get; set; }
+62
View File
@@ -0,0 +1,62 @@
namespace Distribution.Models
{
public enum SchemeRuleType
{
BuyXGetY,
SlabDiscount,
FlatPercent,
FreeBundle
}
public class Scheme : Template
{
public string Name { get; set; }
public SchemeRuleType RuleType { get; set; }
/// <summary>
/// JSON payload for percentage-based rules only (SlabDiscount tiers, FlatPercent). Free-goods
/// rules (BuyXGetY / FreeBundle) are structured fields/child rows below, not JSON, so they can
/// be managed through their own CRUD endpoints instead of a hand-edited blob.
/// </summary>
public string? RuleConfig { get; set; }
// ── BuyXGetY: trigger item/qty; shares FK_FreeItemId/FreeQty below as the payout. ──────────
public Guid? FK_TriggerItemId { get; set; }
public Item? TriggerItem { get; set; }
public int? TriggerQty { get; set; }
// ── Free-goods payout, used by both BuyXGetY and FreeBundle. ─────────────────────────────
public Guid? FK_FreeItemId { get; set; }
public Item? FreeItem { get; set; }
public decimal? FreeQty { get; set; }
/// <summary>Required component items for a FreeBundle scheme — managed via its own CRUD.</summary>
public ICollection<SchemeBundleComponent> BundleComponents { get; set; } = [];
public Guid? FK_RegionId { get; set; }
public Region? Region { get; set; }
/// <summary>Store segment this scheme applies to (Gold/Silver/Key Account/...); null = all segments.</summary>
public string? EligibleSegment { get; set; }
public DateTime DateRangeFrom { get; set; }
public DateTime DateRangeTo { get; set; }
/// <summary>
/// When true, this scheme's discount is exempt from the per-role discount ceiling in
/// 08_sales_agent_controls.md and stacks on top of it unconditionally.
/// </summary>
public bool AgentLimitExempt { get; set; }
/// <summary>
/// Manual baseline (expected sales with no scheme running) used by getSchemeROI. Per the
/// open question in docs/17_promotions_schemes.md, this is entered at scheme creation
/// rather than computed from a trailing-period average.
/// </summary>
public decimal? BaselineExpectedSales { get; set; }
public ICollection<SchemeRedemption> Redemptions { get; set; } = [];
}
}
@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
/// <summary>A required item+qty line of a FreeBundle scheme (e.g. "buy A + B, get C free").</summary>
public class SchemeBundleComponent
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid FK_SchemeId { get; set; }
[ForeignKey("FK_SchemeId")]
public Scheme Scheme { get; set; }
public Guid FK_ItemId { get; set; }
[ForeignKey("FK_ItemId")]
public Item Item { get; set; }
public int Qty { get; set; }
}
}
+36
View File
@@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class SchemeRedemption
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid FK_SchemeId { get; set; }
[ForeignKey("FK_SchemeId")]
public Scheme Scheme { get; set; }
public Guid? FK_OrderId { get; set; }
[ForeignKey("FK_OrderId")]
public Order? Order { get; set; }
/// <summary>Placeholder for the future field-invoice module — no FieldInvoice entity exists yet.</summary>
public Guid? FieldInvoiceId { get; set; }
public decimal? DiscountAmount { get; set; }
public Guid? FK_FreeItemId { get; set; }
[ForeignKey("FK_FreeItemId")]
public Item? FreeItem { get; set; }
public decimal? FreeQty { get; set; }
/// <summary>
/// Cost price of the free item snapshotted at redemption time, so getSchemeROI stays
/// accurate even after the item's cost price later changes.
/// </summary>
public decimal? FreeItemCostValue { get; set; }
public DateTime RedeemedAt { get; set; } = DateTime.Now;
}
}
+3
View File
@@ -12,6 +12,7 @@ using Distribution.Features.Region;
using Distribution.Features.Ride; using Distribution.Features.Ride;
using Distribution.Features.Role; using Distribution.Features.Role;
using Distribution.Features.Route; using Distribution.Features.Route;
using Distribution.Features.Scheme;
using Distribution.Features.Store; using Distribution.Features.Store;
using Distribution.Features.Store.Kyc; using Distribution.Features.Store.Kyc;
using Distribution.Features.Supplier; using Distribution.Features.Supplier;
@@ -59,6 +60,7 @@ builder.Services.AddScoped<IRideCancelRequestRepository, RideCancelRequestReposi
builder.Services.AddScoped<IStoreKycDocumentRepository, StoreKycDocumentRepository>(); builder.Services.AddScoped<IStoreKycDocumentRepository, StoreKycDocumentRepository>();
builder.Services.AddScoped<IPriceListRepository, PriceListRepository>(); builder.Services.AddScoped<IPriceListRepository, PriceListRepository>();
builder.Services.AddScoped<ISupplierRepository, SupplierRepository>(); builder.Services.AddScoped<ISupplierRepository, SupplierRepository>();
builder.Services.AddScoped<ISchemeRepository, SchemeRepository>();
builder.Services.AddScoped<RegionService>(); builder.Services.AddScoped<RegionService>();
builder.Services.AddScoped<RouteService>(); builder.Services.AddScoped<RouteService>();
@@ -81,6 +83,7 @@ builder.Services.AddScoped<SupplierService>();
builder.Services.AddScoped<DriverProfileService>(); builder.Services.AddScoped<DriverProfileService>();
builder.Services.AddScoped<DriverComplianceConfigService>(); builder.Services.AddScoped<DriverComplianceConfigService>();
builder.Services.AddScoped<RideService>(); builder.Services.AddScoped<RideService>();
builder.Services.AddScoped<SchemeService>();
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>(); builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
+16 -8
View File
@@ -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 + `OneTimeAmount` / `CreditAmount`, which stay as-is; this step adds the real credit-limit +
outstanding-balance + KYC-gated-ordering flow on top. outstanding-balance + KYC-gated-ordering flow on top.
`getStoreVisitHistory` and `checkStoreSchemeEligibility` are read-only integration points into `getStoreVisitHistory` and `checkStoreSchemeEligibility` were originally read-only integration
modules that don't exist yet ([11_route_sales_management.md](./11_route_sales_management.md) for points into modules that didn't exist yet ([11_route_sales_management.md](./11_route_sales_management.md)
visits, [17_promotions_schemes.md](./17_promotions_schemes.md) for schemes). Implement them now for visits, [17_promotions_schemes.md](./17_promotions_schemes.md) for schemes) — both shipped as
with the query shape defined below; wire the actual join once those modules land — return an empty placeholders (empty list / `eligible: true`) guarded by a `NotImplementedYet`-style short-circuit.
list / `eligible: true` (no active schemes to check) until then, guarded by a `NotImplementedYet`
style short-circuit, not a broken query. **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 | | GET | /api/stores/{id}/outstanding-balance | stores.view | Returns cached value + CreditLimit + available credit |
| PUT | /api/stores/{id}/segment | stores.update | Body: `{ "segment": "Gold" }` | | 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}/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 | | 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, StoreController.cs (extended — credit-limit, balance, kyc/verify, segment,
visit-history, scheme-eligibility, dashboard endpoints) visit-history, scheme-eligibility, dashboard endpoints)
StoreDTOs.cs (extended — new request/response records) 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) IStoreRepository.cs (extended — GetOrdersForDashboardAsync)
StoreRepository.cs (extended) StoreRepository.cs (extended)
Kyc/ Kyc/
+334 -26
View File
@@ -1,70 +1,378 @@
# 17 — Promotions & Trade Scheme Management # 17 — Promotions & Trade Scheme Management
**Status**: Not Started **Status**: Done — migrations applied to the database
**Depends On**: 01 — Item Catalog, 14 — Pricing & Supplier Master Data **Depends On**: 01 — Item Catalog (Done), 14 — Pricing & Supplier Master Data (Done), 02 — Stock Ledger (Done)
--- ---
## Goal ## Goal
Covers [mainPlan.md §11](./mainPlan.md#11-promotions--trade-scheme-management) 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. `checkStoreSchemeEligibility` (§4) both read from it.
--- ---
## Background / Context ## Background / Context
No scheme/promotion entity exists today. Every reference to "eligible discount" elsewhere in this No scheme/promotion entity existed before this step. Every reference to "eligible discount"
doc set (order pricing, field invoices, agent discount limits) currently has no scheme data source elsewhere in this doc set (order pricing, field invoices, agent discount limits) had no scheme data
— it only enforces the flat per-role discount ceiling from source — it only enforced the flat per-role discount ceiling from
[08_sales_agent_controls.md](./08_sales_agent_controls.md). [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 ## Data Model Changes
### Scheme ### 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` `RuleConfig` JSON shape (SlabDiscount/FlatPercent only):
(store segment / region scope), `DateRangeFrom`, `DateRangeTo`. - `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 ### SchemeRedemption
`trackSchemeSpend` and `getSchemeROI` without re-scanning every order. ```
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 ## API Endpoints
| Function reference | Endpoint intent | | Function reference | Endpoint | Notes |
|---|---| |---|---|---|
| `createScheme(schemeRules, eligibility, dateRange)` | `POST /api/schemes` | | `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 |
| `evaluateSchemeEligibility(storeId, orderId)` | `GET /api/schemes/eligible?storeId=...&orderId=...` | | — | `GET /api/schemes` | Filterable by region |
| `applySchemeDiscount(orderId, schemeId)` | `POST /api/orders/{id}/apply-scheme` | | — | `GET /api/schemes/{id}` | Includes trigger/free item, bundle components |
| `trackSchemeSpend(schemeId)` | `GET /api/schemes/{id}/spend` | | — | `PUT /api/schemes/{id}` | Updates name/eligibility/date range/`RuleConfig` |
| `getSchemeROI(schemeId)` | `GET /api/schemes/{id}/roi` | | — | `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 ## Business Rules
- A scheme discount stacks with, but does not bypass, the per-role discount ceiling in 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 explicitly flagged as an [08_sales_agent_controls.md](./08_sales_agent_controls.md) unless `AgentLimitExempt = true`.
agent-limit-exempt scheme. (Enforcement against the actual ceiling is deferred — 08_sales_agent_controls.md's discount
- `getSchemeROI` requires a baseline (pre-scheme expected sales) to compare against — confirm data ceiling isn't implemented in code yet; this is recorded as the target behavior.)
source before implementing (see Open Questions). 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 ## Open Questions
- What counts as "incremental sales" for `getSchemeROI` — a manual baseline entered when the scheme - What counts as "incremental sales" for `getSchemeROI` — **resolved for v1**: a manual
is created, or a trailing-period average computed automatically? `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 ## 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`.
+11 -9
View File
@@ -10,10 +10,12 @@
| Role Management | Done (read-only) | 6 roles seeded, no create/update via API | | Role Management | Done (read-only) | 6 roles seeded, no create/update via API |
| Region / Route / Vehicle CRUD | Done | Full API surface with permission checks | | Region / Route / Vehicle CRUD | Done | Full API surface with permission checks |
| Store / Warehouse 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) | | Order CRUD | Done | Scope-aware (user only sees their assigned stores) |
| Unit of Work + Repositories | Done | Transaction support throughout | | Unit of Work + Repositories | Done | Transaction support throughout |
| Soft Deletes | Done | IsActive pattern on all entities | | 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) | | 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) ## 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) | | 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) | | 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) | | 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) | | 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) | | 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) | | 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) | | 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) | | 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) | | 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) | | 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. | | `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. | | `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). | | `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). | | `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. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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 ### 5. Vehicle / Fleet Management
@@ -212,11 +214,11 @@ here — see `SRS_User_Management_Module.pdf`.
| Function | What it does | Status | | 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. | | `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. | Not implemented. | | `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. | Not implemented. | | `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. | Not implemented. | | `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. | Not implemented. | | `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 ### 12. Returns & Claims Management