|
|
|
@@ -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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <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)`
|
|
|
|
@@ -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<ShopPOItem> items)
|
|
|
|
|
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:
|
|
|
|
@@ -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.");
|
|
|
|
|
}
|
|
|
|
|