using System.Text.Json; using Distribution.Features.Invoice; using Distribution.Features.Item; using Distribution.Features.PriceList; using Distribution.Features.ShopPO; using Distribution.Features.Stock; using Distribution.Features.Store; using Distribution.Infra.UoW; using Distribution.Models; namespace Distribution.Features.Scheme; public class SchemeService( ISchemeRepository repo, IShopPORepository shopPORepo, IItemRepository itemRepo, IStoreRepository storeRepo, IStockRepository stockRepo, IInvoiceRepository invoiceRepo, PriceListService priceListService, 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_ShopPOId, 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 ? 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) 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; scheme.IsActive = request.IsActive; 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, shopPOId) ────────────────────────── public async Task?> EvaluateEligibilityAsync(Guid storeId, Guid shopPOId) { var store = await storeRepo.GetByIdWithRegionAsync(storeId); if (store is null || !store.IsActive) return null; var shopPO = await shopPORepo.GetByIdAsync(shopPOId); if (shopPO is null || !shopPO.IsActive) return null; var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId); var regionId = store.Route?.FK_RegionId; var candidates = (await repo.GetAllAsync()).Where(s => s.IsActive); var result = new List(); foreach (var scheme in candidates) { if (!IsInScope(scheme, store.Segment, regionId, shopPO.CreatedAt)) continue; result.Add(EvaluateRule(scheme, items)); } return result; } private static bool IsInScope(Models.Scheme scheme, string? storeSegment, Guid? storeRegionId, DateTime asOf) { if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != storeRegionId) return false; if (!string.IsNullOrEmpty(scheme.EligibleSegment) && !string.Equals(scheme.EligibleSegment, storeSegment, StringComparison.OrdinalIgnoreCase)) return false; return asOf >= scheme.DateRangeFrom && asOf.Date <= scheme.DateRangeTo.Date; } /// /// Suggestions for a Shop PO that is still being built (nothing saved yet). Returns eligible schemes, /// plus BuyXGetY/FreeBundle schemes the cart is part-way to, so the UI can nudge ("add 4 more"). /// public async Task?> SuggestAsync(SuggestSchemesRequest request) { var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId); if (store is null || !store.IsActive) return null; var lines = new List(); foreach (var l in request.Items.Where(i => !i.IsFree)) { var price = l.UnitPrice ?? (await priceListService.ResolveByStoreAsync(l.ItemId, request.StoreId, DateTime.Now))?.Price ?? 0m; lines.Add(new ShopPOItem { FK_ItemId = l.ItemId, Quantity = l.Quantity, UnitPrice = price }); } var regionId = store.Route?.FK_RegionId; var inCart = lines.Select(l => l.FK_ItemId).ToHashSet(); var result = new List(); foreach (var scheme in (await repo.GetAllAsync()).Where(s => s.IsActive)) { if (!IsInScope(scheme, store.Segment, regionId, DateTime.Now)) continue; var eval = EvaluateRule(scheme, lines); var relevant = eval.Eligible || scheme.RuleType switch { SchemeRuleType.BuyXGetY => scheme.FK_TriggerItemId.HasValue && inCart.Contains(scheme.FK_TriggerItemId.Value), SchemeRuleType.FreeBundle => scheme.BundleComponents.Any(c => inCart.Contains(c.FK_ItemId)), _ => false }; if (!relevant) continue; result.Add(eval with { FreeItemName = scheme.FreeItem?.Name }); } return result; } /// /// Checks the free lines of a Shop PO that is about to be saved: each must reference an in-scope scheme the /// paid lines still qualify for, be the scheme's free item, stay within the entitled qty, and be in stock. /// Stamps the scheme-name snapshot and forces UnitPrice to 0. Read-only; throws InvalidOperationException. /// public async Task ValidateFreeLinesAsync(Guid storeId, DateTime asOf, List lines) { var freeLines = lines.Where(l => l.IsFree).ToList(); if (freeLines.Count == 0) return; if (freeLines.Any(l => l.FK_SchemeId is null)) throw new InvalidOperationException("A free-issue line must reference the scheme that granted it."); if (freeLines.Any(l => l.Quantity <= 0)) throw new InvalidOperationException("Free-issue quantity must be greater than zero (remove the line instead)."); var store = await storeRepo.GetByIdWithRegionAsync(storeId) ?? throw new InvalidOperationException("Store not found."); var regionId = store.Route?.FK_RegionId; foreach (var group in freeLines.GroupBy(l => l.FK_SchemeId!.Value)) { var scheme = await repo.GetByIdAsync(group.Key); if (scheme is null || !scheme.IsActive) throw new InvalidOperationException("A free-issue line references a scheme that no longer exists."); if (!IsInScope(scheme, store.Segment, regionId, asOf)) throw new InvalidOperationException($"Scheme '{scheme.Name}' is not available for this store or date."); var eval = EvaluateRule(scheme, lines); if (!eval.Eligible || eval.FreeItemId is null || eval.FreeQty is null) throw new InvalidOperationException(eval.Reason ?? $"Order no longer qualifies for scheme '{scheme.Name}'."); if (group.Any(l => l.FK_ItemId != eval.FreeItemId)) throw new InvalidOperationException($"Scheme '{scheme.Name}' grants a different free item."); var qty = group.Sum(l => l.Quantity); if (qty > eval.FreeQty.Value) throw new InvalidOperationException($"Free quantity {qty} exceeds the {eval.FreeQty.Value:0.##} entitled by scheme '{scheme.Name}'."); var available = (await stockRepo.GetBalancesByItemAsync(eval.FreeItemId.Value)).Sum(b => b.Quantity); if (available < qty) throw new InvalidOperationException($"Insufficient stock to give {qty} free of scheme '{scheme.Name}'."); foreach (var l in group) { l.UnitPrice = 0m; l.SchemeName = scheme.Name; } } } /// /// Checks the percentage-discount schemes (FlatPercent / SlabDiscount) the user accepted for a Shop PO that is /// about to be saved: one at most, in scope, and the paid lines must still qualify. Read-only; throws. /// public async Task ValidateDiscountSchemesAsync(Guid storeId, DateTime asOf, List lines, List? schemeIds) { if (schemeIds is not { Count: > 0 }) return; if (schemeIds.Distinct().Count() > 1) throw new InvalidOperationException("Only one discount scheme can be applied to an order."); var store = await storeRepo.GetByIdWithRegionAsync(storeId) ?? throw new InvalidOperationException("Store not found."); var scheme = await repo.GetByIdAsync(schemeIds[0]); if (scheme is null || !scheme.IsActive) throw new InvalidOperationException("The selected discount scheme no longer exists."); if (scheme.RuleType is not (SchemeRuleType.FlatPercent or SchemeRuleType.SlabDiscount)) throw new InvalidOperationException($"Scheme '{scheme.Name}' is not a discount scheme."); if (!IsInScope(scheme, store.Segment, store.Route?.FK_RegionId, asOf)) throw new InvalidOperationException($"Scheme '{scheme.Name}' is not available for this store or date."); var eval = EvaluateRule(scheme, lines); if (!eval.Eligible) throw new InvalidOperationException(eval.Reason ?? $"Order does not qualify for scheme '{scheme.Name}'."); } /// /// Re-evaluates the percentage discounts redeemed against a Shop PO for the given (invoice) lines and returns /// their total. With persist = true the redemption amounts are updated too (the caller commits), so spend/ROI and /// the invoice stay in step; a discount the lines no longer qualify for drops to 0 and comes back if they requalify. /// public async Task CalculateDiscountAsync(Guid shopPOId, IEnumerable lines, bool persist) { var priced = lines.ToList(); decimal total = 0m; foreach (var r in (await repo.GetRedemptionsByShopPOAsync(shopPOId)).Where(r => r.DiscountAmount.HasValue)) { var scheme = await repo.GetByIdAsync(r.FK_SchemeId); var eval = scheme is null ? null : EvaluateRule(scheme, priced); var amount = eval is { Eligible: true, DiscountAmount: not null } ? eval.DiscountAmount.Value : 0m; if (persist) r.DiscountAmount = amount; total += amount; } return total; } // How many free units each scheme currently entitles for these paid lines (0 when no longer eligible). private async Task> FreeEntitlementsAsync(IEnumerable schemeIds, List paidLines) { var result = new Dictionary(); foreach (var id in schemeIds.Distinct()) { var scheme = await repo.GetByIdAsync(id); var eval = scheme is null ? null : EvaluateRule(scheme, paidLines); result[id] = eval is { Eligible: true, FreeQty: not null } ? (int)Math.Floor(eval.FreeQty.Value) : 0; } return result; } /// /// Free-issue entitlement per scheme for unsaved invoice lines (editor preview). Lines carry IsFree / scheme id; /// only the paid ones count toward the scheme rules. /// public async Task> GetFreeEntitlementsAsync(IEnumerable schemeIds, IEnumerable lines) => await FreeEntitlementsAsync(schemeIds, lines.Where(l => !l.IsFree).ToList()); /// /// Keeps an invoice's free-issue lines within what the remaining paid lines still earn: when the paid quantity of the /// qualifying item is lowered (or removed), the free quantity is reduced to match (a line that earns nothing is struck /// out) and the PO's redemption is resized. Stages changes only. Returns true if any free line was changed. /// public async Task ReconcileInvoiceFreeLinesAsync(Guid shopPOId, List lines) { var freeLines = lines.Where(l => l.IsActive && l.IsFree && l.FK_SchemeId.HasValue).ToList(); if (freeLines.Count == 0) return false; var paid = lines.Where(l => l.IsActive && !l.IsRemoved && !l.IsFree) .Select(l => new ShopPOItem { FK_ItemId = l.FK_ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice }) .ToList(); var entitled = await FreeEntitlementsAsync(freeLines.Select(l => l.FK_SchemeId!.Value), paid); var redemptions = await repo.GetRedemptionsByShopPOAsync(shopPOId); var changed = false; foreach (var group in freeLines.GroupBy(l => l.FK_SchemeId!.Value)) { var remaining = entitled[group.Key]; foreach (var line in group.Where(l => !l.IsRemoved).OrderBy(l => l.CreatedAt)) { if (line.Quantity <= remaining) { remaining -= line.Quantity; continue; } if (remaining == 0) line.IsRemoved = true; else line.Quantity = remaining; remaining = 0; changed = true; } var redemption = redemptions.FirstOrDefault(r => r.FK_SchemeId == group.Key && r.FK_FreeItemId.HasValue); if (redemption is not null) redemption.FreeQty = group.Where(l => !l.IsRemoved).Sum(l => l.Quantity); } return changed; } /// /// Discount the given schemes would give for these lines, without touching any redemption (editor preview). /// public async Task CalculateDiscountForSchemesAsync(IEnumerable schemeIds, IEnumerable lines) { var priced = lines.ToList(); decimal total = 0m; foreach (var id in schemeIds.Distinct()) { var scheme = await repo.GetByIdAsync(id); var eval = scheme is null ? null : EvaluateRule(scheme, priced); if (eval is { Eligible: true, DiscountAmount: not null }) total += eval.DiscountAmount.Value; } return total; } /// /// Applies the discount choice made on a Draft invoice (the percentage scheme the user accepted, or none) to the /// PO's redemptions: newly chosen schemes are validated and added, unchosen ones removed. The amount itself is /// then set by CalculateDiscountAsync when the totals are re-derived. Stages changes only. Null = leave as is. /// public async Task ApplyInvoiceDiscountSelectionAsync(Guid shopPOId, Guid storeId, List lines, List? schemeIds) { if (schemeIds is null) return; if (schemeIds.Distinct().Count() > 1) throw new InvalidOperationException("Only one discount scheme can be applied to an order."); var existing = (await repo.GetRedemptionsByShopPOAsync(shopPOId)).Where(r => r.DiscountAmount.HasValue).ToList(); foreach (var r in existing.Where(r => !schemeIds.Contains(r.FK_SchemeId))) repo.RemoveRedemption(r); var paid = lines.Where(l => l.IsActive && !l.IsRemoved && !l.IsFree) .Select(l => new ShopPOItem { FK_ItemId = l.FK_ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice }) .ToList(); var added = schemeIds.Where(id => existing.All(r => r.FK_SchemeId != id)).ToList(); await ValidateDiscountSchemesAsync(storeId, DateTime.Now, paid, added); foreach (var id in added) await repo.AddRedemptionAsync(new SchemeRedemption { FK_SchemeId = id, FK_ShopPOId = shopPOId, DiscountAmount = 0m }); } /// Discounts already redeemed against a Shop PO, for display and for editing. public async Task> GetDiscountsForShopPOAsync(Guid shopPOId) => (await repo.GetRedemptionsByShopPOAsync(shopPOId)) .Where(r => r.DiscountAmount.HasValue) .Select(r => new ShopPODiscountResponse(r.FK_SchemeId, r.Scheme?.Name ?? string.Empty, r.DiscountAmount!.Value)) .ToList(); /// /// Brings the Shop PO's scheme redemptions in line with its saved lines: free-goods redemptions follow the /// free lines (created, resized or removed), discount redemptions are recomputed or dropped if the order no /// longer qualifies. Stages changes only — the caller owns the commit. /// public async Task SyncRedemptionsAsync(Guid shopPOId, List lines, List? discountSchemeIds = null) { var existing = await repo.GetRedemptionsByShopPOAsync(shopPOId); var freeBySchemes = lines.Where(l => l.IsFree && l.FK_SchemeId.HasValue) .GroupBy(l => l.FK_SchemeId!.Value).ToDictionary(g => g.Key, g => g.ToList()); foreach (var r in existing) { if (r.FK_FreeItemId.HasValue) { if (!freeBySchemes.ContainsKey(r.FK_SchemeId)) repo.RemoveRedemption(r); } else if (r.DiscountAmount.HasValue) { // A null list means "leave the accepted discounts alone" (just recompute them). if (discountSchemeIds is not null && !discountSchemeIds.Contains(r.FK_SchemeId)) { repo.RemoveRedemption(r); continue; } var scheme = await repo.GetByIdAsync(r.FK_SchemeId); var eval = scheme is null ? null : EvaluateRule(scheme, lines); if (eval is { Eligible: true, DiscountAmount: not null }) r.DiscountAmount = eval.DiscountAmount; else repo.RemoveRedemption(r); } } foreach (var schemeId in discountSchemeIds ?? []) { if (existing.Any(r => r.FK_SchemeId == schemeId && r.DiscountAmount.HasValue)) continue; var scheme = await repo.GetByIdAsync(schemeId); var eval = scheme is null ? null : EvaluateRule(scheme, lines); if (eval is { Eligible: true, DiscountAmount: not null }) await repo.AddRedemptionAsync(new SchemeRedemption { FK_SchemeId = schemeId, FK_ShopPOId = shopPOId, DiscountAmount = eval.DiscountAmount }); } foreach (var (schemeId, group) in freeBySchemes) { var freeItem = await itemRepo.GetByIdAsync(group[0].FK_ItemId) ?? throw new InvalidOperationException("Free item not found."); var qty = group.Sum(l => l.Quantity); var redemption = existing.FirstOrDefault(r => r.FK_SchemeId == schemeId && r.FK_FreeItemId.HasValue); if (redemption is null) await repo.AddRedemptionAsync(new SchemeRedemption { FK_SchemeId = schemeId, FK_ShopPOId = shopPOId, FK_FreeItemId = freeItem.Id, FreeQty = qty, FreeItemCostValue = freeItem.CostPrice ?? 0m }); else { redemption.FK_FreeItemId = freeItem.Id; redemption.FreeQty = qty; redemption.FreeItemCostValue = freeItem.CostPrice ?? 0m; } } } /// /// Scope-only check (region, segment, date range as of today) with no ShopPO 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 ShopPO'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.Date > scheme.DateRangeTo.Date) return (false, "Scheme is not currently active (outside its date range)."); return (true, null); } private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List allItems) { // Free-issue lines (added by a previous redemption) must not count toward a trigger/bundle // quantity, otherwise the free units inflate the multiplier. var items = allItems.Where(i => !i.IsFree && i.UnitPrice > 0).ToList(); 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 = items.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; ShopPO 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 => items.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, "ShopPO 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 = items.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 = items.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, "ShopPO 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(shopPOId, schemeId) ─────────────────────────────── public async Task ApplySchemeAsync(Guid shopPOId, Guid schemeId, CancellationToken ct = default) { var scheme = await repo.GetByIdAsync(schemeId); if (scheme is null || !scheme.IsActive) return null; var shopPO = await shopPORepo.GetByIdAsync(shopPOId); if (shopPO is null || !shopPO.IsActive) return null; if (await repo.HasRedemptionAsync(schemeId, shopPOId)) throw new InvalidOperationException("This scheme has already been redeemed against this ShopPO."); if (await repo.HasLockedInvoiceAsync(shopPOId)) throw new InvalidOperationException("This order's invoice is already printed or finalized, so a scheme can no longer be applied. Apply schemes before the invoice is printed."); var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId); var eligibility = EvaluateRule(scheme, items); if (!eligibility.Eligible) throw new InvalidOperationException(eligibility.Reason ?? "ShopPO is not eligible for this scheme."); var redemption = new SchemeRedemption { FK_SchemeId = schemeId, FK_ShopPOId = shopPOId, DiscountAmount = eligibility.DiscountAmount }; var newItems = 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."); newItems.Add(new ShopPOItem { FK_ShopPOId = shopPOId, FK_ItemId = freeItem.Id, Quantity = (int)eligibility.FreeQty.Value, UnitPrice = 0m, IsFree = true, FK_SchemeId = scheme.Id, SchemeName = scheme.Name }); redemption.FK_FreeItemId = freeItem.Id; redemption.FreeQty = eligibility.FreeQty; redemption.FreeItemCostValue = freeItem.CostPrice ?? 0m; } await uow.BeginAsync(ct); try { if (newItems.Count > 0) { await shopPORepo.AddItemsAsync(newItems); // A still-draft invoice was copied from the PO before this line existed, so mirror it there. if (shopPO.InvoiceId is Guid invoiceId && await invoiceRepo.GetByIdAsync(invoiceId) is { Status: InvoiceStatus.Draft }) { await invoiceRepo.AddItemsAsync(newItems.Select(n => new InvoiceItem { FK_InvoiceId = invoiceId, FK_ShopPOItemId = n.Id, FK_ItemId = n.FK_ItemId, Quantity = n.Quantity, UnitPrice = 0m, IsFree = true, FK_SchemeId = n.FK_SchemeId, SchemeName = n.SchemeName })); } } 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.ShopPO is not null) .Select(r => r.ShopPO!) .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 (freeQty.Value != decimal.Truncate(freeQty.Value)) throw new InvalidOperationException("FreeQty must be a whole number (order lines hold whole units)."); 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}'."); } } }