From 095d35e1b87bc5eb070e7d901acbf8f03b927bd0 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Mon, 24 Aug 2026 16:52:12 +0530 Subject: [PATCH] Add supplier evaluation and block delete when referenced by a purchase order --- .../Features/Supplier/SupplierController.cs | 15 +++- .../Features/Supplier/SupplierService.cs | 81 ++++++++++++++++++- 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/Distribution/Features/Supplier/SupplierController.cs b/Distribution/Features/Supplier/SupplierController.cs index f1c83f9..ad950ea 100644 --- a/Distribution/Features/Supplier/SupplierController.cs +++ b/Distribution/Features/Supplier/SupplierController.cs @@ -36,7 +36,18 @@ public class SupplierController(SupplierService service) : ControllerBase [HttpDelete("{id:guid}")] public async Task Delete(Guid id, CancellationToken ct) { - var deleted = await service.DeleteAsync(id, ct); - return deleted ? NoContent() : NotFound(); + try + { + var deleted = await service.DeleteAsync(id, ct); + return deleted ? NoContent() : NotFound(); + } + catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); } + } + + [HttpPost("{id:guid}/evaluate")] + public async Task Evaluate(Guid id, CancellationToken ct) + { + var result = await service.EvaluateAsync(id, ct); + return result is null ? NotFound() : Ok(result); } } diff --git a/Distribution/Features/Supplier/SupplierService.cs b/Distribution/Features/Supplier/SupplierService.cs index 27013ec..4b4ce43 100644 --- a/Distribution/Features/Supplier/SupplierService.cs +++ b/Distribution/Features/Supplier/SupplierService.cs @@ -1,8 +1,14 @@ +using Distribution.Features.Grn; +using Distribution.Features.Po; using Distribution.Infra.UoW; namespace Distribution.Features.Supplier; -public class SupplierService(ISupplierRepository repo, IUnitOfWork uow) +public class SupplierService( + ISupplierRepository repo, + IPoRepository purchaseOrderRepo, + IGrnRepository goodsReceiptRepo, + IUnitOfWork uow) { private static SupplierResponse Map(Models.Supplier s) => new(s.Id, s.Name, s.ContactInfo, s.PerformanceScore, s.IsActive, s.CreatedAt); @@ -52,10 +58,83 @@ public class SupplierService(ISupplierRepository repo, IUnitOfWork uow) var supplier = await repo.GetByIdAsync(id); if (supplier is null || !supplier.IsActive) return false; + if (await purchaseOrderRepo.HasActiveForSupplierAsync(id)) + throw new InvalidOperationException( + "Cannot delete a supplier that is referenced by an active purchase order."); + await uow.BeginAsync(ct); try { repo.Remove(supplier); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return true; } + + /// + /// The only writer of Supplier.PerformanceScore. Computes a simple 0-100 score from this + /// supplier's own purchase-order history: + /// - lineFulfillmentRatio: received (non-cancelled, fully-received) Po lines / all non-active + /// Po lines seen (received + cancelled) - how reliably the supplier fulfils what it commits to. + /// - acceptanceRatio: AcceptedQuantity / (AcceptedQuantity + RejectedQuantity) across every + /// GoodsReceipt against this supplier's Pos - how much of what arrives is usable. + /// score = round(average(lineFulfillmentRatio, acceptanceRatio) * 100, 2). + /// Defaults to 50 (neutral) when there is no history yet, rather than hardcoding a fixed score. + /// + public async Task EvaluateAsync(Guid id, CancellationToken ct = default) + { + var supplier = await repo.GetByIdAsync(id); + if (supplier is null || !supplier.IsActive) return null; + + var purchaseOrders = await purchaseOrderRepo.GetBySupplierAsync(id); + + var receivedLines = 0; + var cancelledLines = 0; + foreach (var po in purchaseOrders) + { + foreach (var item in po.Items.Where(i => i.IsActive)) + { + if (item.IsCancelled) cancelledLines++; + else if (item.ReceivedQuantity >= item.Quantity) receivedLines++; + } + } + + var totalAccepted = 0; + var totalRejected = 0; + foreach (var po in purchaseOrders) + { + foreach (var receipt in await goodsReceiptRepo.GetByPurchaseOrderIdAsync(po.Id)) + { + foreach (var item in receipt.Items.Where(i => i.IsActive)) + { + totalAccepted += item.AcceptedQuantity; + totalRejected += item.RejectedQuantity; + } + } + } + + var lineFulfillmentRatio = receivedLines + cancelledLines > 0 + ? (decimal)receivedLines / (receivedLines + cancelledLines) + : (decimal?)null; + + var acceptanceRatio = totalAccepted + totalRejected > 0 + ? (decimal)totalAccepted / (totalAccepted + totalRejected) + : (decimal?)null; + + decimal score; + if (lineFulfillmentRatio.HasValue && acceptanceRatio.HasValue) + score = Math.Round((lineFulfillmentRatio.Value + acceptanceRatio.Value) / 2m * 100m, 2); + else if (lineFulfillmentRatio.HasValue) + score = Math.Round(lineFulfillmentRatio.Value * 100m, 2); + else if (acceptanceRatio.HasValue) + score = Math.Round(acceptanceRatio.Value * 100m, 2); + else + score = 50m; + + supplier.PerformanceScore = score; + + await uow.BeginAsync(ct); + try { repo.Update(supplier); await uow.CommitAsync(ct); } + catch { await uow.RollbackAsync(ct); throw; } + + return Map(supplier); + } }