diff --git a/Distribution/Features/Scheme/ISchemeRepository.cs b/Distribution/Features/Scheme/ISchemeRepository.cs index 39d4b86..cfd9764 100644 --- a/Distribution/Features/Scheme/ISchemeRepository.cs +++ b/Distribution/Features/Scheme/ISchemeRepository.cs @@ -10,6 +10,8 @@ public interface ISchemeRepository Task> GetRedemptionsAsync(Guid schemeId); Task AddRedemptionAsync(Models.SchemeRedemption entity); + Task> GetRedemptionsByShopPOAsync(Guid shopPOId); + void RemoveRedemption(Models.SchemeRedemption entity); Task HasRedemptionAsync(Guid schemeId, Guid orderId); Task HasLockedInvoiceAsync(Guid shopPOId); diff --git a/Distribution/Features/Scheme/SchemeController.cs b/Distribution/Features/Scheme/SchemeController.cs index 6858999..99f50fd 100644 --- a/Distribution/Features/Scheme/SchemeController.cs +++ b/Distribution/Features/Scheme/SchemeController.cs @@ -112,6 +112,14 @@ public class SchemeController(SchemeService service) : ControllerBase return result is null ? NotFound() : Ok(result); } + [HttpPost("suggest")] + [Authorize("scheme.view")] + public async Task Suggest(SuggestSchemesRequest request) + { + var result = await service.SuggestAsync(request); + return result is null ? NotFound() : Ok(result); + } + [HttpGet("{id:guid}/spend")] [Authorize("scheme.view")] public async Task GetSpend(Guid id) diff --git a/Distribution/Features/Scheme/SchemeDTOs.cs b/Distribution/Features/Scheme/SchemeDTOs.cs index 28ec165..58078a2 100644 --- a/Distribution/Features/Scheme/SchemeDTOs.cs +++ b/Distribution/Features/Scheme/SchemeDTOs.cs @@ -94,7 +94,13 @@ public record SchemeEligibilityResponse( string? Reason, decimal? DiscountAmount, Guid? FreeItemId, - decimal? FreeQty); + decimal? FreeQty, + string? FreeItemName = null); + +/// A cart line for scheme suggestion before the Shop PO exists. UnitPrice null = resolve from the price list. +public record SuggestLine(Guid ItemId, int Quantity, decimal? UnitPrice = null, bool IsFree = false); + +public record SuggestSchemesRequest(Guid StoreId, List Items); public record SchemeRedemptionResponse( Guid Id, diff --git a/Distribution/Features/Scheme/SchemeRepository.cs b/Distribution/Features/Scheme/SchemeRepository.cs index 12f6ee1..daa154f 100644 --- a/Distribution/Features/Scheme/SchemeRepository.cs +++ b/Distribution/Features/Scheme/SchemeRepository.cs @@ -49,6 +49,18 @@ public class SchemeRepository(AppDBContext context) : ISchemeRepository public async Task AddRedemptionAsync(Models.SchemeRedemption entity) => await context.SchemeRedemptions.AddAsync(entity); + public async Task> GetRedemptionsByShopPOAsync(Guid shopPOId) + { + var list = await context.SchemeRedemptions.Include(r => r.Scheme).Where(r => r.FK_ShopPOId == shopPOId).ToListAsync(); + // Rows added earlier in this unit of work are not in the database yet; rows removed are still there. + foreach (var added in context.SchemeRedemptions.Local.Where(r => r.FK_ShopPOId == shopPOId && !list.Contains(r))) + list.Add(added); + return list.Where(r => context.Entry(r).State != EntityState.Deleted).ToList(); + } + + public void RemoveRedemption(Models.SchemeRedemption entity) => + context.SchemeRedemptions.Remove(entity); + public async Task HasRedemptionAsync(Guid schemeId, Guid shopPOId) => await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_ShopPOId == shopPOId); diff --git a/Distribution/Features/Scheme/SchemeService.cs b/Distribution/Features/Scheme/SchemeService.cs index cf39dd7..7103586 100644 --- a/Distribution/Features/Scheme/SchemeService.cs +++ b/Distribution/Features/Scheme/SchemeService.cs @@ -1,5 +1,7 @@ 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; @@ -14,6 +16,8 @@ public class SchemeService( IItemRepository itemRepo, IStoreRepository storeRepo, IStockRepository stockRepo, + IInvoiceRepository invoiceRepo, + PriceListService priceListService, IUnitOfWork uow) { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); @@ -261,12 +265,7 @@ public class SchemeService( 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 (shopPO.CreatedAt < scheme.DateRangeFrom || shopPO.CreatedAt > scheme.DateRangeTo) + if (!IsInScope(scheme, store.Segment, regionId, shopPO.CreatedAt)) continue; result.Add(EvaluateRule(scheme, items)); @@ -275,6 +274,328 @@ public class SchemeService( 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)` @@ -299,14 +620,18 @@ public class SchemeService( return (false, $"Scheme is only available to the '{scheme.EligibleSegment}' segment."); var today = DateTime.Now; - if (today < scheme.DateRangeFrom || today > scheme.DateRangeTo) + 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 items) + 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: @@ -414,7 +739,10 @@ public class SchemeService( FK_ShopPOId = shopPOId, FK_ItemId = freeItem.Id, Quantity = (int)eligibility.FreeQty.Value, - UnitPrice = 0m + UnitPrice = 0m, + IsFree = true, + FK_SchemeId = scheme.Id, + SchemeName = scheme.Name }); redemption.FK_FreeItemId = freeItem.Id; @@ -425,7 +753,27 @@ public class SchemeService( await uow.BeginAsync(ct); try { - if (newItems.Count > 0) await shopPORepo.AddItemsAsync(newItems); + 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); } @@ -487,6 +835,8 @@ public class SchemeService( 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."); }