[CLAUDE] Infra: PE workflow service N-stage logic + Migration 19 unique filter (Chunk C)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m2s

Migration 19 `AlterPeDeptApprovalsUniqueFilteredForInnerSteps` — fix
UNIQUE constraint conflict khi N-stage có nhiều inner step cùng dept:
- Drop UX_PEDeptApprovals_PE_Phase_Dept_Stage (Mig 16)
- Recreate filtered: UNIQUE (PEId, Phase, Dept, Stage) WHERE InnerStepId IS NULL
  (legacy 2-stage rows giữ nguyên invariant)
- New filtered: UNIQUE (PEId, Phase, InnerStepId) WHERE InnerStepId IS NOT NULL
  (N-stage 1 row per inner step per phase)

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

Backward compat: workflow cũ (no InnerSteps configured) chạy đúng logic
2-stage Mig 16. Data legacy InnerStepId=null vẫn match unique cũ qua
filtered index. Tests 83 pass — no regression.

Verify:
- dotnet build SolutionErp.slnx 0 error
- dotnet ef database update LocalDB applied Mig 19
- dotnet test 83 pass

Pending Chunk D: Tests N-stage workflow (~6-7 test mới: sequential pass /
bypass cùng dept / reject reset / resume jump-back / legacy fallback).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-07 18:20:17 +07:00
parent 0e56bd0a67
commit 0c62e241d0
5 changed files with 3884 additions and 22 deletions

View File

@ -53,13 +53,24 @@ public class PurchaseEvaluationDepartmentApprovalConfiguration
b.Property(x => x.ApproverRoleSnapshot).HasMaxLength(100);
b.Property(x => x.Comment).HasMaxLength(1000);
// Legacy 2-stage rows (Mig 16): UNIQUE (PEId, Phase, Dept, Stage) chỉ áp
// khi InnerStepId IS NULL — tránh conflict với N-stage rows nhiều InnerStep
// cùng dept cùng Stage=Confirm.
b.HasIndex(x => new { x.PurchaseEvaluationId, x.PhaseAtApproval, x.DepartmentId, x.Stage })
.IsUnique()
.HasFilter("[InnerStepId] IS NULL")
.HasDatabaseName("UX_PEDeptApprovals_PE_Phase_Dept_Stage");
// N-stage rows (Mig 18+): UNIQUE (PEId, Phase, InnerStepId) — 1 approval row
// per (phase × inner step) khi InnerStepId IS NOT NULL.
b.HasIndex(x => new { x.PurchaseEvaluationId, x.PhaseAtApproval, x.InnerStepId })
.IsUnique()
.HasFilter("[InnerStepId] IS NOT NULL")
.HasDatabaseName("UX_PEDeptApprovals_PE_Phase_InnerStep");
b.HasIndex(x => x.PurchaseEvaluationId);
b.HasIndex(x => x.DepartmentId);
b.HasIndex(x => x.ApproverUserId);
b.HasIndex(x => x.InnerStepId); // Mig 18 — query rows by sub-step
b.HasOne(x => x.PurchaseEvaluation)
.WithMany(c => c.DepartmentApprovals)

View File

@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AlterPeDeptApprovalsUniqueFilteredForInnerSteps : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "UX_PEDeptApprovals_PE_Phase_Dept_Stage",
table: "PurchaseEvaluationDepartmentApprovals");
migrationBuilder.CreateIndex(
name: "UX_PEDeptApprovals_PE_Phase_Dept_Stage",
table: "PurchaseEvaluationDepartmentApprovals",
columns: new[] { "PurchaseEvaluationId", "PhaseAtApproval", "DepartmentId", "Stage" },
unique: true,
filter: "[InnerStepId] IS NULL");
migrationBuilder.CreateIndex(
name: "UX_PEDeptApprovals_PE_Phase_InnerStep",
table: "PurchaseEvaluationDepartmentApprovals",
columns: new[] { "PurchaseEvaluationId", "PhaseAtApproval", "InnerStepId" },
unique: true,
filter: "[InnerStepId] IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "UX_PEDeptApprovals_PE_Phase_Dept_Stage",
table: "PurchaseEvaluationDepartmentApprovals");
migrationBuilder.DropIndex(
name: "UX_PEDeptApprovals_PE_Phase_InnerStep",
table: "PurchaseEvaluationDepartmentApprovals");
migrationBuilder.CreateIndex(
name: "UX_PEDeptApprovals_PE_Phase_Dept_Stage",
table: "PurchaseEvaluationDepartmentApprovals",
columns: new[] { "PurchaseEvaluationId", "PhaseAtApproval", "DepartmentId", "Stage" },
unique: true);
}
}
}

