516 lines
23 KiB
C#
516 lines
23 KiB
C#
using System.Text.Json;
|
|
using Distribution.Features.Item;
|
|
using Distribution.Features.Order;
|
|
using Distribution.Features.Stock;
|
|
using Distribution.Features.Store;
|
|
using Distribution.Infra.UoW;
|
|
using Distribution.Models;
|
|
|
|
namespace Distribution.Features.Scheme;
|
|
|
|
public class SchemeService(
|
|
ISchemeRepository repo,
|
|
IOrderRepository orderRepo,
|
|
IItemRepository itemRepo,
|
|
IStoreRepository storeRepo,
|
|
IStockRepository stockRepo,
|
|
IUnitOfWork uow)
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
|
|
|
private static SchemeResponse ToResponse(Models.Scheme s) =>
|
|
new(s.Id, s.Name, s.RuleType, s.RuleConfig,
|
|
s.FK_TriggerItemId, s.TriggerItem?.Name, s.TriggerQty,
|
|
s.FK_FreeItemId, s.FreeItem?.Name, s.FreeQty,
|
|
s.BundleComponents.Select(c => new BundleComponentResponse(c.FK_ItemId, c.Item.Sku, c.Item.Name, c.Qty)).ToList(),
|
|
s.FK_RegionId, s.Region?.Name, s.EligibleSegment,
|
|
s.DateRangeFrom, s.DateRangeTo, s.AgentLimitExempt, s.BaselineExpectedSales, s.IsActive, s.CreatedAt);
|
|
|
|
private static SchemeRedemptionResponse ToRedemptionResponse(SchemeRedemption r) =>
|
|
new(r.Id, r.FK_SchemeId, r.FK_OrderId, r.DiscountAmount, r.FK_FreeItemId, r.FreeItem?.Name,
|
|
r.FreeQty, r.FreeItemCostValue, r.RedeemedAt);
|
|
|
|
// ── CRUD ───────────────────────────────────────────────────────────────
|
|
|
|
public async Task<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 || !scheme.IsActive ? 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 || !scheme.IsActive) return null;
|
|
|
|
ValidatePercentRuleConfig(scheme.RuleType, request.RuleConfig);
|
|
if (request.DateRangeFrom > request.DateRangeTo)
|
|
throw new InvalidOperationException("DateRangeFrom must not be after DateRangeTo.");
|
|
|
|
scheme.Name = request.Name;
|
|
scheme.RuleConfig = request.RuleConfig;
|
|
scheme.FK_RegionId = request.RegionId;
|
|
scheme.EligibleSegment = request.EligibleSegment;
|
|
scheme.DateRangeFrom = request.DateRangeFrom;
|
|
scheme.DateRangeTo = request.DateRangeTo;
|
|
scheme.AgentLimitExempt = request.AgentLimitExempt;
|
|
scheme.BaselineExpectedSales = request.BaselineExpectedSales;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try { repo.Update(scheme); await uow.CommitAsync(ct); }
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
return ToResponse(scheme);
|
|
}
|
|
|
|
public async Task<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, orderId) ──────────────────────────
|
|
|
|
public async Task<List<SchemeEligibilityResponse>?> EvaluateEligibilityAsync(Guid storeId, Guid orderId)
|
|
{
|
|
var store = await storeRepo.GetByIdWithRegionAsync(storeId);
|
|
if (store is null || !store.IsActive) return null;
|
|
|
|
var order = await orderRepo.GetByIdAsync(orderId);
|
|
if (order is null || !order.IsActive) return null;
|
|
|
|
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
|
|
var regionId = store.Route?.FK_RegionId;
|
|
|
|
var candidates = await repo.GetAllAsync();
|
|
var result = new List<SchemeEligibilityResponse>();
|
|
|
|
foreach (var scheme in candidates)
|
|
{
|
|
if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != regionId)
|
|
continue;
|
|
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
|
|
!string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
if (order.OrderDate < scheme.DateRangeFrom || order.OrderDate > scheme.DateRangeTo)
|
|
continue;
|
|
|
|
result.Add(EvaluateRule(scheme, orderItems));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scope-only check (region, segment, date range as of today) with no order to evaluate line
|
|
/// items against — used by Store Management's `checkStoreSchemeEligibility(storeId, schemeId)`
|
|
/// (12_store_management.md), which has no order context. Returns null if the store or scheme
|
|
/// doesn't exist; otherwise (Eligible, Reason) — Eligible here means "in scope for this store",
|
|
/// not "this order's items satisfy the rule" (use EvaluateEligibilityAsync for that).
|
|
/// </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 > scheme.DateRangeTo)
|
|
return (false, "Scheme is not currently active (outside its date range).");
|
|
|
|
return (true, null);
|
|
}
|
|
|
|
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<OrderItem> orderItems)
|
|
{
|
|
switch (scheme.RuleType)
|
|
{
|
|
case SchemeRuleType.BuyXGetY:
|
|
{
|
|
if (scheme.FK_TriggerItemId is null || scheme.TriggerQty is null or <= 0 ||
|
|
scheme.FK_FreeItemId is null || scheme.FreeQty is null or <= 0)
|
|
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
|
|
"Scheme has no free-goods rule configured.", null, null, null);
|
|
|
|
var boughtQty = orderItems.Where(i => i.FK_ItemId == scheme.FK_TriggerItemId).Sum(i => i.Quantity);
|
|
if (boughtQty < scheme.TriggerQty)
|
|
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
|
|
$"Requires {scheme.TriggerQty} of the trigger item; order has {boughtQty}.", null, null, null);
|
|
|
|
var multiplier = boughtQty / scheme.TriggerQty.Value;
|
|
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
|
|
null, scheme.FK_FreeItemId, scheme.FreeQty * multiplier);
|
|
}
|
|
case SchemeRuleType.FreeBundle:
|
|
{
|
|
if (scheme.BundleComponents.Count == 0 || scheme.FK_FreeItemId is null || scheme.FreeQty is null or <= 0)
|
|
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
|
|
"Scheme has no bundle components or free-goods payout configured.", null, null, null);
|
|
|
|
var missing = scheme.BundleComponents
|
|
.Where(c => orderItems.Where(i => i.FK_ItemId == c.FK_ItemId).Sum(i => i.Quantity) < c.Qty)
|
|
.ToList();
|
|
if (missing.Count > 0)
|
|
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
|
|
"Order is missing required bundle components.", null, null, null);
|
|
|
|
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
|
|
null, scheme.FK_FreeItemId, scheme.FreeQty);
|
|
}
|
|
case SchemeRuleType.FlatPercent:
|
|
{
|
|
if (string.IsNullOrEmpty(scheme.RuleConfig))
|
|
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Scheme has no RuleConfig.", null, null, null);
|
|
var cfg = JsonSerializer.Deserialize<FlatPercentConfig>(scheme.RuleConfig, JsonOptions)!;
|
|
var orderTotal = orderItems.Sum(i => i.Quantity * i.UnitPrice);
|
|
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
|
|
Math.Round(orderTotal * cfg.DiscountPercent / 100m, 2), null, null);
|
|
}
|
|
case SchemeRuleType.SlabDiscount:
|
|
{
|
|
if (string.IsNullOrEmpty(scheme.RuleConfig))
|
|
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Scheme has no RuleConfig.", null, null, null);
|
|
var cfg = JsonSerializer.Deserialize<SlabDiscountConfig>(scheme.RuleConfig, JsonOptions)!;
|
|
var orderTotal = orderItems.Sum(i => i.Quantity * i.UnitPrice);
|
|
var tier = cfg.Tiers.Where(t => orderTotal >= t.MinOrderAmount)
|
|
.OrderByDescending(t => t.MinOrderAmount).FirstOrDefault();
|
|
if (tier is null)
|
|
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
|
|
"Order total does not meet any slab threshold.", null, null, null);
|
|
|
|
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
|
|
Math.Round(orderTotal * tier.DiscountPercent / 100m, 2), null, null);
|
|
}
|
|
default:
|
|
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Unknown rule type.", null, null, null);
|
|
}
|
|
}
|
|
|
|
// ── applySchemeDiscount(orderId, schemeId) ───────────────────────────────
|
|
|
|
public async Task<SchemeRedemptionResponse?> ApplySchemeAsync(Guid orderId, Guid schemeId, CancellationToken ct = default)
|
|
{
|
|
var scheme = await repo.GetByIdAsync(schemeId);
|
|
if (scheme is null || !scheme.IsActive) return null;
|
|
|
|
var order = await orderRepo.GetByIdAsync(orderId);
|
|
if (order is null || !order.IsActive) return null;
|
|
|
|
if (await repo.HasRedemptionAsync(schemeId, orderId))
|
|
throw new InvalidOperationException("This scheme has already been redeemed against this order.");
|
|
|
|
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
|
|
var eligibility = EvaluateRule(scheme, orderItems);
|
|
if (!eligibility.Eligible)
|
|
throw new InvalidOperationException(eligibility.Reason ?? "Order is not eligible for this scheme.");
|
|
|
|
var redemption = new SchemeRedemption
|
|
{
|
|
FK_SchemeId = schemeId,
|
|
FK_OrderId = orderId,
|
|
DiscountAmount = eligibility.DiscountAmount
|
|
};
|
|
|
|
var newOrderItems = new List<OrderItem>();
|
|
|
|
if (eligibility.FreeItemId.HasValue && eligibility.FreeQty is > 0)
|
|
{
|
|
var freeItem = await itemRepo.GetByIdAsync(eligibility.FreeItemId.Value)
|
|
?? throw new InvalidOperationException("Free item not found.");
|
|
|
|
var availableQty = (await stockRepo.GetBalancesByItemAsync(freeItem.Id)).Sum(b => b.Quantity);
|
|
if (availableQty < eligibility.FreeQty.Value)
|
|
throw new InvalidOperationException($"Insufficient stock of '{freeItem.Sku}' to fulfil the free-goods scheme.");
|
|
|
|
newOrderItems.Add(new OrderItem
|
|
{
|
|
FK_OrderId = orderId,
|
|
FK_ItemId = freeItem.Id,
|
|
Quantity = (int)eligibility.FreeQty.Value,
|
|
UnitPrice = 0m
|
|
});
|
|
|
|
redemption.FK_FreeItemId = freeItem.Id;
|
|
redemption.FreeQty = eligibility.FreeQty;
|
|
redemption.FreeItemCostValue = freeItem.CostPrice ?? 0m;
|
|
}
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
if (newOrderItems.Count > 0) await orderRepo.AddItemsAsync(newOrderItems);
|
|
await repo.AddRedemptionAsync(redemption);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
var reloaded = (await repo.GetRedemptionsAsync(schemeId)).First(r => r.Id == redemption.Id);
|
|
return ToRedemptionResponse(reloaded);
|
|
}
|
|
|
|
// ── trackSchemeSpend(schemeId) ───────────────────────────────────────────
|
|
|
|
public async Task<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.Order is not null)
|
|
.Select(r => r.Order!)
|
|
.Distinct()
|
|
.Sum(o => o.TotalAmount);
|
|
|
|
var baseline = scheme.BaselineExpectedSales ?? 0m;
|
|
var incremental = actualSales - baseline;
|
|
decimal? roi = spend!.TotalSpend > 0 ? incremental / spend.TotalSpend : null;
|
|
|
|
return new SchemeROIResponse(schemeId, baseline, actualSales, incremental, spend.TotalSpend, roi);
|
|
}
|
|
|
|
private async Task ValidateFreeGoodsRuleAsync(SchemeRuleType ruleType, Guid? triggerItemId, int? triggerQty, Guid? freeItemId, decimal? freeQty)
|
|
{
|
|
if (ruleType is not (SchemeRuleType.BuyXGetY or SchemeRuleType.FreeBundle))
|
|
throw new InvalidOperationException($"Free-goods rule only applies to BuyXGetY or FreeBundle schemes, not '{ruleType}'.");
|
|
|
|
if (ruleType == SchemeRuleType.BuyXGetY)
|
|
{
|
|
if (triggerItemId is null || triggerQty is null or <= 0)
|
|
throw new InvalidOperationException("BuyXGetY requires TriggerItemId and a positive TriggerQty.");
|
|
if (await itemRepo.GetByIdAsync(triggerItemId.Value) is null)
|
|
throw new InvalidOperationException("Trigger item not found.");
|
|
}
|
|
|
|
if (freeItemId is null || freeQty is null or <= 0)
|
|
throw new InvalidOperationException("FreeItemId and a positive FreeQty are required.");
|
|
if (await itemRepo.GetByIdAsync(freeItemId.Value) is null)
|
|
throw new InvalidOperationException("Free item not found.");
|
|
}
|
|
|
|
private static void ValidatePercentRuleConfig(SchemeRuleType ruleType, string? ruleConfig)
|
|
{
|
|
if (ruleType is not (SchemeRuleType.SlabDiscount or SchemeRuleType.FlatPercent)) return;
|
|
|
|
if (string.IsNullOrWhiteSpace(ruleConfig))
|
|
throw new InvalidOperationException($"RuleConfig is required for RuleType '{ruleType}'.");
|
|
|
|
try
|
|
{
|
|
if (ruleType == SchemeRuleType.SlabDiscount)
|
|
{
|
|
var slab = JsonSerializer.Deserialize<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}'.");
|
|
}
|
|
}
|
|
}
|