[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:
pqhuy1987
2026-04-23 16:43:47 +07:00
parent 2c6f0cabfb
commit 4678d192e2
8 changed files with 1261 additions and 0 deletions

View File

@ -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<IDocumentConverter, LibreOfficeDocumentConverter>();
services.AddScoped<IContractCodeGenerator, ContractCodeGenerator>();
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
services.AddScoped<IPurchaseEvaluationWorkflowService, PurchaseEvaluationWorkflowService>();
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
services.AddScoped<INotificationService, NotificationService>();
services.AddScoped<IChangelogService, ChangelogService>();

View File

@ -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<User> userManager) : IPurchaseEvaluationWorkflowService
{
public TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase) =>
PurchaseEvaluationPolicies.NccOnly.PhaseSla.GetValueOrDefault(phase);
public async Task TransitionAsync(
PurchaseEvaluation evaluation,
PurchaseEvaluationPhase targetPhase,
Guid? actorUserId,
IReadOnlyList<string> 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);
}
}