[CLAUDE] Domain+Infra: Migration 20 Contract workflow inner steps mirror PE (Chunk A)
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled

Mirror PE N-stage Mig 18+19 pattern sang Contract module. Gộp 1 migration
(CREATE TABLE + ALTER + filtered unique alter) thay vì tách 2 như PE.

Schema:
- entity WorkflowStepInnerStep (Domain/Contracts/) — Order, DeptId,
  PositionLevel, Name, SlaDays, IsRequired
- WorkflowStep nav +InnerSteps List
- ContractDepartmentApproval +InnerStepId Guid? FK Restrict
- CREATE TABLE WorkflowStepInnerSteps (no Contract prefix vì module dùng
  entity gốc Workflow* từ Mig 8)
- DROP UX_ContractDeptApprovals_Contract_Phase_Dept_Stage cũ
- RECREATE filtered: WHERE InnerStepId IS NULL (legacy 2-stage Mig 16)
  + new filtered UNIQUE (ContractId, Phase, InnerStepId) WHERE InnerStepId
  IS NOT NULL (N-stage)
- 3 INDEX: IX_InnerStepId, IX_(StepId, Order), IX_DeptId

Backward compat 100%: workflow Contract no InnerSteps configured →
service fallback legacy 2-stage. Data legacy InnerStepId=null vẫn enforce
unique cũ qua filtered index.

Note: Budget defer (chưa có versioned WorkflowDefinition entity —
hardcoded BudgetPolicy.Default). Cần migration AddBudgetVersionedWorkflow
trước khi mirror N-stage Budget.

Verify:
- dotnet build SolutionErp.slnx 0 error
- dotnet ef database update LocalDB applied OK
- dotnet test 89 pass (54 + 35) — no regression

Pending Chunk B: WorkflowAdminFeatures.cs DTO/Input/Validator/Handler
extend mirror PE Chunk B pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-07 19:00:45 +07:00
parent 5e5042d717
commit 951ffa3ed8
8 changed files with 4037 additions and 1 deletions

View File

@ -23,5 +23,9 @@ public class ContractDepartmentApproval : AuditableEntity
public DateTime ApprovedAt { get; set; } public DateTime ApprovedAt { get; set; }
public bool IsBypassed { get; set; } // true nếu NV bypass (User.CanBypassReview=true) public bool IsBypassed { get; set; } // true nếu NV bypass (User.CanBypassReview=true)
// N-stage inner step link (Mig 20) — null cho data legacy 2-stage Review/Confirm.
// Có giá trị khi step cha có InnerSteps configured. Mirror PE Mig 18 pattern.
public Guid? InnerStepId { get; set; }
public Contract? Contract { get; set; } public Contract? Contract { get; set; }
} }

View File

@ -1,4 +1,5 @@
using SolutionErp.Domain.Common; using SolutionErp.Domain.Common;
using SolutionErp.Domain.Identity; // reuse PositionLevel
namespace SolutionErp.Domain.Contracts; namespace SolutionErp.Domain.Contracts;
@ -32,6 +33,11 @@ public class WorkflowStep : BaseEntity
public WorkflowDefinition? WorkflowDefinition { get; set; } public WorkflowDefinition? WorkflowDefinition { get; set; }
public List<WorkflowStepApprover> Approvers { get; set; } = new(); public List<WorkflowStepApprover> Approvers { get; set; } = new();
// Inner steps (Mig 20) — N-stage approval Phòng × PositionLevel sequential.
// Mirror PE pattern (Mig 18). Empty list → service fallback logic 2-stage
// Review/Confirm legacy (Mig 16) per dept.
public List<WorkflowStepInnerStep> InnerSteps { get; set; } = new();
} }
public enum WorkflowApproverKind public enum WorkflowApproverKind
@ -48,3 +54,23 @@ public class WorkflowStepApprover : BaseEntity
public WorkflowStep? Step { get; set; } public WorkflowStep? Step { get; set; }
} }
// Inner step (Mig 20 — Phase 9+) — sub-step level con cấu hình bên trong 1
// WorkflowStep cha (= 1 phase). Mirror PurchaseEvaluationWorkflowStepInnerStep
// pattern (Mig 18). Cho phép admin định nghĩa thứ tự duyệt N-stage theo
// Department × PositionLevel: NV.A → PP.A → TP.A → NV.B → PP.B → TP.B → ...
//
// User khớp DepartmentId + PositionLevel + Order tiếp theo chưa duyệt = approver
// hợp lệ. CanBypassReview ở User cho TP skip NV+PP cùng dept (audit IsBypassed).
public class WorkflowStepInnerStep : BaseEntity
{
public Guid WorkflowStepId { get; set; }
public int Order { get; set; }
public Guid DepartmentId { get; set; }
public PositionLevel PositionLevel { get; set; } // NV / PP / TP
public string? Name { get; set; } // hiển thị FE — vd "NV.PRO duyệt"
public int? SlaDays { get; set; }
public bool IsRequired { get; set; } = true;
public WorkflowStep? Step { get; set; }
}

