Refactor sales pipeline: ShopPO, Invoice, SysConfig models

Major refactor replacing Sale/Order with ShopPO → Invoice → LoadingSheet flow. Removed legacy Sale/Order models and features; introduced ShopPO, ShopPOItem, Invoice, LoadingSheetOrder, and SysConfig models. Updated related entities, enums, repositories, and controllers. Added new migrations for schema changes and system config. Overhauled business logic and DI registrations. Updated documentation with migration plan.
This commit is contained in:
Kalana B. Thilakarathna
2026-09-17 10:03:22 +05:30
parent 6c40421b2d
commit 93f2f1bd3b
83 changed files with 11274 additions and 2538 deletions
+1 -1
View File
@@ -99,7 +99,7 @@ public record SchemeEligibilityResponse(
public record SchemeRedemptionResponse(
Guid Id,
Guid SchemeId,
Guid? OrderId,
Guid? ShopPOId,
decimal? DiscountAmount,
Guid? FreeItemId,
string? FreeItemName,
@@ -42,15 +42,15 @@ public class SchemeRepository(AppDBContext context) : ISchemeRepository
public async Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId) =>
await context.SchemeRedemptions
.Include(r => r.FreeItem)
.Include(r => r.Order)
.Include(r => r.ShopPO)
.Where(r => r.FK_SchemeId == schemeId)
.ToListAsync();
public async Task AddRedemptionAsync(Models.SchemeRedemption entity) =>
await context.SchemeRedemptions.AddAsync(entity);
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId) =>
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_OrderId == orderId);
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid shopPOId) =>
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_ShopPOId == shopPOId);
public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
await context.SchemeBundleComponents
+36 -36
View File
@@ -1,6 +1,6 @@
using System.Text.Json;
using Distribution.Features.Item;
using Distribution.Features.Order;
using Distribution.Features.ShopPO;
using Distribution.Features.Stock;
using Distribution.Features.Store;
using Distribution.Infra.UoW;
@@ -10,7 +10,7 @@ namespace Distribution.Features.Scheme;
public class SchemeService(
ISchemeRepository repo,
IOrderRepository orderRepo,
IShopPORepository shopPORepo,
IItemRepository itemRepo,
IStoreRepository storeRepo,
IStockRepository stockRepo,
@@ -27,7 +27,7 @@ public class SchemeService(
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,
new(r.Id, r.FK_SchemeId, r.FK_ShopPOId, r.DiscountAmount, r.FK_FreeItemId, r.FreeItem?.Name,
r.FreeQty, r.FreeItemCostValue, r.RedeemedAt);
// ── CRUD ───────────────────────────────────────────────────────────────
@@ -243,17 +243,17 @@ public class SchemeService(
return (await GetByIdAsync(schemeId))!;
}
// ── evaluateSchemeEligibility(storeId, orderId) ──────────────────────────
// ── evaluateSchemeEligibility(storeId, shopPOId) ──────────────────────────
public async Task<List<SchemeEligibilityResponse>?> EvaluateEligibilityAsync(Guid storeId, Guid orderId)
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 order = await orderRepo.GetByIdAsync(orderId);
if (order is null || !order.IsActive) return null;
var shopPO = await shopPORepo.GetByIdAsync(shopPOId);
if (shopPO is null || !shopPO.IsActive) return null;
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
var regionId = store.Route?.FK_RegionId;
var candidates = (await repo.GetAllAsync()).Where(s => s.IsActive);
@@ -266,21 +266,21 @@ public class SchemeService(
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
!string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase))
continue;
if (order.OrderDate < scheme.DateRangeFrom || order.OrderDate > scheme.DateRangeTo)
if (shopPO.CreatedAt < scheme.DateRangeFrom || shopPO.CreatedAt > scheme.DateRangeTo)
continue;
result.Add(EvaluateRule(scheme, orderItems));
result.Add(EvaluateRule(scheme, items));
}
return result;
}
/// <summary>
/// Scope-only check (region, segment, date range as of today) with no order to evaluate line
/// 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 order's items satisfy the rule" (use EvaluateEligibilityAsync for that).
/// 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)
{
@@ -305,7 +305,7 @@ public class SchemeService(
return (true, null);
}
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<OrderItem> orderItems)
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<ShopPOItem> items)
{
switch (scheme.RuleType)
{
@@ -316,10 +316,10 @@ public class SchemeService(
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);
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; order has {boughtQty}.", null, null, null);
$"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,
@@ -332,11 +332,11 @@ public class SchemeService(
"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)
.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,
"Order is missing required bundle components.", null, null, null);
"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);
@@ -346,7 +346,7 @@ public class SchemeService(
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);
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);
}
@@ -355,12 +355,12 @@ public class SchemeService(
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 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,
"Order total does not meet any slab threshold.", null, null, null);
"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);
@@ -370,32 +370,32 @@ public class SchemeService(
}
}
// ── applySchemeDiscount(orderId, schemeId) ───────────────────────────────
// ── applySchemeDiscount(shopPOId, schemeId) ───────────────────────────────
public async Task<SchemeRedemptionResponse?> ApplySchemeAsync(Guid orderId, Guid schemeId, CancellationToken ct = default)
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 order = await orderRepo.GetByIdAsync(orderId);
if (order is null || !order.IsActive) return null;
var shopPO = await shopPORepo.GetByIdAsync(shopPOId);
if (shopPO is null || !shopPO.IsActive) return null;
if (await repo.HasRedemptionAsync(schemeId, orderId))
throw new InvalidOperationException("This scheme has already been redeemed against this order.");
if (await repo.HasRedemptionAsync(schemeId, shopPOId))
throw new InvalidOperationException("This scheme has already been redeemed against this ShopPO.");
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
var eligibility = EvaluateRule(scheme, orderItems);
var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
var eligibility = EvaluateRule(scheme, items);
if (!eligibility.Eligible)
throw new InvalidOperationException(eligibility.Reason ?? "Order is not eligible for this scheme.");
throw new InvalidOperationException(eligibility.Reason ?? "ShopPO is not eligible for this scheme.");
var redemption = new SchemeRedemption
{
FK_SchemeId = schemeId,
FK_OrderId = orderId,
FK_ShopPOId = shopPOId,
DiscountAmount = eligibility.DiscountAmount
};
var newOrderItems = new List<OrderItem>();
var newItems = new List<ShopPOItem>();
if (eligibility.FreeItemId.HasValue && eligibility.FreeQty is > 0)
{
@@ -406,9 +406,9 @@ public class SchemeService(
if (availableQty < eligibility.FreeQty.Value)
throw new InvalidOperationException($"Insufficient stock of '{freeItem.Sku}' to fulfil the free-goods scheme.");
newOrderItems.Add(new OrderItem
newItems.Add(new ShopPOItem
{
FK_OrderId = orderId,
FK_ShopPOId = shopPOId,
FK_ItemId = freeItem.Id,
Quantity = (int)eligibility.FreeQty.Value,
UnitPrice = 0m
@@ -422,7 +422,7 @@ public class SchemeService(
await uow.BeginAsync(ct);
try
{
if (newOrderItems.Count > 0) await orderRepo.AddItemsAsync(newOrderItems);
if (newItems.Count > 0) await shopPORepo.AddItemsAsync(newItems);
await repo.AddRedemptionAsync(redemption);
await uow.CommitAsync(ct);
}
@@ -457,8 +457,8 @@ public class SchemeService(
var spend = await GetSpendAsync(schemeId);
var actualSales = redemptions
.Where(r => r.Order is not null)
.Select(r => r.Order!)
.Where(r => r.ShopPO is not null)
.Select(r => r.ShopPO!)
.Distinct()
.Sum(o => o.TotalAmount);