[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

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