View File

@ -41,6 +41,7 @@ public class ApplicationDbContext
public DbSet<WorkflowDefinition> WorkflowDefinitions => Set<WorkflowDefinition>(); public DbSet<WorkflowDefinition> WorkflowDefinitions => Set<WorkflowDefinition>();
public DbSet<WorkflowStep> WorkflowSteps => Set<WorkflowStep>(); public DbSet<WorkflowStep> WorkflowSteps => Set<WorkflowStep>();
public DbSet<WorkflowStepApprover> WorkflowStepApprovers => Set<WorkflowStepApprover>(); public DbSet<WorkflowStepApprover> WorkflowStepApprovers => Set<WorkflowStepApprover>();
public DbSet<WorkflowStepInnerStep> WorkflowStepInnerSteps => Set<WorkflowStepInnerStep>();
public DbSet<ThauPhuDetail> ThauPhuDetails => Set<ThauPhuDetail>(); public DbSet<ThauPhuDetail> ThauPhuDetails => Set<ThauPhuDetail>();
public DbSet<GiaoKhoanDetail> GiaoKhoanDetails => Set<GiaoKhoanDetail>(); public DbSet<GiaoKhoanDetail> GiaoKhoanDetails => Set<GiaoKhoanDetail>();
public DbSet<NhaCungCapDetail> NhaCungCapDetails => Set<NhaCungCapDetail>(); public DbSet<NhaCungCapDetail> NhaCungCapDetails => Set<NhaCungCapDetail>();

View File

@ -27,9 +27,19 @@ public class ContractDepartmentApprovalConfiguration
b.Property(x => x.ApproverRoleSnapshot).HasMaxLength(100); b.Property(x => x.ApproverRoleSnapshot).HasMaxLength(100);
b.Property(x => x.Comment).HasMaxLength(1000); b.Property(x => x.Comment).HasMaxLength(1000);
// Legacy 2-stage rows (Mig 16): UNIQUE chỉ áp khi InnerStepId IS NULL
// (Mig 20 mirror PE Mig 19 filtered split).
b.HasIndex(x => new { x.ContractId, x.PhaseAtApproval, x.DepartmentId, x.Stage }) b.HasIndex(x => new { x.ContractId, x.PhaseAtApproval, x.DepartmentId, x.Stage })
.IsUnique() .IsUnique()
.HasFilter("[InnerStepId] IS NULL")
.HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_Dept_Stage"); .HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_Dept_Stage");
// N-stage rows (Mig 20): UNIQUE 1 row per (phase × inner step).
b.HasIndex(x => new { x.ContractId, x.PhaseAtApproval, x.InnerStepId })
.IsUnique()
.HasFilter("[InnerStepId] IS NOT NULL")
.HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_InnerStep");
b.HasIndex(x => x.ContractId); b.HasIndex(x => x.ContractId);
b.HasIndex(x => x.DepartmentId); b.HasIndex(x => x.DepartmentId);
b.HasIndex(x => x.ApproverUserId); b.HasIndex(x => x.ApproverUserId);
@ -38,6 +48,12 @@ public class ContractDepartmentApprovalConfiguration
.WithMany(c => c.DepartmentApprovals) .WithMany(c => c.DepartmentApprovals)
.HasForeignKey(x => x.ContractId) .HasForeignKey(x => x.ContractId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
// FK InnerStepId nullable — Restrict (không xóa InnerStep nếu còn approval row)
b.HasOne<WorkflowStepInnerStep>()
.WithMany()
.HasForeignKey(x => x.InnerStepId)
.OnDelete(DeleteBehavior.Restrict);
} }
} }

