diff --git a/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs b/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs new file mode 100644 index 0000000..8b9b4ee --- /dev/null +++ b/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs @@ -0,0 +1,170 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SolutionErp.Application.Common.Models; +using SolutionErp.Application.PurchaseEvaluations; +using SolutionErp.Application.PurchaseEvaluations.Dtos; +using SolutionErp.Domain.Contracts; +using SolutionErp.Domain.PurchaseEvaluations; + +namespace SolutionErp.Api.Controllers; + +[ApiController] +[Route("api/purchase-evaluations")] +[Authorize] +public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase +{ + [HttpGet] + public async Task>> List( + [FromQuery] int page = 1, [FromQuery] int pageSize = 20, + [FromQuery] string? search = null, [FromQuery] bool sortDesc = true, + [FromQuery] PurchaseEvaluationType? type = null, + [FromQuery] PurchaseEvaluationPhase? phase = null, + [FromQuery] Guid? projectId = null, + CancellationToken ct = default) + => Ok(await mediator.Send(new ListPurchaseEvaluationsQuery(type, phase, projectId) + { Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct)); + + [HttpGet("inbox")] + public async Task>> Inbox( + [FromQuery] PurchaseEvaluationType? type = null, CancellationToken ct = default) + => Ok(await mediator.Send(new GetMyPurchaseEvaluationInboxQuery(type), ct)); + + [HttpGet("{id:guid}")] + public async Task> Get(Guid id, CancellationToken ct) + => Ok(await mediator.Send(new GetPurchaseEvaluationQuery(id), ct)); + + [HttpPost] + public async Task> Create([FromBody] CreatePurchaseEvaluationCommand cmd, CancellationToken ct) + { + var id = await mediator.Send(cmd, ct); + return CreatedAtAction(nameof(Get), new { id }, new { id }); + } + + [HttpPut("{id:guid}")] + public async Task Update(Guid id, [FromBody] UpdatePurchaseEvaluationDraftCommand cmd, CancellationToken ct) + { + if (id != cmd.Id) return BadRequest(new { detail = "ID không khớp" }); + await mediator.Send(cmd, ct); + return NoContent(); + } + + [HttpPost("{id:guid}/transitions")] + public async Task Transition(Guid id, [FromBody] TransitionPeBody body, CancellationToken ct) + { + await mediator.Send(new TransitionPurchaseEvaluationCommand(id, body.TargetPhase, body.Decision, body.Comment), ct); + return NoContent(); + } + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id, CancellationToken ct) + { + await mediator.Send(new DeletePurchaseEvaluationCommand(id), ct); + return NoContent(); + } + + // ========== Suppliers (N:M) ========== + + [HttpPost("{id:guid}/suppliers")] + public async Task> AddSupplier(Guid id, [FromBody] AddSupplierBody body, CancellationToken ct) + { + var newId = await mediator.Send(new AddPurchaseEvaluationSupplierCommand( + id, body.SupplierId, body.DisplayName, body.ContactName, body.ContactEmail, + body.ContactPhone, body.PaymentTermText, body.Note), ct); + return Ok(new { id = newId }); + } + + [HttpPut("{id:guid}/suppliers/{supplierRowId:guid}")] + public async Task UpdateSupplier(Guid id, Guid supplierRowId, [FromBody] AddSupplierBody body, CancellationToken ct) + { + await mediator.Send(new UpdatePurchaseEvaluationSupplierCommand( + id, supplierRowId, body.DisplayName, body.ContactName, body.ContactEmail, + body.ContactPhone, body.PaymentTermText, body.Note), ct); + return NoContent(); + } + + [HttpDelete("{id:guid}/suppliers/{supplierRowId:guid}")] + public async Task RemoveSupplier(Guid id, Guid supplierRowId, CancellationToken ct) + { + await mediator.Send(new RemovePurchaseEvaluationSupplierCommand(id, supplierRowId), ct); + return NoContent(); + } + + [HttpPost("{id:guid}/select-winner")] + public async Task SelectWinner(Guid id, [FromBody] SelectWinnerBody body, CancellationToken ct) + { + await mediator.Send(new SelectPurchaseEvaluationWinnerCommand(id, body.SupplierId), ct); + return NoContent(); + } + + // ========== Details (hạng mục + ngân sách) ========== + + [HttpPost("{id:guid}/details")] + public async Task> AddDetail(Guid id, [FromBody] DetailBody body, CancellationToken ct) + { + var newId = await mediator.Send(new AddPurchaseEvaluationDetailCommand( + id, body.GroupCode, body.GroupName, body.ItemCode, body.NoiDung, body.DonViTinh, + body.KhoiLuongNganSach, body.KhoiLuongThiCong, body.DonGiaNganSach, + body.ThanhTienNganSach, body.GhiChu), ct); + return Ok(new { id = newId }); + } + + [HttpPut("{id:guid}/details/{detailId:guid}")] + public async Task UpdateDetail(Guid id, Guid detailId, [FromBody] DetailBody body, CancellationToken ct) + { + await mediator.Send(new UpdatePurchaseEvaluationDetailCommand( + id, detailId, body.GroupCode, body.GroupName, body.ItemCode, body.NoiDung, body.DonViTinh, + body.KhoiLuongNganSach, body.KhoiLuongThiCong, body.DonGiaNganSach, + body.ThanhTienNganSach, body.GhiChu), ct); + return NoContent(); + } + + [HttpDelete("{id:guid}/details/{detailId:guid}")] + public async Task DeleteDetail(Guid id, Guid detailId, CancellationToken ct) + { + await mediator.Send(new DeletePurchaseEvaluationDetailCommand(id, detailId), ct); + return NoContent(); + } + + // ========== Quotes (báo giá per NCC per Detail) ========== + + [HttpPost("{id:guid}/quotes")] + public async Task> UpsertQuote(Guid id, [FromBody] QuoteBody body, CancellationToken ct) + { + var qid = await mediator.Send(new UpsertPurchaseEvaluationQuoteCommand( + id, body.PurchaseEvaluationDetailId, body.PurchaseEvaluationSupplierId, + body.BgVat, body.ChuaVat, body.ThanhTien, body.IsSelected, body.Note), ct); + return Ok(new { id = qid }); + } + + [HttpDelete("{id:guid}/quotes/{quoteId:guid}")] + public async Task DeleteQuote(Guid id, Guid quoteId, CancellationToken ct) + { + await mediator.Send(new DeletePurchaseEvaluationQuoteCommand(id, quoteId), ct); + return NoContent(); + } + + // ========== Changelogs ========== + + [HttpGet("{id:guid}/changelogs")] + public async Task> GetChangelogs(Guid id, CancellationToken ct) + => await mediator.Send(new ListPurchaseEvaluationChangelogsQuery(id), ct); +} + +public record TransitionPeBody(PurchaseEvaluationPhase TargetPhase, ApprovalDecision Decision, string? Comment); + +public record AddSupplierBody( + Guid SupplierId, + string? DisplayName, string? ContactName, string? ContactEmail, string? ContactPhone, + string? PaymentTermText, string? Note); + +public record SelectWinnerBody(Guid SupplierId); + +public record DetailBody( + string GroupCode, string GroupName, string? ItemCode, string NoiDung, string? DonViTinh, + decimal KhoiLuongNganSach, decimal KhoiLuongThiCong, decimal DonGiaNganSach, + decimal ThanhTienNganSach, string? GhiChu); + +public record QuoteBody( + Guid PurchaseEvaluationDetailId, Guid PurchaseEvaluationSupplierId, + decimal BgVat, decimal ChuaVat, decimal ThanhTien, bool IsSelected, string? Note); diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs new file mode 100644 index 0000000..00de179 --- /dev/null +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs @@ -0,0 +1,110 @@ +using SolutionErp.Domain.Contracts; +using SolutionErp.Domain.PurchaseEvaluations; + +namespace SolutionErp.Application.PurchaseEvaluations.Dtos; + +public record PurchaseEvaluationListItemDto( + Guid Id, + string? MaPhieu, + string TenGoiThau, + PurchaseEvaluationType Type, + PurchaseEvaluationPhase Phase, + Guid ProjectId, + string ProjectName, + Guid? SelectedSupplierId, + string? SelectedSupplierName, + Guid? ContractId, + DateTime? SlaDeadline, + DateTime CreatedAt); + +public record PurchaseEvaluationSupplierDto( + Guid Id, + Guid SupplierId, + string SupplierName, + string? DisplayName, + string? ContactName, + string? ContactEmail, + string? ContactPhone, + string? PaymentTermText, + string? Note, + int Order); + +public record PurchaseEvaluationQuoteDto( + Guid Id, + Guid PurchaseEvaluationDetailId, + Guid PurchaseEvaluationSupplierId, + decimal BgVat, + decimal ChuaVat, + decimal ThanhTien, + bool IsSelected, + string? Note); + +public record PurchaseEvaluationDetailDto( + Guid Id, + string GroupCode, + string GroupName, + string? ItemCode, + string NoiDung, + string? DonViTinh, + decimal KhoiLuongNganSach, + decimal KhoiLuongThiCong, + decimal DonGiaNganSach, + decimal ThanhTienNganSach, + int Order, + string? GhiChu, + List Quotes); + +public record PurchaseEvaluationApprovalDto( + Guid Id, + PurchaseEvaluationPhase FromPhase, + PurchaseEvaluationPhase ToPhase, + Guid? ApproverUserId, + string? ApproverName, + ApprovalDecision Decision, + string? Comment, + DateTime ApprovedAt); + +public record PurchaseEvaluationChangelogDto( + Guid Id, + PurchaseEvaluationEntityType EntityType, + Guid? EntityId, + ChangelogAction Action, + PurchaseEvaluationPhase? PhaseAtChange, + Guid? UserId, + string? UserName, + string? Summary, + string? FieldChangesJson, + string? ContextNote, + DateTime CreatedAt); + +public record PurchaseEvaluationWorkflowSummaryDto( + string PolicyName, + string PolicyDescription, + List ActivePhases, + List NextPhases); + +public record PurchaseEvaluationDetailBundleDto( + Guid Id, + string? MaPhieu, + PurchaseEvaluationType Type, + PurchaseEvaluationPhase Phase, + string TenGoiThau, + string? DiaDiem, + string? MoTa, + Guid ProjectId, + string ProjectName, + Guid? DepartmentId, + string? DepartmentName, + Guid? DrafterUserId, + string? DrafterName, + Guid? SelectedSupplierId, + string? SelectedSupplierName, + Guid? ContractId, + string? PaymentTerms, + DateTime? SlaDeadline, + DateTime CreatedAt, + DateTime? UpdatedAt, + List Suppliers, + List Details, + List Approvals, + PurchaseEvaluationWorkflowSummaryDto Workflow); diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationDetailFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationDetailFeatures.cs new file mode 100644 index 0000000..a86f338 --- /dev/null +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationDetailFeatures.cs @@ -0,0 +1,249 @@ +using FluentValidation; +using MediatR; +using Microsoft.EntityFrameworkCore; +using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Domain.Contracts; +using SolutionErp.Domain.PurchaseEvaluations; + +namespace SolutionErp.Application.PurchaseEvaluations; + +// ========== Detail (hạng mục + ngân sách) ========== + +public record AddPurchaseEvaluationDetailCommand( + Guid PurchaseEvaluationId, + string GroupCode, + string GroupName, + string? ItemCode, + string NoiDung, + string? DonViTinh, + decimal KhoiLuongNganSach, + decimal KhoiLuongThiCong, + decimal DonGiaNganSach, + decimal ThanhTienNganSach, + string? GhiChu) : IRequest; + +public class AddPurchaseEvaluationDetailCommandValidator : AbstractValidator +{ + public AddPurchaseEvaluationDetailCommandValidator() + { + RuleFor(x => x.PurchaseEvaluationId).NotEmpty(); + RuleFor(x => x.GroupCode).NotEmpty().MaximumLength(50); + RuleFor(x => x.GroupName).NotEmpty().MaximumLength(200); + RuleFor(x => x.NoiDung).NotEmpty().MaximumLength(500); + } +} + +public class AddPurchaseEvaluationDetailCommandHandler( + IApplicationDbContext db, + ICurrentUser currentUser) : IRequestHandler +{ + public async Task Handle(AddPurchaseEvaluationDetailCommand request, CancellationToken ct) + { + var evaluation = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct) + ?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId); + + var maxOrder = await db.PurchaseEvaluationDetails + .Where(d => d.PurchaseEvaluationId == request.PurchaseEvaluationId) + .Select(d => (int?)d.Order) + .MaxAsync(ct); + + var entity = new PurchaseEvaluationDetail + { + PurchaseEvaluationId = request.PurchaseEvaluationId, + GroupCode = request.GroupCode, + GroupName = request.GroupName, + ItemCode = request.ItemCode, + NoiDung = request.NoiDung, + DonViTinh = request.DonViTinh, + KhoiLuongNganSach = request.KhoiLuongNganSach, + KhoiLuongThiCong = request.KhoiLuongThiCong, + DonGiaNganSach = request.DonGiaNganSach, + ThanhTienNganSach = request.ThanhTienNganSach, + GhiChu = request.GhiChu, + Order = (maxOrder ?? 0) + 1, + }; + db.PurchaseEvaluationDetails.Add(entity); + + db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog + { + PurchaseEvaluationId = request.PurchaseEvaluationId, + EntityType = PurchaseEvaluationEntityType.Detail, + EntityId = entity.Id, + Action = ChangelogAction.Insert, + PhaseAtChange = evaluation.Phase, + UserId = currentUser.UserId, + Summary = $"Thêm hạng mục {request.GroupCode} — {request.NoiDung}", + }); + + await db.SaveChangesAsync(ct); + return entity.Id; + } +} + +public record UpdatePurchaseEvaluationDetailCommand( + Guid PurchaseEvaluationId, + Guid DetailId, + string GroupCode, + string GroupName, + string? ItemCode, + string NoiDung, + string? DonViTinh, + decimal KhoiLuongNganSach, + decimal KhoiLuongThiCong, + decimal DonGiaNganSach, + decimal ThanhTienNganSach, + string? GhiChu) : IRequest; + +public class UpdatePurchaseEvaluationDetailCommandHandler( + IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(UpdatePurchaseEvaluationDetailCommand request, CancellationToken ct) + { + var entity = await db.PurchaseEvaluationDetails + .FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) + ?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId); + + entity.GroupCode = request.GroupCode; + entity.GroupName = request.GroupName; + entity.ItemCode = request.ItemCode; + entity.NoiDung = request.NoiDung; + entity.DonViTinh = request.DonViTinh; + entity.KhoiLuongNganSach = request.KhoiLuongNganSach; + entity.KhoiLuongThiCong = request.KhoiLuongThiCong; + entity.DonGiaNganSach = request.DonGiaNganSach; + entity.ThanhTienNganSach = request.ThanhTienNganSach; + entity.GhiChu = request.GhiChu; + + await db.SaveChangesAsync(ct); + } +} + +public record DeletePurchaseEvaluationDetailCommand(Guid PurchaseEvaluationId, Guid DetailId) : IRequest; + +public class DeletePurchaseEvaluationDetailCommandHandler( + IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(DeletePurchaseEvaluationDetailCommand request, CancellationToken ct) + { + var entity = await db.PurchaseEvaluationDetails + .FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) + ?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId); + + db.PurchaseEvaluationDetails.Remove(entity); + await db.SaveChangesAsync(ct); + } +} + +// ========== Quote (báo giá per NCC per Detail) — upsert ========== + +public record UpsertPurchaseEvaluationQuoteCommand( + Guid PurchaseEvaluationId, + Guid PurchaseEvaluationDetailId, + Guid PurchaseEvaluationSupplierId, + decimal BgVat, + decimal ChuaVat, + decimal ThanhTien, + bool IsSelected, + string? Note) : IRequest; + +public class UpsertPurchaseEvaluationQuoteCommandHandler( + IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(UpsertPurchaseEvaluationQuoteCommand request, CancellationToken ct) + { + // Verify parents exist + same phiếu + var detail = await db.PurchaseEvaluationDetails.FirstOrDefaultAsync( + d => d.Id == request.PurchaseEvaluationDetailId && d.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) + ?? throw new NotFoundException("PurchaseEvaluationDetail", request.PurchaseEvaluationDetailId); + + var supplier = await db.PurchaseEvaluationSuppliers.FirstOrDefaultAsync( + s => s.Id == request.PurchaseEvaluationSupplierId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) + ?? throw new NotFoundException("PurchaseEvaluationSupplier", request.PurchaseEvaluationSupplierId); + + var existing = await db.PurchaseEvaluationQuotes.FirstOrDefaultAsync( + q => q.PurchaseEvaluationDetailId == request.PurchaseEvaluationDetailId + && q.PurchaseEvaluationSupplierId == request.PurchaseEvaluationSupplierId, ct); + + if (existing is not null) + { + existing.BgVat = request.BgVat; + existing.ChuaVat = request.ChuaVat; + existing.ThanhTien = request.ThanhTien; + existing.IsSelected = request.IsSelected; + existing.Note = request.Note; + await db.SaveChangesAsync(ct); + return existing.Id; + } + + var entity = new PurchaseEvaluationQuote + { + PurchaseEvaluationDetailId = request.PurchaseEvaluationDetailId, + PurchaseEvaluationSupplierId = request.PurchaseEvaluationSupplierId, + BgVat = request.BgVat, + ChuaVat = request.ChuaVat, + ThanhTien = request.ThanhTien, + IsSelected = request.IsSelected, + Note = request.Note, + }; + db.PurchaseEvaluationQuotes.Add(entity); + await db.SaveChangesAsync(ct); + return entity.Id; + } +} + +public record DeletePurchaseEvaluationQuoteCommand(Guid PurchaseEvaluationId, Guid QuoteId) : IRequest; + +public class DeletePurchaseEvaluationQuoteCommandHandler( + IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(DeletePurchaseEvaluationQuoteCommand request, CancellationToken ct) + { + var quote = await ( + from q in db.PurchaseEvaluationQuotes + join d in db.PurchaseEvaluationDetails on q.PurchaseEvaluationDetailId equals d.Id + where q.Id == request.QuoteId && d.PurchaseEvaluationId == request.PurchaseEvaluationId + select q).FirstOrDefaultAsync(ct) + ?? throw new NotFoundException("PurchaseEvaluationQuote", request.QuoteId); + + db.PurchaseEvaluationQuotes.Remove(quote); + await db.SaveChangesAsync(ct); + } +} + +// ========== Select winner (NCC được chọn tổng thể) ========== + +public record SelectPurchaseEvaluationWinnerCommand(Guid PurchaseEvaluationId, Guid SupplierId) : IRequest; + +public class SelectPurchaseEvaluationWinnerCommandHandler( + IApplicationDbContext db, + ICurrentUser currentUser) : IRequestHandler +{ + public async Task Handle(SelectPurchaseEvaluationWinnerCommand request, CancellationToken ct) + { + var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct) + ?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId); + + _ = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, ct) + ?? throw new NotFoundException("Supplier", request.SupplierId); + + // Verify supplier nằm trong danh sách phiếu + var hasSupplier = await db.PurchaseEvaluationSuppliers + .AnyAsync(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId && s.SupplierId == request.SupplierId, ct); + if (!hasSupplier) throw new ConflictException("NCC chưa được thêm vào phiếu đánh giá."); + + entity.SelectedSupplierId = request.SupplierId; + + db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog + { + PurchaseEvaluationId = entity.Id, + EntityType = PurchaseEvaluationEntityType.Header, + Action = ChangelogAction.Update, + PhaseAtChange = entity.Phase, + UserId = currentUser.UserId, + Summary = "Chọn NCC trúng thầu", + }); + + await db.SaveChangesAsync(ct); + } +} diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs new file mode 100644 index 0000000..d3bc88a --- /dev/null +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs @@ -0,0 +1,440 @@ +using FluentValidation; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Application.Common.Models; +using SolutionErp.Application.PurchaseEvaluations.Dtos; +using SolutionErp.Application.PurchaseEvaluations.Services; +using SolutionErp.Domain.Contracts; +using SolutionErp.Domain.Identity; +using SolutionErp.Domain.PurchaseEvaluations; + +namespace SolutionErp.Application.PurchaseEvaluations; + +// ========== CREATE ========== + +public record CreatePurchaseEvaluationCommand( + PurchaseEvaluationType Type, + string TenGoiThau, + Guid ProjectId, + Guid? DepartmentId, + string? DiaDiem, + string? MoTa, + string? PaymentTerms) : IRequest; + +public class CreatePurchaseEvaluationCommandValidator : AbstractValidator +{ + public CreatePurchaseEvaluationCommandValidator() + { + RuleFor(x => x.Type).IsInEnum(); + RuleFor(x => x.TenGoiThau).NotEmpty().MaximumLength(500); + RuleFor(x => x.ProjectId).NotEmpty(); + RuleFor(x => x.DiaDiem).MaximumLength(500); + RuleFor(x => x.MoTa).MaximumLength(2000); + } +} + +public class CreatePurchaseEvaluationCommandHandler( + IApplicationDbContext db, + ICurrentUser currentUser, + IPurchaseEvaluationWorkflowService workflow) : IRequestHandler +{ + public async Task Handle(CreatePurchaseEvaluationCommand request, CancellationToken ct) + { + _ = await db.Projects.FirstOrDefaultAsync(p => p.Id == request.ProjectId, ct) + ?? throw new NotFoundException("Project", request.ProjectId); + + var activeWfId = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking() + .Where(w => w.EvaluationType == request.Type && w.IsActive) + .Select(w => (Guid?)w.Id) + .FirstOrDefaultAsync(ct); + + var entity = new PurchaseEvaluation + { + Type = request.Type, + Phase = PurchaseEvaluationPhase.DangSoanThao, + TenGoiThau = request.TenGoiThau, + ProjectId = request.ProjectId, + DepartmentId = request.DepartmentId, + DiaDiem = request.DiaDiem, + MoTa = request.MoTa, + DrafterUserId = currentUser.UserId, + WorkflowDefinitionId = activeWfId, + PaymentTerms = request.PaymentTerms, + SlaDeadline = DateTime.UtcNow.Add( + workflow.GetPhaseSla(PurchaseEvaluationPhase.DangSoanThao) ?? TimeSpan.FromDays(3)), + }; + + // Auto-gen MaPhieu đơn giản PE-YYYYMM-XXXX (4 digit random) — format sau + entity.MaPhieu = $"PE-{DateTime.UtcNow:yyyyMM}-{Random.Shared.Next(1000, 9999)}"; + + db.PurchaseEvaluations.Add(entity); + + db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog + { + PurchaseEvaluationId = entity.Id, + EntityType = PurchaseEvaluationEntityType.Header, + Action = ChangelogAction.Insert, + PhaseAtChange = entity.Phase, + UserId = currentUser.UserId, + Summary = $"Tạo phiếu {entity.MaPhieu} — {entity.TenGoiThau}", + }); + + await db.SaveChangesAsync(ct); + return entity.Id; + } +} + +// ========== UPDATE draft ========== + +public record UpdatePurchaseEvaluationDraftCommand( + Guid Id, + string TenGoiThau, + string? DiaDiem, + string? MoTa, + string? PaymentTerms) : IRequest; + +public class UpdatePurchaseEvaluationDraftCommandHandler( + IApplicationDbContext db, + ICurrentUser currentUser) : IRequestHandler +{ + public async Task Handle(UpdatePurchaseEvaluationDraftCommand request, CancellationToken ct) + { + var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct) + ?? throw new NotFoundException("PurchaseEvaluation", request.Id); + + if (entity.Phase != PurchaseEvaluationPhase.DangSoanThao) + throw new ConflictException("Chỉ sửa được phiếu khi ở phase Đang soạn thảo."); + + entity.TenGoiThau = request.TenGoiThau; + entity.DiaDiem = request.DiaDiem; + entity.MoTa = request.MoTa; + entity.PaymentTerms = request.PaymentTerms; + + db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog + { + PurchaseEvaluationId = entity.Id, + EntityType = PurchaseEvaluationEntityType.Header, + Action = ChangelogAction.Update, + PhaseAtChange = entity.Phase, + UserId = currentUser.UserId, + Summary = "Cập nhật thông tin phiếu", + }); + + await db.SaveChangesAsync(ct); + } +} + +// ========== TRANSITION ========== + +public record TransitionPurchaseEvaluationCommand( + Guid Id, + PurchaseEvaluationPhase TargetPhase, + ApprovalDecision Decision, + string? Comment) : IRequest; + +public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator +{ + public TransitionPurchaseEvaluationCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.TargetPhase).IsInEnum(); + RuleFor(x => x.Decision).IsInEnum(); + RuleFor(x => x.Comment).MaximumLength(1000); + } +} + +public class TransitionPurchaseEvaluationCommandHandler( + IApplicationDbContext db, + ICurrentUser currentUser, + IPurchaseEvaluationWorkflowService workflow) : IRequestHandler +{ + public async Task Handle(TransitionPurchaseEvaluationCommand request, CancellationToken ct) + { + if (!currentUser.IsAuthenticated || currentUser.UserId is null) + throw new UnauthorizedException(); + + var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct) + ?? throw new NotFoundException("PurchaseEvaluation", request.Id); + + await workflow.TransitionAsync( + entity, + request.TargetPhase, + currentUser.UserId, + currentUser.Roles, + request.Decision, + request.Comment, + ct); + } +} + +// ========== LIST ========== + +public record ListPurchaseEvaluationsQuery( + PurchaseEvaluationType? Type = null, + PurchaseEvaluationPhase? Phase = null, + Guid? ProjectId = null) : PagedRequest, IRequest>; + +public class ListPurchaseEvaluationsQueryHandler( + IApplicationDbContext db, + ICurrentUser currentUser) : IRequestHandler> +{ + public async Task> Handle( + ListPurchaseEvaluationsQuery request, CancellationToken ct) + { + var q = from e in db.PurchaseEvaluations.AsNoTracking() + join p in db.Projects.AsNoTracking() on e.ProjectId equals p.Id + join s in db.Suppliers.AsNoTracking() on e.SelectedSupplierId equals s.Id into sj + from s in sj.DefaultIfEmpty() + select new { e, p, s }; + + // IDOR: non-admin chỉ thấy phiếu mình là Drafter hoặc role eligible phase + if (!currentUser.Roles.Contains(AppRoles.Admin)) + { + var userId = currentUser.UserId; + var eligiblePhases = GetEligiblePhases(currentUser.Roles); + q = q.Where(x => x.e.DrafterUserId == userId || eligiblePhases.Contains(x.e.Phase)); + } + + if (request.Type is not null) q = q.Where(x => x.e.Type == request.Type); + if (request.Phase is not null) q = q.Where(x => x.e.Phase == request.Phase); + if (request.ProjectId is not null) q = q.Where(x => x.e.ProjectId == request.ProjectId); + + if (!string.IsNullOrWhiteSpace(request.Search)) + { + var s = request.Search.Trim(); + q = q.Where(x => + (x.e.MaPhieu != null && x.e.MaPhieu.Contains(s)) || + x.e.TenGoiThau.Contains(s) || + x.p.Name.Contains(s)); + } + + q = request.SortDesc ? q.OrderByDescending(x => x.e.CreatedAt) : q.OrderBy(x => x.e.CreatedAt); + + var total = await q.CountAsync(ct); + var items = await q + .Skip((request.Page - 1) * request.PageSize).Take(request.PageSize) + .Select(x => new PurchaseEvaluationListItemDto( + x.e.Id, x.e.MaPhieu, x.e.TenGoiThau, x.e.Type, x.e.Phase, + x.e.ProjectId, x.p.Name, + x.e.SelectedSupplierId, x.s != null ? x.s.Name : null, + x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt)) + .ToListAsync(ct); + + return new PagedResult(items, total, request.Page, request.PageSize); + } + + internal static List GetEligiblePhases(IReadOnlyList userRoles) + { + var phases = new HashSet(); + void AddIfAny(string[] required, params PurchaseEvaluationPhase[] toAdd) + { + if (userRoles.Any(r => required.Contains(r))) + foreach (var p in toAdd) phases.Add(p); + } + AddIfAny([AppRoles.Drafter, AppRoles.DeptManager], PurchaseEvaluationPhase.DangSoanThao); + AddIfAny([AppRoles.Procurement], PurchaseEvaluationPhase.ChoPurchasing); + AddIfAny([AppRoles.ProjectManager], PurchaseEvaluationPhase.ChoDuAn); + AddIfAny([AppRoles.CostControl], PurchaseEvaluationPhase.ChoCCM); + AddIfAny([AppRoles.Director, AppRoles.AuthorizedSigner], + PurchaseEvaluationPhase.ChoCEODuyetPA, PurchaseEvaluationPhase.ChoCEODuyetNCC); + return phases.ToList(); + } +} + +// ========== INBOX ========== + +public record GetMyPurchaseEvaluationInboxQuery(PurchaseEvaluationType? Type = null) + : IRequest>; + +public class GetMyPurchaseEvaluationInboxQueryHandler( + IApplicationDbContext db, + ICurrentUser currentUser) : IRequestHandler> +{ + public async Task> Handle( + GetMyPurchaseEvaluationInboxQuery request, CancellationToken ct) + { + if (!currentUser.IsAuthenticated) throw new UnauthorizedException(); + + var userRoles = currentUser.Roles; + var isAdmin = userRoles.Contains(AppRoles.Admin); + var eligiblePhases = isAdmin + ? [ + PurchaseEvaluationPhase.DangSoanThao, + PurchaseEvaluationPhase.ChoPurchasing, + PurchaseEvaluationPhase.ChoDuAn, + PurchaseEvaluationPhase.ChoCCM, + PurchaseEvaluationPhase.ChoCEODuyetPA, + PurchaseEvaluationPhase.ChoCEODuyetNCC, + ] + : ListPurchaseEvaluationsQueryHandler.GetEligiblePhases(userRoles); + + if (eligiblePhases.Count == 0) return []; + + var q = from e in db.PurchaseEvaluations.AsNoTracking() + join p in db.Projects.AsNoTracking() on e.ProjectId equals p.Id + join s in db.Suppliers.AsNoTracking() on e.SelectedSupplierId equals s.Id into sj + from s in sj.DefaultIfEmpty() + where eligiblePhases.Contains(e.Phase) + select new { e, p, s }; + + if (request.Type is not null) q = q.Where(x => x.e.Type == request.Type); + + return await q + .OrderBy(x => x.e.SlaDeadline ?? DateTime.MaxValue) + .Select(x => new PurchaseEvaluationListItemDto( + x.e.Id, x.e.MaPhieu, x.e.TenGoiThau, x.e.Type, x.e.Phase, + x.e.ProjectId, x.p.Name, + x.e.SelectedSupplierId, x.s != null ? x.s.Name : null, + x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt)) + .Take(100) + .ToListAsync(ct); + } +} + +// ========== GET detail bundle ========== + +public record GetPurchaseEvaluationQuery(Guid Id) : IRequest; + +public class GetPurchaseEvaluationQueryHandler( + IApplicationDbContext db, + UserManager userManager, + ICurrentUser currentUser) : IRequestHandler +{ + public async Task Handle( + GetPurchaseEvaluationQuery request, CancellationToken ct) + { + var e = await db.PurchaseEvaluations.AsNoTracking() + .Include(x => x.Suppliers) + .Include(x => x.Details).ThenInclude(d => d.Quotes) + .Include(x => x.Approvals) + .FirstOrDefaultAsync(x => x.Id == request.Id, ct) + ?? throw new NotFoundException("PurchaseEvaluation", request.Id); + + var isAdmin = currentUser.Roles.Contains(AppRoles.Admin); + if (!isAdmin) + { + var isDrafter = e.DrafterUserId == currentUser.UserId; + var eligiblePhases = ListPurchaseEvaluationsQueryHandler.GetEligiblePhases(currentUser.Roles); + if (!isDrafter && !eligiblePhases.Contains(e.Phase)) + throw new ForbiddenException("Bạn không có quyền xem phiếu này."); + } + + var project = await db.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == e.ProjectId, ct); + var department = e.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == e.DepartmentId, ct); + var selectedSupplier = e.SelectedSupplierId is null ? null : await db.Suppliers.AsNoTracking().FirstOrDefaultAsync(s => s.Id == e.SelectedSupplierId, ct); + + // Load supplier names for PE suppliers + approver names + var supplierIds = e.Suppliers.Select(s => s.SupplierId).ToList(); + var suppliers = await db.Suppliers.AsNoTracking().Where(s => supplierIds.Contains(s.Id)) + .ToDictionaryAsync(s => s.Id, s => s.Name, ct); + + var userIds = new HashSet(); + if (e.DrafterUserId is Guid did) userIds.Add(did); + foreach (var a in e.Approvals) if (a.ApproverUserId is Guid aid) userIds.Add(aid); + var users = await userManager.Users.AsNoTracking() + .Where(u => userIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, u => u.FullName, ct); + + // Resolve workflow policy + PurchaseEvaluationPolicy policy; + if (e.WorkflowDefinitionId is Guid wfId) + { + var def = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking() + .Include(d => d.Steps.OrderBy(s => s.Order)) + .ThenInclude(s => s.Approvers) + .FirstOrDefaultAsync(d => d.Id == wfId, ct); + policy = def is not null + ? PurchaseEvaluationPolicyRegistry.FromDefinition(def) + : PurchaseEvaluationPolicyRegistry.ForEvaluation(e); + } + else + { + policy = PurchaseEvaluationPolicyRegistry.ForEvaluation(e); + } + + return new PurchaseEvaluationDetailBundleDto( + e.Id, e.MaPhieu, e.Type, e.Phase, e.TenGoiThau, e.DiaDiem, e.MoTa, + e.ProjectId, project?.Name ?? "", + e.DepartmentId, department?.Name, + e.DrafterUserId, e.DrafterUserId is Guid d && users.TryGetValue(d, out var dn) ? dn : null, + e.SelectedSupplierId, selectedSupplier?.Name, + e.ContractId, + e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt, + e.Suppliers + .OrderBy(s => s.Order) + .Select(s => new PurchaseEvaluationSupplierDto( + s.Id, s.SupplierId, + suppliers.TryGetValue(s.SupplierId, out var sn) ? sn : "", + s.DisplayName, s.ContactName, s.ContactEmail, s.ContactPhone, + s.PaymentTermText, s.Note, s.Order)) + .ToList(), + e.Details + .OrderBy(d => d.Order) + .Select(d => new PurchaseEvaluationDetailDto( + d.Id, d.GroupCode, d.GroupName, d.ItemCode, d.NoiDung, d.DonViTinh, + d.KhoiLuongNganSach, d.KhoiLuongThiCong, d.DonGiaNganSach, d.ThanhTienNganSach, + d.Order, d.GhiChu, + d.Quotes.Select(q => new PurchaseEvaluationQuoteDto( + q.Id, q.PurchaseEvaluationDetailId, q.PurchaseEvaluationSupplierId, + q.BgVat, q.ChuaVat, q.ThanhTien, q.IsSelected, q.Note)).ToList())) + .ToList(), + e.Approvals + .OrderBy(a => a.ApprovedAt) + .Select(a => new PurchaseEvaluationApprovalDto( + a.Id, a.FromPhase, a.ToPhase, a.ApproverUserId, + a.ApproverUserId is Guid uid && users.TryGetValue(uid, out var an) ? an : null, + a.Decision, a.Comment, a.ApprovedAt)) + .ToList(), + new PurchaseEvaluationWorkflowSummaryDto( + policy.Name, policy.Description, + policy.ActivePhases.ToList(), + policy.NextPhasesFrom(e.Phase).ToList())); + } +} + +// ========== DELETE ========== + +public record DeletePurchaseEvaluationCommand(Guid Id) : IRequest; + +public class DeletePurchaseEvaluationCommandHandler( + IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(DeletePurchaseEvaluationCommand request, CancellationToken ct) + { + var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct) + ?? throw new NotFoundException("PurchaseEvaluation", request.Id); + + if (entity.Phase != PurchaseEvaluationPhase.DangSoanThao + && entity.Phase != PurchaseEvaluationPhase.TuChoi) + throw new ConflictException("Chỉ xóa được phiếu ở phase Soạn thảo hoặc Từ chối."); + + db.PurchaseEvaluations.Remove(entity); + await db.SaveChangesAsync(ct); + } +} + +// ========== CHANGELOG list ========== + +public record ListPurchaseEvaluationChangelogsQuery(Guid PurchaseEvaluationId, int Take = 200) + : IRequest>; + +public class ListPurchaseEvaluationChangelogsQueryHandler(IApplicationDbContext db) + : IRequestHandler> +{ + public async Task> Handle( + ListPurchaseEvaluationChangelogsQuery request, CancellationToken ct) + { + return await db.PurchaseEvaluationChangelogs.AsNoTracking() + .Where(c => c.PurchaseEvaluationId == request.PurchaseEvaluationId) + .OrderByDescending(c => c.CreatedAt) + .Take(request.Take) + .Select(c => new PurchaseEvaluationChangelogDto( + c.Id, c.EntityType, c.EntityId, c.Action, c.PhaseAtChange, + c.UserId, c.UserName, c.Summary, c.FieldChangesJson, c.ContextNote, + c.CreatedAt)) + .ToListAsync(ct); + } +} diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationSupplierFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationSupplierFeatures.cs new file mode 100644 index 0000000..51a781c --- /dev/null +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationSupplierFeatures.cs @@ -0,0 +1,137 @@ +using FluentValidation; +using MediatR; +using Microsoft.EntityFrameworkCore; +using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Domain.Contracts; +using SolutionErp.Domain.PurchaseEvaluations; + +namespace SolutionErp.Application.PurchaseEvaluations; + +// Add NCC vào phiếu đánh giá (section II + E của form) — admin/drafter làm +// ở phase soạn thảo. +public record AddPurchaseEvaluationSupplierCommand( + Guid PurchaseEvaluationId, + Guid SupplierId, + string? DisplayName, + string? ContactName, + string? ContactEmail, + string? ContactPhone, + string? PaymentTermText, + string? Note) : IRequest; + +public class AddPurchaseEvaluationSupplierCommandValidator : AbstractValidator +{ + public AddPurchaseEvaluationSupplierCommandValidator() + { + RuleFor(x => x.PurchaseEvaluationId).NotEmpty(); + RuleFor(x => x.SupplierId).NotEmpty(); + RuleFor(x => x.DisplayName).MaximumLength(200); + RuleFor(x => x.ContactName).MaximumLength(200); + RuleFor(x => x.ContactEmail).MaximumLength(200); + RuleFor(x => x.ContactPhone).MaximumLength(50); + RuleFor(x => x.PaymentTermText).MaximumLength(200); + RuleFor(x => x.Note).MaximumLength(500); + } +} + +public class AddPurchaseEvaluationSupplierCommandHandler( + IApplicationDbContext db, + ICurrentUser currentUser) : IRequestHandler +{ + public async Task Handle(AddPurchaseEvaluationSupplierCommand request, CancellationToken ct) + { + var evaluation = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct) + ?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId); + + _ = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, ct) + ?? throw new NotFoundException("Supplier", request.SupplierId); + + var dup = await db.PurchaseEvaluationSuppliers + .AnyAsync(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId && s.SupplierId == request.SupplierId, ct); + if (dup) throw new ConflictException("NCC đã được thêm vào phiếu."); + + var maxOrder = await db.PurchaseEvaluationSuppliers + .Where(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId) + .Select(s => (int?)s.Order) + .MaxAsync(ct); + + var entity = new PurchaseEvaluationSupplier + { + PurchaseEvaluationId = request.PurchaseEvaluationId, + SupplierId = request.SupplierId, + DisplayName = request.DisplayName, + ContactName = request.ContactName, + ContactEmail = request.ContactEmail, + ContactPhone = request.ContactPhone, + PaymentTermText = request.PaymentTermText, + Note = request.Note, + Order = (maxOrder ?? 0) + 1, + }; + db.PurchaseEvaluationSuppliers.Add(entity); + + db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog + { + PurchaseEvaluationId = request.PurchaseEvaluationId, + EntityType = PurchaseEvaluationEntityType.Supplier, + EntityId = entity.Id, + Action = ChangelogAction.Insert, + PhaseAtChange = evaluation.Phase, + UserId = currentUser.UserId, + Summary = $"Thêm NCC {request.DisplayName ?? "#" + request.SupplierId.ToString()[..8]}", + }); + + await db.SaveChangesAsync(ct); + return entity.Id; + } +} + +public record UpdatePurchaseEvaluationSupplierCommand( + Guid PurchaseEvaluationId, + Guid SupplierRowId, + string? DisplayName, + string? ContactName, + string? ContactEmail, + string? ContactPhone, + string? PaymentTermText, + string? Note) : IRequest; + +public class UpdatePurchaseEvaluationSupplierCommandHandler( + IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(UpdatePurchaseEvaluationSupplierCommand request, CancellationToken ct) + { + var row = await db.PurchaseEvaluationSuppliers + .FirstOrDefaultAsync(s => s.Id == request.SupplierRowId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) + ?? throw new NotFoundException("PurchaseEvaluationSupplier", request.SupplierRowId); + + row.DisplayName = request.DisplayName; + row.ContactName = request.ContactName; + row.ContactEmail = request.ContactEmail; + row.ContactPhone = request.ContactPhone; + row.PaymentTermText = request.PaymentTermText; + row.Note = request.Note; + + await db.SaveChangesAsync(ct); + } +} + +public record RemovePurchaseEvaluationSupplierCommand(Guid PurchaseEvaluationId, Guid SupplierRowId) : IRequest; + +public class RemovePurchaseEvaluationSupplierCommandHandler( + IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(RemovePurchaseEvaluationSupplierCommand request, CancellationToken ct) + { + var row = await db.PurchaseEvaluationSuppliers + .FirstOrDefaultAsync(s => s.Id == request.SupplierRowId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) + ?? throw new NotFoundException("PurchaseEvaluationSupplier", request.SupplierRowId); + + // Block nếu có Quote row reference (FK Restrict) — user phải xóa quote trước + var hasQuotes = await db.PurchaseEvaluationQuotes.AnyAsync(q => q.PurchaseEvaluationSupplierId == row.Id, ct); + if (hasQuotes) throw new ConflictException("Không thể xóa NCC khi còn báo giá. Xóa báo giá trước."); + + db.PurchaseEvaluationSuppliers.Remove(row); + await db.SaveChangesAsync(ct); + } +} diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Services/IPurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Services/IPurchaseEvaluationWorkflowService.cs new file mode 100644 index 0000000..2839ba2 --- /dev/null +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Services/IPurchaseEvaluationWorkflowService.cs @@ -0,0 +1,20 @@ +using SolutionErp.Domain.Contracts; +using SolutionErp.Domain.PurchaseEvaluations; + +namespace SolutionErp.Application.PurchaseEvaluations.Services; + +public interface IPurchaseEvaluationWorkflowService +{ + // Kiểm tra + thực hiện transition. Throw ForbiddenException nếu không hợp lệ. + // Tự tạo PurchaseEvaluationApproval + update Phase + SlaDeadline. + Task TransitionAsync( + PurchaseEvaluation evaluation, + PurchaseEvaluationPhase targetPhase, + Guid? actorUserId, + IReadOnlyList actorRoles, + ApprovalDecision decision, + string? comment, + CancellationToken ct = default); + + TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase); +} diff --git a/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs b/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs index e8b3d13..8f0ecc0 100644 --- a/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs +++ b/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs @@ -6,6 +6,7 @@ using SolutionErp.Application.Common.Interfaces; using SolutionErp.Application.Contracts.Services; using SolutionErp.Application.Forms.Services; using SolutionErp.Application.Notifications; +using SolutionErp.Application.PurchaseEvaluations.Services; using SolutionErp.Application.Reports.Services; using SolutionErp.Domain.Identity; using SolutionErp.Infrastructure.Forms; @@ -32,6 +33,7 @@ public static class DependencyInjection services.AddSingleton(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs new file mode 100644 index 0000000..f26dd5d --- /dev/null +++ b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs @@ -0,0 +1,133 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Application.Notifications; +using SolutionErp.Application.PurchaseEvaluations.Services; +using SolutionErp.Domain.Contracts; +using SolutionErp.Domain.Identity; +using SolutionErp.Domain.Notifications; +using SolutionErp.Domain.PurchaseEvaluations; + +namespace SolutionErp.Infrastructure.Services; + +// Mirror ContractWorkflowService. Load policy từ pinned +// WorkflowDefinition (nếu có) hoặc fallback hardcoded registry. +public class PurchaseEvaluationWorkflowService( + IApplicationDbContext db, + IDateTime dateTime, + INotificationService notifications, + UserManager userManager) : IPurchaseEvaluationWorkflowService +{ + public TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase) => + PurchaseEvaluationPolicies.NccOnly.PhaseSla.GetValueOrDefault(phase); + + public async Task TransitionAsync( + PurchaseEvaluation evaluation, + PurchaseEvaluationPhase targetPhase, + Guid? actorUserId, + IReadOnlyList actorRoles, + ApprovalDecision decision, + string? comment, + CancellationToken ct = default) + { + if (evaluation.Phase == targetPhase) + throw new ConflictException("Phiếu đã ở phase đích."); + + PurchaseEvaluationPolicy policy; + if (evaluation.WorkflowDefinitionId is Guid wfId) + { + var def = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking() + .Include(d => d.Steps.OrderBy(s => s.Order)) + .ThenInclude(s => s.Approvers) + .FirstOrDefaultAsync(d => d.Id == wfId, ct); + policy = def is not null + ? PurchaseEvaluationPolicyRegistry.FromDefinition(def) + : PurchaseEvaluationPolicyRegistry.ForEvaluation(evaluation); + } + else + { + policy = PurchaseEvaluationPolicyRegistry.ForEvaluation(evaluation); + } + + var isAdmin = actorRoles.Contains(AppRoles.Admin); + var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove; + + if (!isAdmin && !isSystem) + { + if (!policy.Transitions.TryGetValue((evaluation.Phase, targetPhase), out var allowedRoles)) + throw new ForbiddenException( + $"Policy '{policy.Name}' không cho phép {evaluation.Phase} → {targetPhase}."); + + if (!policy.IsTransitionAllowed(evaluation.Phase, targetPhase, actorRoles, actorUserId)) + { + throw new ForbiddenException( + $"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {evaluation.Phase} → {targetPhase}. " + + $"Policy '{policy.Name}' yêu cầu: {string.Join(",", allowedRoles)}."); + } + } + + var fromPhase = evaluation.Phase; + evaluation.SlaWarningSent = false; + evaluation.Phase = targetPhase; + + var sla = policy.PhaseSla.GetValueOrDefault(targetPhase); + evaluation.SlaDeadline = sla is null ? null : dateTime.UtcNow.Add(sla.Value); + + db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval + { + PurchaseEvaluationId = evaluation.Id, + FromPhase = fromPhase, + ToPhase = targetPhase, + ApproverUserId = actorUserId, + Decision = decision, + Comment = comment, + ApprovedAt = dateTime.UtcNow, + }); + + // Resolve actor name for changelog + string? actorName = null; + if (actorUserId is Guid uid) + { + var user = await userManager.FindByIdAsync(uid.ToString()); + actorName = user?.FullName ?? user?.Email; + } + + db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog + { + PurchaseEvaluationId = evaluation.Id, + EntityType = PurchaseEvaluationEntityType.Workflow, + Action = ChangelogAction.Transition, + PhaseAtChange = targetPhase, + UserId = actorUserId, + UserName = actorName ?? "Hệ thống", + Summary = $"Chuyển phase {fromPhase} → {targetPhase}", + ContextNote = comment, + }); + + // Notify drafter + if (evaluation.DrafterUserId is Guid drafterId && drafterId != actorUserId) + { + var title = targetPhase switch + { + PurchaseEvaluationPhase.DaDuyet => $"Phiếu {evaluation.MaPhieu ?? evaluation.TenGoiThau} đã duyệt", + PurchaseEvaluationPhase.TuChoi => $"Phiếu {evaluation.TenGoiThau} bị từ chối", + _ => $"Phiếu {evaluation.TenGoiThau} chuyển phase mới", + }; + var type = targetPhase switch + { + PurchaseEvaluationPhase.DaDuyet => NotificationType.ContractPublished, + PurchaseEvaluationPhase.TuChoi => NotificationType.ContractRejected, + _ => NotificationType.ContractPhaseTransition, + }; + await notifications.NotifyAsync( + drafterId, type, title, + description: $"{fromPhase} → {targetPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"), + href: $"/purchase-evaluations/{evaluation.Id}", + refId: evaluation.Id, + ct: ct); + } + + await db.SaveChangesAsync(ct); + } +}