Files

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 &lt; 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);
}
}