[CLAUDE] Contract: K2 SP-002 phieu KHKK mang hang-muc + nhom duyet N1-8 (Mig 71) + FE x2 app + 11 test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m12s

Mig 71 AddKhkkApprovalGroupCatalogAndFinalizeRuntime: +ApprovalGroup (backfill=1)
+EndedByLevelFinalize (K2 de, K3 tieu - ghost-window khai) + Line +CatalogEntryId/
TenHangMuc + unique (Plan,Supplier,CatalogEntry) filtered IsDeleted=0. Rao
1-PE-1-phieu doi (PeId,ApprovalGroup) 2 site + picker OG-7 ExistingGroups + nut
'Tao them cho nhom khac'. Submit-guard va-5 (CHAN null, thang nen cho-qua-legacy;
ghost-window K2->K4b khai) + dem-so-khop (gate F-1: bit duong hang-muc xoa-mem).
List ?group= end-to-end + Tong-Approved list (va-8). FE: select nhom default 1 +
badge N{g} + cot Hang-muc + cot Tong-duyet, SHA-pair 5/5. Gate K2 PASS-WITH-FLAGS
10f: F-1 guard fix + 2 test bit (xoa-mem chan + reflection ?group=); F-2/F-5/F-7/
F-8/F-9 comment-honest; F-3/F-4 docs-delta. Suite 609/0 (45D+564I). Wave co #53 x5,
artifact + relaunch-cat-got cuu 100% (0 mat cong).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-31 17:05:27 +07:00
parent 89b5bbee8a
commit 0779f2d45a
23 changed files with 8905 additions and 109 deletions

View File

@ -28,6 +28,17 @@ public class ContractSigningPlanConfiguration : IEntityTypeConfiguration<Contrac
b.HasIndex(x => x.MaKeHoach).IsUnique().HasFilter("[MaKeHoach] IS NOT NULL");
b.HasIndex(x => x.PurchaseEvaluationId);
// [K2 Mig 71 — S164] Rào "1 PE = 1 phiếu" đổi thành "(PE × NHÓM DUYỆT)" [OG-7] ⇒ 1 PE
// được N phiếu miễn khác nhóm. 🔴 CỐ Ý **KHÔNG** dựng unique (PeId, ApprovalGroup) —
// lý do THẬT (gate-K2 F-7 sửa; bản cũ "unique không diễn tả được Phase != TuChoi" SAI:
// filtered index `WHERE [Phase] <> 3 AND [IsDeleted] = 0` diễn tả được). Đợt này chấp
// nhận rào handler (check-then-insert, còn cửa race nhỏ) vì Phase mutable + UI-hướng-1;
// chống race tuyệt đối = dựng filtered unique ở wave sau. Rào sống ở handler
// (`ContractSigningPlanFeatures.cs` Region 1); index dưới đây chỉ để lọc/đếm theo nhóm
// (list `?group=` + cây 8 folder) cho nhanh.
b.HasIndex(x => new { x.ApprovalGroup, x.Phase });
b.HasIndex(x => x.ProjectId);
b.HasIndex(x => x.DepartmentId);
b.HasIndex(x => x.DrafterUserId);

View File

