[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);
});