[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:
@ -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>();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user