@ -18,11 +18,27 @@ public class ContractSigningPlanLineConfiguration : IEntityTypeConfiguration<Con
b.Property(x => x.ProposedAmount).HasPrecision(18, 2);
b.Property(x => x.ApprovedAmount).HasPrecision(18, 2);
b.Property(x => x.Note).HasMaxLength(1000);
// MaxLength khớp `ContractCatalogEntry.TenVi` (500, `ContractCatalogEntryConfiguration.cs`)
// — denorm mà hẹp hơn nguồn thì tên dài nhất (A3-09 ~150 ký tự) vẫn lọt, nhưng để
// BẰNG nguồn thì không bao giờ phải cắt. Validator lấy số từ ĐÂY (EF = source of truth).
b.Property(x => x.TenHangMuc).HasMaxLength(500);
b.HasIndex(x => new { x.ContractSigningPlanId, x.SupplierId })
// [K2 Mig 71 — S164] UNIQUE đổi (Plan, Supplier) → (Plan, Supplier, CatalogEntry):
// 1 NCC được nhiều dòng trên cùng phiếu, mỗi dòng 1 hạng mục khác nhau.
// 🔴 BẪY NULL-UNIQUE (vá-9): trong UNIQUE index SQL Server, NULL == NULL ⇒ HAI dòng
// cùng (Plan, Supplier) mà đều `CatalogEntryId NULL` sẽ VI PHẠM index này. Dữ liệu cũ
// an toàn (unique cũ bảo đảm ≤ 1 dòng/cặp ⇒ tối đa 1 NULL/cặp). Với dòng phát sinh
// MỚI, mắt xích chặn KHÔNG PHẢI index này (index cũ đã bị DROP trong chính Mig 71)
// mà là (gate-K2 F-2 — nói đúng THÌ): (1) SUBMIT-GUARD chặn null trước khi trình
// (`ContractSigningPlanWorkflowService`) — lớp DUY NHẤT ĐANG TỒN TẠI; +
// (2) [K4b — THÌ TƯƠNG LAI] validator đường gán hạng mục khi Lines-editor ra đời.
// 🔴 CỐ Ý KHÔNG thêm `AND [CatalogEntryId] IS NOT NULL` vào filter: làm vậy là thả
// trôi vô hạn dòng trùng (Plan, Supplier, NULL).
b.HasIndex(x => new { x.ContractSigningPlanId, x.SupplierId, x.CatalogEntryId })
.IsUnique()
.HasFilter("[IsDeleted] = 0");
b.HasIndex(x => x.SupplierId);
b.HasIndex(x => x.CatalogEntryId);
b.HasIndex(x => x.ContractId);
b.HasOne(x => x.ContractSigningPlan)

View File

