[CLAUDE] Drastic refactor: flat workflow Phòng × Cấp + Migration 21 (Chunk A)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m18s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m18s
User chốt drastic refactor — bỏ phase enum hoàn toàn, dùng ChoDuyet=10 đơn nhất + currentStepIndex tracking. Workflow flat list (Phòng × Cấp × Approvers). Mỗi PE/HĐ pin WorkflowDefinitionId chạy hết quy trình đó. Schema (Migration 21 `RefactorWorkflowToFlatModel`): - Phase enum +ChoDuyet=10 (PE + Contract). Legacy 2-9 + 98 deprecated. - WorkflowStep + DepartmentId Guid? (FK Restrict) + PositionLevel int? (PE + Contract — mirror). - PE/Contract + CurrentWorkflowStepIndex int? + RejectedAtStepIndex int? - DROP table PurchaseEvaluationWorkflowStepInnerSteps (Mig 18) - DROP table WorkflowStepInnerSteps (Mig 20) - DROP column ContractDeptApproval.InnerStepId (Mig 20) - DROP column PEDeptApproval.InnerStepId (Mig 18) - DROP filtered indexes (Mig 19/20) + restore simple unique (TargetId, Phase, Dept, Stage) non-filtered Service rewrite (PE + Contract WorkflowService.TransitionAsync): - Phase transitions: DangSoanThao → ChoDuyet (Drafter trình, init idx=0) - ChoDuyet → ChoDuyet (advance idx per approve) - ChoDuyet → DaDuyet/DaPhatHanh (idx >= steps.Count → terminal) - ChoDuyet → DangSoanThao (Trả lại — save RejectedAtStepIndex) - ChoDuyet → TuChoi (Từ chối — khoá vĩnh viễn) - DangSoanThao + RejectedAtStepIndex → ChoDuyet jump-back to saved idx - Approver match: actor.Dept == step.Dept AND actor.PositionLevel >= step.PositionLevel (OR-of-many cùng cấp/dept = pass) OR Approvers.Any(Kind=User AND id match) OR Approvers.Any(Kind=Role AND actorRoles contains) - Admin role bypass policy. Last step done → gen mã HĐ (Contract only) App CQRS: - WorkflowStepDto + WorkflowStepInput drop InnerStep, add DepartmentId + PositionLevel fields. PE + Contract mirror. Tests rewrite: - DROP PeNStageApprovalTests.cs (6 test) + ContractNStageApprovalTests.cs (6 test) + PeTwoStageApprovalTests.cs (7 test) — legacy N-stage/2-stage no longer applicable - UPDATE PeWorkflowAdminTests signature to new flat input - 96 → 77 test pass (drop 19 legacy) Reference Domain entities removed: - WorkflowStepInnerStep (Contract) - PurchaseEvaluationWorkflowStepInnerStep (PE) - DTOs WorkflowStepInnerStepDto / CreateWorkflowStepInnerStepInput per module Memory `feedback_drastic_refactor_scope.md` validated: drastic refactor done in dedicated session với context fresh, scope ~5h actual (planned ~8-10h with 2x buffer). Verify: - dotnet build SolutionErp.slnx 0 error - dotnet ef database update Mig 21 LocalDB applied OK - dotnet test 77 pass (54 Domain + 23 Infra) - 3-file rule: Migration .cs + Designer.cs + Snapshot updated Pending Chunk B: FE Designer flat UI (PeWorkflowsPage + WorkflowsPage). Pending Chunk C: FE PeWorkflowPanel + workflow timeline display. Pending Chunk D: Docs + Skill + Memory + session log. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -11,9 +11,10 @@ using SolutionErp.Domain.Notifications;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Services;
|
||||
|
||||
// Thin orchestrator — all phase/role/SLA rules live in WorkflowPolicy (Domain).
|
||||
// This class is responsible only for *applying* transitions: DB writes, code
|
||||
// generation at DangDongDau, SLA deadline computation, notification dispatch.
|
||||
// Contract Workflow Service — Session 16 drastic refactor (Mig 21):
|
||||
// Flat workflow model. Mỗi step = 1 (Phòng × Cấp + Approvers). Service iterate
|
||||
// steps OrderBy Order, advance Contract.CurrentWorkflowStepIndex per approve.
|
||||
// Phase enum simplified: DangSoanThao → ChoDuyet → DaPhatHanh / TuChoi.
|
||||
public class ContractWorkflowService(
|
||||
IApplicationDbContext db,
|
||||
IContractCodeGenerator codeGenerator,
|
||||
@ -22,11 +23,8 @@ public class ContractWorkflowService(
|
||||
IChangelogService changelog,
|
||||
UserManager<User> userManager) : IContractWorkflowService
|
||||
{
|
||||
// Expose per-policy SLA via the contract — accepts optional contract so the
|
||||
// caller (CreateContractCommand) can ask for a specific type's SLA even
|
||||
// before the contract exists.
|
||||
public TimeSpan? GetPhaseSla(ContractPhase phase) =>
|
||||
WorkflowPolicies.Standard.PhaseSla.GetValueOrDefault(phase);
|
||||
phase == ContractPhase.ChoDuyet ? TimeSpan.FromDays(7) : null;
|
||||
|
||||
public async Task TransitionAsync(
|
||||
Contract contract,
|
||||
@ -37,378 +35,196 @@ public class ContractWorkflowService(
|
||||
string? comment,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (contract.Phase == targetPhase)
|
||||
throw new ConflictException("HĐ đã ở phase đích.");
|
||||
|
||||
// ===== Smart reject + resume (Phase 9 — Migration 16) =====
|
||||
// Reject: override target = DangSoanThao + lưu phase gốc → Drafter sửa.
|
||||
// Resume sau reject: Drafter trình từ DangSoanThao + RejectedFromPhase
|
||||
// != null → jump straight tới phase đã reject, bypass phase trung gian.
|
||||
var fromPhase = contract.Phase;
|
||||
var isResumingAfterReject = decision == ApprovalDecision.Approve
|
||||
&& fromPhase == ContractPhase.DangSoanThao
|
||||
&& contract.RejectedFromPhase != null;
|
||||
|
||||
if (decision == ApprovalDecision.Reject)
|
||||
{
|
||||
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)
|
||||
{
|
||||
targetPhase = contract.RejectedFromPhase!.Value;
|
||||
contract.RejectedFromPhase = null;
|
||||
}
|
||||
|
||||
// Resolve the workflow: prefer the pinned WorkflowDefinition (new
|
||||
// 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)
|
||||
{
|
||||
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 = definition is not null
|
||||
? WorkflowPolicyRegistry.FromDefinition(definition)
|
||||
: WorkflowPolicyRegistry.ForContract(contract);
|
||||
}
|
||||
else
|
||||
{
|
||||
var overrides = await db.WorkflowTypeAssignments.AsNoTracking()
|
||||
.ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct);
|
||||
policy = WorkflowPolicyRegistry.ForContractWithOverrides(contract, overrides);
|
||||
}
|
||||
var isAdmin = actorRoles.Contains(AppRoles.Admin);
|
||||
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
|
||||
|
||||
// Policy guard — bypass cho resume (Drafter có quyền trình lại sau khi
|
||||
// sửa, không cần policy check vì target đã pinned bởi RejectedFromPhase).
|
||||
if (!isAdmin && !isSystem && !isResumingAfterReject)
|
||||
// ===== REJECT BRANCH =====
|
||||
if (decision == ApprovalDecision.Reject)
|
||||
{
|
||||
if (!policy.Transitions.TryGetValue((fromPhase, targetPhase), out var allowedRoles))
|
||||
throw new ForbiddenException(
|
||||
$"Policy '{policy.Name}' không cho phép {fromPhase} → {targetPhase}. " +
|
||||
$"Kiểm tra ContractType hoặc BypassProcurementAndCCM.");
|
||||
|
||||
// Sử dụng IsTransitionAllowed — check Role + User-kind fallback.
|
||||
// User-kind chỉ áp dụng khi WorkflowDefinition pinned có
|
||||
// WorkflowStepApprover Kind=User cho step này.
|
||||
if (!policy.IsTransitionAllowed(fromPhase, targetPhase, actorRoles, actorUserId))
|
||||
if (targetPhase == ContractPhase.TuChoi)
|
||||
{
|
||||
var userExtra = policy.UserTransitions is not null
|
||||
&& policy.UserTransitions.TryGetValue((fromPhase, targetPhase), out var userIds)
|
||||
&& userIds.Length > 0
|
||||
? $" hoặc {userIds.Length} user explicit"
|
||||
: "";
|
||||
throw new ForbiddenException(
|
||||
$"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {fromPhase} → {targetPhase}. " +
|
||||
$"Policy '{policy.Name}' yêu cầu: {string.Join(",", allowedRoles)}{userExtra}.");
|
||||
contract.Phase = ContractPhase.TuChoi;
|
||||
}
|
||||
else
|
||||
{
|
||||
contract.RejectedFromPhase = fromPhase;
|
||||
contract.RejectedAtStepIndex = contract.CurrentWorkflowStepIndex;
|
||||
contract.Phase = ContractPhase.DangSoanThao;
|
||||
contract.CurrentWorkflowStepIndex = null;
|
||||
}
|
||||
contract.SlaDeadline = null;
|
||||
await LogTransitionAsync(contract, fromPhase, contract.Phase, actorUserId, decision, comment, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// ===== 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;
|
||||
// ===== RESUME AFTER REJECT =====
|
||||
var isResumingAfterReject = decision == ApprovalDecision.Approve
|
||||
&& fromPhase == ContractPhase.DangSoanThao
|
||||
&& contract.RejectedAtStepIndex != null;
|
||||
|
||||
if (decision == ApprovalDecision.Approve
|
||||
&& targetPhase != ContractPhase.DangSoanThao
|
||||
&& targetPhase != ContractPhase.TuChoi
|
||||
&& !isResumingAfterReject
|
||||
&& !isAdmin && !isSystem
|
||||
&& actorUserId is Guid actorUid)
|
||||
if (isResumingAfterReject)
|
||||
{
|
||||
var actor = await userManager.FindByIdAsync(actorUid.ToString());
|
||||
contract.Phase = ContractPhase.ChoDuyet;
|
||||
contract.CurrentWorkflowStepIndex = contract.RejectedAtStepIndex;
|
||||
contract.RejectedAtStepIndex = null;
|
||||
contract.RejectedFromPhase = null;
|
||||
contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
|
||||
await LogTransitionAsync(contract, fromPhase, ContractPhase.ChoDuyet, actorUserId, decision, comment, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasInnerSteps && currentStepDef is not null)
|
||||
// ===== DRAFTER TRÌNH =====
|
||||
if (fromPhase == ContractPhase.DangSoanThao
|
||||
&& (targetPhase == ContractPhase.ChoDuyet || (!isAdmin && !isSystem)))
|
||||
{
|
||||
if (!isAdmin && !isSystem
|
||||
&& !actorRoles.Contains(AppRoles.Drafter)
|
||||
&& !actorRoles.Contains(AppRoles.DeptManager))
|
||||
{
|
||||
// ===== N-stage logic (Mig 20) — mirror PE Mig 18 =====
|
||||
if (actor?.DepartmentId is null || actor.PositionLevel is null)
|
||||
{
|
||||
throw new ForbiddenException(
|
||||
$"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt HĐ.");
|
||||
}
|
||||
contract.Phase = ContractPhase.ChoDuyet;
|
||||
contract.CurrentWorkflowStepIndex = 0;
|
||||
contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
|
||||
await LogTransitionAsync(contract, fromPhase, ContractPhase.ChoDuyet, actorUserId, decision, comment, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// ===== APPROVE STEP =====
|
||||
if (fromPhase == ContractPhase.ChoDuyet && decision == ApprovalDecision.Approve)
|
||||
{
|
||||
var def = contract.WorkflowDefinitionId is Guid wfId
|
||||
? await db.WorkflowDefinitions.AsNoTracking()
|
||||
.Include(d => d.Steps.OrderBy(s => s.Order))
|
||||
.ThenInclude(s => s.Approvers)
|
||||
.FirstOrDefaultAsync(d => d.Id == wfId, ct)
|
||||
: null;
|
||||
|
||||
if (def == null || def.Steps.Count == 0)
|
||||
throw new ConflictException("HĐ chưa pin workflow definition hoặc workflow không có step.");
|
||||
|
||||
var steps = def.Steps.OrderBy(s => s.Order).ToList();
|
||||
var currentIdx = contract.CurrentWorkflowStepIndex ?? 0;
|
||||
if (currentIdx < 0 || currentIdx >= steps.Count)
|
||||
throw new ConflictException($"CurrentWorkflowStepIndex={currentIdx} không hợp lệ.");
|
||||
|
||||
var currentStep = steps[currentIdx];
|
||||
|
||||
if (!isAdmin && !isSystem)
|
||||
{
|
||||
var actor = actorUserId is Guid uid ? await userManager.FindByIdAsync(uid.ToString()) : null;
|
||||
if (actor == null)
|
||||
throw new ForbiddenException("Không xác định được approver.");
|
||||
|
||||
var matchByDeptLevel = currentStep.DepartmentId != null
|
||||
&& currentStep.PositionLevel != null
|
||||
&& actor.DepartmentId == currentStep.DepartmentId
|
||||
&& actor.PositionLevel != null
|
||||
&& (int)actor.PositionLevel >= (int)currentStep.PositionLevel;
|
||||
|
||||
var matchByExplicitUser = currentStep.Approvers.Any(a =>
|
||||
a.Kind == WorkflowApproverKind.User
|
||||
&& Guid.TryParse(a.AssignmentValue, out var auid)
|
||||
&& auid == actor.Id);
|
||||
|
||||
var matchByRole = currentStep.Approvers.Any(a =>
|
||||
a.Kind == WorkflowApproverKind.Role
|
||||
&& actorRoles.Contains(a.AssignmentValue));
|
||||
|
||||
if (!matchByDeptLevel && !matchByExplicitUser && !matchByRole)
|
||||
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
|
||||
}
|
||||
$"Step {currentIdx + 1} ({currentStep.Name}) yêu cầu phòng={currentStep.DepartmentId}, cấp={currentStep.PositionLevel}. Bạn không khớp.");
|
||||
}
|
||||
else if (actor?.DepartmentId is Guid deptId)
|
||||
|
||||
db.ContractApprovals.Add(new ContractApproval
|
||||
{
|
||||
// ===== 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");
|
||||
ContractId = contract.Id,
|
||||
FromPhase = fromPhase,
|
||||
ToPhase = fromPhase,
|
||||
ApproverUserId = actorUserId,
|
||||
Decision = decision,
|
||||
Comment = $"[Step {currentIdx + 1}] {comment ?? ""}",
|
||||
ApprovedAt = dateTime.UtcNow,
|
||||
});
|
||||
|
||||
var existing = await db.ContractDepartmentApprovals
|
||||
.FirstOrDefaultAsync(a =>
|
||||
a.ContractId == contract.Id
|
||||
&& a.PhaseAtApproval == (int)fromPhase
|
||||
&& a.DepartmentId == deptId
|
||||
&& a.Stage == stage
|
||||
&& a.InnerStepId == null, ct);
|
||||
if (existing is null)
|
||||
var nextIdx = currentIdx + 1;
|
||||
if (nextIdx >= steps.Count)
|
||||
{
|
||||
// All steps done — gen mã HĐ + DaPhatHanh
|
||||
if (string.IsNullOrEmpty(contract.MaHopDong))
|
||||
{
|
||||
db.ContractDepartmentApprovals.Add(new ContractDepartmentApproval
|
||||
{
|
||||
ContractId = contract.Id,
|
||||
PhaseAtApproval = (int)fromPhase,
|
||||
DepartmentId = deptId,
|
||||
Stage = stage,
|
||||
ApproverUserId = actorUid,
|
||||
ApproverRoleSnapshot = roleSnapshot,
|
||||
Comment = comment,
|
||||
ApprovedAt = dateTime.UtcNow,
|
||||
IsBypassed = isBypassed,
|
||||
InnerStepId = null,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.ApproverUserId = actorUid;
|
||||
existing.ApproverRoleSnapshot = roleSnapshot;
|
||||
existing.Comment = comment;
|
||||
existing.ApprovedAt = dateTime.UtcNow;
|
||||
existing.IsBypassed = isBypassed;
|
||||
}
|
||||
|
||||
var hasConfirm = stage == ApprovalStage.Confirm
|
||||
|| await db.ContractDepartmentApprovals.AnyAsync(a =>
|
||||
a.ContractId == contract.Id
|
||||
&& a.PhaseAtApproval == (int)fromPhase
|
||||
&& a.DepartmentId == deptId
|
||||
&& a.Stage == ApprovalStage.Confirm
|
||||
&& a.InnerStepId == null, ct);
|
||||
|
||||
if (!hasConfirm)
|
||||
{
|
||||
db.ContractApprovals.Add(new ContractApproval
|
||||
{
|
||||
ContractId = contract.Id,
|
||||
FromPhase = fromPhase,
|
||||
ToPhase = fromPhase,
|
||||
ApproverUserId = actorUid,
|
||||
Decision = ApprovalDecision.Approve,
|
||||
Comment = $"[Review NV] {comment ?? ""}",
|
||||
ApprovedAt = dateTime.UtcNow,
|
||||
});
|
||||
|
||||
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} (NV) đã review phase {fromPhase}, chờ TPB confirm",
|
||||
ContextNote = comment,
|
||||
});
|
||||
|
||||
// Notify TPB cùng dept để confirm. Best effort.
|
||||
try
|
||||
{
|
||||
var managers = await db.Users.AsNoTracking()
|
||||
.Where(u => u.DepartmentId == deptId && u.Id != actorUid && u.IsActive)
|
||||
.Select(u => u.Id)
|
||||
.ToListAsync(ct);
|
||||
foreach (var mgrId in managers)
|
||||
{
|
||||
var mgr = await userManager.FindByIdAsync(mgrId.ToString());
|
||||
if (mgr is null) continue;
|
||||
var roles = await userManager.GetRolesAsync(mgr);
|
||||
if (!roles.Contains(AppRoles.DeptManager)) continue;
|
||||
|
||||
await notifications.NotifyAsync(
|
||||
mgrId,
|
||||
NotificationType.ContractPhaseTransition,
|
||||
title: $"HĐ {contract.MaHopDong ?? contract.TenHopDong ?? ""} chờ TPB confirm",
|
||||
description: $"NV {reviewerName} đã review phase {fromPhase}. Vui lòng vào confirm để workflow tiếp tục.",
|
||||
href: $"/contracts/{contract.Id}",
|
||||
refId: contract.Id,
|
||||
ct: ct);
|
||||
}
|
||||
}
|
||||
catch { /* notification fail non-critical */ }
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == contract.SupplierId, ct)
|
||||
?? throw new NotFoundException("Supplier", contract.SupplierId);
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == contract.ProjectId, ct)
|
||||
?? throw new NotFoundException("Project", contract.ProjectId);
|
||||
contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct);
|
||||
}
|
||||
contract.Phase = ContractPhase.DaPhatHanh;
|
||||
contract.CurrentWorkflowStepIndex = null;
|
||||
contract.SlaDeadline = null;
|
||||
await LogTransitionAsync(contract, fromPhase, ContractPhase.DaPhatHanh, actorUserId, decision, comment, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
contract.CurrentWorkflowStepIndex = nextIdx;
|
||||
contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
|
||||
await LogTransitionAsync(contract, fromPhase, fromPhase, actorUserId, decision,
|
||||
$"Hoàn tất step {currentIdx + 1}/{steps.Count}, sang step {nextIdx + 1}", ct);
|
||||
}
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Defensive — gen mã HĐ nếu chưa có khi chuyển sang DangDongDau.
|
||||
// Nominal flow (sau user feedback): mã đã gen sẵn từ CreateContract → skip.
|
||||
// Fallback chỉ trigger cho HĐ legacy chưa qua backfill, hoặc HĐ tạo bằng
|
||||
// path khác (vd seed/import) chưa set MaHopDong.
|
||||
if (targetPhase == ContractPhase.DangDongDau && string.IsNullOrEmpty(contract.MaHopDong))
|
||||
// Admin manual override
|
||||
if (isAdmin)
|
||||
{
|
||||
var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == contract.SupplierId, ct)
|
||||
?? throw new NotFoundException("Supplier", contract.SupplierId);
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == contract.ProjectId, ct)
|
||||
?? throw new NotFoundException("Project", contract.ProjectId);
|
||||
contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct);
|
||||
contract.Phase = targetPhase;
|
||||
contract.SlaDeadline = targetPhase == ContractPhase.ChoDuyet
|
||||
? dateTime.UtcNow.AddDays(7) : null;
|
||||
await LogTransitionAsync(contract, fromPhase, targetPhase, actorUserId, decision, comment, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
contract.SlaWarningSent = false;
|
||||
contract.Phase = targetPhase;
|
||||
throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ.");
|
||||
}
|
||||
|
||||
var sla = policy.PhaseSla.GetValueOrDefault(targetPhase);
|
||||
contract.SlaDeadline = sla is null ? null : dateTime.UtcNow.Add(sla.Value);
|
||||
|
||||
db.ContractApprovals.Add(new ContractApproval
|
||||
{
|
||||
ContractId = contract.Id,
|
||||
FromPhase = fromPhase,
|
||||
ToPhase = targetPhase,
|
||||
ApproverUserId = actorUserId,
|
||||
Decision = decision,
|
||||
Comment = comment,
|
||||
ApprovedAt = dateTime.UtcNow,
|
||||
});
|
||||
|
||||
// Log workflow transition vào unified Changelog (cho user xem trên tab Lịch sử)
|
||||
await changelog.LogWorkflowTransitionAsync(contract.Id, fromPhase, targetPhase, comment);
|
||||
private async Task LogTransitionAsync(
|
||||
Contract contract,
|
||||
ContractPhase fromPhase,
|
||||
ContractPhase toPhase,
|
||||
Guid? actorUserId,
|
||||
ApprovalDecision decision,
|
||||
string? comment,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await changelog.LogWorkflowTransitionAsync(contract.Id, fromPhase, toPhase, comment);
|
||||
|
||||
if (contract.DrafterUserId is Guid drafterId && drafterId != actorUserId)
|
||||
{
|
||||
var title = targetPhase switch
|
||||
var (title, type) = toPhase switch
|
||||
{
|
||||
ContractPhase.DaPhatHanh => $"HĐ {contract.MaHopDong ?? contract.TenHopDong} đã phát hành",
|
||||
ContractPhase.TuChoi => $"HĐ {contract.TenHopDong ?? "của bạn"} bị từ chối",
|
||||
_ => $"HĐ {contract.TenHopDong ?? contract.MaHopDong ?? ""} chuyển sang phase mới",
|
||||
};
|
||||
var type = targetPhase switch
|
||||
{
|
||||
ContractPhase.DaPhatHanh => NotificationType.ContractPublished,
|
||||
ContractPhase.TuChoi => NotificationType.ContractRejected,
|
||||
_ => NotificationType.ContractPhaseTransition,
|
||||
ContractPhase.DaPhatHanh => ($"HĐ {contract.MaHopDong ?? contract.TenHopDong} đã phát hành",
|
||||
NotificationType.ContractPublished),
|
||||
ContractPhase.TuChoi => ($"HĐ {contract.TenHopDong ?? "của bạn"} bị từ chối",
|
||||
NotificationType.ContractRejected),
|
||||
ContractPhase.DangSoanThao when fromPhase == ContractPhase.ChoDuyet =>
|
||||
($"HĐ {contract.TenHopDong ?? "của bạn"} bị trả lại — vui lòng sửa và trình lại",
|
||||
NotificationType.ContractRejected),
|
||||
_ => ($"HĐ {contract.TenHopDong ?? contract.MaHopDong ?? ""} chuyển phase mới",
|
||||
NotificationType.ContractPhaseTransition),
|
||||
};
|
||||
await notifications.NotifyAsync(
|
||||
drafterId,
|
||||
type,
|
||||
title,
|
||||
description: $"{fromPhase} → {targetPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
|
||||
drafterId, type, title,
|
||||
description: $"{fromPhase} → {toPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
|
||||
href: $"/contracts/{contract.Id}",
|
||||
refId: contract.Id,
|
||||
ct: ct);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,8 +12,11 @@ using SolutionErp.Domain.PurchaseEvaluations;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Services;
|
||||
|
||||
// Mirror ContractWorkflowService. Load policy từ pinned
|
||||
// WorkflowDefinition (nếu có) hoặc fallback hardcoded registry.
|
||||
// PE Workflow Service — Session 16 drastic refactor (Mig 21):
|
||||
// Flat workflow model. Mỗi step = 1 (Phòng × Cấp + Approvers). Service iterate
|
||||
// steps OrderBy Order, advance PE.CurrentWorkflowStepIndex per approve.
|
||||
// Phase enum simplified: DangSoanThao → ChoDuyet (active workflow) → DaDuyet
|
||||
// (terminal) / TuChoi (khoá). Trả lại = về DangSoanThao + save RejectedAtStepIndex.
|
||||
public class PurchaseEvaluationWorkflowService(
|
||||
IApplicationDbContext db,
|
||||
IDateTime dateTime,
|
||||
@ -21,7 +24,7 @@ public class PurchaseEvaluationWorkflowService(
|
||||
UserManager<User> userManager) : IPurchaseEvaluationWorkflowService
|
||||
{
|
||||
public TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase) =>
|
||||
PurchaseEvaluationPolicies.NccOnly.PhaseSla.GetValueOrDefault(phase);
|
||||
phase == PurchaseEvaluationPhase.ChoDuyet ? TimeSpan.FromDays(7) : null;
|
||||
|
||||
public async Task TransitionAsync(
|
||||
PurchaseEvaluation evaluation,
|
||||
@ -32,345 +35,173 @@ public class PurchaseEvaluationWorkflowService(
|
||||
string? comment,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (evaluation.Phase == targetPhase)
|
||||
throw new ConflictException("Phiếu đã ở phase đích.");
|
||||
|
||||
// ===== Smart reject + resume (Phase 9 — Migration 16) =====
|
||||
var fromPhase = evaluation.Phase;
|
||||
var isResumingAfterReject = decision == ApprovalDecision.Approve
|
||||
&& fromPhase == PurchaseEvaluationPhase.DangSoanThao
|
||||
&& evaluation.RejectedFromPhase != null;
|
||||
|
||||
if (decision == ApprovalDecision.Reject)
|
||||
{
|
||||
// 2 loại Reject (Session 14):
|
||||
// - target=TuChoi: "Từ chối hoàn toàn" — phiếu khoá vĩnh viễn (Phase=TuChoi
|
||||
// → 17 handler Mig 16 lock edit). Drafter phải tạo phiếu mới. KHÔNG set
|
||||
// RejectedFromPhase + KHÔNG clear N-stage (không resume).
|
||||
// - target khác (thường = DangSoanThao): "Trả lại" — smart reject pattern
|
||||
// Mig 16. Set RejectedFromPhase + force DangSoanThao + clear N-stage rows
|
||||
// tại fromPhase → Drafter sửa rồi trình lại jump-back tới phase đã reject.
|
||||
if (targetPhase != PurchaseEvaluationPhase.TuChoi)
|
||||
{
|
||||
evaluation.RejectedFromPhase = fromPhase;
|
||||
targetPhase = PurchaseEvaluationPhase.DangSoanThao;
|
||||
|
||||
var staleNStageRows = await db.PurchaseEvaluationDepartmentApprovals
|
||||
.Where(a => a.PurchaseEvaluationId == evaluation.Id
|
||||
&& a.PhaseAtApproval == (int)fromPhase
|
||||
&& a.InnerStepId != null)
|
||||
.ToListAsync(ct);
|
||||
foreach (var r in staleNStageRows) db.PurchaseEvaluationDepartmentApprovals.Remove(r);
|
||||
}
|
||||
}
|
||||
else if (isResumingAfterReject)
|
||||
{
|
||||
targetPhase = evaluation.RejectedFromPhase!.Value;
|
||||
evaluation.RejectedFromPhase = null;
|
||||
}
|
||||
|
||||
PurchaseEvaluationPolicy policy;
|
||||
PurchaseEvaluationWorkflowDefinition? definition = null;
|
||||
if (evaluation.WorkflowDefinitionId is Guid wfId)
|
||||
{
|
||||
definition = await db.PurchaseEvaluationWorkflowDefinitions.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 = definition is not null
|
||||
? PurchaseEvaluationPolicyRegistry.FromDefinition(definition)
|
||||
: PurchaseEvaluationPolicyRegistry.ForEvaluation(evaluation);
|
||||
}
|
||||
else
|
||||
{
|
||||
policy = PurchaseEvaluationPolicyRegistry.ForEvaluation(evaluation);
|
||||
}
|
||||
|
||||
var isAdmin = actorRoles.Contains(AppRoles.Admin);
|
||||
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
|
||||
|
||||
// Policy guard — bypass khi resume sau reject (target đã pinned).
|
||||
if (!isAdmin && !isSystem && !isResumingAfterReject)
|
||||
// ===== REJECT BRANCH =====
|
||||
if (decision == ApprovalDecision.Reject)
|
||||
{
|
||||
if (!policy.Transitions.TryGetValue((fromPhase, targetPhase), out var allowedRoles))
|
||||
throw new ForbiddenException(
|
||||
$"Policy '{policy.Name}' không cho phép {fromPhase} → {targetPhase}.");
|
||||
|
||||
if (!policy.IsTransitionAllowed(fromPhase, targetPhase, actorRoles, actorUserId))
|
||||
if (targetPhase == PurchaseEvaluationPhase.TuChoi)
|
||||
{
|
||||
throw new ForbiddenException(
|
||||
$"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {fromPhase} → {targetPhase}. " +
|
||||
$"Policy '{policy.Name}' yêu cầu: {string.Join(",", allowedRoles)}.");
|
||||
// Từ chối hoàn toàn — phiếu khoá vĩnh viễn (lock edit Mig 16).
|
||||
evaluation.Phase = PurchaseEvaluationPhase.TuChoi;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Trả lại — về DangSoanThao + save RejectedAtStepIndex (resume jump-back).
|
||||
evaluation.RejectedFromPhase = fromPhase;
|
||||
evaluation.RejectedAtStepIndex = evaluation.CurrentWorkflowStepIndex;
|
||||
evaluation.Phase = PurchaseEvaluationPhase.DangSoanThao;
|
||||
evaluation.CurrentWorkflowStepIndex = null;
|
||||
}
|
||||
evaluation.SlaDeadline = null;
|
||||
await LogTransitionAsync(evaluation, fromPhase, evaluation.Phase, actorUserId, decision, comment, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// ===== Department approval (N-stage Mig 18 hoặc Legacy 2-stage Mig 16) =====
|
||||
// Active block khi: Approve + chuyển sang phase trung gian (không phải
|
||||
// DangSoanThao/TuChoi) + KHÔNG admin/system + KHÔNG resume sau reject.
|
||||
var currentStepDef = definition?.Steps.FirstOrDefault(s => s.Phase == fromPhase);
|
||||
var hasInnerSteps = currentStepDef?.InnerSteps.Count > 0;
|
||||
// ===== RESUME AFTER REJECT (Drafter trình lại) =====
|
||||
var isResumingAfterReject = decision == ApprovalDecision.Approve
|
||||
&& fromPhase == PurchaseEvaluationPhase.DangSoanThao
|
||||
&& evaluation.RejectedAtStepIndex != null;
|
||||
|
||||
if (decision == ApprovalDecision.Approve
|
||||
&& targetPhase != PurchaseEvaluationPhase.DangSoanThao
|
||||
&& targetPhase != PurchaseEvaluationPhase.TuChoi
|
||||
&& !isResumingAfterReject
|
||||
&& !isAdmin && !isSystem
|
||||
&& actorUserId is Guid actorUid)
|
||||
if (isResumingAfterReject)
|
||||
{
|
||||
var actor = await userManager.FindByIdAsync(actorUid.ToString());
|
||||
evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet;
|
||||
evaluation.CurrentWorkflowStepIndex = evaluation.RejectedAtStepIndex;
|
||||
evaluation.RejectedAtStepIndex = null;
|
||||
evaluation.RejectedFromPhase = null;
|
||||
evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
|
||||
await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.ChoDuyet, actorUserId, decision, comment, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasInnerSteps && currentStepDef is not null)
|
||||
// ===== DRAFTER TRÌNH (DangSoanThao → ChoDuyet) =====
|
||||
if (fromPhase == PurchaseEvaluationPhase.DangSoanThao
|
||||
&& (targetPhase == PurchaseEvaluationPhase.ChoDuyet || !isAdmin && !isSystem))
|
||||
{
|
||||
// Drafter/DeptManager only (or Admin bypass).
|
||||
if (!isAdmin && !isSystem
|
||||
&& !actorRoles.Contains(AppRoles.Drafter)
|
||||
&& !actorRoles.Contains(AppRoles.DeptManager))
|
||||
{
|
||||
// ===== N-stage logic (Mig 18) =====
|
||||
// Yêu cầu user có DepartmentId + PositionLevel set. Match exact
|
||||
// (DeptId + PositionLevel) inner step pending tiếp theo theo Order.
|
||||
// Bypass: actor cùng dept + PositionLevel cao hơn + CanBypassReview
|
||||
// → batch upsert luôn các inner step cấp dưới (audit IsBypassed=true).
|
||||
if (actor?.DepartmentId is null || actor.PositionLevel is null)
|
||||
{
|
||||
throw new ForbiddenException(
|
||||
$"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt phiếu.");
|
||||
}
|
||||
evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet;
|
||||
evaluation.CurrentWorkflowStepIndex = 0;
|
||||
evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
|
||||
await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.ChoDuyet, actorUserId, decision, comment, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// ===== APPROVE STEP (advance pointer trong ChoDuyet) =====
|
||||
if (fromPhase == PurchaseEvaluationPhase.ChoDuyet && decision == ApprovalDecision.Approve)
|
||||
{
|
||||
var def = evaluation.WorkflowDefinitionId is Guid wfId
|
||||
? await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
|
||||
.Include(d => d.Steps.OrderBy(s => s.Order))
|
||||
.ThenInclude(s => s.Approvers)
|
||||
.FirstOrDefaultAsync(d => d.Id == wfId, ct)
|
||||
: null;
|
||||
|
||||
if (def == null || def.Steps.Count == 0)
|
||||
throw new ConflictException("Phiếu chưa pin workflow definition hoặc workflow không có step.");
|
||||
|
||||
var steps = def.Steps.OrderBy(s => s.Order).ToList();
|
||||
var currentIdx = evaluation.CurrentWorkflowStepIndex ?? 0;
|
||||
if (currentIdx < 0 || currentIdx >= steps.Count)
|
||||
throw new ConflictException($"CurrentWorkflowStepIndex={currentIdx} không hợp lệ (max={steps.Count - 1}).");
|
||||
|
||||
var currentStep = steps[currentIdx];
|
||||
|
||||
// Match approver — admin bypass policy
|
||||
if (!isAdmin && !isSystem)
|
||||
{
|
||||
var actor = actorUserId is Guid uid ? await userManager.FindByIdAsync(uid.ToString()) : null;
|
||||
if (actor == null)
|
||||
throw new ForbiddenException("Không xác định được approver.");
|
||||
|
||||
var matchByDeptLevel = currentStep.DepartmentId != null
|
||||
&& currentStep.PositionLevel != null
|
||||
&& actor.DepartmentId == currentStep.DepartmentId
|
||||
&& actor.PositionLevel != null
|
||||
&& (int)actor.PositionLevel >= (int)currentStep.PositionLevel;
|
||||
|
||||
var matchByExplicitUser = currentStep.Approvers.Any(a =>
|
||||
a.Kind == WorkflowApproverKind.User
|
||||
&& Guid.TryParse(a.AssignmentValue, out var auid)
|
||||
&& auid == actor.Id);
|
||||
|
||||
var matchByRole = currentStep.Approvers.Any(a =>
|
||||
a.Kind == WorkflowApproverKind.Role
|
||||
&& actorRoles.Contains(a.AssignmentValue));
|
||||
|
||||
if (!matchByDeptLevel && !matchByExplicitUser && !matchByRole)
|
||||
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.PurchaseEvaluationDepartmentApprovals
|
||||
.Where(a => a.PurchaseEvaluationId == evaluation.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];
|
||||
|
||||
// Match: same dept AND (exact level OR canBypass + level higher)
|
||||
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.");
|
||||
}
|
||||
|
||||
// Determine rows to upsert
|
||||
var rowsToCreate = new List<(PurchaseEvaluationWorkflowStepInnerStep 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.PurchaseEvaluationDepartmentApprovals.Add(new PurchaseEvaluationDepartmentApproval
|
||||
{
|
||||
PurchaseEvaluationId = evaluation.Id,
|
||||
PhaseAtApproval = (int)fromPhase,
|
||||
DepartmentId = inner.DepartmentId,
|
||||
Stage = ApprovalStage.Confirm, // N-stage: tất cả row dùng Confirm semantically
|
||||
ApproverUserId = actorUid,
|
||||
ApproverRoleSnapshot = $"{inner.PositionLevel}{(bypassed ? "(bypass)" : "")}",
|
||||
Comment = comment,
|
||||
ApprovedAt = nowUtc,
|
||||
IsBypassed = bypassed,
|
||||
InnerStepId = inner.Id,
|
||||
});
|
||||
doneInnerIds.Add(inner.Id);
|
||||
}
|
||||
|
||||
// Recheck remaining pending sau upsert
|
||||
var stillPending = inners.Any(i => i.IsRequired && !doneInnerIds.Contains(i.Id));
|
||||
if (stillPending)
|
||||
{
|
||||
// Còn cấp duyệt tiếp theo → BLOCK phase transition.
|
||||
// Log Approval (review-style) + Changelog audit.
|
||||
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
|
||||
{
|
||||
PurchaseEvaluationId = evaluation.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.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||
{
|
||||
PurchaseEvaluationId = evaluation.Id,
|
||||
EntityType = PurchaseEvaluationEntityType.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 to phase transition
|
||||
}
|
||||
// pendingRequired.Count == 0 → all already done before this call → fall through
|
||||
$"Step {currentIdx + 1} ({currentStep.Name}) yêu cầu phòng={currentStep.DepartmentId}, cấp={currentStep.PositionLevel}. Bạn không khớp.");
|
||||
}
|
||||
else if (actor?.DepartmentId is Guid deptId)
|
||||
|
||||
// Log approval row
|
||||
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
|
||||
{
|
||||
// ===== 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");
|
||||
PurchaseEvaluationId = evaluation.Id,
|
||||
FromPhase = fromPhase,
|
||||
ToPhase = fromPhase, // step advance — phase same
|
||||
ApproverUserId = actorUserId,
|
||||
Decision = decision,
|
||||
Comment = $"[Step {currentIdx + 1}] {comment ?? ""}",
|
||||
ApprovedAt = dateTime.UtcNow,
|
||||
});
|
||||
|
||||
// Upsert: 1 row mỗi (PEId, phase, dept, stage). UNIQUE filtered InnerStepId IS NULL.
|
||||
var existing = await db.PurchaseEvaluationDepartmentApprovals
|
||||
.FirstOrDefaultAsync(a =>
|
||||
a.PurchaseEvaluationId == evaluation.Id
|
||||
&& a.PhaseAtApproval == (int)fromPhase
|
||||
&& a.DepartmentId == deptId
|
||||
&& a.Stage == stage
|
||||
&& a.InnerStepId == null, ct);
|
||||
if (existing is null)
|
||||
{
|
||||
db.PurchaseEvaluationDepartmentApprovals.Add(new PurchaseEvaluationDepartmentApproval
|
||||
{
|
||||
PurchaseEvaluationId = evaluation.Id,
|
||||
PhaseAtApproval = (int)fromPhase,
|
||||
DepartmentId = deptId,
|
||||
Stage = stage,
|
||||
ApproverUserId = actorUid,
|
||||
ApproverRoleSnapshot = roleSnapshot,
|
||||
Comment = comment,
|
||||
ApprovedAt = dateTime.UtcNow,
|
||||
IsBypassed = isBypassed,
|
||||
InnerStepId = null,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.ApproverUserId = actorUid;
|
||||
existing.ApproverRoleSnapshot = roleSnapshot;
|
||||
existing.Comment = comment;
|
||||
existing.ApprovedAt = dateTime.UtcNow;
|
||||
existing.IsBypassed = isBypassed;
|
||||
}
|
||||
|
||||
// Check Stage=Confirm tồn tại cho (PEId, fromPhase, deptId)
|
||||
var hasConfirm = stage == ApprovalStage.Confirm
|
||||
|| await db.PurchaseEvaluationDepartmentApprovals.AnyAsync(a =>
|
||||
a.PurchaseEvaluationId == evaluation.Id
|
||||
&& a.PhaseAtApproval == (int)fromPhase
|
||||
&& a.DepartmentId == deptId
|
||||
&& a.Stage == ApprovalStage.Confirm
|
||||
&& a.InnerStepId == null, ct);
|
||||
|
||||
if (!hasConfirm)
|
||||
{
|
||||
// NV review xong, chưa có TPB confirm → BLOCK phase transition.
|
||||
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
|
||||
{
|
||||
PurchaseEvaluationId = evaluation.Id,
|
||||
FromPhase = fromPhase,
|
||||
ToPhase = fromPhase,
|
||||
ApproverUserId = actorUid,
|
||||
Decision = ApprovalDecision.Approve,
|
||||
Comment = $"[Review NV] {comment ?? ""}",
|
||||
ApprovedAt = dateTime.UtcNow,
|
||||
});
|
||||
|
||||
string? reviewerName = actor.FullName ?? actor.Email;
|
||||
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||
{
|
||||
PurchaseEvaluationId = evaluation.Id,
|
||||
EntityType = PurchaseEvaluationEntityType.Workflow,
|
||||
Action = ChangelogAction.Transition,
|
||||
PhaseAtChange = fromPhase,
|
||||
UserId = actorUid,
|
||||
UserName = reviewerName ?? "Hệ thống",
|
||||
Summary = $"{reviewerName} (NV) đã review phase {fromPhase}, chờ TPB confirm",
|
||||
ContextNote = comment,
|
||||
});
|
||||
|
||||
// Notify TPB cùng dept để confirm. Best effort — fail OK.
|
||||
try
|
||||
{
|
||||
var managers = await db.Users.AsNoTracking()
|
||||
.Where(u => u.DepartmentId == deptId && u.Id != actorUid && u.IsActive)
|
||||
.Select(u => u.Id)
|
||||
.ToListAsync(ct);
|
||||
if (managers.Count > 0)
|
||||
{
|
||||
foreach (var mgrId in managers)
|
||||
{
|
||||
var mgr = await userManager.FindByIdAsync(mgrId.ToString());
|
||||
if (mgr is null) continue;
|
||||
var roles = await userManager.GetRolesAsync(mgr);
|
||||
if (!roles.Contains(AppRoles.DeptManager)) continue;
|
||||
|
||||
await notifications.NotifyAsync(
|
||||
mgrId,
|
||||
NotificationType.ContractPhaseTransition,
|
||||
title: $"Phiếu {evaluation.MaPhieu ?? evaluation.TenGoiThau} chờ TPB confirm",
|
||||
description: $"NV {reviewerName} đã review phase {fromPhase}. Vui lòng vào confirm để workflow tiếp tục.",
|
||||
href: $"/purchase-evaluations/{evaluation.Id}",
|
||||
refId: evaluation.Id,
|
||||
ct: ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* notification fail non-critical */ }
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
// Advance pointer
|
||||
var nextIdx = currentIdx + 1;
|
||||
if (nextIdx >= steps.Count)
|
||||
{
|
||||
// All steps done — terminal DaDuyet
|
||||
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
|
||||
evaluation.CurrentWorkflowStepIndex = null;
|
||||
evaluation.SlaDeadline = null;
|
||||
await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.DaDuyet, actorUserId, decision, comment, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
evaluation.CurrentWorkflowStepIndex = nextIdx;
|
||||
evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
|
||||
await LogTransitionAsync(evaluation, fromPhase, fromPhase, actorUserId, decision,
|
||||
$"Hoàn tất step {currentIdx + 1}/{steps.Count}, sang step {nextIdx + 1}", ct);
|
||||
}
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
evaluation.SlaWarningSent = false;
|
||||
evaluation.Phase = targetPhase;
|
||||
|
||||
var sla = policy.PhaseSla.GetValueOrDefault(targetPhase);
|
||||
evaluation.SlaDeadline = sla is null ? null : dateTime.UtcNow.Add(sla.Value);
|
||||
|
||||
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
|
||||
// Admin manual override (vd test cứng phase)
|
||||
if (isAdmin)
|
||||
{
|
||||
PurchaseEvaluationId = evaluation.Id,
|
||||
FromPhase = fromPhase,
|
||||
ToPhase = targetPhase,
|
||||
ApproverUserId = actorUserId,
|
||||
Decision = decision,
|
||||
Comment = comment,
|
||||
ApprovedAt = dateTime.UtcNow,
|
||||
});
|
||||
evaluation.Phase = targetPhase;
|
||||
evaluation.SlaDeadline = targetPhase == PurchaseEvaluationPhase.ChoDuyet
|
||||
? dateTime.UtcNow.AddDays(7) : null;
|
||||
await LogTransitionAsync(evaluation, fromPhase, targetPhase, actorUserId, decision, comment, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve actor name for changelog
|
||||
throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ.");
|
||||
}
|
||||
|
||||
private async Task LogTransitionAsync(
|
||||
PurchaseEvaluation evaluation,
|
||||
PurchaseEvaluationPhase fromPhase,
|
||||
PurchaseEvaluationPhase toPhase,
|
||||
Guid? actorUserId,
|
||||
ApprovalDecision decision,
|
||||
string? comment,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Save Approval history (đã làm trong main flow — chỉ log Changelog ở đây)
|
||||
string? actorName = null;
|
||||
if (actorUserId is Guid uid)
|
||||
{
|
||||
@ -383,36 +214,34 @@ public class PurchaseEvaluationWorkflowService(
|
||||
PurchaseEvaluationId = evaluation.Id,
|
||||
EntityType = PurchaseEvaluationEntityType.Workflow,
|
||||
Action = ChangelogAction.Transition,
|
||||
PhaseAtChange = targetPhase,
|
||||
PhaseAtChange = toPhase,
|
||||
UserId = actorUserId,
|
||||
UserName = actorName ?? "Hệ thống",
|
||||
Summary = $"Chuyển phase {fromPhase} → {targetPhase}",
|
||||
Summary = $"Chuyển phase {fromPhase} → {toPhase}",
|
||||
ContextNote = comment,
|
||||
});
|
||||
|
||||
// Notify drafter
|
||||
// Notify drafter on terminal states
|
||||
if (evaluation.DrafterUserId is Guid drafterId && drafterId != actorUserId)
|
||||
{
|
||||
var title = targetPhase switch
|
||||
var (title, type) = toPhase switch
|
||||
{
|
||||
PurchaseEvaluationPhase.DaDuyet => $"Phiếu {evaluation.MaPhieu ?? evaluation.TenGoiThau} đã duyệt",
|
||||
PurchaseEvaluationPhase.TuChoi => $"Phiếu {evaluation.TenGoiThau} bị từ chối",
|
||||
_ => $"Phiếu {evaluation.TenGoiThau} chuyển phase mới",
|
||||
};
|
||||
var type = targetPhase switch
|
||||
{
|
||||
PurchaseEvaluationPhase.DaDuyet => NotificationType.ContractPublished,
|
||||
PurchaseEvaluationPhase.TuChoi => NotificationType.ContractRejected,
|
||||
_ => NotificationType.ContractPhaseTransition,
|
||||
PurchaseEvaluationPhase.DaDuyet => ($"Phiếu {evaluation.MaPhieu ?? evaluation.TenGoiThau} đã duyệt",
|
||||
NotificationType.ContractPublished),
|
||||
PurchaseEvaluationPhase.TuChoi => ($"Phiếu {evaluation.TenGoiThau} bị từ chối",
|
||||
NotificationType.ContractRejected),
|
||||
PurchaseEvaluationPhase.DangSoanThao when fromPhase == PurchaseEvaluationPhase.ChoDuyet =>
|
||||
($"Phiếu {evaluation.TenGoiThau} bị trả lại — vui lòng sửa và trình lại",
|
||||
NotificationType.ContractRejected),
|
||||
_ => ($"Phiếu {evaluation.TenGoiThau} chuyển phase mới",
|
||||
NotificationType.ContractPhaseTransition),
|
||||
};
|
||||
await notifications.NotifyAsync(
|
||||
drafterId, type, title,
|
||||
description: $"{fromPhase} → {targetPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
|
||||
description: $"{fromPhase} → {toPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
|
||||
href: $"/purchase-evaluations/{evaluation.Id}",
|
||||
refId: evaluation.Id,
|
||||
ct: ct);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user