[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
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:
@ -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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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());
|
||||
|
||||
Reference in New Issue
Block a user