@ -0,0 +1,144 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
// [K2 Mig 71 — S164 2026-07-31] KHKK mang HẠNG MỤC + NHÓM DUYỆT (SOL-CCM-SP-002).
// 6 item, KHÔNG bảng mới (96 giữ nguyên), 0 RenameColumn:
// 1. ContractSigningPlans +ApprovalGroup int — nhóm duyệt 1..8, BACKFILL = 1
// 2. ContractSigningPlans +EndedByLevelFinalize bit default 0 — cờ RUNTIME (xem ghi chú 🔴)
// 3. ContractSigningPlanLines +CatalogEntryId uniqueidentifier NULL — loose-Guid, KHÔNG FK
// 4. ContractSigningPlanLines +TenHangMuc nvarchar(500) NULL — denorm tên lúc gán
// 5. UNIQUE (Plan, Supplier) → (Plan, Supplier, CatalogEntry) filtered [IsDeleted]=0 (#57)
// 6. +2 index tra cứu: (ApprovalGroup, Phase) trên header · CatalogEntryId trên dòng
//
// 🔴 GHOST-WINDOW KHAI TRƯỚC (item 2): `EndedByLevelFinalize` do K2 ĐẺ, K3 TIÊU THỤ.
// Từ Mig 71 tới khi K3 land, cột này KHÔNG có site nào ghi ⇒ luôn = 0. Cố ý gộp vào
// đây để K3 = 0-mig (spec §②-9 + vá-7 item-6). Ai đọc cột này TRƯỚC K3 sẽ thấy "mọi
// phiếu đều không kết thúc sớm" — ĐÚNG với hiện trạng (service KHKK hiện chỉ có đúng
// MỘT nhánh set DaDuyet, là đường thường), không phải dữ liệu hỏng.
//
// 🔴 BẤT-BIẾN TIỀN: migration này có **0 câu lệnh** chạm `PeReferenceAmount` /
// `ProposedAmount` / `ApprovedAmount` ⇒ Σ trước == Σ sau là đúng theo CẤU TRÚC, không
// chỉ theo phép đo. (Đo local: cả Dev lẫn Design đều 0 dòng KHKK ⇒ Σ = tập RỖNG hai
// phía — khai thẳng vì "trích rỗng đọc thành sạch" là bẫy, phép đo prod mới có răng.)
public partial class AddKhkkApprovalGroupCatalogAndFinalizeRuntime : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 🔴 UNIQUE CŨ CHẾT TỪ ĐÂY (item 5). Sau lệnh này, mắt xích chặn "2 dòng cùng
// (Plan, Supplier) đều CatalogEntryId NULL" KHÔNG còn là index nữa — trong UNIQUE
// index SQL Server NULL == NULL nên index MỚI vẫn chặn cặp NULL-trùng, nhưng nó
// chặn bằng lỗi 2601 lúc SaveChanges (500 khó hiểu), không phải bằng nghiệp vụ.
// Mắt xích THẬT do tầng ứng dụng giữ (vá-9) — hiện 1 lớp, K4b thêm lớp 2 (gate-K2 F-2):
// (a) SUBMIT-GUARD `ContractSigningPlanWorkflowService.SubmitAsync` — mọi dòng
// phải có hạng mục trước khi trình duyệt (ĐANG TỒN TẠI);
// (b) [K4b — THÌ TƯƠNG LAI] validator đường gán hạng mục của Lines-editor:
// `CatalogEntryId` BẮT BUỘC trên dòng mới qua API.
// Dữ liệu cũ AN TOÀN không cần backfill dòng: unique cũ đã bảo đảm ≤ 1 dòng cho
// mỗi cặp (Plan, Supplier) ⇒ tối đa 1 dòng NULL mỗi cặp, không thể đụng nhau.
migrationBuilder.DropIndex(
name: "IX_ContractSigningPlanLines_ContractSigningPlanId_SupplierId",
table: "ContractSigningPlanLines");
migrationBuilder.AddColumn<int>(
name: "ApprovalGroup",
table: "ContractSigningPlans",
type: "int",
nullable: false,
defaultValue: 0);
// BACKFILL = 1 (item 1). Mọi phiếu tồn tại trước K2 thuộc NHÓM 1 — acceptance
// `COUNT(phiếu) == Σ COUNT 8 folder` (không phiếu nào rơi ngoài cây 8 nhóm).
// Khuôn Mig 64 `Sql("UPDATE Suppliers SET IsPublic=1")`: `Sql()` trần, KHÔNG
// provider-guard (khuôn Mig 61/65/67) — SQLite trong test dùng EnsureCreated nên
// không chạy migration, không có nhánh nào cần rẽ. Vô điều kiện (KHÔNG
// `WHERE ApprovalGroup = 0`) để phép đo có răng: rowcount phải == số phiếu cũ;
// nếu viết WHERE thì backfill hụt sẽ trông y hệt backfill thành công.
migrationBuilder.Sql("UPDATE [ContractSigningPlans] SET [ApprovalGroup] = 1;");
migrationBuilder.AddColumn<bool>(
name: "EndedByLevelFinalize",
table: "ContractSigningPlans",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<Guid>(
name: "CatalogEntryId",
table: "ContractSigningPlanLines",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "TenHangMuc",
table: "ContractSigningPlanLines",
type: "nvarchar(500)",
maxLength: 500,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlans_ApprovalGroup_Phase",
table: "ContractSigningPlans",
columns: new[] { "ApprovalGroup", "Phase" });
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlanLines_CatalogEntryId",
table: "ContractSigningPlanLines",
column: "CatalogEntryId");
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlanLines_ContractSigningPlanId_SupplierId_CatalogEntryId",
table: "ContractSigningPlanLines",
columns: new[] { "ContractSigningPlanId", "SupplierId", "CatalogEntryId" },
unique: true,
filter: "[IsDeleted] = 0");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_ContractSigningPlans_ApprovalGroup_Phase",
table: "ContractSigningPlans");
migrationBuilder.DropIndex(
name: "IX_ContractSigningPlanLines_CatalogEntryId",
table: "ContractSigningPlanLines");
migrationBuilder.DropIndex(
name: "IX_ContractSigningPlanLines_ContractSigningPlanId_SupplierId_CatalogEntryId",
table: "ContractSigningPlanLines");
migrationBuilder.DropColumn(
name: "ApprovalGroup",
table: "ContractSigningPlans");
migrationBuilder.DropColumn(
name: "EndedByLevelFinalize",
table: "ContractSigningPlans");
migrationBuilder.DropColumn(
name: "CatalogEntryId",
table: "ContractSigningPlanLines");
migrationBuilder.DropColumn(
name: "TenHangMuc",
table: "ContractSigningPlanLines");
// [gate-K2 F-8] Down chỉ AN TOÀN khi CHƯA có dữ liệu tách-dòng (pre-K4b): một khi 1 NCC
// có nhiều dòng/phiếu (khác hạng mục), DropColumn ở trên đã xoá chiều phân biệt và
// CreateIndex 2-cột dưới đây FAIL vì trùng (Plan, Supplier). Rollback lúc đó = quyết định
// owner + dọn/gộp dữ liệu trước, không chạy Down mù.
migrationBuilder.CreateIndex(
name: "IX_ContractSigningPlanLines_ContractSigningPlanId_SupplierId",
table: "ContractSigningPlanLines",
columns: new[] { "ContractSigningPlanId", "SupplierId" },
unique: true,
filter: "[IsDeleted] = 0");
}
}
}

