Files

870 lines
41 KiB
C#

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<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 ? 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) 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<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, shopPOId) ──────────────────────────
public async Task<List<SchemeEligibilityResponse>?> 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<SchemeEligibilityResponse>();
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;
}
/// <summary>
/// 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").
/// </summary>
public async Task<List<SchemeEligibilityResponse>?> SuggestAsync(SuggestSchemesRequest request)
{
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
if (store is null || !store.IsActive) return null;
var lines = new List<ShopPOItem>();
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<SchemeEligibilityResponse>();
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;
}
/// <summary>
/// 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.
/// </summary>
public async Task ValidateFreeLinesAsync(Guid storeId, DateTime asOf, List<ShopPOItem> 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;
}
}
}
/// <summary>
/// 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.
/// </summary>
public async Task ValidateDiscountSchemesAsync(Guid storeId, DateTime asOf, List<ShopPOItem> lines, List<Guid>? 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}'.");
}
/// <summary>
/// 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.
/// </summary>
public async Task<decimal> CalculateDiscountAsync(Guid shopPOId, IEnumerable<ShopPOItem> 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<Dictionary<Guid, int>> FreeEntitlementsAsync(IEnumerable<Guid> schemeIds, List<ShopPOItem> paidLines)
{
var result = new Dictionary<Guid, int>();
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;
}
/// <summary>
/// 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.
/// </summary>
public async Task<Dictionary<Guid, int>> GetFreeEntitlementsAsync(IEnumerable<Guid> schemeIds, IEnumerable<ShopPOItem> lines) =>
await FreeEntitlementsAsync(schemeIds, lines.Where(l => !l.IsFree).ToList());
/// <summary>
/// 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.
/// </summary>
public async Task<bool> ReconcileInvoiceFreeLinesAsync(Guid shopPOId, List<InvoiceItem> 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;
}
/// <summary>
/// Discount the given schemes would give for these lines, without touching any redemption (editor preview).
/// </summary>
public async Task<decimal> CalculateDiscountForSchemesAsync(IEnumerable<Guid> schemeIds, IEnumerable<ShopPOItem> 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;
}
/// <summary>
/// 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.
/// </summary>
public async Task ApplyInvoiceDiscountSelectionAsync(Guid shopPOId, Guid storeId, List<InvoiceItem> lines, List<Guid>? 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 });
}
/// <summary>Discounts already redeemed against a Shop PO, for display and for editing.</summary>
public async Task<List<ShopPODiscountResponse>> 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();
/// <summary>
/// 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.
/// </summary>
public async Task SyncRedemptionsAsync(Guid shopPOId, List<ShopPOItem> lines, List<Guid>? 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;
}
}
}
/// <summary>
/// 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).
/// </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.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<ShopPOItem> 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<FlatPercentConfig>(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<SlabDiscountConfig>(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<SchemeRedemptionResponse?> 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<ShopPOItem>();
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<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.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<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}'.");
}
}
}