Dev #6

Merged
DeepnaPooja merged 4 commits from dev into production 2026-08-21 14:22:16 +05:30
6 changed files with 854 additions and 0 deletions
Showing only changes of commit aabace1bba - Show all commits
@@ -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}'.");
}
}
}
+3
View File
@@ -12,6 +12,7 @@ using Distribution.Features.Region;
using Distribution.Features.Ride;
using Distribution.Features.Role;
using Distribution.Features.Route;
using Distribution.Features.Scheme;
using Distribution.Features.Store;
using Distribution.Features.Store.Kyc;
using Distribution.Features.Supplier;
@@ -59,6 +60,7 @@ builder.Services.AddScoped<IRideCancelRequestRepository, RideCancelRequestReposi
builder.Services.AddScoped<IStoreKycDocumentRepository, StoreKycDocumentRepository>();
builder.Services.AddScoped<IPriceListRepository, PriceListRepository>();
builder.Services.AddScoped<ISupplierRepository, SupplierRepository>();
builder.Services.AddScoped<ISchemeRepository, SchemeRepository>();
builder.Services.AddScoped<RegionService>();
builder.Services.AddScoped<RouteService>();
@@ -81,6 +83,7 @@ builder.Services.AddScoped<SupplierService>();
builder.Services.AddScoped<DriverProfileService>();
builder.Services.AddScoped<DriverComplianceConfigService>();
builder.Services.AddScoped<RideService>();
builder.Services.AddScoped<SchemeService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();