[CLAUDE] Infra: ContractWorkflowService N-stage logic mirror PE (Chunk C)
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled

Refactor TransitionAsync mirror PurchaseEvaluationWorkflowService Mig 18
N-stage pattern:
- Reject branch: clear N-stage approval rows tại fromPhase (resume sẽ
  approve lại từ inner đầu)
- Load definition with InnerSteps eager + assign outer scope
- Department approval block split:
  * hasInnerSteps=true → N-stage logic:
    - Yêu cầu actor có DeptId + PositionLevel set (else throw 403)
    - Match firstPending (Order asc IsRequired) same dept + (exact level
      OR canBypass + level≥)
    - exact match: upsert 1 row InnerStepId, IsBypassed=false
    - bypass: batch upsert NV+PP+TP cùng dept ≤ actor (audit IsBypassed
      cho cấp dưới)
    - Recheck stillPending → BLOCK + log Approval/Changelog "duyệt cấp X
      (còn Y pending)"
    - All done → fall through phase transition
  * hasInnerSteps=false → Legacy 2-stage Mig 16 (giữ nguyên với
    InnerStepId=null filter)

Backward compat 100%: workflow Contract no InnerSteps configured →
service fallback legacy 2-stage NV.Review/TPB.Confirm. Tests 89 pass —
no regression.

Verify: dotnet build 0 error, dotnet test 89 pass.

Pending Chunk D: ContractNStageApprovalTests 6 test mirror PE pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-07 19:04:16 +07:00
parent 04cf2a0385
commit e247b67681

View File