View File

@ -2693,9 +2693,15 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasIndex("PurchaseEvaluationId");
b.HasIndex("PurchaseEvaluationId", "PhaseAtApproval", "InnerStepId")
.IsUnique()
.HasDatabaseName("UX_PEDeptApprovals_PE_Phase_InnerStep")
.HasFilter("[InnerStepId] IS NOT NULL");
b.HasIndex("PurchaseEvaluationId", "PhaseAtApproval", "DepartmentId", "Stage")
.IsUnique()
.HasDatabaseName("UX_PEDeptApprovals_PE_Phase_Dept_Stage");
.HasDatabaseName("UX_PEDeptApprovals_PE_Phase_Dept_Stage")
.HasFilter("[InnerStepId] IS NULL");
b.ToTable("PurchaseEvaluationDepartmentApprovals", (string)null);
});

View File

@ -45,6 +45,15 @@ public class PurchaseEvaluationWorkflowService(
{
evaluation.RejectedFromPhase = fromPhase;
targetPhase = PurchaseEvaluationPhase.DangSoanThao;
// N-stage state reset (Mig 18): clear inner step approval rows tại
// fromPhase. User resume sẽ approve lại từ inner step đầu.
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)
{
@ -53,14 +62,17 @@ public class PurchaseEvaluationWorkflowService(
}
PurchaseEvaluationPolicy policy;
PurchaseEvaluationWorkflowDefinition? definition = null;
if (evaluation.WorkflowDefinitionId is Guid wfId)
{
var def = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
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 = def is not null
? PurchaseEvaluationPolicyRegistry.FromDefinition(def)
policy = definition is not null
? PurchaseEvaluationPolicyRegistry.FromDefinition(definition)
: PurchaseEvaluationPolicyRegistry.ForEvaluation(evaluation);
}
else
@ -86,13 +98,12 @@ public class PurchaseEvaluationWorkflowService(
}
}
// ===== 2-stage department approval (Phase 9 — Migration 16) =====
// Bug fix anh Kiệt: NV duyệt được hết phase. Logic mới:
// - User.DepartmentId != null + KHÔNG admin/system + KHÔNG resume:
// - DeptManager (TPB) → Stage=Confirm trực tiếp
// - CanBypassReview=true → Stage=Confirm + IsBypassed=true
// - Else (NV) → Stage=Review only, BLOCK phase transition cho đến khi TPB confirm
// - Skip với reject + resume + admin + system + actor không thuộc dept.
// ===== 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;
if (decision == ApprovalDecision.Approve
&& targetPhase != PurchaseEvaluationPhase.DangSoanThao
&& targetPhase != PurchaseEvaluationPhase.TuChoi
@ -101,21 +112,143 @@ public class PurchaseEvaluationWorkflowService(
&& 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 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(
"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
}
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 (PEId, phase, dept, stage). UNIQUE index enforce.
// 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, ct);
&& a.Stage == stage
&& a.InnerStepId == null, ct);
if (existing is null)
{
db.PurchaseEvaluationDepartmentApprovals.Add(new PurchaseEvaluationDepartmentApproval
@ -129,6 +262,7 @@ public class PurchaseEvaluationWorkflowService(
Comment = comment,
ApprovedAt = dateTime.UtcNow,
IsBypassed = isBypassed,
InnerStepId = null,
});
}
else
@ -146,24 +280,24 @@ public class PurchaseEvaluationWorkflowService(
a.PurchaseEvaluationId == evaluation.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.
// Log Approval + Changelog "đã review" để audit. Phase giữ nguyên.
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
{
PurchaseEvaluationId = evaluation.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.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = evaluation.Id,
@ -185,9 +319,6 @@ public class PurchaseEvaluationWorkflowService(
.ToListAsync(ct);
if (managers.Count > 0)
{
// Filter: chỉ notify user có role DeptManager (TPB).
// Không có direct join với UserRoles ở IApplicationDbContext —
// dùng UserManager để filter từng user.
foreach (var mgrId in managers)
{
var mgr = await userManager.FindByIdAsync(mgrId.ToString());