View File

@ -318,6 +318,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("ApprovalGroup")
.HasColumnType("int");
b.Property<Guid?>("ApprovalWorkflowId")
.HasColumnType("uniqueidentifier");
@ -345,6 +348,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid>("DrafterUserId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("EndedByLevelFinalize")
.HasColumnType("bit");
b.Property<string>("GhiChu")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
@ -394,6 +400,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasIndex("PurchaseEvaluationId");
b.HasIndex("ApprovalGroup", "Phase");
b.HasIndex("Phase", "IsDeleted");
b.ToTable("ContractSigningPlans", (string)null);
@ -710,6 +718,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<Guid?>("CatalogEntryId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("ContractId")
.HasColumnType("uniqueidentifier");
@ -746,6 +757,10 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid>("SupplierId")
.HasColumnType("uniqueidentifier");
b.Property<string>("TenHangMuc")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
@ -754,11 +769,13 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasKey("Id");
b.HasIndex("CatalogEntryId");
b.HasIndex("ContractId");
b.HasIndex("SupplierId");
b.HasIndex("ContractSigningPlanId", "SupplierId")
b.HasIndex("ContractSigningPlanId", "SupplierId", "CatalogEntryId")
.IsUnique()
.HasFilter("[IsDeleted] = 0");

View File

@ -164,6 +164,26 @@ public class ContractSigningPlanWorkflowService(
await EnsureCanSubmitAsync(plan, actorId, isAdmin, ct);
// ── [K2 S164 — vá-5 spec-cụm1, THẮNG nền K2.d vế null] Rào HẠNG MỤC trước khi trình:
// (i) mọi dòng phải đã gán hạng mục — auto-gen mồi CatalogEntryId=null chỉ hợp lệ trong
// nháp; nền `:126` định CHO-QUA null-legacy nhưng vá-5 chốt CHẶN cho NHẤT QUÁN.
// Ghost-window K2→K4b khai chủ-đích: phiếu cũ bị Trả-lại sẽ kẹt trình tới khi
// Lines-editor (K4b) land — message chỉ rõ việc cần làm.
// (ii) hạng mục phải CÙNG nhóm duyệt với phiếu (code mẫu nền K2.d) — chốt CUỐI chống
// data cũ/lách API; validator đường-gán K4b là fail-fast tầng trước.
if (plan.Lines.Any(l => l.CatalogEntryId == null))
throw new ConflictException(
"Còn dòng chưa gán hạng mục công việc (danh mục SP-002) — gán hạng mục cho mọi dòng trước khi trình.");
// [gate-K2 F-1] Hỏi câu ĐÚNG: "MỌI id resolve tới hạng mục CÒN SỐNG + ĐÚNG nhóm" — không
// phải "có id nào SAI nhóm". Query filter !IsDeleted nuốt hạng mục xoá mềm ⇒ Any(sai nhóm)
// = false cho cả Guid rác lẫn hạng mục đã xoá (vắng-mặt đọc thành sạch). Đếm-so-khớp bịt cả hai.
var catalogIds = plan.Lines.Select(l => l.CatalogEntryId!.Value).Distinct().ToList();
var okCount = await db.ContractCatalogEntries
.CountAsync(c => catalogIds.Contains(c.Id) && c.ApprovalGroup == plan.ApprovalGroup, ct);
if (okCount != catalogIds.Count)
throw new ConflictException(
$"Có hạng mục không hợp lệ (đã xoá / không tồn tại) hoặc không thuộc nhóm duyệt của phiếu — mọi dòng phải cùng nhóm N{plan.ApprovalGroup}.");
var steps = await LoadStepsAsync(awId, ct);
var firstLevelGroup = steps[0].Levels.Where(l => l.Order == 1).ToList();
if (firstLevelGroup.Count == 0)