Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7f58e6b73 | |||
| e13ec0f109 | |||
| 13fdb28ec7 | |||
| 39ad424d21 | |||
| ee9a5385bf | |||
| 138bf2f925 | |||
| 1b4461dc12 | |||
| cc33ecdd54 | |||
| f77db46a02 | |||
| 267144f7a1 | |||
| efb5657b6b | |||
| 622b75e187 | |||
| 293df41d4e | |||
| 5e81fd77b1 | |||
| a1fd5f1a08 |
@@ -52,13 +52,6 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/preview-totals")]
|
||||
public async Task<IActionResult> PreviewTotals(Guid id, [FromBody] PreviewInvoiceTotalsRequest request)
|
||||
{
|
||||
var result = await service.PreviewTotalsAsync(id, request);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/versions")]
|
||||
public async Task<IActionResult> GetVersions(Guid id)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Distribution.Features.ShopPO;
|
||||
using Distribution.Models;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Invoice;
|
||||
|
||||
@@ -10,14 +9,7 @@ public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
|
||||
|
||||
// InvoiceItemId set = edit/remove/restore an existing line; ItemId set (InvoiceItemId null) = add a new line.
|
||||
public record InvoiceItemEdit(Guid? InvoiceItemId, Guid? ItemId, int Quantity, decimal? UnitPrice = null, bool IsRemoved = false);
|
||||
// DiscountSchemeIds: the percentage-discount scheme chosen in the editor (at most one); null = leave as is.
|
||||
public record UpdateInvoiceItemsRequest(List<InvoiceItemEdit> Items, Guid? EditedByUserId = null, List<Guid>? DiscountSchemeIds = null);
|
||||
|
||||
public record PreviewLine(Guid ItemId, int Quantity, decimal UnitPrice, bool IsFree = false, bool IsRemoved = false, Guid? SchemeId = null);
|
||||
public record PreviewInvoiceTotalsRequest(List<PreviewLine> Items, List<Guid>? DiscountSchemeIds = null);
|
||||
// Quantity = how many free units the scheme still entitles for the lines as typed.
|
||||
public record FreeEntitlement(Guid SchemeId, int Quantity);
|
||||
public record InvoiceTotalsPreview(decimal DiscountAmount, decimal TotalAmount, List<FreeEntitlement> FreeEntitlements);
|
||||
public record UpdateInvoiceItemsRequest(List<InvoiceItemEdit> Items, Guid? EditedByUserId = null);
|
||||
|
||||
public record InvoiceVersionItemResponse(Guid? ShopPOItemId, Guid ItemId, string ItemName, int Quantity, decimal UnitPrice, bool IsRemoved);
|
||||
|
||||
@@ -38,10 +30,7 @@ public record InvoiceItemResponse(
|
||||
int? OrderedQuantity,
|
||||
int Quantity,
|
||||
decimal UnitPrice,
|
||||
bool IsRemoved,
|
||||
bool IsFree = false,
|
||||
Guid? SchemeId = null,
|
||||
string? SchemeName = null);
|
||||
bool IsRemoved);
|
||||
|
||||
public record InvoiceResponse(
|
||||
Guid Id,
|
||||
@@ -60,7 +49,6 @@ public record InvoiceResponse(
|
||||
decimal PaidAmount,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt,
|
||||
List<InvoiceItemResponse> Items,
|
||||
List<ShopPODiscountResponse>? Discounts = null);
|
||||
List<InvoiceItemResponse> Items);
|
||||
|
||||
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Distribution.Features.Scheme;
|
||||
using Distribution.Features.ShopPO;
|
||||
using Distribution.Features.ShopPO;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
@@ -10,7 +9,6 @@ namespace Distribution.Features.Invoice;
|
||||
public class InvoiceService(
|
||||
IInvoiceRepository repo,
|
||||
IShopPORepository shopPoRepo,
|
||||
SchemeService schemeService,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
@@ -27,53 +25,20 @@ public class InvoiceService(
|
||||
i.Items.Where(x => x.IsActive).Select(x =>
|
||||
new InvoiceItemResponse(x.Id, x.FK_ShopPOItemId, x.FK_ItemId,
|
||||
x.Item?.Name ?? string.Empty, x.ShopPOItem?.Quantity,
|
||||
x.Quantity, x.UnitPrice, x.IsRemoved, x.IsFree, x.FK_SchemeId, x.SchemeName)).ToList());
|
||||
x.Quantity, x.UnitPrice, x.IsRemoved)).ToList());
|
||||
|
||||
private static decimal BillableTotal(IEnumerable<InvoiceItem> items) =>
|
||||
items.Where(i => i.IsActive && !i.IsRemoved).Sum(i => i.Quantity * i.UnitPrice);
|
||||
|
||||
// The lines that count toward a percentage discount: active, not struck out. Free issues carry the flag so the
|
||||
// scheme rules ignore them.
|
||||
private static List<ShopPOItem> DiscountableLines(IEnumerable<InvoiceItem> items) =>
|
||||
items.Where(i => i.IsActive && !i.IsRemoved)
|
||||
.Select(i => new ShopPOItem { FK_ItemId = i.FK_ItemId, Quantity = i.Quantity, UnitPrice = i.UnitPrice, IsFree = i.IsFree })
|
||||
.ToList();
|
||||
|
||||
// TotalAmount = billable lines - scheme discount. The discount is re-evaluated against the current lines
|
||||
// (so adding/removing/re-pricing items moves it, and a slab level can be gained or lost) whenever lines change
|
||||
// and at print/finalize. The redemption amount is updated with it.
|
||||
// TotalAmount = billable lines - scheme discount redeemed on the PO. Re-derived whenever lines change
|
||||
// and at finalize, so a scheme applied after the invoice was drafted is still picked up before it locks.
|
||||
private async Task ApplyTotalsAsync(Models.Invoice invoice, IEnumerable<InvoiceItem> items)
|
||||
{
|
||||
var lines = items.ToList();
|
||||
// Lowering/removing the paid item that earned a free issue lowers the free quantity with it.
|
||||
await schemeService.ReconcileInvoiceFreeLinesAsync(invoice.FK_ShopPOId, lines);
|
||||
var billable = BillableTotal(lines);
|
||||
var discount = await schemeService.CalculateDiscountAsync(invoice.FK_ShopPOId, DiscountableLines(lines), persist: true);
|
||||
invoice.DiscountAmount = Math.Min(discount, billable);
|
||||
var billable = BillableTotal(items);
|
||||
invoice.DiscountAmount = Math.Min(await repo.GetSchemeDiscountAsync(invoice.FK_ShopPOId), billable);
|
||||
invoice.TotalAmount = billable - invoice.DiscountAmount;
|
||||
}
|
||||
|
||||
// Live preview for the draft editor: what the discount and total would be for these unsaved lines. Changes nothing.
|
||||
public async Task<InvoiceTotalsPreview?> PreviewTotalsAsync(Guid id, PreviewInvoiceTotalsRequest request)
|
||||
{
|
||||
var invoice = await repo.GetByIdAsync(id);
|
||||
if (invoice is null) return null;
|
||||
|
||||
var lines = request.Items.Where(l => !l.IsRemoved)
|
||||
.Select(l => new ShopPOItem { FK_ItemId = l.ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice, IsFree = l.IsFree, FK_SchemeId = l.SchemeId })
|
||||
.ToList();
|
||||
var billable = lines.Sum(l => l.Quantity * l.UnitPrice);
|
||||
var chosen = request.DiscountSchemeIds is null
|
||||
? await schemeService.CalculateDiscountAsync(invoice.FK_ShopPOId, lines, persist: false)
|
||||
: await schemeService.CalculateDiscountForSchemesAsync(request.DiscountSchemeIds, lines);
|
||||
var discount = Math.Min(chosen, billable);
|
||||
|
||||
var schemeIds = lines.Where(l => l.IsFree && l.FK_SchemeId.HasValue).Select(l => l.FK_SchemeId!.Value).ToList();
|
||||
var entitlements = (await schemeService.GetFreeEntitlementsAsync(schemeIds, lines))
|
||||
.Select(e => new FreeEntitlement(e.Key, e.Value)).ToList();
|
||||
return new InvoiceTotalsPreview(discount, billable - discount, entitlements);
|
||||
}
|
||||
|
||||
// Immutable copy of the invoice lines as they stand right now.
|
||||
public static InvoiceVersion BuildVersion(Models.Invoice invoice, IEnumerable<InvoiceItem> items, int versionNumber, InvoiceVersionStage stage, Guid userId) =>
|
||||
new()
|
||||
@@ -118,17 +83,13 @@ public class InvoiceService(
|
||||
FK_ShopPOItemId = i.Id,
|
||||
FK_ItemId = i.FK_ItemId,
|
||||
Quantity = i.Quantity,
|
||||
UnitPrice = i.UnitPrice,
|
||||
IsFree = i.IsFree,
|
||||
FK_SchemeId = i.FK_SchemeId,
|
||||
SchemeName = i.SchemeName
|
||||
UnitPrice = i.UnitPrice
|
||||
}).ToList();
|
||||
|
||||
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var invoice = await repo.GetByIdAsync(id);
|
||||
if (invoice is null) return null;
|
||||
return ToResponse(invoice) with { Discounts = await schemeService.GetDiscountsForShopPOAsync(invoice.FK_ShopPOId) };
|
||||
return invoice is null ? null : ToResponse(invoice);
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize)
|
||||
@@ -211,12 +172,7 @@ public class InvoiceService(
|
||||
ValidateQuantity(item, edit.Quantity);
|
||||
item.Quantity = edit.Quantity;
|
||||
}
|
||||
if (edit.UnitPrice is decimal price)
|
||||
{
|
||||
if (item.IsFree && price != 0m)
|
||||
throw new InvalidOperationException("A free-issue line must stay at zero price");
|
||||
item.UnitPrice = price;
|
||||
}
|
||||
if (edit.UnitPrice is decimal price) item.UnitPrice = price;
|
||||
}
|
||||
else if (edit.ItemId is Guid itemId)
|
||||
{
|
||||
@@ -245,10 +201,7 @@ public class InvoiceService(
|
||||
throw new InvalidOperationException("Each edit needs an invoiceItemId (existing line) or an itemId (new line)");
|
||||
}
|
||||
|
||||
var allLines = invoice.Items.Concat(newLines).ToList();
|
||||
await schemeService.ApplyInvoiceDiscountSelectionAsync(
|
||||
invoice.FK_ShopPOId, invoice.ShopPO?.FK_StoreId ?? Guid.Empty, allLines, request.DiscountSchemeIds);
|
||||
await ApplyTotalsAsync(invoice, allLines);
|
||||
await ApplyTotalsAsync(invoice, invoice.Items.Concat(newLines));
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
@@ -260,7 +213,7 @@ public class InvoiceService(
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return (await GetByIdAsync(id))!;
|
||||
return ToResponse((await repo.GetByIdAsync(id))!);
|
||||
}
|
||||
|
||||
private static void ValidateQuantity(InvoiceItem item, int quantity)
|
||||
|
||||
@@ -10,8 +10,6 @@ public interface ISchemeRepository
|
||||
|
||||
Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId);
|
||||
Task AddRedemptionAsync(Models.SchemeRedemption entity);
|
||||
Task<List<Models.SchemeRedemption>> GetRedemptionsByShopPOAsync(Guid shopPOId);
|
||||
void RemoveRedemption(Models.SchemeRedemption entity);
|
||||
Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId);
|
||||
Task<bool> HasLockedInvoiceAsync(Guid shopPOId);
|
||||
|
||||
|
||||
@@ -112,14 +112,6 @@ public class SchemeController(SchemeService service) : ControllerBase
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("suggest")]
|
||||
[Authorize("scheme.view")]
|
||||
public async Task<IActionResult> 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<IActionResult> GetSpend(Guid id)
|
||||
|
||||
@@ -94,13 +94,7 @@ public record SchemeEligibilityResponse(
|
||||
string? Reason,
|
||||
decimal? DiscountAmount,
|
||||
Guid? FreeItemId,
|
||||
decimal? FreeQty,
|
||||
string? FreeItemName = null);
|
||||
|
||||
/// <summary>A cart line for scheme suggestion before the Shop PO exists. UnitPrice null = resolve from the price list.</summary>
|
||||
public record SuggestLine(Guid ItemId, int Quantity, decimal? UnitPrice = null, bool IsFree = false);
|
||||
|
||||
public record SuggestSchemesRequest(Guid StoreId, List<SuggestLine> Items);
|
||||
decimal? FreeQty);
|
||||
|
||||
public record SchemeRedemptionResponse(
|
||||
Guid Id,
|
||||
|
||||
@@ -49,18 +49,6 @@ public class SchemeRepository(AppDBContext context) : ISchemeRepository
|
||||
public async Task AddRedemptionAsync(Models.SchemeRedemption entity) =>
|
||||
await context.SchemeRedemptions.AddAsync(entity);
|
||||
|
||||
public async Task<List<Models.SchemeRedemption>> 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<bool> HasRedemptionAsync(Guid schemeId, Guid shopPOId) =>
|
||||
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_ShopPOId == shopPOId);
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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;
|
||||
@@ -16,8 +14,6 @@ public class SchemeService(
|
||||
IItemRepository itemRepo,
|
||||
IStoreRepository storeRepo,
|
||||
IStockRepository stockRepo,
|
||||
IInvoiceRepository invoiceRepo,
|
||||
PriceListService priceListService,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
@@ -265,7 +261,12 @@ public class SchemeService(
|
||||
|
||||
foreach (var scheme in candidates)
|
||||
{
|
||||
if (!IsInScope(scheme, store.Segment, regionId, shopPO.CreatedAt))
|
||||
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)
|
||||
continue;
|
||||
|
||||
result.Add(EvaluateRule(scheme, items));
|
||||
@@ -274,328 +275,6 @@ 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)`
|
||||
@@ -620,18 +299,14 @@ public class SchemeService(
|
||||
return (false, $"Scheme is only available to the '{scheme.EligibleSegment}' segment.");
|
||||
|
||||
var today = DateTime.Now;
|
||||
if (today < scheme.DateRangeFrom || today.Date > scheme.DateRangeTo.Date)
|
||||
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<ShopPOItem> allItems)
|
||||
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<ShopPOItem> items)
|
||||
{
|
||||
// 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:
|
||||
@@ -739,10 +414,7 @@ public class SchemeService(
|
||||
FK_ShopPOId = shopPOId,
|
||||
FK_ItemId = freeItem.Id,
|
||||
Quantity = (int)eligibility.FreeQty.Value,
|
||||
UnitPrice = 0m,
|
||||
IsFree = true,
|
||||
FK_SchemeId = scheme.Id,
|
||||
SchemeName = scheme.Name
|
||||
UnitPrice = 0m
|
||||
});
|
||||
|
||||
redemption.FK_FreeItemId = freeItem.Id;
|
||||
@@ -753,27 +425,7 @@ public class SchemeService(
|
||||
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
|
||||
}));
|
||||
}
|
||||
}
|
||||
if (newItems.Count > 0) await shopPORepo.AddItemsAsync(newItems);
|
||||
await repo.AddRedemptionAsync(redemption);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
@@ -835,8 +487,6 @@ 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.");
|
||||
}
|
||||
|
||||
@@ -2,18 +2,13 @@
|
||||
|
||||
namespace Distribution.Features.ShopPO;
|
||||
|
||||
// DiscountSchemeIds: the FlatPercent/SlabDiscount scheme the user accepted (at most one). Null = leave as is.
|
||||
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null, List<Guid>? DiscountSchemeIds = null);
|
||||
// IsFree + SchemeId mark a free-issue line the user accepted from a scheme suggestion; its price is forced to 0.
|
||||
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null, bool IsFree = false, Guid? SchemeId = null);
|
||||
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items, List<Guid>? DiscountSchemeIds = null);
|
||||
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null);
|
||||
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
|
||||
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items);
|
||||
public record ConfirmShopPORequest(Guid? ConfirmedByUserId = null);
|
||||
public record CancelShopPORequest(string Reason, Guid? CancelledByUserId = null);
|
||||
|
||||
public record ShopPOItemResponse(Guid Id, Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice, bool IsActive,
|
||||
bool IsFree = false, Guid? SchemeId = null, string? SchemeName = null);
|
||||
|
||||
public record ShopPODiscountResponse(Guid SchemeId, string SchemeName, decimal Amount);
|
||||
public record ShopPOItemResponse(Guid Id, Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice, bool IsActive);
|
||||
|
||||
public record ShopPOResponse(
|
||||
Guid Id,
|
||||
@@ -39,5 +34,4 @@ public record ShopPOResponse(
|
||||
DateTime? CancelledAt = null,
|
||||
string? CancelReason = null,
|
||||
string? CancelledByName = null,
|
||||
string? CancelledByRole = null,
|
||||
List<ShopPODiscountResponse>? Discounts = null);
|
||||
string? CancelledByRole = null);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Distribution.Features.Invoice;
|
||||
using Distribution.Features.Notifications;
|
||||
using Distribution.Features.PriceList;
|
||||
using Distribution.Features.Scheme;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
@@ -12,30 +11,9 @@ public class ShopPOService(
|
||||
IShopPORepository repo,
|
||||
IInvoiceRepository invoiceRepo,
|
||||
PriceListService priceListService,
|
||||
SchemeService schemeService,
|
||||
INotificationService notifications,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
// Paid lines are priced from the request or the price list; accepted free-issue lines are always priced 0.
|
||||
private async Task<ShopPOItem> BuildLineAsync(ShopPOItemRequest line, Guid storeId)
|
||||
{
|
||||
if (line.IsFree)
|
||||
return new ShopPOItem
|
||||
{
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity,
|
||||
UnitPrice = 0m,
|
||||
IsFree = true,
|
||||
FK_SchemeId = line.SchemeId
|
||||
};
|
||||
|
||||
var unitPrice = line.UnitPrice
|
||||
?? (await priceListService.ResolveByStoreAsync(line.ItemId, storeId, DateTime.Now))?.Price
|
||||
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
||||
|
||||
return new ShopPOItem { FK_ItemId = line.ItemId, Quantity = line.Quantity, UnitPrice = unitPrice };
|
||||
}
|
||||
|
||||
// Tells the person who raised the order (and their supervisor) what happened to it, unless they did it themselves.
|
||||
private Task NotifyOrderAsync(string type, Models.ShopPO po, Guid actingUserId, string? reason, CancellationToken ct) =>
|
||||
notifications.NotifyAsync(
|
||||
@@ -44,15 +22,14 @@ public class ShopPOService(
|
||||
new NotificationArgs(new { StoreName = po.Store?.StoreName, Reason = reason }, "ShopPO", po.Id, "/sales/orders", actingUserId),
|
||||
ct);
|
||||
|
||||
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null, List<ShopPODiscountResponse>? discounts = null) =>
|
||||
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null) =>
|
||||
new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
|
||||
p.Store?.StoreAddress, p.Store?.ContactNumber,
|
||||
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
|
||||
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
|
||||
p.Items.Where(i => i.IsActive).Select(i =>
|
||||
new ShopPOItemResponse(i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
|
||||
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive,
|
||||
i.IsFree, i.FK_SchemeId, i.SchemeName)).ToList(),
|
||||
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive)).ToList(),
|
||||
p.IsActive, p.CreatedAt,
|
||||
users != null && users.TryGetValue(p.RaisedById, out var u) ? u.Name : null,
|
||||
users != null && users.TryGetValue(p.RaisedById, out var r) ? r.Role : null,
|
||||
@@ -60,8 +37,7 @@ public class ShopPOService(
|
||||
users != null && p.ConfirmedBy is Guid cb2 && users.TryGetValue(cb2, out var c2) ? c2.Role : null,
|
||||
p.CancelledBy, p.CancelledAt, p.CancelReason,
|
||||
users != null && p.CancelledBy is Guid xb && users.TryGetValue(xb, out var x) ? x.Name : null,
|
||||
users != null && p.CancelledBy is Guid xb2 && users.TryGetValue(xb2, out var x2) ? x2.Role : null,
|
||||
discounts ?? []);
|
||||
users != null && p.CancelledBy is Guid xb2 && users.TryGetValue(xb2, out var x2) ? x2.Role : null);
|
||||
|
||||
private static IEnumerable<Guid> UserIds(Models.ShopPO p)
|
||||
{
|
||||
@@ -86,7 +62,7 @@ public class ShopPOService(
|
||||
var po = await repo.GetByIdAsync(id);
|
||||
if (po is null) return null;
|
||||
var users = await repo.GetUserInfoAsync(UserIds(po));
|
||||
return ToResponse(po, users, await schemeService.GetDiscountsForShopPOAsync(po.Id));
|
||||
return ToResponse(po, users);
|
||||
}
|
||||
|
||||
public async Task<List<ShopPOResponse>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId)
|
||||
@@ -106,13 +82,19 @@ public class ShopPOService(
|
||||
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
var item = await BuildLineAsync(line, request.StoreId);
|
||||
items.Add(item);
|
||||
total += item.Quantity * item.UnitPrice;
|
||||
}
|
||||
var unitPrice = line.UnitPrice
|
||||
?? (await priceListService.ResolveByStoreAsync(line.ItemId, request.StoreId, DateTime.Now))?.Price
|
||||
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
||||
|
||||
await schemeService.ValidateFreeLinesAsync(request.StoreId, DateTime.Now, items);
|
||||
await schemeService.ValidateDiscountSchemesAsync(request.StoreId, DateTime.Now, items, request.DiscountSchemeIds);
|
||||
items.Add(new ShopPOItem
|
||||
{
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity,
|
||||
QuantityFulfilled = 0,
|
||||
UnitPrice = unitPrice
|
||||
});
|
||||
total += line.Quantity * unitPrice;
|
||||
}
|
||||
|
||||
var po = new Models.ShopPO
|
||||
{
|
||||
@@ -128,7 +110,6 @@ public class ShopPOService(
|
||||
await repo.AddAsync(po);
|
||||
foreach (var item in items) item.FK_ShopPOId = po.Id;
|
||||
await repo.AddItemsAsync(items);
|
||||
await schemeService.SyncRedemptionsAsync(po.Id, items, request.DiscountSchemeIds);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
@@ -156,14 +137,20 @@ public class ShopPOService(
|
||||
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
var item = await BuildLineAsync(line, po.FK_StoreId);
|
||||
item.FK_ShopPOId = id;
|
||||
newItems.Add(item);
|
||||
total += item.Quantity * item.UnitPrice;
|
||||
}
|
||||
var unitPrice = line.UnitPrice
|
||||
?? (await priceListService.ResolveByStoreAsync(line.ItemId, po.FK_StoreId, DateTime.Now))?.Price
|
||||
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
||||
|
||||
await schemeService.ValidateFreeLinesAsync(po.FK_StoreId, po.CreatedAt, newItems);
|
||||
await schemeService.ValidateDiscountSchemesAsync(po.FK_StoreId, po.CreatedAt, newItems, request.DiscountSchemeIds);
|
||||
newItems.Add(new ShopPOItem
|
||||
{
|
||||
FK_ShopPOId = id,
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity,
|
||||
QuantityFulfilled = 0,
|
||||
UnitPrice = unitPrice
|
||||
});
|
||||
total += line.Quantity * unitPrice;
|
||||
}
|
||||
|
||||
po.TotalAmount = total;
|
||||
|
||||
@@ -171,7 +158,6 @@ public class ShopPOService(
|
||||
try
|
||||
{
|
||||
await repo.ReplaceItemsAsync(id, newItems);
|
||||
await schemeService.SyncRedemptionsAsync(id, newItems, request.DiscountSchemeIds);
|
||||
repo.Update(po);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
@@ -226,9 +212,6 @@ public class ShopPOService(
|
||||
CreatedBy = userId,
|
||||
TotalAmount = po.TotalAmount
|
||||
};
|
||||
// Start the invoice at the discounted total so it matches what print/finalize will recompute.
|
||||
invoice.DiscountAmount = Math.Min(await invoiceRepo.GetSchemeDiscountAsync(po.Id), po.TotalAmount);
|
||||
invoice.TotalAmount = po.TotalAmount - invoice.DiscountAmount;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,99 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFreeIssueLines : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "FK_SchemeId",
|
||||
table: "ShopPOItems",
|
||||
type: "char(36)",
|
||||
nullable: true,
|
||||
collation: "ascii_general_ci");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsFree",
|
||||
table: "ShopPOItems",
|
||||
type: "tinyint(1)",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SchemeName",
|
||||
table: "ShopPOItems",
|
||||
type: "longtext",
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "FK_SchemeId",
|
||||
table: "InvoiceItems",
|
||||
type: "char(36)",
|
||||
nullable: true,
|
||||
collation: "ascii_general_ci");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsFree",
|
||||
table: "InvoiceItems",
|
||||
type: "tinyint(1)",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SchemeName",
|
||||
table: "InvoiceItems",
|
||||
type: "longtext",
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
// Existing free-issue lines were plain zero-priced lines; flag them from their scheme redemption.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE ShopPOItems i
|
||||
JOIN SchemeRedemptions r ON r.FK_ShopPOId = i.FK_ShopPOId AND r.FK_FreeItemId = i.FK_ItemId
|
||||
JOIN Schemes s ON s.Id = r.FK_SchemeId
|
||||
SET i.IsFree = 1, i.FK_SchemeId = r.FK_SchemeId, i.SchemeName = s.Name
|
||||
WHERE i.UnitPrice = 0 AND i.IsActive = 1;");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE InvoiceItems ii
|
||||
JOIN ShopPOItems pi ON ii.FK_ShopPOItemId = pi.Id
|
||||
SET ii.IsFree = pi.IsFree, ii.FK_SchemeId = pi.FK_SchemeId, ii.SchemeName = pi.SchemeName
|
||||
WHERE pi.IsFree = 1;");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FK_SchemeId",
|
||||
table: "ShopPOItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsFree",
|
||||
table: "ShopPOItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SchemeName",
|
||||
table: "ShopPOItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FK_SchemeId",
|
||||
table: "InvoiceItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsFree",
|
||||
table: "InvoiceItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SchemeName",
|
||||
table: "InvoiceItems");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -932,27 +932,18 @@ namespace Distribution.Migrations
|
||||
b.Property<Guid>("FK_ItemId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid?>("FK_SchemeId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid?>("FK_ShopPOItemId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<bool>("IsFree")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<bool>("IsRemoved")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<int>("Quantity")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("SchemeName")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<decimal>("UnitPrice")
|
||||
.HasColumnType("decimal(65,30)");
|
||||
|
||||
@@ -3726,27 +3717,18 @@ namespace Distribution.Migrations
|
||||
b.Property<Guid>("FK_ItemId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid?>("FK_SchemeId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("FK_ShopPOId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<bool>("IsFree")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<int>("Quantity")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("QuantityFulfilled")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("SchemeName")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<decimal>("UnitPrice")
|
||||
.HasColumnType("decimal(65,30)");
|
||||
|
||||
@@ -3766,7 +3748,6 @@ namespace Distribution.Migrations
|
||||
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000001"),
|
||||
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000001"),
|
||||
IsActive = true,
|
||||
IsFree = false,
|
||||
Quantity = 30,
|
||||
QuantityFulfilled = 30,
|
||||
UnitPrice = 60m
|
||||
@@ -3778,7 +3759,6 @@ namespace Distribution.Migrations
|
||||
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000003"),
|
||||
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000001"),
|
||||
IsActive = true,
|
||||
IsFree = false,
|
||||
Quantity = 24,
|
||||
QuantityFulfilled = 24,
|
||||
UnitPrice = 50m
|
||||
@@ -3790,7 +3770,6 @@ namespace Distribution.Migrations
|
||||
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000002"),
|
||||
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000002"),
|
||||
IsActive = true,
|
||||
IsFree = false,
|
||||
Quantity = 10,
|
||||
QuantityFulfilled = 0,
|
||||
UnitPrice = 120m
|
||||
@@ -3802,7 +3781,6 @@ namespace Distribution.Migrations
|
||||
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000002"),
|
||||
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000003"),
|
||||
IsActive = true,
|
||||
IsFree = false,
|
||||
Quantity = 20,
|
||||
QuantityFulfilled = 20,
|
||||
UnitPrice = 120m
|
||||
|
||||
@@ -19,10 +19,5 @@ namespace Distribution.Models
|
||||
public int Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public bool IsRemoved { get; set; }
|
||||
|
||||
/// <summary>Free issue copied from the Shop PO line (UnitPrice 0).</summary>
|
||||
public bool IsFree { get; set; }
|
||||
public Guid? FK_SchemeId { get; set; }
|
||||
public string? SchemeName { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,5 @@ namespace Distribution.Models
|
||||
public int Quantity { get; set; }
|
||||
public int QuantityFulfilled { get; set; } = 0;
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
/// <summary>Free issue granted by a scheme (UnitPrice 0); shown in the Free Issue column.</summary>
|
||||
public bool IsFree { get; set; }
|
||||
public Guid? FK_SchemeId { get; set; }
|
||||
/// <summary>Scheme name when it was applied, so later scheme edits don't rewrite history.</summary>
|
||||
public string? SchemeName { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user