diff --git a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs index b1d3e0b..315e7b6 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs @@ -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,