[CLAUDE] Infra+App: Chunk C — Smart reject + Resume after reject (3 module)
Ràng buộc 2 (Phase 9): khi reject, trả về Drafter (DangSoanThao) + lưu phase nguồn. Drafter sửa lại + trình lại → quay về phase đã reject (skip phase trung gian). Logic flow: 1. Reject (Decision=Reject): - entity.RejectedFromPhase = currentPhase // snapshot phase đang reject - targetPhase override = DangSoanThao // force về Drafter - Approval row: FromPhase=X, ToPhase=DangSoanThao, Decision=Reject - Notification cho Drafter 2. Resume after reject (Decision=Approve, fromPhase=DangSoanThao, RejectedFromPhase != null): - targetPhase override = entity.RejectedFromPhase!.Value - entity.RejectedFromPhase = null // clear field - Skip policy guard (Drafter có quyền trình lại sau khi sửa) - Approval row: FromPhase=DangSoanThao, ToPhase=ResumePhase, Decision=Approve 3. Normal transition (chưa reject hoặc đã clear): - Logic cũ giữ nguyên — policy guard check + transition Pattern unified cho 3 module: - ContractWorkflowService.TransitionAsync: 2 case detect + override - PurchaseEvaluationWorkflowService.TransitionAsync: tương tự - TransitionBudgetCommandHandler.Handle: tương tự (Budget không có service riêng, logic ở handler) Files: - src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs - src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs - src/Backend/SolutionErp.Application/Budgets/BudgetFeatures.cs Verify: - Build pass (2 warning DocxRenderer cũ, không liên quan) - 77 unit test pass — Domain policy không đổi, tests giữ nguyên Note: Approval history giờ track đầy đủ cycle reject→sửa→resume: Approval 1: DangGopY → DangSoanThao, Decision=Reject (CCM reject) Approval 2: DangSoanThao → DangGopY, Decision=Approve (Drafter resume) UI có thể detect "đã từng reject" qua RejectedFromPhase != null hoặc qua Approval history (Decision=Reject row gần nhất). Hiển thị banner đỏ "Phiếu đã bị reject từ phase X, lý do: Y" cho Drafter. Smart reject hoàn tất Ràng buộc 2. Còn Ràng buộc 3 (2-stage dept approval) ở Chunk D. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -131,24 +131,43 @@ public class TransitionBudgetCommandHandler(
|
||||
var entity = await db.Budgets.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
||||
?? throw new NotFoundException("Budget", request.Id);
|
||||
|
||||
// ===== Smart reject + resume (Phase 9 — Migration 16) =====
|
||||
var fromPhase = entity.Phase;
|
||||
var targetPhase = request.TargetPhase;
|
||||
var isResumingAfterReject = request.Decision == ApprovalDecision.Approve
|
||||
&& fromPhase == BudgetPhase.DangSoanThao
|
||||
&& entity.RejectedFromPhase != null;
|
||||
|
||||
if (request.Decision == ApprovalDecision.Reject)
|
||||
{
|
||||
entity.RejectedFromPhase = fromPhase;
|
||||
targetPhase = BudgetPhase.DangSoanThao;
|
||||
}
|
||||
else if (isResumingAfterReject)
|
||||
{
|
||||
targetPhase = entity.RejectedFromPhase!.Value;
|
||||
entity.RejectedFromPhase = null;
|
||||
}
|
||||
|
||||
var policy = BudgetPolicies.Default;
|
||||
var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
|
||||
|
||||
if (!isAdmin && !policy.IsTransitionAllowed(entity.Phase, request.TargetPhase, currentUser.Roles))
|
||||
// Policy guard — bypass khi resume sau reject.
|
||||
if (!isAdmin && !isResumingAfterReject
|
||||
&& !policy.IsTransitionAllowed(fromPhase, targetPhase, currentUser.Roles))
|
||||
throw new ForbiddenException(
|
||||
$"Role không đủ quyền chuyển {entity.Phase} → {request.TargetPhase}.");
|
||||
$"Role không đủ quyền chuyển {fromPhase} → {targetPhase}.");
|
||||
|
||||
var fromPhase = entity.Phase;
|
||||
entity.SlaWarningSent = false;
|
||||
entity.Phase = request.TargetPhase;
|
||||
var sla = policy.PhaseSla.GetValueOrDefault(request.TargetPhase);
|
||||
entity.Phase = targetPhase;
|
||||
var sla = policy.PhaseSla.GetValueOrDefault(targetPhase);
|
||||
entity.SlaDeadline = sla is null ? null : DateTime.UtcNow.Add(sla.Value);
|
||||
|
||||
db.BudgetApprovals.Add(new BudgetApproval
|
||||
{
|
||||
BudgetId = entity.Id,
|
||||
FromPhase = fromPhase,
|
||||
ToPhase = request.TargetPhase,
|
||||
ToPhase = targetPhase,
|
||||
ApproverUserId = currentUser.UserId,
|
||||
Decision = request.Decision,
|
||||
Comment = request.Comment,
|
||||
@ -166,10 +185,10 @@ public class TransitionBudgetCommandHandler(
|
||||
BudgetId = entity.Id,
|
||||
EntityType = BudgetEntityType.Workflow,
|
||||
Action = ChangelogAction.Transition,
|
||||
PhaseAtChange = request.TargetPhase,
|
||||
PhaseAtChange = targetPhase,
|
||||
UserId = currentUser.UserId,
|
||||
UserName = actorName ?? "Hệ thống",
|
||||
Summary = $"Chuyển phase {fromPhase} → {request.TargetPhase}",
|
||||
Summary = $"Chuyển phase {fromPhase} → {targetPhase}",
|
||||
ContextNote = request.Comment,
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
@ -37,6 +37,26 @@ public class ContractWorkflowService(
|
||||
if (contract.Phase == targetPhase)
|
||||
throw new ConflictException("HĐ đã ở phase đích.");
|
||||
|
||||
// ===== Smart reject + resume (Phase 9 — Migration 16) =====
|
||||
// Reject: override target = DangSoanThao + lưu phase gốc → Drafter sửa.
|
||||
// Resume sau reject: Drafter trình từ DangSoanThao + RejectedFromPhase
|
||||
// != null → jump straight tới phase đã reject, bypass phase trung gian.
|
||||
var fromPhase = contract.Phase;
|
||||
var isResumingAfterReject = decision == ApprovalDecision.Approve
|
||||
&& fromPhase == ContractPhase.DangSoanThao
|
||||
&& contract.RejectedFromPhase != null;
|
||||
|
||||
if (decision == ApprovalDecision.Reject)
|
||||
{
|
||||
contract.RejectedFromPhase = fromPhase;
|
||||
targetPhase = ContractPhase.DangSoanThao;
|
||||
}
|
||||
else if (isResumingAfterReject)
|
||||
{
|
||||
targetPhase = contract.RejectedFromPhase!.Value;
|
||||
contract.RejectedFromPhase = null;
|
||||
}
|
||||
|
||||
// Resolve the workflow: prefer the pinned WorkflowDefinition (new
|
||||
// versioned system), else fall back to the static/override registry
|
||||
// (legacy path for contracts created before versioning rolled out).
|
||||
@ -60,31 +80,31 @@ public class ContractWorkflowService(
|
||||
var isAdmin = actorRoles.Contains(AppRoles.Admin);
|
||||
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
|
||||
|
||||
if (!isAdmin && !isSystem)
|
||||
// Policy guard — bypass cho resume (Drafter có quyền trình lại sau khi
|
||||
// sửa, không cần policy check vì target đã pinned bởi RejectedFromPhase).
|
||||
if (!isAdmin && !isSystem && !isResumingAfterReject)
|
||||
{
|
||||
if (!policy.Transitions.TryGetValue((contract.Phase, targetPhase), out var allowedRoles))
|
||||
if (!policy.Transitions.TryGetValue((fromPhase, targetPhase), out var allowedRoles))
|
||||
throw new ForbiddenException(
|
||||
$"Policy '{policy.Name}' không cho phép {contract.Phase} → {targetPhase}. " +
|
||||
$"Policy '{policy.Name}' không cho phép {fromPhase} → {targetPhase}. " +
|
||||
$"Kiểm tra ContractType hoặc BypassProcurementAndCCM.");
|
||||
|
||||
// Sử dụng IsTransitionAllowed — check Role + User-kind fallback.
|
||||
// User-kind chỉ áp dụng khi WorkflowDefinition pinned có
|
||||
// WorkflowStepApprover Kind=User cho step này.
|
||||
if (!policy.IsTransitionAllowed(contract.Phase, targetPhase, actorRoles, actorUserId))
|
||||
if (!policy.IsTransitionAllowed(fromPhase, targetPhase, actorRoles, actorUserId))
|
||||
{
|
||||
var userExtra = policy.UserTransitions is not null
|
||||
&& policy.UserTransitions.TryGetValue((contract.Phase, targetPhase), out var userIds)
|
||||
&& policy.UserTransitions.TryGetValue((fromPhase, targetPhase), out var userIds)
|
||||
&& userIds.Length > 0
|
||||
? $" hoặc {userIds.Length} user explicit"
|
||||
: "";
|
||||
throw new ForbiddenException(
|
||||
$"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {contract.Phase} → {targetPhase}. " +
|
||||
$"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {fromPhase} → {targetPhase}. " +
|
||||
$"Policy '{policy.Name}' yêu cầu: {string.Join(",", allowedRoles)}{userExtra}.");
|
||||
}
|
||||
}
|
||||
|
||||
var fromPhase = contract.Phase;
|
||||
|
||||
// Defensive — gen mã HĐ nếu chưa có khi chuyển sang DangDongDau.
|
||||
// Nominal flow (sau user feedback): mã đã gen sẵn từ CreateContract → skip.
|
||||
// Fallback chỉ trigger cho HĐ legacy chưa qua backfill, hoặc HĐ tạo bằng
|
||||
|
||||
@ -34,6 +34,23 @@ public class PurchaseEvaluationWorkflowService(
|
||||
if (evaluation.Phase == targetPhase)
|
||||
throw new ConflictException("Phiếu đã ở phase đích.");
|
||||
|
||||
// ===== Smart reject + resume (Phase 9 — Migration 16) =====
|
||||
var fromPhase = evaluation.Phase;
|
||||
var isResumingAfterReject = decision == ApprovalDecision.Approve
|
||||
&& fromPhase == PurchaseEvaluationPhase.DangSoanThao
|
||||
&& evaluation.RejectedFromPhase != null;
|
||||
|
||||
if (decision == ApprovalDecision.Reject)
|
||||
{
|
||||
evaluation.RejectedFromPhase = fromPhase;
|
||||
targetPhase = PurchaseEvaluationPhase.DangSoanThao;
|
||||
}
|
||||
else if (isResumingAfterReject)
|
||||
{
|
||||
targetPhase = evaluation.RejectedFromPhase!.Value;
|
||||
evaluation.RejectedFromPhase = null;
|
||||
}
|
||||
|
||||
PurchaseEvaluationPolicy policy;
|
||||
if (evaluation.WorkflowDefinitionId is Guid wfId)
|
||||
{
|
||||
@ -53,21 +70,21 @@ public class PurchaseEvaluationWorkflowService(
|
||||
var isAdmin = actorRoles.Contains(AppRoles.Admin);
|
||||
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
|
||||
|
||||
if (!isAdmin && !isSystem)
|
||||
// Policy guard — bypass khi resume sau reject (target đã pinned).
|
||||
if (!isAdmin && !isSystem && !isResumingAfterReject)
|
||||
{
|
||||
if (!policy.Transitions.TryGetValue((evaluation.Phase, targetPhase), out var allowedRoles))
|
||||
if (!policy.Transitions.TryGetValue((fromPhase, targetPhase), out var allowedRoles))
|
||||
throw new ForbiddenException(
|
||||
$"Policy '{policy.Name}' không cho phép {evaluation.Phase} → {targetPhase}.");
|
||||
$"Policy '{policy.Name}' không cho phép {fromPhase} → {targetPhase}.");
|
||||
|
||||
if (!policy.IsTransitionAllowed(evaluation.Phase, targetPhase, actorRoles, actorUserId))
|
||||
if (!policy.IsTransitionAllowed(fromPhase, targetPhase, actorRoles, actorUserId))
|
||||
{
|
||||
throw new ForbiddenException(
|
||||
$"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {evaluation.Phase} → {targetPhase}. " +
|
||||
$"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {fromPhase} → {targetPhase}. " +
|
||||
$"Policy '{policy.Name}' yêu cầu: {string.Join(",", allowedRoles)}.");
|
||||
}
|
||||
}
|
||||
|
||||
var fromPhase = evaluation.Phase;
|
||||
evaluation.SlaWarningSent = false;
|
||||
evaluation.Phase = targetPhase;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user