@ -53,6 +53,15 @@ public class ContractWorkflowService(
{ {
contract.RejectedFromPhase = fromPhase; contract.RejectedFromPhase = fromPhase;
targetPhase = ContractPhase.DangSoanThao; targetPhase = ContractPhase.DangSoanThao;
// N-stage state reset (Mig 20): clear inner step approval rows tại
// fromPhase. User resume sẽ approve lại từ inner step đầu.
var staleNStageRows = await db.ContractDepartmentApprovals
.Where(a => a.ContractId == contract.Id
&& a.PhaseAtApproval == (int)fromPhase
&& a.InnerStepId != null)
.ToListAsync(ct);
foreach (var r in staleNStageRows) db.ContractDepartmentApprovals.Remove(r);
} }
else if (isResumingAfterReject) else if (isResumingAfterReject)
{ {
@ -64,14 +73,17 @@ public class ContractWorkflowService(
// versioned system), else fall back to the static/override registry // versioned system), else fall back to the static/override registry
// (legacy path for contracts created before versioning rolled out). // (legacy path for contracts created before versioning rolled out).
WorkflowPolicy policy; WorkflowPolicy policy;
WorkflowDefinition? definition = null;
if (contract.WorkflowDefinitionId is Guid wfId) if (contract.WorkflowDefinitionId is Guid wfId)
{ {
var def = await db.WorkflowDefinitions.AsNoTracking() definition = await db.WorkflowDefinitions.AsNoTracking()
.Include(d => d.Steps.OrderBy(s => s.Order)) .Include(d => d.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Approvers) .ThenInclude(s => s.Approvers)
.Include(d => d.Steps)
.ThenInclude(s => s.InnerSteps.OrderBy(i => i.Order))
.FirstOrDefaultAsync(d => d.Id == wfId, ct); .FirstOrDefaultAsync(d => d.Id == wfId, ct);
policy = def is not null policy = definition is not null
? WorkflowPolicyRegistry.FromDefinition(def) ? WorkflowPolicyRegistry.FromDefinition(definition)
: WorkflowPolicyRegistry.ForContract(contract); : WorkflowPolicyRegistry.ForContract(contract);
} }
else else
@ -108,10 +120,13 @@ public class ContractWorkflowService(
} }
} }
// ===== 2-stage department approval (Phase 9 — Migration 16) ===== // ===== Department approval (N-stage Mig 20 hoặc Legacy 2-stage Mig 16) =====
// Mirror PE workflow service. NV thuộc dept review → BLOCK transition // Mirror PE workflow service. Step có InnerSteps → N-stage logic
// cho đến khi TPB cùng dept confirm. CanBypassReview cho NV → đẩy thẳng // (Phòng × PositionLevel sequential). Else fallback legacy 2-stage
// Confirm (skip Review). Skip với reject + resume + admin + system. // (NV.Review/TPB.Confirm). Skip với reject + resume + admin + system.
var currentStepDef = definition?.Steps.FirstOrDefault(s => s.Phase == fromPhase);
var hasInnerSteps = currentStepDef?.InnerSteps.Count > 0;
if (decision == ApprovalDecision.Approve if (decision == ApprovalDecision.Approve
&& targetPhase != ContractPhase.DangSoanThao && targetPhase != ContractPhase.DangSoanThao
&& targetPhase != ContractPhase.TuChoi && targetPhase != ContractPhase.TuChoi
@ -120,21 +135,132 @@ public class ContractWorkflowService(
&& actorUserId is Guid actorUid) && actorUserId is Guid actorUid)
{ {
var actor = await userManager.FindByIdAsync(actorUid.ToString()); var actor = await userManager.FindByIdAsync(actorUid.ToString());
if (actor?.DepartmentId is Guid deptId)
if (hasInnerSteps && currentStepDef is not null)
{ {
// ===== N-stage logic (Mig 20) — mirror PE Mig 18 =====
if (actor?.DepartmentId is null || actor.PositionLevel is null)
{
throw new ForbiddenException(
"User phải có Phòng + Cấp chức danh (NV/PP/TP) để duyệt N-stage workflow.");
}
var actorDept = actor.DepartmentId.Value;
var actorPos = actor.PositionLevel.Value;
var canBypass = actor.CanBypassReview;
var inners = currentStepDef.InnerSteps.OrderBy(i => i.Order).ToList();
var innerIds = inners.Select(i => i.Id).ToList();
var existingApprovals = await db.ContractDepartmentApprovals
.Where(a => a.ContractId == contract.Id
&& a.PhaseAtApproval == (int)fromPhase
&& a.InnerStepId != null
&& innerIds.Contains(a.InnerStepId!.Value))
.ToListAsync(ct);
var doneInnerIds = existingApprovals.Select(a => a.InnerStepId!.Value).ToHashSet();
var pendingRequired = inners.Where(i => i.IsRequired && !doneInnerIds.Contains(i.Id)).ToList();
if (pendingRequired.Count > 0)
{
var firstPending = pendingRequired[0];
var levelOk = actorPos == firstPending.PositionLevel
|| (canBypass && (int)actorPos >= (int)firstPending.PositionLevel);
if (actorDept != firstPending.DepartmentId || !levelOk)
{
throw new ForbiddenException(
$"Cấp duyệt tiếp theo: phòng {firstPending.DepartmentId} cấp {firstPending.PositionLevel}. " +
$"Bạn (phòng {actorDept} cấp {actorPos}{(canBypass ? "+bypass" : "")}) không khớp.");
}
var rowsToCreate = new List<(WorkflowStepInnerStep i, bool bypassed)>();
if (actorPos == firstPending.PositionLevel)
{
rowsToCreate.Add((firstPending, false));
}
else
{
// Bypass cùng dept: upsert tất cả pending inner trong dept actor
// có level từ firstPending.PositionLevel đến actorPos (inclusive)
foreach (var inner in inners
.Where(i => i.DepartmentId == actorDept
&& (int)i.PositionLevel >= (int)firstPending.PositionLevel
&& (int)i.PositionLevel <= (int)actorPos
&& !doneInnerIds.Contains(i.Id)))
{
rowsToCreate.Add((inner, inner.PositionLevel != actorPos));
}
}
var nowUtc = dateTime.UtcNow;
foreach (var (inner, bypassed) in rowsToCreate)
{
db.ContractDepartmentApprovals.Add(new ContractDepartmentApproval
{
ContractId = contract.Id,
PhaseAtApproval = (int)fromPhase,
DepartmentId = inner.DepartmentId,
Stage = ApprovalStage.Confirm,
ApproverUserId = actorUid,
ApproverRoleSnapshot = $"{inner.PositionLevel}{(bypassed ? "(bypass)" : "")}",
Comment = comment,
ApprovedAt = nowUtc,
IsBypassed = bypassed,
InnerStepId = inner.Id,
});
doneInnerIds.Add(inner.Id);
}
var stillPending = inners.Any(i => i.IsRequired && !doneInnerIds.Contains(i.Id));
if (stillPending)
{
db.ContractApprovals.Add(new ContractApproval
{
ContractId = contract.Id,
FromPhase = fromPhase,
ToPhase = fromPhase,
ApproverUserId = actorUid,
Decision = ApprovalDecision.Approve,
Comment = $"[Inner step duyệt {actorPos}] {comment ?? ""}",
ApprovedAt = nowUtc,
});
string? reviewerName = actor.FullName ?? actor.Email;
db.ContractChangelogs.Add(new ContractChangelog
{
ContractId = contract.Id,
EntityType = ChangelogEntityType.Workflow,
Action = ChangelogAction.Transition,
PhaseAtChange = fromPhase,
UserId = actorUid,
UserName = reviewerName ?? "Hệ thống",
Summary = $"{reviewerName} duyệt cấp {actorPos} phase {fromPhase} (còn {inners.Count(i => i.IsRequired && !doneInnerIds.Contains(i.Id))} cấp pending)",
ContextNote = comment,
});
await db.SaveChangesAsync(ct);
return;
}
// All required inner steps done → fall through phase transition
}
}
else if (actor?.DepartmentId is Guid deptId)
{
// ===== Legacy 2-stage logic (Mig 16) — fallback khi step KHÔNG có InnerSteps =====
var isManager = actorRoles.Contains(AppRoles.DeptManager); var isManager = actorRoles.Contains(AppRoles.DeptManager);
var canBypass = actor.CanBypassReview; var canBypass = actor.CanBypassReview;
var stage = (isManager || canBypass) ? ApprovalStage.Confirm : ApprovalStage.Review; var stage = (isManager || canBypass) ? ApprovalStage.Confirm : ApprovalStage.Review;
var isBypassed = !isManager && canBypass; var isBypassed = !isManager && canBypass;
var roleSnapshot = isManager ? "TPB" : (canBypass ? "NV(bypass)" : "NV"); var roleSnapshot = isManager ? "TPB" : (canBypass ? "NV(bypass)" : "NV");
// Upsert: 1 row mỗi (ContractId, phase, dept, stage). UNIQUE index enforce.
var existing = await db.ContractDepartmentApprovals var existing = await db.ContractDepartmentApprovals
.FirstOrDefaultAsync(a => .FirstOrDefaultAsync(a =>
a.ContractId == contract.Id a.ContractId == contract.Id
&& a.PhaseAtApproval == (int)fromPhase && a.PhaseAtApproval == (int)fromPhase
&& a.DepartmentId == deptId && a.DepartmentId == deptId
&& a.Stage == stage, ct); && a.Stage == stage
&& a.InnerStepId == null, ct);
if (existing is null) if (existing is null)
{ {
db.ContractDepartmentApprovals.Add(new ContractDepartmentApproval db.ContractDepartmentApprovals.Add(new ContractDepartmentApproval
@ -148,6 +274,7 @@ public class ContractWorkflowService(
Comment = comment, Comment = comment,
ApprovedAt = dateTime.UtcNow, ApprovedAt = dateTime.UtcNow,
IsBypassed = isBypassed, IsBypassed = isBypassed,
InnerStepId = null,
}); });
} }
else else
@ -164,23 +291,23 @@ public class ContractWorkflowService(
a.ContractId == contract.Id a.ContractId == contract.Id
&& a.PhaseAtApproval == (int)fromPhase && a.PhaseAtApproval == (int)fromPhase
&& a.DepartmentId == deptId && a.DepartmentId == deptId
&& a.Stage == ApprovalStage.Confirm, ct); && a.Stage == ApprovalStage.Confirm
&& a.InnerStepId == null, ct);
if (!hasConfirm) if (!hasConfirm)
{ {
// NV review xong, chưa có TPB confirm → BLOCK phase transition.
db.ContractApprovals.Add(new ContractApproval db.ContractApprovals.Add(new ContractApproval
{ {
ContractId = contract.Id, ContractId = contract.Id,
FromPhase = fromPhase, FromPhase = fromPhase,
ToPhase = fromPhase, // không đổi phase ToPhase = fromPhase,
ApproverUserId = actorUid, ApproverUserId = actorUid,
Decision = ApprovalDecision.Approve, Decision = ApprovalDecision.Approve,
Comment = $"[Review NV] {comment ?? ""}", Comment = $"[Review NV] {comment ?? ""}",
ApprovedAt = dateTime.UtcNow, ApprovedAt = dateTime.UtcNow,
}); });
string? reviewerName = (actor.FullName ?? actor.Email); string? reviewerName = actor.FullName ?? actor.Email;
db.ContractChangelogs.Add(new ContractChangelog db.ContractChangelogs.Add(new ContractChangelog
{ {
ContractId = contract.Id, ContractId = contract.Id,