[CLAUDE] Domain+App+Infra: Plan B Chunk C — Mig 33 ContractLevelOpinions cookie-cutter mirror PE Mig 26

- Domain/Contracts/ContractLevelOpinion.cs (NEW entity mirror PE — AuditableEntity, 4 field core + 2 nav)
- Domain/Contracts/Contract.cs (+LevelOpinions nav collection)
- Migrations/20260522052240_AddContractLevelOpinions.cs (3-file rule: .cs + .Designer.cs + Snapshot)
- Configurations/ContractLevelOpinionConfiguration.cs (NEW separate file, mirror PE pattern)
- IApplicationDbContext.cs + ApplicationDbContext.cs (+DbSet<ContractLevelOpinion>)

UNIQUE composite (ContractId, ApprovalWorkflowLevelId) — 1 row per HĐ × Level.
FK Cascade Contract + Restrict ApprovalWorkflowLevel.
SignedByUserId KHÔNG nav (denorm SignedByFullName tránh cascade khi xoá user).

Mirror PE Mig 26 pattern (S19 2026-05-09) EXACT — UPSERT row khi Approver duyệt qua
Service ApproveV2Async (Plan B Chunk B em main 138469d đã có TODO marker).
Em main sẽ add UPSERT block sau Chunk C done (Chunk D).

Verify:
- dotnet build PASS 0 err (2 pre-existing warn DocxRenderer unrelated)
- dotnet ef database update PASS (Mig 33 applied SolutionErp_Dev + _Design)
- dotnet test 111/111 PASS (58 Domain + 53 Infra — no regression)

Plan B chain (6 chunks):
- A1 58898e8  ContractApprovalWorkflowV2 entity scaffold
- A2 a85e437  Contract.ApprovalWorkflowId + ContractConfiguration FK
- B 138469d  ContractWorkflowService ApproveV2Async skeleton + TODO LevelOpinion UPSERT
- C (this)  ContractLevelOpinions entity + Mig 33 + config + DbSet
- D FE Workspace V2 (Implementer, pending)
- E FE Section 5 V2 (Implementer, pending)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-22 12:24:38 +07:00
parent 138469db4e
commit 26c98d3c11
8 changed files with 4265 additions and 0 deletions

View File

@ -35,6 +35,9 @@ public interface IApplicationDbContext
DbSet<ContractCodeSequence> ContractCodeSequences { get; }
DbSet<ContractChangelog> ContractChangelogs { get; }
DbSet<ContractDepartmentApproval> ContractDepartmentApprovals { get; }
// Plan B Chunk C (S29 — Mig 33) — Ý kiến cấp duyệt V2 dynamic cho HĐ.
// Cookie-cutter mirror PE Mig 26 PurchaseEvaluationLevelOpinions.
DbSet<ContractLevelOpinion> ContractLevelOpinions { get; }
DbSet<Notification> Notifications { get; }
DbSet<WorkflowTypeAssignment> WorkflowTypeAssignments { get; }
DbSet<WorkflowDefinition> WorkflowDefinitions { get; }

View File

@ -48,6 +48,12 @@ public class Contract : AuditableEntity
public List<ContractApproval> Approvals { get; set; } = new();
public List<ContractComment> Comments { get; set; } = new();
public List<ContractAttachment> Attachments { get; set; } = new();
// Plan B Chunk C (S29 — Mig 33, 2026-05-22) — Ý kiến cấp duyệt V2 dynamic
// cookie-cutter mirror PE Mig 26. UPSERT auto từ ApproveV2Async (Plan B
// Chunk D em main wire). Section 5 FE render dynamic theo flow.steps[].levels[].
// HĐ V1 (WorkflowDefinitionId) KHÔNG dùng.
public List<ContractLevelOpinion> LevelOpinions { get; set; } = new();
public List<ContractChangelog> Changelogs { get; set; } = new();
public List<ContractDepartmentApproval> DepartmentApprovals { get; set; } = new();

View File

