[CLAUDE] PurchaseEvaluation: Mig 26 PeLevelOpinions V2 dynamic — Chunk A Domain + EF

Schema mới cho Section 5 "Ý kiến cấp duyệt" V2 dynamic theo
ApprovalWorkflowsV2 (Mig 22-25). Thay thế Mig 15 cố định 4 box (V1).

Entity `PurchaseEvaluationLevelOpinion : AuditableEntity`:
- (PEId, ApprovalWorkflowLevelId) UNIQUE composite
- Comment nvarchar(2000) — text ý kiến hoặc "(duyệt — không ý kiến)" placeholder (Q4 bonus)
- SignedAt datetime2 (luôn có khi UPSERT từ ApproveV2Async)
- SignedByUserId Guid (NV chính chủ HOẶC Admin override)
- SignedByFullName nvarchar(200) — denorm tránh user bị xóa/đổi tên

EF: FK Cascade Pe + Restrict Level. SignedByUserId KHÔNG nav (denorm only).
Migration 26 `AddPeLevelOpinionsForV2`: 1 CREATE TABLE + 2 FK + 2 index.
3-file rule commit đủ (.cs + Designer + Snapshot).

Apply LocalDB SolutionErp_Dev OK (Mig 25 + 26 catchup).

Verify: dotnet build pass + dotnet test 81 pass (no regression).

Chunk B kế tiếp: Service V2 hook UPSERT auto trong ApproveV2Async.
This commit is contained in:
pqhuy1987
2026-05-09 10:56:16 +07:00
parent 873e7a1b7b
commit 77a30584fc
8 changed files with 4120 additions and 0 deletions

View File

@ -64,6 +64,8 @@ public interface IApplicationDbContext
DbSet<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences { get; } DbSet<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences { get; }
DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions { get; } DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions { get; }
DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals { get; } DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals { get; }
// Mig 26 (Session 19) — Ý kiến cấp duyệt V2 dynamic theo ApprovalWorkflowLevel
DbSet<PurchaseEvaluationLevelOpinion> PurchaseEvaluationLevelOpinions { get; }
// Quy trình duyệt MỚI (Mig 22 — Session 17): schema riêng UAT trước khi // Quy trình duyệt MỚI (Mig 22 — Session 17): schema riêng UAT trước khi
// drop legacy WorkflowDefinition. Cấu trúc: Quy trình > Bước (Phòng) > Cấp (NV cụ thể). // drop legacy WorkflowDefinition. Cấu trúc: Quy trình > Bước (Phòng) > Cấp (NV cụ thể).

View File

@ -62,4 +62,9 @@ public class PurchaseEvaluation : AuditableEntity
public List<PurchaseEvaluationAttachment> Attachments { get; set; } = new(); public List<PurchaseEvaluationAttachment> Attachments { get; set; } = new();
public List<PurchaseEvaluationDepartmentOpinion> DepartmentOpinions { get; set; } = new(); public List<PurchaseEvaluationDepartmentOpinion> DepartmentOpinions { get; set; } = new();
public List<PurchaseEvaluationDepartmentApproval> DepartmentApprovals { get; set; } = new(); public List<PurchaseEvaluationDepartmentApproval> DepartmentApprovals { get; set; } = new();
// Mig 26 (Session 19) — Ý kiến cấp duyệt V2 dynamic. UPSERT auto từ
// ApproveV2Async theo Cấp hiện tại. Section 5 FE render dynamic theo
// flow.steps[].levels[]. Phiếu V1 (WorkflowDefinitionId) KHÔNG dùng.
public List<PurchaseEvaluationLevelOpinion> LevelOpinions { get; set; } = new();
} }

View File

@ -0,0 +1,36 @@
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.PurchaseEvaluations;
// "Ý kiến cấp duyệt" V2 — sign-off DYNAMIC theo workflow ApprovalWorkflowV2
// (Mig 22-25). Thay thế `PurchaseEvaluationDepartmentOpinion` (Mig 15) cố
// định 4 box (PheDuyet/Ccm/MuaHang/SmPm) cho phiếu V1.
//
// Mỗi row = 1 (PE × ApprovalWorkflowLevel). Service `ApproveV2Async` sau khi
// approve thành công Cấp hiện tại sẽ UPSERT row này:
// Comment = approval.Comment ?? "(duyệt — không ý kiến)" (Q4 bonus)
// SignedAt = clock.UtcNow
// SignedByUserId = actor.Id (NV chính chủ HOẶC Admin override)
// SignedByFullName = actor.FullName (denorm — tránh user bị xóa/đổi tên)
//
// Reject (Trả lại / Từ chối) KHÔNG sync (vì không phải sign-off của level đó).
// Khi user resubmit từ TraLai → workflow chạy lại từ Cấp 1, opinion cũ bị
// OVERWRITE bằng UPSERT mới (latest-write-wins).
//
// Section 5 FE detect V2 qua `pe.approvalWorkflowId != null` → render dynamic
// theo flow.steps[].levels[]. Phiếu V1 (WorkflowDefinitionId set) → fallback
// message "phiếu cũ không có ý kiến dynamic" (Q3 chốt: chuyển V2 hết).
public class PurchaseEvaluationLevelOpinion : AuditableEntity
{
public Guid PurchaseEvaluationId { get; set; }
public Guid ApprovalWorkflowLevelId { get; set; }
public string? Comment { get; set; } // ý kiến (max 2000) hoặc placeholder "(duyệt — không ý kiến)"
public DateTime SignedAt { get; set; } // luôn có khi UPSERT (Service set khi Approve)
public Guid SignedByUserId { get; set; } // người ký thực sự (có thể là Admin thay NV)
public string SignedByFullName { get; set; } = string.Empty; // snapshot tên — denorm
public PurchaseEvaluation? PurchaseEvaluation { get; set; }
public ApprovalWorkflowLevel? Level { get; set; }
}

