feat: Implement procurement services and number sequence management
- Added INumberSequenceService interface for generating document numbers. - Created NumberSequenceService to handle atomic document number issuance. - Introduced IPurchaseOrderService interface and implemented PurchaseOrderService for managing purchase orders. - Added IRequisitionService interface and implemented RequisitionService for handling requisitions. - Created IRfqService interface and implemented RfqService for managing RFQs and vendor quotations. - Defined necessary DTOs and domain entities for procurement processes. - Ensured proper validation and error handling across services.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Issues gap-controlled, per-year document numbers (FR-X-03). Call inside the
|
||||
/// document's UoW transaction so the reserved number rolls back with the document
|
||||
/// on failure.
|
||||
/// </summary>
|
||||
public interface INumberSequenceService
|
||||
{
|
||||
/// <summary>
|
||||
/// Reserve and return the next number for <paramref name="docType"/> in the
|
||||
/// current year, formatted e.g. <c>PO-2026-00042</c>.
|
||||
/// </summary>
|
||||
Task<string> NextAsync(string docType, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Purchase-order business logic (docs/11 §3.3; FR-PROC-03..07).</summary>
|
||||
public interface IPurchaseOrderService
|
||||
{
|
||||
Task<PagedResponse<PurchaseOrderSummaryDto>> ListAsync(
|
||||
PageQuery query, PurchaseOrderStatus? status, long? vendorId, CancellationToken ct = default);
|
||||
|
||||
Task<ETagged<PurchaseOrderDto>?> GetAsync(long poId, CancellationToken ct = default);
|
||||
Task<ETagged<PurchaseOrderDto>> CreateAsync(CreatePurchaseOrderRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<PurchaseOrderDto>> UpdateAsync(long poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task<PurchaseOrderDto> ApproveAsync(long poId, CancellationToken ct = default);
|
||||
Task<PurchaseOrderDto> CancelAsync(long poId, string? reason, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Purchase-requisition business logic (docs/11 §3.1).</summary>
|
||||
public interface IRequisitionService
|
||||
{
|
||||
Task<PagedResponse<RequisitionSummaryDto>> ListAsync(PageQuery query, CancellationToken ct = default);
|
||||
Task<RequisitionDto?> GetAsync(long requisitionId, CancellationToken ct = default);
|
||||
Task<RequisitionDto> CreateAsync(CreateRequisitionRequest request, CancellationToken ct = default);
|
||||
Task<RequisitionDto> SubmitAsync(long requisitionId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using ERPCore.Dtos.Procurement;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>RFQ & vendor-quotation business logic (docs/11 §3.2).</summary>
|
||||
public interface IRfqService
|
||||
{
|
||||
Task<RfqDto?> GetAsync(long rfqId, CancellationToken ct = default);
|
||||
Task<RfqDto> CreateAsync(CreateRfqRequest request, CancellationToken ct = default);
|
||||
Task<VendorQuotationDto> AddQuotationAsync(long rfqId, CreateQuotationRequest request, CancellationToken ct = default);
|
||||
Task<RfqComparisonDto> GetComparisonAsync(long rfqId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Data;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Atomic document-number issuer. Uses a single <c>INSERT … ON CONFLICT … DO UPDATE
|
||||
/// … RETURNING</c> so concurrent issues for the same (docType, year) cannot get the
|
||||
/// same number (the row is locked for the duration of the upsert). Runs as a direct
|
||||
/// ADO.NET command enlisted in the DbContext's current transaction so it commits or
|
||||
/// rolls back with the document (FR-X-03). EF's <c>SqlQuery</c> is avoided here
|
||||
/// because it wraps the statement in a subquery, which PostgreSQL disallows for a
|
||||
/// data-modifying statement.
|
||||
/// </summary>
|
||||
public sealed class NumberSequenceService : INumberSequenceService
|
||||
{
|
||||
private readonly ErpDbContext _db;
|
||||
|
||||
public NumberSequenceService(ErpDbContext db) => _db = db;
|
||||
|
||||
public async Task<string> NextAsync(string docType, CancellationToken ct = default)
|
||||
{
|
||||
var year = DateTime.UtcNow.Year;
|
||||
|
||||
var conn = _db.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open)
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.Transaction = _db.Database.CurrentTransaction?.GetDbTransaction();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO number_sequences (doc_type, year, last_number)
|
||||
VALUES (@docType, @year, 1)
|
||||
ON CONFLICT (doc_type, year)
|
||||
DO UPDATE SET last_number = number_sequences.last_number + 1
|
||||
RETURNING last_number;
|
||||
""";
|
||||
AddParam(cmd, "docType", docType);
|
||||
AddParam(cmd, "year", year);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(ct)
|
||||
?? throw new InvalidOperationException($"Failed to issue a document number for '{docType}'.");
|
||||
var next = Convert.ToInt64(result);
|
||||
|
||||
return $"{docType}-{year}-{next:D5}";
|
||||
}
|
||||
|
||||
private static void AddParam(IDbCommand cmd, string name, object value)
|
||||
{
|
||||
var p = cmd.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = value;
|
||||
cmd.Parameters.Add(p);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Purchase-order service. Phase 1 auto-approves on creation (FR-PROC-04),
|
||||
/// PO is freely editable while open (FR-PROC-05), totals are computed server-side
|
||||
/// (02-SECURITY C.2), and cancel is blocked once any receipt exists.
|
||||
/// </summary>
|
||||
public sealed class PurchaseOrderService : IPurchaseOrderService
|
||||
{
|
||||
private const string BaseCurrency = "LKR";
|
||||
|
||||
private readonly IRepository<PurchaseOrder> _pos;
|
||||
private readonly IRepository<Vendor> _vendors;
|
||||
private readonly IRepository<Requisition> _requisitions;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public PurchaseOrderService(
|
||||
IRepository<PurchaseOrder> pos, IRepository<Vendor> vendors, IRepository<Requisition> requisitions,
|
||||
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_pos = pos;
|
||||
_vendors = vendors;
|
||||
_requisitions = requisitions;
|
||||
_items = items;
|
||||
_uoms = uoms;
|
||||
_warehouses = warehouses;
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<PurchaseOrderSummaryDto>> ListAsync(
|
||||
PageQuery query, PurchaseOrderStatus? status, long? vendorId, CancellationToken ct = default)
|
||||
{
|
||||
var q = _pos.Query().AsNoTracking().Include(p => p.Lines).AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(p => EF.Functions.ILike(p.DocNo, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(p => p.Status == status);
|
||||
if (vendorId is not null) q = q.Where(p => p.VendorId == vendorId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(p => p.PoId)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var summaries = rows.Select(p => new PurchaseOrderSummaryDto(
|
||||
p.PoId, p.DocNo, p.VendorId, p.Status, p.ApprovalRequired, p.CreatedAt, ComputeTotals(p.Lines))).ToList();
|
||||
|
||||
return PagedResponse<PurchaseOrderSummaryDto>.Create(summaries, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ETagged<PurchaseOrderDto>?> GetAsync(long poId, CancellationToken ct = default)
|
||||
{
|
||||
var po = await _pos.Query().AsNoTracking()
|
||||
.Include(p => p.Lines)
|
||||
.FirstOrDefaultAsync(p => p.PoId == poId, ct);
|
||||
return po is null ? null : new ETagged<PurchaseOrderDto>(Map(po), po.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<PurchaseOrderDto>> CreateAsync(CreatePurchaseOrderRequest request, CancellationToken ct = default)
|
||||
{
|
||||
await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct);
|
||||
var actor = _currentUser.AuditUserId;
|
||||
|
||||
var po = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var docNo = await _numbers.NextAsync(DocumentTypes.PurchaseOrder, token);
|
||||
var entity = new PurchaseOrder
|
||||
{
|
||||
DocNo = docNo,
|
||||
VendorId = request.VendorId,
|
||||
RequisitionId = request.RequisitionId,
|
||||
// Phase 1: approvalRequired defaults off → auto-approved on creation (FR-PROC-04).
|
||||
ApprovalRequired = false,
|
||||
Status = PurchaseOrderStatus.Approved,
|
||||
CreatedBy = actor,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = request.Lines.Select(ToLine).ToList()
|
||||
};
|
||||
await _pos.AddAsync(entity, token);
|
||||
return entity;
|
||||
}, ct);
|
||||
|
||||
return new ETagged<PurchaseOrderDto>(Map(po), po.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<PurchaseOrderDto>> UpdateAsync(
|
||||
long poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var po = await _pos.Query()
|
||||
.Include(p => p.Lines)
|
||||
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
|
||||
?? throw new NotFoundException($"Purchase order {poId} was not found.");
|
||||
|
||||
if (po.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The purchase order was modified by another request.", 412);
|
||||
|
||||
if (!IsEditable(po.Status))
|
||||
throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be edited.", 409);
|
||||
|
||||
await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct);
|
||||
|
||||
po.VendorId = request.VendorId;
|
||||
po.RequisitionId = request.RequisitionId;
|
||||
po.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Full line replacement (Phase 1: no receipts yet, so qtyReceived is 0 on every line).
|
||||
po.Lines.Clear();
|
||||
foreach (var input in request.Lines)
|
||||
po.Lines.Add(ToLine(input));
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The purchase order was modified by another request.", 412);
|
||||
}
|
||||
|
||||
return new ETagged<PurchaseOrderDto>(Map(po), po.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<PurchaseOrderDto> ApproveAsync(long poId, CancellationToken ct = default)
|
||||
{
|
||||
var po = await _pos.Query()
|
||||
.Include(p => p.Lines)
|
||||
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
|
||||
?? throw new NotFoundException($"Purchase order {poId} was not found.");
|
||||
|
||||
// Phase 1 no-op: POs are already Approved on creation. Kept for the future
|
||||
// approval workflow (PendingApproval → Approved) — FR-PROC-04.
|
||||
if (po.Status == PurchaseOrderStatus.PendingApproval)
|
||||
{
|
||||
po.Status = PurchaseOrderStatus.Approved;
|
||||
po.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
return Map(po);
|
||||
}
|
||||
|
||||
public async Task<PurchaseOrderDto> CancelAsync(long poId, string? reason, CancellationToken ct = default)
|
||||
{
|
||||
var po = await _pos.Query()
|
||||
.Include(p => p.Lines)
|
||||
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
|
||||
?? throw new NotFoundException($"Purchase order {poId} was not found.");
|
||||
|
||||
if (po.Lines.Any(l => l.QtyReceived > 0))
|
||||
throw new ConflictException($"Purchase order {poId} cannot be cancelled because goods have been received against it.");
|
||||
|
||||
if (po.Status != PurchaseOrderStatus.Cancelled)
|
||||
{
|
||||
po.Status = PurchaseOrderStatus.Cancelled;
|
||||
po.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
return Map(po);
|
||||
}
|
||||
|
||||
private static bool IsEditable(PurchaseOrderStatus status) => status is not (
|
||||
PurchaseOrderStatus.FullyReceived or PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled);
|
||||
|
||||
private static PoLine ToLine(CreatePoLineInput l) => new()
|
||||
{
|
||||
ItemId = l.ItemId,
|
||||
UomId = l.UomId,
|
||||
WarehouseId = l.WarehouseId,
|
||||
Qty = l.Qty,
|
||||
UnitPrice = l.UnitPrice,
|
||||
Tax = l.Tax,
|
||||
QtyReceived = 0
|
||||
};
|
||||
|
||||
private static PoTotalsDto ComputeTotals(IEnumerable<PoLine> lines)
|
||||
{
|
||||
decimal sub = 0, tax = 0;
|
||||
foreach (var l in lines)
|
||||
{
|
||||
var net = l.Qty * l.UnitPrice;
|
||||
sub += net;
|
||||
tax += net * l.Tax;
|
||||
}
|
||||
sub = Math.Round(sub, 2, MidpointRounding.AwayFromZero);
|
||||
tax = Math.Round(tax, 2, MidpointRounding.AwayFromZero);
|
||||
return new PoTotalsDto(sub, tax, sub + tax, BaseCurrency);
|
||||
}
|
||||
|
||||
private async Task ValidateReferencesAsync(
|
||||
long vendorId, long? requisitionId, IReadOnlyCollection<CreatePoLineInput> lines, CancellationToken ct)
|
||||
{
|
||||
var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == vendorId, ct);
|
||||
if (vendor is null)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} does not exist.", 422);
|
||||
if (vendor.Status != EntityStatus.Active)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} is inactive.", 422);
|
||||
|
||||
if (requisitionId is not null
|
||||
&& !await _requisitions.Query().AnyAsync(r => r.RequisitionId == requisitionId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Requisition {requisitionId} does not exist.", 422);
|
||||
|
||||
await EnsureAllExistAsync(_items.Query().Select(i => i.ItemId), lines.Select(l => l.ItemId), "Item", ct);
|
||||
await EnsureAllExistAsync(_uoms.Query().Select(u => u.UomId), lines.Select(l => l.UomId), "UOM", ct);
|
||||
await EnsureAllExistAsync(_warehouses.Query().Select(w => w.WarehouseId), lines.Select(l => l.WarehouseId), "Warehouse", ct);
|
||||
}
|
||||
|
||||
private static async Task EnsureAllExistAsync(
|
||||
IQueryable<long> keySource, IEnumerable<long> requested, string label, CancellationToken ct)
|
||||
{
|
||||
var ids = requested.Distinct().ToList();
|
||||
var found = await keySource.Where(k => ids.Contains(k)).ToListAsync(ct);
|
||||
var missing = ids.Except(found).ToList();
|
||||
if (missing.Count > 0)
|
||||
throw new DomainException(ErrorCodes.Validation, $"{label}(s) not found: {string.Join(", ", missing)}.", 422);
|
||||
}
|
||||
|
||||
private static PurchaseOrderDto Map(PurchaseOrder p) => new(
|
||||
p.PoId, p.DocNo, p.VendorId, p.RequisitionId, p.Status, p.ApprovalRequired,
|
||||
p.CreatedBy, p.CreatedAt, p.UpdatedAt, ComputeTotals(p.Lines),
|
||||
p.Lines.OrderBy(l => l.PoLineId).Select(l => new PoLineDto(
|
||||
l.PoLineId, l.ItemId, l.UomId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived)).ToList());
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
public sealed class RequisitionService : IRequisitionService
|
||||
{
|
||||
private readonly IRepository<Requisition> _requisitions;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public RequisitionService(
|
||||
IRepository<Requisition> requisitions, IRepository<Item> items,
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_requisitions = requisitions;
|
||||
_items = items;
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<RequisitionSummaryDto>> ListAsync(PageQuery query, CancellationToken ct = default)
|
||||
{
|
||||
var q = _requisitions.Query().AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%"));
|
||||
}
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(r => r.RequisitionId)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(r => new RequisitionSummaryDto(r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<RequisitionSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<RequisitionDto?> GetAsync(long requisitionId, CancellationToken ct = default)
|
||||
{
|
||||
var req = await _requisitions.Query().AsNoTracking()
|
||||
.Include(r => r.Lines)
|
||||
.FirstOrDefaultAsync(r => r.RequisitionId == requisitionId, ct);
|
||||
return req is null ? null : Map(req);
|
||||
}
|
||||
|
||||
public async Task<RequisitionDto> CreateAsync(CreateRequisitionRequest request, CancellationToken ct = default)
|
||||
{
|
||||
await EnsureItemsExistAsync(request.Lines.Select(l => l.ItemId), ct);
|
||||
var actor = _currentUser.AuditUserId;
|
||||
|
||||
var req = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var docNo = await _numbers.NextAsync(DocumentTypes.Requisition, token);
|
||||
var entity = new Requisition
|
||||
{
|
||||
DocNo = docNo,
|
||||
RequestedBy = actor,
|
||||
Status = RequisitionStatus.Draft,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = request.Lines.Select(l => new RequisitionLine
|
||||
{
|
||||
ItemId = l.ItemId,
|
||||
Qty = l.Qty,
|
||||
RequiredBy = l.RequiredBy
|
||||
}).ToList()
|
||||
};
|
||||
await _requisitions.AddAsync(entity, token);
|
||||
return entity;
|
||||
}, ct);
|
||||
|
||||
return Map(req);
|
||||
}
|
||||
|
||||
public async Task<RequisitionDto> SubmitAsync(long requisitionId, CancellationToken ct = default)
|
||||
{
|
||||
var req = await _requisitions.Query()
|
||||
.Include(r => r.Lines)
|
||||
.FirstOrDefaultAsync(r => r.RequisitionId == requisitionId, ct)
|
||||
?? throw new NotFoundException($"Requisition {requisitionId} was not found.");
|
||||
|
||||
if (req.Status != RequisitionStatus.Submitted)
|
||||
{
|
||||
req.Status = RequisitionStatus.Submitted;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
return Map(req);
|
||||
}
|
||||
|
||||
private async Task EnsureItemsExistAsync(IEnumerable<long> itemIds, CancellationToken ct)
|
||||
{
|
||||
var ids = itemIds.Distinct().ToList();
|
||||
var found = await _items.Query().AsNoTracking()
|
||||
.Where(i => ids.Contains(i.ItemId)).Select(i => i.ItemId).ToListAsync(ct);
|
||||
var missing = ids.Except(found).ToList();
|
||||
if (missing.Count > 0)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Item(s) not found: {string.Join(", ", missing)}.", 422);
|
||||
}
|
||||
|
||||
private static RequisitionDto Map(Requisition r) => new(
|
||||
r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.CreatedAt,
|
||||
r.Lines.OrderBy(l => l.ReqLineId)
|
||||
.Select(l => new RequisitionLineDto(l.ReqLineId, l.ItemId, l.Qty, l.RequiredBy))
|
||||
.ToList());
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
public sealed class RfqService : IRfqService
|
||||
{
|
||||
private readonly IRepository<Rfq> _rfqs;
|
||||
private readonly IRepository<Requisition> _requisitions;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Vendor> _vendors;
|
||||
private readonly IRepository<VendorQuotation> _quotations;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public RfqService(
|
||||
IRepository<Rfq> rfqs, IRepository<Requisition> requisitions, IRepository<Item> items,
|
||||
IRepository<Vendor> vendors, IRepository<VendorQuotation> quotations,
|
||||
INumberSequenceService numbers, IUnitOfWork uow)
|
||||
{
|
||||
_rfqs = rfqs;
|
||||
_requisitions = requisitions;
|
||||
_items = items;
|
||||
_vendors = vendors;
|
||||
_quotations = quotations;
|
||||
_numbers = numbers;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<RfqDto?> GetAsync(long rfqId, CancellationToken ct = default)
|
||||
{
|
||||
var rfq = await _rfqs.Query().AsNoTracking()
|
||||
.Include(r => r.Lines)
|
||||
.FirstOrDefaultAsync(r => r.RfqId == rfqId, ct);
|
||||
return rfq is null ? null : MapRfq(rfq);
|
||||
}
|
||||
|
||||
public async Task<RfqDto> CreateAsync(CreateRfqRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _requisitions.Query().AnyAsync(r => r.RequisitionId == request.RequisitionId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Requisition {request.RequisitionId} does not exist.", 422);
|
||||
|
||||
await EnsureItemsExistAsync(request.Lines.Select(l => l.ItemId), ct);
|
||||
await EnsureVendorsExistAsync(request.VendorIds, ct);
|
||||
|
||||
var rfq = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var docNo = await _numbers.NextAsync(DocumentTypes.Rfq, token);
|
||||
var entity = new Rfq
|
||||
{
|
||||
DocNo = docNo,
|
||||
RequisitionId = request.RequisitionId,
|
||||
Status = RfqStatus.Open,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = request.Lines.Select(l => new RfqLine { ItemId = l.ItemId, Qty = l.Qty }).ToList()
|
||||
};
|
||||
await _rfqs.AddAsync(entity, token);
|
||||
return entity;
|
||||
}, ct);
|
||||
|
||||
return MapRfq(rfq);
|
||||
}
|
||||
|
||||
public async Task<VendorQuotationDto> AddQuotationAsync(long rfqId, CreateQuotationRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var rfq = await _rfqs.Query().AsNoTracking()
|
||||
.Include(r => r.Lines)
|
||||
.FirstOrDefaultAsync(r => r.RfqId == rfqId, ct)
|
||||
?? throw new NotFoundException($"RFQ {rfqId} was not found.");
|
||||
|
||||
if (!await _vendors.Query().AnyAsync(v => v.VendorId == request.VendorId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
|
||||
|
||||
var rfqItemIds = rfq.Lines.Select(l => l.ItemId).ToHashSet();
|
||||
var offLine = request.Lines.Select(l => l.ItemId).FirstOrDefault(id => !rfqItemIds.Contains(id));
|
||||
if (offLine != 0)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Item {offLine} is not part of RFQ {rfqId}.", 422);
|
||||
|
||||
if (await _quotations.Query().AnyAsync(q => q.RfqId == rfqId && q.VendorId == request.VendorId, ct))
|
||||
throw new ConflictException($"Vendor {request.VendorId} has already quoted RFQ {rfqId}.");
|
||||
|
||||
var quotation = new VendorQuotation
|
||||
{
|
||||
RfqId = rfqId,
|
||||
VendorId = request.VendorId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = request.Lines.Select(l => new VendorQuotationLine
|
||||
{
|
||||
ItemId = l.ItemId,
|
||||
UnitPrice = l.UnitPrice,
|
||||
LeadDays = l.LeadDays
|
||||
}).ToList()
|
||||
};
|
||||
await _quotations.AddAsync(quotation, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new VendorQuotationDto(
|
||||
quotation.QuotationId, quotation.RfqId, quotation.VendorId,
|
||||
quotation.Lines.Select(l => new QuotationLineDto(l.ItemId, l.UnitPrice, l.LeadDays)).ToList());
|
||||
}
|
||||
|
||||
public async Task<RfqComparisonDto> GetComparisonAsync(long rfqId, CancellationToken ct = default)
|
||||
{
|
||||
var rfq = await _rfqs.Query().AsNoTracking()
|
||||
.Include(r => r.Lines)
|
||||
.Include(r => r.Quotations).ThenInclude(q => q.Lines)
|
||||
.FirstOrDefaultAsync(r => r.RfqId == rfqId, ct)
|
||||
?? throw new NotFoundException($"RFQ {rfqId} was not found.");
|
||||
|
||||
var vendorIds = rfq.Quotations.Select(q => q.VendorId).Distinct().OrderBy(v => v).ToList();
|
||||
|
||||
var rows = rfq.Lines.OrderBy(l => l.RfqLineId).Select(line =>
|
||||
{
|
||||
var cells = rfq.Quotations
|
||||
.Select(q => new { q.VendorId, q.QuotationId, Line = q.Lines.FirstOrDefault(ql => ql.ItemId == line.ItemId) })
|
||||
.Where(x => x.Line is not null)
|
||||
.OrderBy(x => x.VendorId)
|
||||
.Select(x => new RfqComparisonCellDto(x.VendorId, x.QuotationId, x.Line!.UnitPrice, x.Line!.LeadDays))
|
||||
.ToList();
|
||||
return new RfqComparisonRowDto(line.ItemId, line.Qty, cells);
|
||||
}).ToList();
|
||||
|
||||
return new RfqComparisonDto(rfqId, vendorIds, rows);
|
||||
}
|
||||
|
||||
private async Task EnsureItemsExistAsync(IEnumerable<long> itemIds, CancellationToken ct)
|
||||
{
|
||||
var ids = itemIds.Distinct().ToList();
|
||||
var found = await _items.Query().AsNoTracking()
|
||||
.Where(i => ids.Contains(i.ItemId)).Select(i => i.ItemId).ToListAsync(ct);
|
||||
var missing = ids.Except(found).ToList();
|
||||
if (missing.Count > 0)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Item(s) not found: {string.Join(", ", missing)}.", 422);
|
||||
}
|
||||
|
||||
private async Task EnsureVendorsExistAsync(IEnumerable<long> vendorIds, CancellationToken ct)
|
||||
{
|
||||
var ids = vendorIds.Distinct().ToList();
|
||||
if (ids.Count == 0) return;
|
||||
var found = await _vendors.Query().AsNoTracking()
|
||||
.Where(v => ids.Contains(v.VendorId)).Select(v => v.VendorId).ToListAsync(ct);
|
||||
var missing = ids.Except(found).ToList();
|
||||
if (missing.Count > 0)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Vendor(s) not found: {string.Join(", ", missing)}.", 422);
|
||||
}
|
||||
|
||||
private static RfqDto MapRfq(Rfq r) => new(
|
||||
r.RfqId, r.DocNo, r.RequisitionId, r.Status,
|
||||
r.Lines.OrderBy(l => l.RfqLineId).Select(l => new RfqLineDto(l.RfqLineId, l.ItemId, l.Qty)).ToList());
|
||||
}
|
||||
Reference in New Issue
Block a user