From aabace1bba80f4d7de392becc40452ddc800f6fa Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Fri, 21 Aug 2026 14:14:07 +0530 Subject: [PATCH] Add scheme APIs and application services --- .../Features/Scheme/ISchemeRepository.cs | 19 + .../Features/Scheme/SchemeController.cs | 139 +++++ Distribution/Features/Scheme/SchemeDTOs.cs | 111 ++++ .../Features/Scheme/SchemeRepository.cs | 67 +++ Distribution/Features/Scheme/SchemeService.cs | 515 ++++++++++++++++++ Distribution/Program.cs | 3 + 6 files changed, 854 insertions(+) create mode 100644 Distribution/Features/Scheme/ISchemeRepository.cs create mode 100644 Distribution/Features/Scheme/SchemeController.cs create mode 100644 Distribution/Features/Scheme/SchemeDTOs.cs create mode 100644 Distribution/Features/Scheme/SchemeRepository.cs create mode 100644 Distribution/Features/Scheme/SchemeService.cs diff --git a/Distribution/Features/Scheme/ISchemeRepository.cs b/Distribution/Features/Scheme/ISchemeRepository.cs new file mode 100644 index 0000000..518e3c5 --- /dev/null +++ b/Distribution/Features/Scheme/ISchemeRepository.cs @@ -0,0 +1,19 @@ +namespace Distribution.Features.Scheme; + +public interface ISchemeRepository +{ + Task GetByIdAsync(Guid id); + Task> GetAllAsync(Guid? regionId = null); + Task AddAsync(Models.Scheme entity); + void Update(Models.Scheme entity); + void Remove(Models.Scheme entity); + + Task> GetRedemptionsAsync(Guid schemeId); + Task AddRedemptionAsync(Models.SchemeRedemption entity); + Task HasRedemptionAsync(Guid schemeId, Guid orderId); + + Task GetBundleComponentAsync(Guid schemeId, Guid itemId); + Task AddBundleComponentAsync(Models.SchemeBundleComponent entity); + void UpdateBundleComponent(Models.SchemeBundleComponent entity); + void RemoveBundleComponent(Models.SchemeBundleComponent entity); +} diff --git a/Distribution/Features/Scheme/SchemeController.cs b/Distribution/Features/Scheme/SchemeController.cs new file mode 100644 index 0000000..6a230eb --- /dev/null +++ b/Distribution/Features/Scheme/SchemeController.cs @@ -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 GetAll([FromQuery] Guid? regionId) => + Ok(await service.GetAllAsync(regionId)); + + [HttpGet("{id:guid}")] + public async Task GetById(Guid id) + { + var result = await service.GetByIdAsync(id); + return result is null ? NotFound() : Ok(result); + } + + [HttpPost] + public async Task 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 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 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 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 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 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 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 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 GetSpend(Guid id) + { + var result = await service.GetSpendAsync(id); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("{id:guid}/roi")] + public async Task 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 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 }); + } + } +} diff --git a/Distribution/Features/Scheme/SchemeDTOs.cs b/Distribution/Features/Scheme/SchemeDTOs.cs new file mode 100644 index 0000000..be901c3 --- /dev/null +++ b/Distribution/Features/Scheme/SchemeDTOs.cs @@ -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 Tiers); + +public record SlabTier(decimal MinOrderAmount, decimal DiscountPercent); + +public record FlatPercentConfig(decimal DiscountPercent); + +// ── Requests ──────────────────────────────────────────────────────────── + +public record BundleComponentRequest(Guid ItemId, int Qty); + +/// +/// 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}. +/// +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? 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); + +/// +/// 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. +/// +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 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); diff --git a/Distribution/Features/Scheme/SchemeRepository.cs b/Distribution/Features/Scheme/SchemeRepository.cs new file mode 100644 index 0000000..ae7123c --- /dev/null +++ b/Distribution/Features/Scheme/SchemeRepository.cs @@ -0,0 +1,67 @@ +using Distribution.Data; +using Microsoft.EntityFrameworkCore; + +namespace Distribution.Features.Scheme; + +public class SchemeRepository(AppDBContext context) : ISchemeRepository +{ + public async Task 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> 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> 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 HasRedemptionAsync(Guid schemeId, Guid orderId) => + await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_OrderId == orderId); + + public async Task 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); +} diff --git a/Distribution/Features/Scheme/SchemeService.cs b/Distribution/Features/Scheme/SchemeService.cs new file mode 100644 index 0000000..5d88d34 --- /dev/null +++ b/Distribution/Features/Scheme/SchemeService.cs @@ -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> GetAllAsync(Guid? regionId = null) + { + var schemes = await repo.GetAllAsync(regionId); + return schemes.Select(ToResponse).ToList(); + } + + public async Task GetByIdAsync(Guid id) + { + var scheme = await repo.GetByIdAsync(id); + return scheme is null || !scheme.IsActive ? null : ToResponse(scheme); + } + + /// + /// 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. + /// + public async Task 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 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 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 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 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 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 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?> 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(); + + 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; + } + + /// + /// 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). + /// + 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 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(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(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 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(); + + 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 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 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(ruleConfig, JsonOptions) ?? throw new JsonException(); + if (slab.Tiers.Count == 0) + throw new InvalidOperationException("SlabDiscount scheme requires at least one tier."); + } + else + { + _ = JsonSerializer.Deserialize(ruleConfig, JsonOptions) ?? throw new JsonException(); + } + } + catch (JsonException) + { + throw new InvalidOperationException($"RuleConfig is not valid JSON for RuleType '{ruleType}'."); + } + } +} diff --git a/Distribution/Program.cs b/Distribution/Program.cs index a0b44eb..d6001fc 100644 --- a/Distribution/Program.cs +++ b/Distribution/Program.cs @@ -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(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -81,6 +83,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped();