246 lines
10 KiB
C#
246 lines
10 KiB
C#
using Distribution.Features.Grn;
|
|
using Distribution.Infra.UoW;
|
|
using Distribution.Models;
|
|
|
|
namespace Distribution.Features.Po;
|
|
|
|
public class PoService(
|
|
IPoRepository repo,
|
|
IGrnRepository goodsReceiptRepo,
|
|
IUnitOfWork uow)
|
|
{
|
|
private static PoItemResponse MapItem(PurchaseOrderItem i) =>
|
|
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.UnitCost, i.ReceivedQuantity, i.CancelledQuantity, i.IsCancelled);
|
|
|
|
private static PoResponse Map(Models.PurchaseOrder po) =>
|
|
new(po.Id, po.FK_SupplierId, po.Supplier?.Name ?? string.Empty, po.FK_CreatedByUserId,
|
|
po.Status, po.CancelReason, po.IsActive, po.CreatedAt,
|
|
po.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
|
|
|
public async Task<List<PoResponse>> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status) =>
|
|
(await repo.GetAllAsync(supplierId, status)).Select(Map).ToList();
|
|
|
|
public async Task<PoResponse?> GetByIdAsync(Guid id)
|
|
{
|
|
var po = await repo.GetByIdAsync(id);
|
|
if (po is null || !po.IsActive) return null;
|
|
return Map(po);
|
|
}
|
|
|
|
public async Task<PoResponse> CreateAsync(CreatePoRequest request, Guid createdByUserId, CancellationToken ct = default)
|
|
{
|
|
if (request.Items is null || request.Items.Count == 0)
|
|
throw new InvalidOperationException("A purchase order must have at least one line item.");
|
|
|
|
var po = new Models.PurchaseOrder
|
|
{
|
|
FK_SupplierId = request.SupplierId,
|
|
FK_CreatedByUserId = createdByUserId,
|
|
Status = PurchaseOrderStatus.Issued
|
|
};
|
|
|
|
foreach (var line in request.Items)
|
|
{
|
|
po.Items.Add(new PurchaseOrderItem
|
|
{
|
|
FK_ItemId = line.ItemId,
|
|
Quantity = line.Quantity,
|
|
UnitCost = line.UnitCost
|
|
});
|
|
}
|
|
|
|
await uow.BeginAsync(ct);
|
|
try { await repo.AddAsync(po); await uow.CommitAsync(ct); }
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
return (await GetByIdAsync(po.Id))!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// May only change unreceived lines (ReceivedQuantity == 0) and only while the Po is Issued.
|
|
/// Lines with a PurchaseOrderItemId update an existing line; lines without one add a new line.
|
|
/// </summary>
|
|
public async Task<PoResponse?> UpdateAsync(Guid id, UpdatePoRequest request, CancellationToken ct = default)
|
|
{
|
|
var po = await repo.GetByIdAsync(id);
|
|
if (po is null || !po.IsActive) return null;
|
|
|
|
if (po.Status != PurchaseOrderStatus.Issued)
|
|
throw new InvalidOperationException($"Cannot update a purchase order in {po.Status} status; only an Issued Po can be updated.");
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
foreach (var line in request.Items)
|
|
{
|
|
if (line.PurchaseOrderItemId.HasValue)
|
|
{
|
|
var existing = po.Items.FirstOrDefault(i => i.Id == line.PurchaseOrderItemId.Value && i.IsActive)
|
|
?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po.");
|
|
|
|
if (existing.ReceivedQuantity > 0)
|
|
throw new InvalidOperationException(
|
|
$"Line for item {existing.FK_ItemId} has already received stock and cannot be edited; use cancelPurchaseOrderLine or createPurchaseReturn instead.");
|
|
|
|
existing.FK_ItemId = line.ItemId;
|
|
existing.Quantity = line.Quantity;
|
|
existing.UnitCost = line.UnitCost;
|
|
}
|
|
else
|
|
{
|
|
await repo.AddItemAsync(new PurchaseOrderItem
|
|
{
|
|
FK_PurchaseOrderId = po.Id,
|
|
FK_ItemId = line.ItemId,
|
|
Quantity = line.Quantity,
|
|
UnitCost = line.UnitCost
|
|
});
|
|
}
|
|
}
|
|
|
|
repo.Update(po);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
return (await GetByIdAsync(id))!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Only valid once a Po is Received, or manually closed early (outstanding quantity written off).
|
|
/// </summary>
|
|
public async Task<PoResponse?> CloseAsync(Guid id, CancellationToken ct = default)
|
|
{
|
|
var po = await repo.GetByIdAsync(id);
|
|
if (po is null || !po.IsActive) return null;
|
|
|
|
if (po.Status is PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled)
|
|
throw new InvalidOperationException($"Cannot close a purchase order already in {po.Status} status.");
|
|
|
|
po.Status = PurchaseOrderStatus.Closed;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try { repo.Update(po); await uow.CommitAsync(ct); }
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
return Map(po);
|
|
}
|
|
|
|
/// <summary>Only valid before any goods receipt exists against the Po.</summary>
|
|
public async Task<PoResponse?> CancelAsync(Guid id, CancelPoRequest request, CancellationToken ct = default)
|
|
{
|
|
var po = await repo.GetByIdAsync(id);
|
|
if (po is null || !po.IsActive) return null;
|
|
|
|
if (po.Status is PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled)
|
|
throw new InvalidOperationException($"Cannot cancel a purchase order already in {po.Status} status.");
|
|
|
|
if (await goodsReceiptRepo.HasAnyForPurchaseOrderAsync(id))
|
|
throw new InvalidOperationException("Cannot cancel a purchase order that already has a goods receipt against it.");
|
|
|
|
po.Status = PurchaseOrderStatus.Cancelled;
|
|
po.CancelReason = request.Reason;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try { repo.Update(po); await uow.CommitAsync(ct); }
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
return Map(po);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cancels a single line instead of the whole Po. Only valid while ReceivedQuantity < Quantity
|
|
/// and the line isn't already fully received or cancelled. Auto-completes the Po to Received once
|
|
/// every non-cancelled line is fully received.
|
|
/// </summary>
|
|
public async Task<PoResponse?> CancelLineAsync(Guid id, Guid poItemId, CancelPoLineRequest request, CancellationToken ct = default)
|
|
{
|
|
var po = await repo.GetByIdAsync(id);
|
|
if (po is null || !po.IsActive) return null;
|
|
|
|
var line = po.Items.FirstOrDefault(i => i.Id == poItemId && i.IsActive)
|
|
?? throw new InvalidOperationException("Purchase order line not found on this Po.");
|
|
|
|
if (line.IsCancelled)
|
|
throw new InvalidOperationException("This line is already cancelled.");
|
|
|
|
if (line.ReceivedQuantity >= line.Quantity)
|
|
throw new InvalidOperationException("This line is already fully received and cannot be cancelled.");
|
|
|
|
line.IsCancelled = true;
|
|
line.CancelledQuantity = line.Quantity - line.ReceivedQuantity;
|
|
|
|
var activeLines = po.Items.Where(i => i.IsActive).ToList();
|
|
if (activeLines.Where(i => !i.IsCancelled).All(i => i.ReceivedQuantity >= i.Quantity)
|
|
&& activeLines.Any(i => !i.IsCancelled))
|
|
{
|
|
po.Status = PurchaseOrderStatus.Received;
|
|
}
|
|
|
|
await uow.BeginAsync(ct);
|
|
try { repo.Update(po); await uow.CommitAsync(ct); }
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
return Map(po);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Minimal Po-vs-Grn reconciliation (matchInvoiceToPo). Full 3-way match against an Invoice
|
|
/// entity activates once Finance (step 19) exists; this only detects and reports, never adjusts.
|
|
/// </summary>
|
|
public async Task<InvoiceMatchResult?> MatchInvoiceToPoAsync(Guid poId, Guid? invoiceId, Guid? grnId)
|
|
{
|
|
var po = await repo.GetByIdAsync(poId);
|
|
if (po is null || !po.IsActive) return null;
|
|
|
|
var receipts = grnId.HasValue
|
|
? (await goodsReceiptRepo.GetByPurchaseOrderIdAsync(poId)).Where(g => g.Id == grnId.Value).ToList()
|
|
: await goodsReceiptRepo.GetByPurchaseOrderIdAsync(poId);
|
|
|
|
var acceptedByItem = receipts
|
|
.SelectMany(g => g.Items.Where(i => i.IsActive))
|
|
.GroupBy(i => i.PurchaseOrderItem.FK_ItemId)
|
|
.ToDictionary(g => g.Key, g => g.Sum(i => i.AcceptedQuantity));
|
|
|
|
var lines = po.Items.Where(i => i.IsActive).Select(i =>
|
|
{
|
|
acceptedByItem.TryGetValue(i.FK_ItemId, out var accepted);
|
|
return new InvoiceMatchLineResult(i.Id, i.FK_ItemId, i.Quantity, accepted, i.UnitCost, accepted == i.Quantity);
|
|
}).ToList();
|
|
|
|
var isMatched = lines.All(l => l.QuantityMatches);
|
|
|
|
return new InvoiceMatchResult(
|
|
poId, grnId, invoiceId, isMatched, lines,
|
|
"Compares PurchaseOrderItem.Quantity against GoodsReceiptItem.AcceptedQuantity only; " +
|
|
"invoice line comparison activates once Finance's Invoice entity (step 19) exists.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records a Po/Grn(/invoice) variance and its resolution. Never silently adjusts anything except
|
|
/// when the resolution explicitly requests a retroactive UnitCost adjustment on a named line.
|
|
/// </summary>
|
|
public async Task<InvoiceVarianceResolutionResponse?> ResolveInvoiceVarianceAsync(Guid poId, ResolveInvoiceVarianceRequest request, CancellationToken ct = default)
|
|
{
|
|
var po = await repo.GetByIdAsync(poId);
|
|
if (po is null || !po.IsActive) return null;
|
|
|
|
if (request.AdjustedUnitCost.HasValue)
|
|
{
|
|
if (!request.PurchaseOrderItemId.HasValue)
|
|
throw new InvalidOperationException("PurchaseOrderItemId is required when adjusting UnitCost.");
|
|
|
|
var line = po.Items.FirstOrDefault(i => i.Id == request.PurchaseOrderItemId.Value && i.IsActive)
|
|
?? throw new InvalidOperationException("Purchase order line not found on this Po.");
|
|
|
|
line.UnitCost = request.AdjustedUnitCost.Value;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try { repo.Update(po); await uow.CommitAsync(ct); }
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
}
|
|
|
|
return new InvoiceVarianceResolutionResponse(poId, request.Variance, request.Resolution, DateTime.Now);
|
|
}
|
|
}
|