[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
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:
@ -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);
|
||||
|
||||
@ -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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user