[CLAUDE] App+Api: PurchaseEvaluation CQRS + Controller + WorkflowService
Application (4 file, ~900 lines): - IPurchaseEvaluationWorkflowService + PurchaseEvaluationDtos - PurchaseEvaluationFeatures: Create / UpdateDraft / Transition / List / Inbox / GetDetail (bundle Suppliers+Details+Quotes+Approvals+Workflow) / Delete / ListChangelogs. IDOR filter role-based phase eligibility. - PurchaseEvaluationSupplierFeatures: Add / Update / Remove supplier (N:M Phiếu × Supplier). Block remove nếu còn Quote FK reference. - PurchaseEvaluationDetailFeatures: Add/Update/Delete hạng mục + Upsert/Delete Quote + SelectWinner (set SelectedSupplierId). Infrastructure: - PurchaseEvaluationWorkflowService: policy load pinned definition → guard role + transition rules. Emit Notification drafter khi state-change. Tạo PurchaseEvaluationApproval + Changelog row. Api: - PurchaseEvaluationsController ~15 endpoint: CRUD phiếu, N:M supplier, hạng mục CRUD, Quote upsert, SelectWinner, Changelog list. Route /api/purchase-evaluations. DI: đăng ký IPurchaseEvaluationWorkflowService scoped. Skip MVP: Attachments upload, Admin PeWorkflows designer UI (sẽ phase sau — framework versioned WF table đã sẵn, designer pattern copy từ HĐ).
This commit is contained in:
@ -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<PurchaseEvaluationQuoteDto> 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<PurchaseEvaluationPhase> ActivePhases,
|
||||
List<PurchaseEvaluationPhase> 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<PurchaseEvaluationSupplierDto> Suppliers,
|
||||
List<PurchaseEvaluationDetailDto> Details,
|
||||
List<PurchaseEvaluationApprovalDto> Approvals,
|
||||
PurchaseEvaluationWorkflowSummaryDto Workflow);
|
||||
@ -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<Guid>;
|
||||
|
||||
public class AddPurchaseEvaluationDetailCommandValidator : AbstractValidator<AddPurchaseEvaluationDetailCommand>
|
||||
{
|
||||
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<AddPurchaseEvaluationDetailCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> 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<UpdatePurchaseEvaluationDetailCommand>
|
||||
{
|
||||
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<DeletePurchaseEvaluationDetailCommand>
|
||||
{
|
||||
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<Guid>;
|
||||
|
||||
public class UpsertPurchaseEvaluationQuoteCommandHandler(
|
||||
IApplicationDbContext db) : IRequestHandler<UpsertPurchaseEvaluationQuoteCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> 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<DeletePurchaseEvaluationQuoteCommand>
|
||||
{
|
||||
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<SelectPurchaseEvaluationWinnerCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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<Guid>;
|
||||
|
||||
public class CreatePurchaseEvaluationCommandValidator : AbstractValidator<CreatePurchaseEvaluationCommand>
|
||||
{
|
||||
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<CreatePurchaseEvaluationCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> 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<UpdatePurchaseEvaluationDraftCommand>
|
||||
{
|
||||
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<TransitionPurchaseEvaluationCommand>
|
||||
{
|
||||
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<TransitionPurchaseEvaluationCommand>
|
||||
{
|
||||
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<PagedResult<PurchaseEvaluationListItemDto>>;
|
||||
|
||||
public class ListPurchaseEvaluationsQueryHandler(
|
||||
IApplicationDbContext db,
|
||||
ICurrentUser currentUser) : IRequestHandler<ListPurchaseEvaluationsQuery, PagedResult<PurchaseEvaluationListItemDto>>
|
||||
{
|
||||
public async Task<PagedResult<PurchaseEvaluationListItemDto>> 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<PurchaseEvaluationListItemDto>(items, total, request.Page, request.PageSize);
|
||||
}
|
||||
|
||||
internal static List<PurchaseEvaluationPhase> GetEligiblePhases(IReadOnlyList<string> userRoles)
|
||||
{
|
||||
var phases = new HashSet<PurchaseEvaluationPhase>();
|
||||
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<List<PurchaseEvaluationListItemDto>>;
|
||||
|
||||
public class GetMyPurchaseEvaluationInboxQueryHandler(
|
||||
IApplicationDbContext db,
|
||||
ICurrentUser currentUser) : IRequestHandler<GetMyPurchaseEvaluationInboxQuery, List<PurchaseEvaluationListItemDto>>
|
||||
{
|
||||
public async Task<List<PurchaseEvaluationListItemDto>> 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<PurchaseEvaluationDetailBundleDto>;
|
||||
|
||||
public class GetPurchaseEvaluationQueryHandler(
|
||||
IApplicationDbContext db,
|
||||
UserManager<User> userManager,
|
||||
ICurrentUser currentUser) : IRequestHandler<GetPurchaseEvaluationQuery, PurchaseEvaluationDetailBundleDto>
|
||||
{
|
||||
public async Task<PurchaseEvaluationDetailBundleDto> 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<Guid>();
|
||||
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<DeletePurchaseEvaluationCommand>
|
||||
{
|
||||
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<List<PurchaseEvaluationChangelogDto>>;
|
||||
|
||||
public class ListPurchaseEvaluationChangelogsQueryHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<ListPurchaseEvaluationChangelogsQuery, List<PurchaseEvaluationChangelogDto>>
|
||||
{
|
||||
public async Task<List<PurchaseEvaluationChangelogDto>> 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);
|
||||
}
|
||||
}
|
||||
@ -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<Guid>;
|
||||
|
||||
public class AddPurchaseEvaluationSupplierCommandValidator : AbstractValidator<AddPurchaseEvaluationSupplierCommand>
|
||||
{
|
||||
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<AddPurchaseEvaluationSupplierCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> 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<UpdatePurchaseEvaluationSupplierCommand>
|
||||
{
|
||||
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<RemovePurchaseEvaluationSupplierCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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<string> actorRoles,
|
||||
ApprovalDecision decision,
|
||||
string? comment,
|
||||
CancellationToken ct = default);
|
||||
|
||||
TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase);
|
||||
}
|
||||
Reference in New Issue
Block a user