[CLAUDE] Workflow: Mig 22 schema mới ApprovalWorkflowsV2 (Chunk A)

Session 17 — schema riêng UAT trước khi drop legacy WorkflowDefinition.
Cấu trúc 3 bảng theo yêu cầu user:
  Quy trình (Code+Name+ApplicableType)
    Bước (Phòng A — DepartmentId hint)
      Cấp (NV X — ApproverUserId 1 user cụ thể, KHÔNG OR-of-many)

Khác Mig 21: Levels match 1 NV CHÍNH XÁC qua ApproverUserId, không
match group Dept+PositionLevel/Role/User. Service sau UAT iterate
Steps OrderBy Order → Levels OrderBy Order → ApproverUserId duyệt.

Files:
- Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs (3 entity + enum
  ApplicableType: DuyetNcc/DuyetNccPhuongAn/Contract)
- Infra/Persistence/Configurations/ApprovalWorkflowConfiguration.cs
  (FK Cascade Step→Workflow, Level→Step; Restrict Department + User)
- Infra/Persistence/ApplicationDbContext.cs (3 DbSet)
- Infra/Persistence/DbInitializer.cs (2 menu mới: ApprovalWorkflowsV2
  root dưới System icon Workflow + AwV2_DuyetNcc leaf icon FileCheck)
- Domain/Identity/MenuKeys.cs (2 const + All array)
- Migration 20260508053749_AddApprovalWorkflowsV2 (3 table CREATE +
  2 UNIQUE + 3 index)

Verify:
- Build OK, 77 test pass (54 Domain + 23 Infra) ~3s
- Mig applied cả _Design + _Dev LocalDB

Next chunks:
- B: Application CQRS (Get/Create) + ApprovalWorkflowsV2Controller
- C: FE Designer page /system/approval-workflows-v2/:typeCode
- D: Docs + STATUS update
This commit is contained in:
pqhuy1987
2026-05-08 12:39:37 +07:00
parent 21ee36390e
commit c847dc0b24
8 changed files with 4274 additions and 0 deletions

View File

@ -0,0 +1,63 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.ApprovalWorkflowsV2;
// Quy trình duyệt mới (Mig 22 — Session 17, 2026-05-08).
// Schema riêng để UAT, KHÔNG đụng WorkflowDefinition cũ (Mig 21 flat). Sau UAT
// OK → migrate data PE/HĐ pin sang ApprovalWorkflowId + drop bảng cũ.
//
// Cấu trúc:
// Quy trình (Code + Name + ApplicableType)
// Bước 1 - Phòng A (DepartmentId optional hint)
// Cấp 1 - NV X (ApproverUserId specific)
// Cấp 2 - NV Y
// Bước 2 - Phòng B
// Cấp 1 - NV Z
// ...
//
// Service (sau khi UAT chốt): iterate Steps OrderBy Order. Mỗi step iterate
// Levels OrderBy Order. Mỗi level = 1 NV cụ thể duyệt. Hết level → next step.
// Hết step → DaDuyet.
public class ApprovalWorkflow : BaseEntity
{
public string Code { get; set; } = string.Empty; // Mã quy trình "QT-DN-V2-001"
public int Version { get; set; } // monotonically increases per Code
public ApprovalWorkflowApplicableType ApplicableType { get; set; } // module áp dụng
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public bool IsActive { get; set; }
public DateTime? ActivatedAt { get; set; }
public List<ApprovalWorkflowStep> Steps { get; set; } = new();
}
public enum ApprovalWorkflowApplicableType
{
DuyetNcc = 1, // PE module — Duyệt NCC (default test target)
DuyetNccPhuongAn = 2, // PE — Duyệt NCC + Giải pháp
Contract = 3, // HĐ general (any ContractType)
}
// Bước = Phòng. 1 quy trình có nhiều bước theo Order.
public class ApprovalWorkflowStep : BaseEntity
{
public Guid ApprovalWorkflowId { get; set; }
public int Order { get; set; } // 1-based
public string Name { get; set; } = string.Empty; // "Phòng A", "Phòng B" — display
public Guid? DepartmentId { get; set; } // hint phòng (optional, không strict match)
public ApprovalWorkflow? ApprovalWorkflow { get; set; }
public List<ApprovalWorkflowLevel> Levels { get; set; } = new();
}
// Cấp = 1 NV cụ thể. 1 bước có nhiều cấp theo Order. Approver = ApproverUserId
// chính xác (KHÔNG OR-of-many). Sequential trong cùng bước: cấp 1 → cấp 2 → ...
public class ApprovalWorkflowLevel : BaseEntity
{
public Guid ApprovalWorkflowStepId { get; set; }
public int Order { get; set; } // 1-based trong cùng step
public string? Name { get; set; } // "Cấp 1" — display optional
public Guid ApproverUserId { get; set; } // 1 NV cụ thể duyệt cấp này
public ApprovalWorkflowStep? Step { get; set; }
}

View File

