2de9e5e4ad
Introduced "ModeB" for manual invoice creation in system config. Added backend endpoints and service logic for creating invoices from confirmed Shop POs, with validation and error handling. Updated DTOs to support optional CreatedByUserId. On the frontend, added configuration card to toggle modes, a dialog for manual invoice creation, and UI updates to support the new workflow. Enables admins to choose between automatic and manual invoice creation.
244 lines
9.4 KiB
C#
244 lines
9.4 KiB
C#
using Distribution.Features.Invoice;
|
|
using Distribution.Features.PriceList;
|
|
using Distribution.Features.Stock;
|
|
using Distribution.Infra.UoW;
|
|
using Distribution.Models;
|
|
|
|
namespace Distribution.Features.ShopPO;
|
|
|
|
public class ShopPOService(
|
|
IShopPORepository repo,
|
|
IInvoiceRepository invoiceRepo,
|
|
PriceListService priceListService,
|
|
IUnitOfWork uow)
|
|
{
|
|
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)).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,
|
|
users != null && p.ConfirmedBy is Guid cb && users.TryGetValue(cb, out var c) ? c.Name : null,
|
|
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);
|
|
|
|
private static IEnumerable<Guid> UserIds(Models.ShopPO p)
|
|
{
|
|
yield return p.RaisedById;
|
|
if (p.ConfirmedBy is Guid cb) yield return cb;
|
|
if (p.CancelledBy is Guid xb) yield return xb;
|
|
}
|
|
|
|
private async Task<ShopPOResponse> ToResponseWithUserAsync(Models.ShopPO p) =>
|
|
ToResponse(p, await repo.GetUserInfoAsync(UserIds(p)));
|
|
|
|
public async Task<PagedResponse<ShopPOResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? regionId, Guid? shopId, bool? confirmed, ShopPOStatus? status, int page, int pageSize)
|
|
{
|
|
var total = await repo.GetCountAsync(agentId, routeId, regionId, shopId, confirmed, status);
|
|
var items = await repo.GetAllAsync(agentId, routeId, regionId, shopId, confirmed, status, page, pageSize);
|
|
var users = await repo.GetUserInfoAsync(items.SelectMany(UserIds));
|
|
return new PagedResponse<ShopPOResponse>(page, pageSize, total, items.Select(p => ToResponse(p, users)).ToList());
|
|
}
|
|
|
|
public async Task<ShopPOResponse?> GetByIdAsync(Guid id)
|
|
{
|
|
var po = await repo.GetByIdAsync(id);
|
|
if (po is null) return null;
|
|
var users = await repo.GetUserInfoAsync(UserIds(po));
|
|
return ToResponse(po, users);
|
|
}
|
|
|
|
public async Task<List<ShopPOResponse>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId)
|
|
{
|
|
var items = await repo.GetConfirmedWithoutInvoiceAsync(agentId, routeId, shopId);
|
|
var users = await repo.GetUserInfoAsync(items.SelectMany(UserIds));
|
|
return items.Select(p => ToResponse(p, users)).ToList();
|
|
}
|
|
|
|
public async Task<ShopPOResponse> CreateAsync(CreateShopPORequest request, Guid raisedById, CancellationToken ct = default)
|
|
{
|
|
if (request.Items.Count == 0)
|
|
throw new InvalidOperationException("At least one item is required");
|
|
|
|
var items = new List<ShopPOItem>();
|
|
decimal total = 0;
|
|
|
|
foreach (var line in request.Items)
|
|
{
|
|
var unitPrice = line.UnitPrice
|
|
?? (await priceListService.ResolveByStoreAsync(line.ItemId, request.StoreId, DateTime.Now))?.Price
|
|
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
|
|
|
items.Add(new ShopPOItem
|
|
{
|
|
FK_ItemId = line.ItemId,
|
|
Quantity = line.Quantity,
|
|
QuantityFulfilled = 0,
|
|
UnitPrice = unitPrice
|
|
});
|
|
total += line.Quantity * unitPrice;
|
|
}
|
|
|
|
var po = new Models.ShopPO
|
|
{
|
|
FK_StoreId = request.StoreId,
|
|
RaisedById = raisedById,
|
|
TotalAmount = total,
|
|
Status = ShopPOStatus.Pending
|
|
};
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
await repo.AddAsync(po);
|
|
foreach (var item in items) item.FK_ShopPOId = po.Id;
|
|
await repo.AddItemsAsync(items);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
var saved = await repo.GetByIdAsync(po.Id);
|
|
return await ToResponseWithUserAsync(saved!);
|
|
}
|
|
|
|
public async Task<ShopPOResponse> UpdateItemsAsync(Guid id, UpdateShopPOItemsRequest request, CancellationToken ct = default)
|
|
{
|
|
var po = await repo.GetByIdAsync(id)
|
|
?? throw new KeyNotFoundException("Shop PO not found");
|
|
|
|
if (po.Status == ShopPOStatus.Cancelled)
|
|
throw new InvalidOperationException("Cannot edit a cancelled Shop PO");
|
|
|
|
if (po.ConfirmedAt is not null)
|
|
throw new InvalidOperationException("Items can only be changed before the Shop PO is confirmed");
|
|
|
|
if (request.Items.Count == 0)
|
|
throw new InvalidOperationException("At least one item is required");
|
|
|
|
var newItems = new List<ShopPOItem>();
|
|
decimal total = 0;
|
|
|
|
foreach (var line in request.Items)
|
|
{
|
|
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}");
|
|
|
|
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;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
await repo.ReplaceItemsAsync(id, newItems);
|
|
repo.Update(po);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
var saved = await repo.GetByIdAsync(id);
|
|
return await ToResponseWithUserAsync(saved!);
|
|
}
|
|
|
|
public async Task<ShopPOResponse> ConfirmAsync(Guid id, Guid confirmedBy, CancellationToken ct = default)
|
|
{
|
|
var po = await repo.GetByIdAsync(id)
|
|
?? throw new KeyNotFoundException("Shop PO not found");
|
|
|
|
if (po.ConfirmedAt is not null)
|
|
throw new InvalidOperationException("Shop PO is already confirmed");
|
|
|
|
if (po.Status == ShopPOStatus.Cancelled)
|
|
throw new InvalidOperationException("Cannot confirm a cancelled Shop PO");
|
|
|
|
po.ConfirmedAt = DateTime.Now;
|
|
po.ConfirmedBy = confirmedBy;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try { repo.Update(po); await uow.CommitAsync(ct); }
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
return await ToResponseWithUserAsync(po);
|
|
}
|
|
|
|
public async Task<ShopPOResponse> ConfirmAndCreateInvoiceAsync(Guid id, Guid userId, CancellationToken ct = default)
|
|
{
|
|
var po = await repo.GetByIdAsync(id)
|
|
?? throw new KeyNotFoundException("Shop PO not found");
|
|
|
|
if (po.ConfirmedAt is not null)
|
|
throw new InvalidOperationException("Shop PO is already confirmed");
|
|
|
|
if (po.Status == ShopPOStatus.Cancelled)
|
|
throw new InvalidOperationException("Cannot confirm a cancelled Shop PO");
|
|
|
|
po.ConfirmedAt = DateTime.Now;
|
|
po.ConfirmedBy = userId;
|
|
|
|
var invoice = new Models.Invoice
|
|
{
|
|
FK_ShopPOId = po.Id,
|
|
Status = InvoiceStatus.Draft,
|
|
PaymentStatus = PaymentStatus.Pending,
|
|
CreatedBy = userId,
|
|
TotalAmount = po.TotalAmount
|
|
};
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
await invoiceRepo.AddAsync(invoice);
|
|
po.InvoiceId = invoice.Id;
|
|
repo.Update(po);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
var saved = await repo.GetByIdAsync(id);
|
|
return await ToResponseWithUserAsync(saved!);
|
|
}
|
|
|
|
public async Task<ShopPOResponse> CancelAsync(Guid id, Guid cancelledBy, string reason, CancellationToken ct = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(reason))
|
|
throw new InvalidOperationException("A cancellation reason is required");
|
|
|
|
var po = await repo.GetByIdAsync(id)
|
|
?? throw new KeyNotFoundException("Shop PO not found");
|
|
|
|
if (po.Status == ShopPOStatus.Cancelled)
|
|
throw new InvalidOperationException("Shop PO is already cancelled");
|
|
|
|
if (po.Status is ShopPOStatus.PartiallyFulfilled or ShopPOStatus.Fulfilled)
|
|
throw new InvalidOperationException($"Cannot cancel a Shop PO in {po.Status} status");
|
|
|
|
po.Status = ShopPOStatus.Cancelled;
|
|
po.CancelledAt = DateTime.Now;
|
|
po.CancelledBy = cancelledBy;
|
|
po.CancelReason = reason.Trim();
|
|
|
|
await uow.BeginAsync(ct);
|
|
try { repo.Update(po); await uow.CommitAsync(ct); }
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
return await ToResponseWithUserAsync(po);
|
|
}
|
|
}
|