@ -0,0 +1,36 @@
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Contracts;
// "Ý kiến cấp duyệt" V2 cho HĐ — sign-off DYNAMIC theo workflow ApprovalWorkflowV2
// (Mig 22-25 + Plan B Mig 32). Cookie-cutter mirror PE Mig 26
// `PurchaseEvaluationLevelOpinion` (S19 2026-05-09).
//
// Mỗi row = 1 (Contract × ApprovalWorkflowLevel). Service `ApproveV2Async` sau khi
// approve thành công Cấp hiện tại sẽ UPSERT row này (Plan B Chunk D em main wire):
// Comment = approval.Comment ?? "(duyệt — không ý kiến)"
// 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 `contract.approvalWorkflowId != null` → render dynamic
// theo flow.steps[].levels[]. HĐ V1 legacy (WorkflowDefinitionId set) → fallback
// không có ý kiến dynamic (giống PE V1 behavior).
public class ContractLevelOpinion : AuditableEntity
{
public Guid ContractId { 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 Contract? Contract { get; set; }
public ApprovalWorkflowLevel? Level { get; set; }
}

View File

@ -37,6 +37,8 @@ public class ApplicationDbContext
public DbSet<ContractCodeSequence> ContractCodeSequences => Set<ContractCodeSequence>();
public DbSet<ContractChangelog> ContractChangelogs => Set<ContractChangelog>();
public DbSet<ContractDepartmentApproval> ContractDepartmentApprovals => Set<ContractDepartmentApproval>();
// Plan B Chunk C (S29 — Mig 33) — Ý kiến cấp duyệt V2 cho HĐ. Mirror PE Mig 26.
public DbSet<ContractLevelOpinion> ContractLevelOpinions => Set<ContractLevelOpinion>();
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<WorkflowTypeAssignment> WorkflowTypeAssignments => Set<WorkflowTypeAssignment>();
public DbSet<WorkflowDefinition> WorkflowDefinitions => Set<WorkflowDefinition>();

View File

@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// Plan B Chunk C (S29 — Mig 33, 2026-05-22) — cookie-cutter mirror
// PurchaseEvaluationLevelOpinionConfiguration (Mig 26 S19).
//
// UPSERT auto sync từ ContractWorkflowService.ApproveV2Async (Plan B Chunk D em
// main wire). UNIQUE (ContractId, ApprovalWorkflowLevelId) đảm bảo 1 row/level/HĐ.
// FK Cascade Contract (xoá HĐ → 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 ContractLevelOpinionConfiguration : IEntityTypeConfiguration<ContractLevelOpinion>
{
public void Configure(EntityTypeBuilder<ContractLevelOpinion> e)
{
e.ToTable("ContractLevelOpinions");
e.Property(x => x.Comment).HasMaxLength(2000);
e.Property(x => x.SignedByFullName).HasMaxLength(200).IsRequired();
e.HasOne(x => x.Contract)
.WithMany(c => c.LevelOpinions)
.HasForeignKey(x => x.ContractId)
.OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Level)
.WithMany()
.HasForeignKey(x => x.ApprovalWorkflowLevelId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.ContractId, 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 AddContractLevelOpinions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ContractLevelOpinions",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ContractId = 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_ContractLevelOpinions", x => x.Id);
table.ForeignKey(
name: "FK_ContractLevelOpinions_ApprovalWorkflowLevels_ApprovalWorkflowLevelId",
column: x => x.ApprovalWorkflowLevelId,
principalTable: "ApprovalWorkflowLevels",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ContractLevelOpinions_Contracts_ContractId",
column: x => x.ContractId,
principalTable: "Contracts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ContractLevelOpinions_ApprovalWorkflowLevelId",
table: "ContractLevelOpinions",
column: "ApprovalWorkflowLevelId");
migrationBuilder.CreateIndex(
name: "IX_ContractLevelOpinions_ContractId_ApprovalWorkflowLevelId",
table: "ContractLevelOpinions",
columns: new[] { "ContractId", "ApprovalWorkflowLevelId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ContractLevelOpinions");
}
}
}

View File

@ -1044,6 +1044,64 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("ContractDepartmentApprovals", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractLevelOpinion", 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<Guid>("ContractId")
.HasColumnType("uniqueidentifier");
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<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("ContractId", "ApprovalWorkflowLevelId")
.IsUnique();
b.ToTable("ContractLevelOpinions", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.DichVuDetail", b =>
{
b.Property<Guid>("Id")
@ -3550,6 +3608,25 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Contract");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractLevelOpinion", b =>
{
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level")
.WithMany()
.HasForeignKey("ApprovalWorkflowLevelId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract")
.WithMany("LevelOpinions")
.HasForeignKey("ContractId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Contract");
b.Navigation("Level");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.DichVuDetail", b =>
{
b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract")
@ -3882,6 +3959,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("GiaoKhoanDetails");
b.Navigation("LevelOpinions");
b.Navigation("MuaBanDetails");
b.Navigation("NguyenTacDvDetails");