@ -51,6 +51,20 @@ public static class MenuKeys
public const string PurchaseEvaluations = "PurchaseEvaluations"; // root group
public const string PeWorkflows = "PeWorkflows"; // workflow admin root
// ============================================================
// Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08):
// Schema riêng `ApprovalWorkflow` để UAT trước khi migrate hoàn toàn.
// Cấu trúc: Quy trình > Bước (Phòng) > Cấp (NV cụ thể).
// Mã + Tên Quy trình
// Bước 1 - Phòng A
// Cấp 1 - NV X
// Cấp 2 - NV Y
// Bước 2 - Phòng B
// ...
// ============================================================
public const string ApprovalWorkflowsV2 = "ApprovalWorkflowsV2"; // root admin (mới)
public const string ApprovalWorkflowDuyetNccV2 = "AwV2_DuyetNcc"; // leaf cho Duyệt NCC mới
// ============================================================
// Module Ngân sách (Phase 7) — 4 bảng quản lý ngân sách dự án/gói thầu.
// 1 root + 3 leaf action (Danh sách / Thao tác / Duyệt).
@ -81,6 +95,7 @@ public static class MenuKeys
PurchaseEvaluations,
Budgets, BudgetList, BudgetCreate, BudgetPending,
System, Users, Roles, Permissions, Workflows, PeWorkflows,
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, // Mig 22
];
public static readonly string[] Actions = ["Read", "Create", "Update", "Delete"];

View File

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Budgets;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Contracts.Details;
@ -63,6 +64,11 @@ public class ApplicationDbContext
public DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions => Set<PurchaseEvaluationDepartmentOpinion>();
public DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals => Set<PurchaseEvaluationDepartmentApproval>();
// Quy trình duyệt mới (Mig 22 — Session 17): schema riêng UAT.
public DbSet<ApprovalWorkflow> ApprovalWorkflows => Set<ApprovalWorkflow>();
public DbSet<ApprovalWorkflowStep> ApprovalWorkflowSteps => Set<ApprovalWorkflowStep>();
public DbSet<ApprovalWorkflowLevel> ApprovalWorkflowLevels => Set<ApprovalWorkflowLevel>();
// Module Ngân sách (Phase 7) — 4 bảng: Budget header + Details + Approvals + Changelogs.
public DbSet<Budget> Budgets => Set<Budget>();
public DbSet<BudgetDetail> BudgetDetails => Set<BudgetDetail>();

View File

@ -0,0 +1,69 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.ApprovalWorkflowsV2;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// EF config Mig 22 — schema mới riêng cho Quy trình duyệt v2 (UAT before drop legacy).
public class ApprovalWorkflowConfiguration : IEntityTypeConfiguration<ApprovalWorkflow>
{
public void Configure(EntityTypeBuilder<ApprovalWorkflow> e)
{
e.ToTable("ApprovalWorkflows");
e.Property(x => x.Code).HasMaxLength(100).IsRequired();
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
e.Property(x => x.Description).HasMaxLength(1000);
e.Property(x => x.ApplicableType).HasConversion<int>();
e.HasIndex(x => new { x.Code, x.Version }).IsUnique();
e.HasIndex(x => new { x.ApplicableType, x.IsActive });
}
}
public class ApprovalWorkflowStepConfiguration : IEntityTypeConfiguration<ApprovalWorkflowStep>
{
public void Configure(EntityTypeBuilder<ApprovalWorkflowStep> e)
{
e.ToTable("ApprovalWorkflowSteps");
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
e.HasOne(x => x.ApprovalWorkflow)
.WithMany(d => d.Steps)
.HasForeignKey(x => x.ApprovalWorkflowId)
.OnDelete(DeleteBehavior.Cascade);
// Department FK Restrict (optional hint).
e.HasOne<SolutionErp.Domain.Master.Department>()
.WithMany()
.HasForeignKey(x => x.DepartmentId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.ApprovalWorkflowId, x.Order });
e.HasIndex(x => x.DepartmentId);
}
}
public class ApprovalWorkflowLevelConfiguration : IEntityTypeConfiguration<ApprovalWorkflowLevel>
{
public void Configure(EntityTypeBuilder<ApprovalWorkflowLevel> e)
{
e.ToTable("ApprovalWorkflowLevels");
e.Property(x => x.Name).HasMaxLength(200);
e.HasOne(x => x.Step)
.WithMany(s => s.Levels)
.HasForeignKey(x => x.ApprovalWorkflowStepId)
.OnDelete(DeleteBehavior.Cascade);
// ApproverUserId FK Restrict — không cấu hình nav để giữ nhẹ
// (1 chiều, query qua join nếu cần admin xem detail).
e.HasOne<SolutionErp.Domain.Identity.User>()
.WithMany()
.HasForeignKey(x => x.ApproverUserId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.ApprovalWorkflowStepId, x.Order });
e.HasIndex(x => x.ApproverUserId);
}
}

View File