View File

@ -52,3 +52,31 @@ public class WorkflowStepApproverConfiguration : IEntityTypeConfiguration<Workfl
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
} }
} }
// Inner step (Mig 20) — N-stage approval cấu hình động trong cùng 1 phase.
// Mirror PurchaseEvaluationWorkflowStepInnerStepConfiguration (Mig 18).
public class WorkflowStepInnerStepConfiguration
: IEntityTypeConfiguration<WorkflowStepInnerStep>
{
public void Configure(EntityTypeBuilder<WorkflowStepInnerStep> e)
{
e.ToTable("WorkflowStepInnerSteps");
e.HasKey(x => x.Id);
e.Property(x => x.PositionLevel).HasConversion<int>();
e.Property(x => x.Name).HasMaxLength(200);
e.HasOne(x => x.Step)
.WithMany(s => s.InnerSteps)
.HasForeignKey(x => x.WorkflowStepId)
.OnDelete(DeleteBehavior.Cascade);
e.HasOne<SolutionErp.Domain.Master.Department>()
.WithMany()
.HasForeignKey(x => x.DepartmentId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.WorkflowStepId, x.Order });
e.HasIndex(x => x.DepartmentId);
}
}

View File

@ -0,0 +1,129 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddContractWorkflowInnerStepsAndAlterDeptApprovalUnique : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "UX_ContractDeptApprovals_Contract_Phase_Dept_Stage",
table: "ContractDepartmentApprovals");
migrationBuilder.AddColumn<Guid>(
name: "InnerStepId",
table: "ContractDepartmentApprovals",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.CreateTable(
name: "WorkflowStepInnerSteps",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
WorkflowStepId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Order = table.Column<int>(type: "int", nullable: false),
DepartmentId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PositionLevel = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
SlaDays = table.Column<int>(type: "int", nullable: true),
IsRequired = table.Column<bool>(type: "bit", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_WorkflowStepInnerSteps", x => x.Id);
table.ForeignKey(
name: "FK_WorkflowStepInnerSteps_Departments_DepartmentId",
column: x => x.DepartmentId,
principalTable: "Departments",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_WorkflowStepInnerSteps_WorkflowSteps_WorkflowStepId",
column: x => x.WorkflowStepId,
principalTable: "WorkflowSteps",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ContractDepartmentApprovals_InnerStepId",
table: "ContractDepartmentApprovals",
column: "InnerStepId");
migrationBuilder.CreateIndex(
name: "UX_ContractDeptApprovals_Contract_Phase_Dept_Stage",
table: "ContractDepartmentApprovals",
columns: new[] { "ContractId", "PhaseAtApproval", "DepartmentId", "Stage" },
unique: true,
filter: "[InnerStepId] IS NULL");
migrationBuilder.CreateIndex(
name: "UX_ContractDeptApprovals_Contract_Phase_InnerStep",
table: "ContractDepartmentApprovals",
columns: new[] { "ContractId", "PhaseAtApproval", "InnerStepId" },
unique: true,
filter: "[InnerStepId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_WorkflowStepInnerSteps_DepartmentId",
table: "WorkflowStepInnerSteps",
column: "DepartmentId");
migrationBuilder.CreateIndex(
name: "IX_WorkflowStepInnerSteps_WorkflowStepId_Order",
table: "WorkflowStepInnerSteps",
columns: new[] { "WorkflowStepId", "Order" });
migrationBuilder.AddForeignKey(
name: "FK_ContractDepartmentApprovals_WorkflowStepInnerSteps_InnerStepId",
table: "ContractDepartmentApprovals",
column: "InnerStepId",
principalTable: "WorkflowStepInnerSteps",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ContractDepartmentApprovals_WorkflowStepInnerSteps_InnerStepId",
table: "ContractDepartmentApprovals");
migrationBuilder.DropTable(
name: "WorkflowStepInnerSteps");
migrationBuilder.DropIndex(
name: "IX_ContractDepartmentApprovals_InnerStepId",
table: "ContractDepartmentApprovals");
migrationBuilder.DropIndex(
name: "UX_ContractDeptApprovals_Contract_Phase_Dept_Stage",
table: "ContractDepartmentApprovals");
migrationBuilder.DropIndex(
name: "UX_ContractDeptApprovals_Contract_Phase_InnerStep",
table: "ContractDepartmentApprovals");
migrationBuilder.DropColumn(
name: "InnerStepId",
table: "ContractDepartmentApprovals");
migrationBuilder.CreateIndex(
name: "UX_ContractDeptApprovals_Contract_Phase_Dept_Stage",
table: "ContractDepartmentApprovals",
columns: new[] { "ContractId", "PhaseAtApproval", "DepartmentId", "Stage" },
unique: true);
}
}
}

