[CLAUDE] Infra: ContractWorkflowService N-stage logic mirror PE (Chunk C)
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled
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:
@ -53,6 +53,15 @@ public class ContractWorkflowService(
|
||||
{
|
||||
contract.RejectedFromPhase = fromPhase;
|
||||
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)
|
||||
{
|
||||
@ -64,14 +73,17 @@ public class ContractWorkflowService(
|
||||
// versioned system), else fall back to the static/override registry
|
||||
// (legacy path for contracts created before versioning rolled out).
|
||||
WorkflowPolicy policy;
|
||||
WorkflowDefinition? definition = null;
|
||||
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))
|
||||
.ThenInclude(s => s.Approvers)
|
||||
.Include(d => d.Steps)
|
||||
.ThenInclude(s => s.InnerSteps.OrderBy(i => i.Order))
|
||||
.FirstOrDefaultAsync(d => d.Id == wfId, ct);
|
||||
policy = def is not null
|
||||
? WorkflowPolicyRegistry.FromDefinition(def)
|
||||
policy = definition is not null
|
||||
? WorkflowPolicyRegistry.FromDefinition(definition)
|
||||
: WorkflowPolicyRegistry.ForContract(contract);
|
||||
}
|
||||
else
|
||||
@ -108,10 +120,13 @@ public class ContractWorkflowService(
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 2-stage department approval (Phase 9 — Migration 16) =====
|
||||
// Mirror PE workflow service. NV thuộc dept review → BLOCK transition
|
||||
// cho đến khi TPB cùng dept confirm. CanBypassReview cho NV → đẩy thẳng
|
||||
// Confirm (skip Review). Skip với reject + resume + admin + system.
|
||||
// ===== Department approval (N-stage Mig 20 hoặc Legacy 2-stage Mig 16) =====
|
||||
// Mirror PE workflow service. Step có InnerSteps → N-stage logic
|
||||
// (Phòng × PositionLevel sequential). Else fallback legacy 2-stage
|
||||
// (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
|
||||
&& targetPhase != ContractPhase.DangSoanThao
|
||||
&& targetPhase != ContractPhase.TuChoi
|
||||
@ -120,21 +135,132 @@ public class ContractWorkflowService(
|
||||
&& actorUserId is Guid actorUid)
|
||||
{
|
||||
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 canBypass = actor.CanBypassReview;
|
||||
var stage = (isManager || canBypass) ? ApprovalStage.Confirm : ApprovalStage.Review;
|
||||
var isBypassed = !isManager && canBypass;
|
||||
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
|
||||
.FirstOrDefaultAsync(a =>
|
||||
a.ContractId == contract.Id
|
||||
&& a.PhaseAtApproval == (int)fromPhase
|
||||
&& a.DepartmentId == deptId
|
||||
&& a.Stage == stage, ct);
|
||||
&& a.Stage == stage
|
||||
&& a.InnerStepId == null, ct);
|
||||
if (existing is null)
|
||||
{
|
||||
db.ContractDepartmentApprovals.Add(new ContractDepartmentApproval
|
||||
@ -148,6 +274,7 @@ public class ContractWorkflowService(
|
||||
Comment = comment,
|
||||
ApprovedAt = dateTime.UtcNow,
|
||||
IsBypassed = isBypassed,
|
||||
InnerStepId = null,
|
||||
});
|
||||
}
|
||||
else
|
||||
@ -164,23 +291,23 @@ public class ContractWorkflowService(
|
||||
a.ContractId == contract.Id
|
||||
&& a.PhaseAtApproval == (int)fromPhase
|
||||
&& a.DepartmentId == deptId
|
||||
&& a.Stage == ApprovalStage.Confirm, ct);
|
||||
&& a.Stage == ApprovalStage.Confirm
|
||||
&& a.InnerStepId == null, ct);
|
||||
|
||||
if (!hasConfirm)
|
||||
{
|
||||
// NV review xong, chưa có TPB confirm → BLOCK phase transition.
|
||||
db.ContractApprovals.Add(new ContractApproval
|
||||
{
|
||||
ContractId = contract.Id,
|
||||
FromPhase = fromPhase,
|
||||
ToPhase = fromPhase, // không đổi phase
|
||||
ToPhase = fromPhase,
|
||||
ApproverUserId = actorUid,
|
||||
Decision = ApprovalDecision.Approve,
|
||||
Comment = $"[Review NV] {comment ?? ""}",
|
||||
ApprovedAt = dateTime.UtcNow,
|
||||
});
|
||||
|
||||
string? reviewerName = (actor.FullName ?? actor.Email);
|
||||
string? reviewerName = actor.FullName ?? actor.Email;
|
||||
db.ContractChangelogs.Add(new ContractChangelog
|
||||
{
|
||||
ContractId = contract.Id,
|
||||
|
||||
Reference in New Issue
Block a user