[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:
pqhuy1987
2026-05-04 12:20:21 +07:00
parent 14f3c9f817
commit 9747f8cbf5
3 changed files with 78 additions and 22 deletions

View File

@ -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

View File

@ -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;