View File

@ -824,6 +824,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid>("DepartmentId") b.Property<Guid>("DepartmentId")
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
b.Property<Guid?>("InnerStepId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsBypassed") b.Property<bool>("IsBypassed")
.HasColumnType("bit"); .HasColumnType("bit");
@ -850,9 +853,17 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasIndex("DepartmentId"); b.HasIndex("DepartmentId");
b.HasIndex("InnerStepId");
b.HasIndex("ContractId", "PhaseAtApproval", "InnerStepId")
.IsUnique()
.HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_InnerStep")
.HasFilter("[InnerStepId] IS NOT NULL");
b.HasIndex("ContractId", "PhaseAtApproval", "DepartmentId", "Stage") b.HasIndex("ContractId", "PhaseAtApproval", "DepartmentId", "Stage")
.IsUnique() .IsUnique()
.HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_Dept_Stage"); .HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_Dept_Stage")
.HasFilter("[InnerStepId] IS NULL");
b.ToTable("ContractDepartmentApprovals", (string)null); b.ToTable("ContractDepartmentApprovals", (string)null);
}); });
@ -1477,6 +1488,55 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("WorkflowStepApprovers", (string)null); b.ToTable("WorkflowStepApprovers", (string)null);
}); });
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStepInnerStep", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("DepartmentId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsRequired")
.HasColumnType("bit");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("Order")
.HasColumnType("int");
b.Property<int>("PositionLevel")
.HasColumnType("int");
b.Property<int?>("SlaDays")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("WorkflowStepId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("DepartmentId");
b.HasIndex("WorkflowStepId", "Order");
b.ToTable("WorkflowStepInnerSteps", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowTypeAssignment", b => modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowTypeAssignment", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -3282,6 +3342,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("SolutionErp.Domain.Contracts.WorkflowStepInnerStep", null)
.WithMany()
.HasForeignKey("InnerStepId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Contract"); b.Navigation("Contract");
}); });
@ -3384,6 +3449,23 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Step"); b.Navigation("Step");
}); });
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStepInnerStep", b =>
{
b.HasOne("SolutionErp.Domain.Master.Department", null)
.WithMany()
.HasForeignKey("DepartmentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("SolutionErp.Domain.Contracts.WorkflowStep", "Step")
.WithMany("InnerSteps")
.HasForeignKey("WorkflowStepId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Step");
});
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b => modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
{ {
b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Parent") b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Parent")
@ -3611,6 +3693,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b => modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b =>
{ {
b.Navigation("Approvers"); b.Navigation("Approvers");
b.Navigation("InnerSteps");
}); });
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b => modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>