@ -1308,6 +1308,10 @@ public static class DbInitializer
// Module Duyệt NCC (tiền-HĐ)
(MenuKeys.PurchaseEvaluations, "Quy trình chọn Thầu phụ - NCC", null, 25, "ClipboardCheck"),
(MenuKeys.PeWorkflows, "Quy trình Duyệt NCC", MenuKeys.System, 95, "GitCompareArrows"),
// Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08): schema riêng
// UAT trước khi migrate hoàn toàn. Cấu trúc: Quy trình > Bước (Phòng) > Cấp (NV cụ thể).
(MenuKeys.ApprovalWorkflowsV2, "Quy trình duyệt (Mới)", MenuKeys.System, 96, "Workflow"),
(MenuKeys.ApprovalWorkflowDuyetNccV2, "Duyệt NCC (Mới)", MenuKeys.ApprovalWorkflowsV2, 1, "FileCheck"),
// Module Ngân sách (Phase 7)
(MenuKeys.Budgets, "Ngân sách", null, 27, "Wallet"),
(MenuKeys.BudgetList, "Danh sách", MenuKeys.Budgets, 1, "List"),

View File

@ -0,0 +1,143 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddApprovalWorkflowsV2 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ApprovalWorkflows",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Code = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Version = table.Column<int>(type: "int", nullable: false),
ApplicableType = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
ActivatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
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_ApprovalWorkflows", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ApprovalWorkflowSteps",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ApprovalWorkflowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Order = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
DepartmentId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
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_ApprovalWorkflowSteps", x => x.Id);
table.ForeignKey(
name: "FK_ApprovalWorkflowSteps_ApprovalWorkflows_ApprovalWorkflowId",
column: x => x.ApprovalWorkflowId,
principalTable: "ApprovalWorkflows",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ApprovalWorkflowSteps_Departments_DepartmentId",
column: x => x.DepartmentId,
principalTable: "Departments",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ApprovalWorkflowLevels",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ApprovalWorkflowStepId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Order = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
ApproverUserId = table.Column<Guid>(type: "uniqueidentifier", 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_ApprovalWorkflowLevels", x => x.Id);
table.ForeignKey(
name: "FK_ApprovalWorkflowLevels_ApprovalWorkflowSteps_ApprovalWorkflowStepId",
column: x => x.ApprovalWorkflowStepId,
principalTable: "ApprovalWorkflowSteps",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ApprovalWorkflowLevels_Users_ApproverUserId",
column: x => x.ApproverUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_ApprovalWorkflowLevels_ApprovalWorkflowStepId_Order",
table: "ApprovalWorkflowLevels",
columns: new[] { "ApprovalWorkflowStepId", "Order" });
migrationBuilder.CreateIndex(
name: "IX_ApprovalWorkflowLevels_ApproverUserId",
table: "ApprovalWorkflowLevels",
column: "ApproverUserId");
migrationBuilder.CreateIndex(
name: "IX_ApprovalWorkflows_ApplicableType_IsActive",
table: "ApprovalWorkflows",
columns: new[] { "ApplicableType", "IsActive" });
migrationBuilder.CreateIndex(
name: "IX_ApprovalWorkflows_Code_Version",
table: "ApprovalWorkflows",
columns: new[] { "Code", "Version" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ApprovalWorkflowSteps_ApprovalWorkflowId_Order",
table: "ApprovalWorkflowSteps",
columns: new[] { "ApprovalWorkflowId", "Order" });
migrationBuilder.CreateIndex(
name: "IX_ApprovalWorkflowSteps_DepartmentId",
table: "ApprovalWorkflowSteps",
column: "DepartmentId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ApprovalWorkflowLevels");
migrationBuilder.DropTable(
name: "ApprovalWorkflowSteps");
migrationBuilder.DropTable(
name: "ApprovalWorkflows");
}
}
}

View File

@ -125,6 +125,141 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("UserTokens", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("ActivatedAt")
.HasColumnType("datetime2");
b.Property<int>("ApplicableType")
.HasColumnType("int");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int>("Version")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("ApplicableType", "IsActive");
b.HasIndex("Code", "Version")
.IsUnique();
b.ToTable("ApprovalWorkflows", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ApprovalWorkflowStepId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ApproverUserId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("Order")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ApproverUserId");
b.HasIndex("ApprovalWorkflowStepId", "Order");
b.ToTable("ApprovalWorkflowLevels", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowStep", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ApprovalWorkflowId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("Order")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("DepartmentId");
b.HasIndex("ApprovalWorkflowId", "Order");
b.ToTable("ApprovalWorkflowSteps", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.Budget", b =>
{
b.Property<Guid>("Id")
@ -3154,6 +3289,39 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", b =>
{
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowStep", "Step")
.WithMany("Levels")
.HasForeignKey("ApprovalWorkflowStepId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SolutionErp.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("ApproverUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Step");
});
modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowStep", b =>
{
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", "ApprovalWorkflow")
.WithMany("Steps")
.HasForeignKey("ApprovalWorkflowId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SolutionErp.Domain.Master.Department", null)
.WithMany()
.HasForeignKey("DepartmentId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("ApprovalWorkflow");
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetApproval", b =>
{
b.HasOne("SolutionErp.Domain.Budgets.Budget", "Budget")
@ -3521,6 +3689,16 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Step");
});
modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", b =>
{
b.Navigation("Steps");
});
modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowStep", b =>
{
b.Navigation("Levels");
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.Budget", b =>
{
b.Navigation("Approvals");