View File

@ -63,6 +63,8 @@ public class ApplicationDbContext
public DbSet<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences => Set<PurchaseEvaluationCodeSequence>(); public DbSet<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences => Set<PurchaseEvaluationCodeSequence>();
public DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions => Set<PurchaseEvaluationDepartmentOpinion>(); public DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions => Set<PurchaseEvaluationDepartmentOpinion>();
public DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals => Set<PurchaseEvaluationDepartmentApproval>(); public DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals => Set<PurchaseEvaluationDepartmentApproval>();
// Mig 26 (Session 19) — Ý kiến cấp duyệt V2 dynamic
public DbSet<PurchaseEvaluationLevelOpinion> PurchaseEvaluationLevelOpinions => Set<PurchaseEvaluationLevelOpinion>();
// Quy trình duyệt mới (Mig 22 — Session 17): schema riêng UAT. // Quy trình duyệt mới (Mig 22 — Session 17): schema riêng UAT.
public DbSet<ApprovalWorkflow> ApprovalWorkflows => Set<ApprovalWorkflow>(); public DbSet<ApprovalWorkflow> ApprovalWorkflows => Set<ApprovalWorkflow>();

View File

@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// EF Mig 26 — UPSERT auto sync từ ApproveV2Async. UNIQUE (PEId, LevelId)
// đảm bảo 1 row/level/phiếu. FK Cascade Pe (xoá phiếu → xoá opinions),
// FK Restrict Level (admin xoá Level chặn nếu opinion tồn tại — bảo vệ data).
// SignedByUserId KHÔNG nav (tránh cascade khi xoá user; denorm SignedByFullName).
public class PurchaseEvaluationLevelOpinionConfiguration : IEntityTypeConfiguration<PurchaseEvaluationLevelOpinion>
{
public void Configure(EntityTypeBuilder<PurchaseEvaluationLevelOpinion> e)
{
e.ToTable("PurchaseEvaluationLevelOpinions");
e.Property(x => x.Comment).HasMaxLength(2000);
e.Property(x => x.SignedByFullName).HasMaxLength(200).IsRequired();
e.HasOne(x => x.PurchaseEvaluation)
.WithMany(p => p.LevelOpinions)
.HasForeignKey(x => x.PurchaseEvaluationId)
.OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Level)
.WithMany()
.HasForeignKey(x => x.ApprovalWorkflowLevelId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.PurchaseEvaluationId, x.ApprovalWorkflowLevelId }).IsUnique();
e.HasIndex(x => x.ApprovalWorkflowLevelId);
}
}

View File

@ -0,0 +1,69 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPeLevelOpinionsForV2 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PurchaseEvaluationLevelOpinions",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PurchaseEvaluationId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ApprovalWorkflowLevelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Comment = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
SignedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
SignedByUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SignedByFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, 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),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseEvaluationLevelOpinions", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseEvaluationLevelOpinions_ApprovalWorkflowLevels_ApprovalWorkflowLevelId",
column: x => x.ApprovalWorkflowLevelId,
principalTable: "ApprovalWorkflowLevels",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_PurchaseEvaluationLevelOpinions_PurchaseEvaluations_PurchaseEvaluationId",
column: x => x.PurchaseEvaluationId,
principalTable: "PurchaseEvaluations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationLevelOpinions_ApprovalWorkflowLevelId",
table: "PurchaseEvaluationLevelOpinions",
column: "ApprovalWorkflowLevelId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationLevelOpinions_PurchaseEvaluationId_ApprovalWorkflowLevelId",
table: "PurchaseEvaluationLevelOpinions",
columns: new[] { "PurchaseEvaluationId", "ApprovalWorkflowLevelId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PurchaseEvaluationLevelOpinions");
}
}
}

View File

@ -2990,6 +2990,64 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("PurchaseEvaluationDetails", (string)null); b.ToTable("PurchaseEvaluationDetails", (string)null);
}); });
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationLevelOpinion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ApprovalWorkflowLevelId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Comment")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<Guid>("PurchaseEvaluationId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("SignedAt")
.HasColumnType("datetime2");
b.Property<string>("SignedByFullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid>("SignedByUserId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ApprovalWorkflowLevelId");
b.HasIndex("PurchaseEvaluationId", "ApprovalWorkflowLevelId")
.IsUnique();
b.ToTable("PurchaseEvaluationLevelOpinions", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationQuote", b => modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationQuote", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -3647,6 +3705,25 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("PurchaseEvaluation"); b.Navigation("PurchaseEvaluation");
}); });
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationLevelOpinion", b =>
{
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level")
.WithMany()
.HasForeignKey("ApprovalWorkflowLevelId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation")
.WithMany("LevelOpinions")
.HasForeignKey("PurchaseEvaluationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Level");
b.Navigation("PurchaseEvaluation");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationQuote", b => modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationQuote", b =>
{ {
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", "Detail") b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", "Detail")
@ -3787,6 +3864,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Details"); b.Navigation("Details");
b.Navigation("LevelOpinions");
b.Navigation("Quotes"); b.Navigation("Quotes");
b.Navigation("Suppliers"); b.Navigation("Suppliers");