[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;
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user