feat: enhance Shop PO handling with discount scheme support and free issue tracking

This commit is contained in:
2026-10-22 16:27:14 +05:30
parent 33dc4adce2
commit 855eb44118
3 changed files with 63 additions and 34 deletions
+11 -5
View File
@@ -2,13 +2,18 @@
namespace Distribution.Features.ShopPO; namespace Distribution.Features.ShopPO;
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null); // DiscountSchemeIds: the FlatPercent/SlabDiscount scheme the user accepted (at most one). Null = leave as is.
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null); public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null, List<Guid>? DiscountSchemeIds = null);
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items); // 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 ConfirmShopPORequest(Guid? ConfirmedByUserId = null); public record ConfirmShopPORequest(Guid? ConfirmedByUserId = null);
public record CancelShopPORequest(string Reason, Guid? CancelledByUserId = 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); 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 ShopPOResponse( public record ShopPOResponse(
Guid Id, Guid Id,
@@ -34,4 +39,5 @@ public record ShopPOResponse(
DateTime? CancelledAt = null, DateTime? CancelledAt = null,
string? CancelReason = null, string? CancelReason = null,
string? CancelledByName = null, string? CancelledByName = null,
string? CancelledByRole = null); string? CancelledByRole = null,
List<ShopPODiscountResponse>? Discounts = null);
+46 -29
View File
@@ -1,6 +1,7 @@
using Distribution.Features.Invoice; using Distribution.Features.Invoice;
using Distribution.Features.Notifications; using Distribution.Features.Notifications;
using Distribution.Features.PriceList; using Distribution.Features.PriceList;
using Distribution.Features.Scheme;
using Distribution.Features.Stock; using Distribution.Features.Stock;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
using Distribution.Models; using Distribution.Models;
@@ -11,9 +12,30 @@ public class ShopPOService(
IShopPORepository repo, IShopPORepository repo,
IInvoiceRepository invoiceRepo, IInvoiceRepository invoiceRepo,
PriceListService priceListService, PriceListService priceListService,
SchemeService schemeService,
INotificationService notifications, INotificationService notifications,
IUnitOfWork uow) 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. // 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) => private Task NotifyOrderAsync(string type, Models.ShopPO po, Guid actingUserId, string? reason, CancellationToken ct) =>
notifications.NotifyAsync( notifications.NotifyAsync(
@@ -22,14 +44,15 @@ public class ShopPOService(
new NotificationArgs(new { StoreName = po.Store?.StoreName, Reason = reason }, "ShopPO", po.Id, "/sales/orders", actingUserId), new NotificationArgs(new { StoreName = po.Store?.StoreName, Reason = reason }, "ShopPO", po.Id, "/sales/orders", actingUserId),
ct); ct);
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null) => private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null, List<ShopPODiscountResponse>? discounts = null) =>
new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty, new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
p.Store?.StoreAddress, p.Store?.ContactNumber, p.Store?.StoreAddress, p.Store?.ContactNumber,
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status, p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt, p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
p.Items.Where(i => i.IsActive).Select(i => p.Items.Where(i => i.IsActive).Select(i =>
new ShopPOItemResponse(i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty, new ShopPOItemResponse(i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive)).ToList(), i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive,
i.IsFree, i.FK_SchemeId, i.SchemeName)).ToList(),
p.IsActive, p.CreatedAt, p.IsActive, p.CreatedAt,
users != null && users.TryGetValue(p.RaisedById, out var u) ? u.Name : null, users != null && users.TryGetValue(p.RaisedById, out var u) ? u.Name : null,
users != null && users.TryGetValue(p.RaisedById, out var r) ? r.Role : null, users != null && users.TryGetValue(p.RaisedById, out var r) ? r.Role : null,
@@ -37,7 +60,8 @@ public class ShopPOService(
users != null && p.ConfirmedBy is Guid cb2 && users.TryGetValue(cb2, out var c2) ? c2.Role : null, users != null && p.ConfirmedBy is Guid cb2 && users.TryGetValue(cb2, out var c2) ? c2.Role : null,
p.CancelledBy, p.CancelledAt, p.CancelReason, 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 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); users != null && p.CancelledBy is Guid xb2 && users.TryGetValue(xb2, out var x2) ? x2.Role : null,
discounts ?? []);
private static IEnumerable<Guid> UserIds(Models.ShopPO p) private static IEnumerable<Guid> UserIds(Models.ShopPO p)
{ {
@@ -62,7 +86,7 @@ public class ShopPOService(
var po = await repo.GetByIdAsync(id); var po = await repo.GetByIdAsync(id);
if (po is null) return null; if (po is null) return null;
var users = await repo.GetUserInfoAsync(UserIds(po)); var users = await repo.GetUserInfoAsync(UserIds(po));
return ToResponse(po, users); return ToResponse(po, users, await schemeService.GetDiscountsForShopPOAsync(po.Id));
} }
public async Task<List<ShopPOResponse>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId) public async Task<List<ShopPOResponse>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId)
@@ -82,20 +106,14 @@ public class ShopPOService(
foreach (var line in request.Items) foreach (var line in request.Items)
{ {
var unitPrice = line.UnitPrice var item = await BuildLineAsync(line, request.StoreId);
?? (await priceListService.ResolveByStoreAsync(line.ItemId, request.StoreId, DateTime.Now))?.Price items.Add(item);
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}"); total += item.Quantity * item.UnitPrice;
items.Add(new ShopPOItem
{
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
QuantityFulfilled = 0,
UnitPrice = unitPrice
});
total += line.Quantity * unitPrice;
} }
await schemeService.ValidateFreeLinesAsync(request.StoreId, DateTime.Now, items);
await schemeService.ValidateDiscountSchemesAsync(request.StoreId, DateTime.Now, items, request.DiscountSchemeIds);
var po = new Models.ShopPO var po = new Models.ShopPO
{ {
FK_StoreId = request.StoreId, FK_StoreId = request.StoreId,
@@ -110,6 +128,7 @@ public class ShopPOService(
await repo.AddAsync(po); await repo.AddAsync(po);
foreach (var item in items) item.FK_ShopPOId = po.Id; foreach (var item in items) item.FK_ShopPOId = po.Id;
await repo.AddItemsAsync(items); await repo.AddItemsAsync(items);
await schemeService.SyncRedemptionsAsync(po.Id, items, request.DiscountSchemeIds);
await uow.CommitAsync(ct); await uow.CommitAsync(ct);
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
@@ -137,27 +156,22 @@ public class ShopPOService(
foreach (var line in request.Items) foreach (var line in request.Items)
{ {
var unitPrice = line.UnitPrice var item = await BuildLineAsync(line, po.FK_StoreId);
?? (await priceListService.ResolveByStoreAsync(line.ItemId, po.FK_StoreId, DateTime.Now))?.Price item.FK_ShopPOId = id;
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}"); newItems.Add(item);
total += item.Quantity * item.UnitPrice;
newItems.Add(new ShopPOItem
{
FK_ShopPOId = id,
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
QuantityFulfilled = 0,
UnitPrice = unitPrice
});
total += line.Quantity * unitPrice;
} }
await schemeService.ValidateFreeLinesAsync(po.FK_StoreId, po.CreatedAt, newItems);
await schemeService.ValidateDiscountSchemesAsync(po.FK_StoreId, po.CreatedAt, newItems, request.DiscountSchemeIds);
po.TotalAmount = total; po.TotalAmount = total;
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
{ {
await repo.ReplaceItemsAsync(id, newItems); await repo.ReplaceItemsAsync(id, newItems);
await schemeService.SyncRedemptionsAsync(id, newItems, request.DiscountSchemeIds);
repo.Update(po); repo.Update(po);
await uow.CommitAsync(ct); await uow.CommitAsync(ct);
} }
@@ -212,6 +226,9 @@ public class ShopPOService(
CreatedBy = userId, CreatedBy = userId,
TotalAmount = po.TotalAmount 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); await uow.BeginAsync(ct);
try try
+6
View File
@@ -15,5 +15,11 @@ namespace Distribution.Models
public int Quantity { get; set; } public int Quantity { get; set; }
public int QuantityFulfilled { get; set; } = 0; public int QuantityFulfilled { get; set; } = 0;
public decimal UnitPrice { get; set; } 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; }
} }
} }