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> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status) => (await repo.GetAllAsync(supplierId, status)).Select(Map).ToList(); public async Task GetByIdAsync(Guid id) { var po = await repo.GetByIdAsync(id); if (po is null || !po.IsActive) return null; return Map(po); } public async Task 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))!; } /// /// 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. /// public async Task 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))!; } /// /// Only valid once a Po is Received, or manually closed early (outstanding quantity written off). /// public async Task 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); } /// Only valid before any goods receipt exists against the Po. public async Task 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); } /// /// 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. /// public async Task 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); } /// /// 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. /// public async Task 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."); } /// /// 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. /// public async Task 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); } }