Files
solution-erp/.claude/workflows/runs/2026-07-31-S164-4gd-khkk-fanout/sub-invest-fable-b2-cum1.md
2026-07-31 12:45:46 +07:00

32 KiB
Raw Blame History

sub-invest-fable-b2-cum1 — B2 CỤM-1: SPEC CHI TIẾT K1+K2+K3 (S164, /fable-real, ATT-2)

Engine /fable-real single deep-pass — ATT-2 (att-1 chết skeleton-ruột-rỗng 472B, đọc 152K chưa viết gì). Luật sắt att-2: ĐAN XEN đọc→viết per section. Nguồn pin: spec v2 §② 9 quyết định + danh-muc-sp002-transcribe (86 row) + khuôn code sống (DbInitializer / ContractSigningPlan* / PE Mig 67).

K1 Master-data danh mục (ContractCatalogEntries + Mig 70 + seed 86 + CQRS + FE ×2)

PRE-gate: [OG-6] anh soát danh-muc-sp002-transcribe.md (4 ô ⚠️ cuối file) TRƯỚC khi land seed. Code/schema land trước được, seed-data commit sau gật.

K1.a — Entity + schema bảng ContractCatalogEntries

Entity ContractCatalogEntry : AuditableEntity (soft-delete — mirror khuôn 4 catalog Master sẵn) tại src/Backend/SolutionErp.Domain/Master/ContractCatalogEntry.cs (cạnh WorkItem — folder Domain/Master hiện hữu).

Cột Type SQL Null Ghi chú
Id uniqueidentifier PK NOT NULL BaseEntity
Code nvarchar(20) NOT NULL A1-01CEO-01UNIQUE filtered WHERE [IsDeleted]=0 (#57, khuôn Mig 47)
TenVi nvarchar(500) NOT NULL tên VN (dài nhất A3-09 ~150 ký tự)
TenEn nvarchar(500) NULL tên EN (seed đủ 86; nullable cho row tay sau)
GroupCode nvarchar(10) NOT NULL A1..A4, B1..B3, C1..C3, CEO — section SP-002
ApprovalGroup int NOT NULL 1..8 — map N1..N8 (validator range, KHÔNG DB CHECK — khuôn repo không dùng CHECK)
SignerRole nvarchar(100) NOT NULL TEXT vai ký chính (QĐ-4 2-tầng: TEXT in GĐ4 ⟂ Levels người thật)
InitialsChain nvarchar(300) NULL chuỗi ký nháy TBP.CCM→NV.CCM→TBP.EQU→NV.EQU (Ô1→Ô4, bỏ N/A)
NduqNote nvarchar(500) NULL tham khảo NĐUQ [OG-3] — seed null (ảnh không có số per-dòng, ⚠️3 transcribe)
IsActive bit NOT NULL def 1 ẩn khỏi picker không cần xoá (khuôn WorkItem Mig 49 validator IsActive)
SortOrder int NOT NULL def 0 thứ tự trong nhóm (seed = STT trong section)
audit 7 cột CreatedAt/UpdatedAt/CreatedBy/UpdatedBy + IsDeleted/DeletedAt/DeletedBy

Index: UX_ContractCatalogEntries_Code UNIQUE filtered [IsDeleted]=0 · IX_ContractCatalogEntries_ApprovalGroup non-unique (picker K2 lọc theo nhóm phiếu). Config Persistence/Configurations/ContractCatalogEntryConfiguration.cs: ToTable + HasQueryFilter(!IsDeleted) + 2 index + MaxLength như bảng. DbSet vào ApplicationDbContext + IApplicationDbContext.

Mig 70 AddContractCatalogEntries — 1 CreateTable, bảng 96→97. 3-file rule (Up/Down + Designer + Snapshot — gotcha #17). Down = DropTable (reversible, chưa ai tham chiếu tại K1; FK từ Lines là chuyện Mig 71).

K1.b — Seed SeedContractCatalogAsync (86 row, per-Code idempotent, UNGATED)

  • Gate: UNGATED — mirror SeedRealMasterDataAsync (DbInitializer.cs:2893-2896 "INFRASTRUCTURE master data thật... NOT gated DemoSeed") — danh mục SP-002 là master data thật phải có mặt prod ngay deploy, không phải demo.
  • Né #75/#76 (resurrect): lookup existing codes PHẢI IgnoreQueryFilters(): var existing = await db.ContractCatalogEntries.IgnoreQueryFilters().Select(c => c.Code).ToListAsync(); (⚠️ khuôn SeedRealMasterDataAsync:2970db.Projects.Select(p=>p.Code) KHÔNG IgnoreQueryFilters ⇒ chính là lỗ #75/#76 — row soft-deleted vô hình → RE-CREATE mỗi restart. K1 làm ĐÚNG hơn khuôn, ghi comment lý do. Đây là hit IgnoreQueryFilters đầu tiên trong src/Backend — S155 đo 0-hit — cố ý, khai trong PR.)
  • Foreach 86 tuple (Code, TenVi, TenEn, GroupCode, ApprovalGroup, SignerRole, InitialsChain, SortOrder) hardcode inline (khuôn :2904 — runtime không đọc file ngoài) → skip-if-contains else Add → 1 SaveChanges + log count.
  • Mapping transcribe → field: cột →Code · Tên VN→TenVi · Tên EN→TenEn · heading section (A1..C3, 5.2)→GroupCode · cột → Nn heading→ApprovalGroup · bảng "Nhóm duyệt (8)" đầu file: Ký chính→SignerRole, Nháy Ô1→Ô4 (bỏ N/A)→InitialsChain — denorm per-row (86 row cùng nhóm chép cùng chuỗi; QĐ-4 chấp nhận TEXT). STT trong section→SortOrder.
  • Đếm theo nhóm (khớp transcribe): N1=5 · N2=10 (A2 1+A3 9) · N3=5 · N4=23 · N5=14 (⚠️1 B2 đánh lại 01-14) · N6=15 · N7=13 (C1 6+C2 3+C3 4) · N8=1 → Σ=86.
  • Gọi trong InitializeAsync cạnh SeedRealMasterDataAsync (trước sample workflows).

K1.c — BE CQRS + controller (QUYẾT policy: mirror CatalogsController, KHÔNG policy mới)

SOI MẪU: Api/Controllers/CatalogsController.cs:13-14 = [Route("api/catalogs")] + class [Authorize] (đăng nhập = đọc được); 12 action write đều [Authorize(Roles = "Admin")] (:23-:130). Grep Authorize(Policy = "(WorkItems|MaterialItems|UnitsOfMeasure|ServiceItems) toàn Controllers = 0 hit ⇒ "Catalogs.*"-policy per-action KHÔNG tồn tại trong khuôn catalog. QUYẾT: mirror y khuôn — GET [Authorize] thừa hưởng class, 4 mutation [Authorize(Roles = "Admin")]. Lý do: (i) luật "GIỐNG NHAU — ĐỪNG CHẾ THÊM"; (ii) picker tạo phiếu K2 cần READ rộng mọi user KHKK — nếu khoá KeHoachKyKet.Read thì user thiếu grant leaf sẽ 403 picker (#44/#85); (iii) KeHoachKyKet.Update (drafter sửa phiếu) KHÔNG được phép sửa danh mục — sai vai; (iv) OG-8 "phân quyền tính sau" — 0 policy/menu-key mới = 0 drift STATUS row Policies.

  • CQRS: file MỚI Application/Master/Catalogs/ContractCatalogFeatures.cs (cạnh CatalogsFeatures.cs hiện hữu — mega-file 1 file, Pattern 12-ter): ListContractCatalogQuery {ApprovalGroup?, GroupCode?, Search?, IncludeInactive?} → all-rows sort (GroupCode, SortOrder), KHÔNG paging (86 row; picker + trang admin dùng chung) · GetContractCatalogEntryQuery {Id} · CreateContractCatalogEntryCommand + UpdateContractCatalogEntryCommand (validator: NotEmpty Code/TenVi/GroupCode/SignerRole; ApprovalGroup InclusiveBetween(1,8); dup-Code active-set → ConflictException — so exact Code ==, filtered-unique cho phép trùng với row đã xoá mềm) · DeleteContractCatalogEntryCommand soft-delete (Line K2 giữ FK Restrict chặn hard-delete; soft-delete OK vì Line denorm TenHangMuc).
  • Controller: EXTEND CatalogsController +5 action GET/POST/PUT/DELETE api/catalogs/contract-catalog[/{id}] — không controller mới (khuôn 4 kind sẵn trong 1 controller).

K1.d — FE ×2 app (KIND_CONFIG kind mới)

  • File mẫu: fe-admin/src/pages/master/CatalogsPage.tsx + fe-user/src/pages/master/CatalogsPage.tsx (KIND_CONFIG Record<Kind,...> theo :kind URL — khuôn S29/S35).
  • Thêm kind contract-catalog vào KIND_CONFIG ×2 app: columns {Code, TenVi, TenEn, GroupCode, ApprovalGroup (badge N1..N8), SignerRole, InitialsChain, IsActive, SortOrder}; form create/edit đủ field; endpoint api/catalogs/contract-catalog. Duplicate 2 app CÓ CHỦ ĐÍCH (CLAUDE.md).
  • Menu leaf: +const MenuKeys.ContractCatalog (NGOÀI All — khuôn leaf Pe_*/tiền lệ HopDongCung NGOÀI All = 0 policy mới; hiển thị đi đường Permission row) + 1 MenuItem row con group Catalogs trong SeedMenuTreeAsync + grant admin (SeedAdminPermissionsAsync loop) + mirror fe-{admin,user}/src/lib/menuKeys.ts. Route :kind = contract-catalog — 0 route file mới.

K1.e — Test (k=5)

  1. Seed_ContractCatalog_86Rows_Idempotent — chạy seed ×2 → count active == 86, không nhân row.
  2. Seed_SkipsSoftDeletedCode_NoResurrect — soft-delete A1-01 → re-seed → active == 85, row không hồi sinh (#75/#76 có răng).
  3. Create_DuplicateActiveCode_Throws409_ButAllowsReuseOfSoftDeletedCode — dup active → Conflict; Code trùng row IsDeleted=1 → OK (filtered #57 2 chiều).
  4. CreateOrUpdate_ApprovalGroupOutOfRange_FailsValidation — 0 và 9 fail, 1 và 8 pass.
  5. Delete_IsSoft_ListExcludes_RowRemains — Delete → list 85, row DB IsDeleted=1.

K1.f — Checklist acceptance K1

  • dotnet ef migrations add AddContractCatalogEntries … → đủ 3 file trong Persistence/Migrations/; git status show 3.
  • Restart API → log "Seeded 86 contract catalog entries"; sqlcmd -S "(localdb)\MSSQLLocalDB" -d SolutionErp_Dev -Q "SELECT COUNT(*) FROM ContractCatalogEntries WHERE IsDeleted=0"86; SELECT ApprovalGroup, COUNT(*) … GROUP BY ApprovalGroup → 5/10/5/23/14/15/13/1.
  • Restart API lần 2 → vẫn 86 (idempotent by-restart-THẬT, khuôn S91).
  • curl -H "Authorization: Bearer <token>" localhost:5443/api/catalogs/contract-catalog → 200, 86 item; POST thiếu role Admin → 403.
  • dotnet test SolutionErp.slnx → baseline (STATUS.md row Tests, hiện ~590) +5, khai k=5.
  • DoD spec: N dòng ~85±5 khớp — đúng 86, lệch 0; tên VN+EN đủ 86/86.
  • STATUS.md: bảng 96→97 + Mig 69→70; schema-diagram migration table +row 70.

K2 Phiếu hạng-mục + nhóm (Mig 71: ApprovalGroup + CatalogEntryId + rào (PeId,Group) + budget-freeze QĐ-9)

PRE-gate: [OG-1] + [OG-7] — cả 2 CHỐT @S164 (spec §④). Phụ thuộc K1 (bảng catalog phải có trước để trỏ).

K2.a — Mig 71 AddContractSigningPlanGroupAndLineCatalog (3-file rule, no new table)

  1. ContractSigningPlans +ApprovalGroup int NOT NULL, defaultValue: 1 — AddColumn với default tự backfill mọi row hiện có = QĐ-6 backfill=1 (khuôn EvaluationType l3-F5; KHÔNG cần Sql() riêng — default áp cả Design-DB 0-row lẫn prod-DB có-row, né #64 lệch môi trường).
  2. ContractSigningPlanLines +CatalogEntryId uniqueidentifier NULLloose-Guid KHÔNG FK vật lý (QUYẾT: mirror y Mig 49 PE.WorkItemId "convention PE: ProjectId/SelectedSupplierId đều loose"; chính Line.cs:15 SupplierId đã loose cố ý — thêm FK thật là phá khuôn module) + IX_ContractSigningPlanLines_CatalogEntryId.
  3. ContractSigningPlanLines +TenHangMuc nvarchar(500) NULL — denorm tên tại thời điểm gán (catalog rename sau KHÔNG viết lại lịch sử phiếu — khuôn SignedByFullName denorm Mig 26).
  4. Đổi UNIQUE: DropIndex unique cũ trên (ContractSigningPlanId,SupplierId) (tên EF-sinh — lấy đúng từ ContractSigningPlanLineConfiguration.cs vùng :24-26 khi build) → CreateIndex UNIQUE (ContractSigningPlanId,SupplierId,CatalogEntryId) filtered WHERE [IsDeleted]=0.
    • ⚠️ Bẫy NULL-unique SQL Server: trong UNIQUE index, NULL == NULL ⇒ 2 row cùng (Plan,Supplier) đều CatalogEntryId NULL sẽ VI PHẠM. Data cũ AN TOÀN (unique cũ bảo đảm ≤1 row/cặp ⇒ tối đa 1 NULL-row/cặp). Chặn phát sinh mới bằng validator: line MỚI/SỬA bắt buộc có CatalogEntryId (chỉ row legacy được giữ null). KHÔNG thêm AND CatalogEntryId IS NOT NULL vào filter — làm vậy là thả trôi vô hạn dup (Plan,Supplier,null).
  5. Config update cùng file ContractSigningPlanLineConfiguration.cs: 2 property + index swap; :29 WithMany(p => p.Lines) giữ nguyên.

K2.b — QĐ-9 budget-freeze: QUYẾT per-line, TÁI DÙNG ApprovedAmount — 0 cột tiền mới, KHÔNG chép 11 cột PE Mig 67

Bằng chứng máy-freeze ĐÃ TỒN TẠI:

  • ContractSigningPlanLine.cs:17-20PeReferenceAmount = SNAPSHOT chụp lúc TẠO, comment tự khai "cùng triết lý freeze ngân sách Mig 67"; :23 ApprovedAmount"CHỈ ghi tại choke-point finalize (W3)".
  • ContractSigningPlanWorkflowService.cs:304line.ApprovedAmount ??= line.ProposedAmount; foreach :303 chạy tại finalize; :290 comment cảnh báo đúng bài #81-EXT: "Bỏ sót một nhánh = phiếu 'Đã duyệt' mà ApprovedAmount rỗng ⇒ GĐ3 sinh hợp đồng 0đ".
  • Grep PeWorkItemBudget|WinnerQuoteTotal trong ContractSigningPlan* = 1 hit label (Features.cs:162 DTO picker) — KHKK KHÔNG đọc bảng ngân sách live nào. PE Mig 67 cần 11 cột vì PE detail đọc LIVE PeWorkItemBudgets; KHKK mọi số đã là cột local của chính nó. ⇒ Lý do QUYẾT: freeze = có sẵn về SCHEMA; K2 chỉ thêm RĂNG + GATE (luật "ĐỪNG CHẾ THÊM"). Header KHÔNG cần cột: TotalPeReferenceAmount (list DTO :53, Σ :711) derive từ lines bất biến.

Việc phải làm (răng + gate):

  • #81-EXT sweep — bảng Phase-assignment-site KHKK (grep Phase\s*=[^=] — chú ý KHÔNG grep \.Phase vì object-initializer Features.cs:373 không có dấu chấm, thoát lưới hẹp):
    Site Gán Freeze?
    Features.cs:373 Phase = DangSoanThao (create initializer) không cần (không vào DaDuyet)
    WorkflowService.cs:174 = ChoDuyet (submit) không cần
    WorkflowService.cs:278 = DaDuyet (finalize choke-point) đã gọi hook :303-304
    WorkflowService.cs:342 = targetPhase (gán qua BIẾN — đúng class bẫy :39 comment) 🔴 PHẢI XỬ: nếu nhánh này reachable với targetPhase == DaDuyet (admin-override/return-path) thì hook :304 bị bypass ⇒ hoặc (i) gọi ApplyApprovedAmounts ngay trước :342 khi targetPhase == DaDuyet, hoặc (ii) assert nhánh không nhận DaDuyet + test khoá hành vi. Mirror đúng bài PE Mig 67 site-5 admin-override.
    Acceptance: re-grep khi build, liệt ĐỦ site trong PR (seeder/DbInitializer hiện 0 site gán phase KHKK).
  • Display-gate frozen: DetailDto +budgetFrozen = phase == DaDuyet (khuôn tên PE S133); FE DaDuyet đọc ApprovedAmount + badge khoá, trước đó đọc ProposedAmount; mọi editor line (K4b) chỉ mở ở Nháp/TraLai — sau DaDuyet cấm sửa/xoá line (xoá mềm line sau DaDuyet làm Σ :711 trôi ÂM THẦM — đây là leak gián tiếp duy nhất, chặn bằng phase-guard).

K2.c — Đổi rào 1-PE-1-phiếu → (PeId, ApprovalGroup) [OG-7] — 2 site

  • Site 1 create Features.cs:342-347: AnyAsync(p => p.PurchaseEvaluationId == pe.Id && p.Phase != TuChoi)+&& p.ApprovalGroup == request.ApprovalGroup; message mới: "Phiếu Duyệt NCC này đã có kế hoạch cho NHÓM duyệt này. Chọn nhóm khác hoặc xoá kế hoạch cũ." CreateContractSigningPlanCommand +ApprovalGroup int (validator InclusiveBetween(1,8)); create gán plan.ApprovalGroup = request.ApprovalGroup.
  • Site 2 picker Features.cs:1201-1202 (query PE-eligible): bỏ loại-trừ PE-đã-có-phiếu → trả kèm existingGroups: int[] per PE (groups của các plan sống). UI hướng-1 (OG-7 verbatim): PE đã có phiếu → vẫn hiện, hiển thị phiếu hiện có + nút phụ "Tạo thêm cho nhóm khác" (disable các nhóm ∈ existingGroups trong select nhóm) — thay 409 chặn hẳn. FE KhkkCreatePage ×2 app + types/khkk.ts DTO picker.
  • Auto-gen Lines :389 giữ per-winner, mồi CatalogEntryId = null, TenHangMuc = null (gán hạng mục = Lines-editor K4b, KHÔNG thuộc K2 — create hiện "FE KHÔNG gửi lines" KhkkCreatePage.tsx:8).

K2.d — Validator line-cùng-nhóm (code mẫu)

Chạy ở 2 chỗ: upsert-lines (K4b, fail-fast) + submit-guard (service, chống data cũ lách):

var ids = plan.Lines.Where(l => l.CatalogEntryId != null)
    .Select(l => l.CatalogEntryId!.Value).Distinct().ToList();
var badGroup = await db.ContractCatalogEntries
    .AnyAsync(c => ids.Contains(c.Id) && c.ApprovalGroup != plan.ApprovalGroup, ct);
if (badGroup) throw new ConflictException(
    "Có hạng mụ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);

Line CatalogEntryId == null (legacy nhóm-1 backfill) → CHO QUA (không chặn phiếu cũ resubmit).

K2.e — List-query + DTO + bất-biến Σ

  • ListContractSigningPlansQuery (Features.cs:622-626) +ApprovalGroup int? = nullif (request.ApprovalGroup is not null) q = q.Where(p => p.ApprovalGroup == request.ApprovalGroup);K2 là CHỦ param này (QĐ-3/l3-F6; K4b leaf + K5 cây chỉ TIÊU THỤ).
  • ContractSigningPlanListItemDto (record vùng :34-53) +ApprovalGroup · DetailDto :608-616 +ApprovalGroup +BudgetFrozen · LineDto :570-574 +CatalogEntryId +TenHangMuc · FE types/khkk.ts mirror ×2 app.
  • Bất-biến Σ PeReferenceAmount: Mig 71 KHÔNG đụng data tiền ⇒ SELECT SUM(PeReferenceAmount) FROM ContractSigningPlanLines TRƯỚC == SAU migrate (acceptance đo 2 lần). Redefine semantics (QĐ-2b): row legacy = tham chiếu per-NCC-toàn-phiếu (giữ nguyên); row MỚI cùng NCC (tách hạng mục, K4b) mồi PeReferenceAmount = 0Σ per-(phiếu,NCC) == số PE gốc bất biến khi tách dòng; khai trong doc + tooltip FE.

K2.f — Bảng consumer .Lines ĐÃ-XỬ / KHÔNG-ĐỔI (grep-verified; đối chiếu danh sách 16 = 10 BE + 6 FE của l3-F18 b1b-review-synthesis.md)

# Site Disposition
1 WorkflowService.cs:111 Include Lines KHÔNG-ĐỔI (Include tự nạp cột mới)
2 WorkflowService.cs:296 comment scope KHÔNG-ĐỔI (doc)
3 WorkflowService.cs:303-304 foreach freeze XỬ — thêm răng site-:342 (K2.b)
4 LineConfiguration.cs:29 WithMany KHÔNG-ĐỔI; cùng file XỬ +2 property + index swap
5 Features.cs:389 auto-gen create XỬ — mồi 2 field null + comment semantics
6 Features.cs:412 changelog count KHÔNG-ĐỔI
7 Features.cs:417 result count KHÔNG-ĐỔI
8 Features.cs:500 Include detail KHÔNG-ĐỔI
9 Features.cs:524 supplierIds KHÔNG-ĐỔI
10 Features.cs:568-574 projection LineDto XỬ — +2 field
11 Features.cs:711 Σ TotalPeReference (list) KHÔNG-ĐỔI (bất-biến K2.e giữ nghĩa)
12-13 types/khkk.ts:203 ×2 app XỬKhkkLineDto +2 field optional
14-15 KhkkDetailPage.tsx:425-441 ×2 app XỬ — +cột "Hạng mục" (TenHangMuc, fallback "—")
16-17 KhkkCreatePage.tsx:8 ×2 app KHÔNG-ĐỔI đợt này (create không gửi lines; editor = K4b)

(17 site đếm được > "16" của l3-F18 vì :296 comment + :711 — dư an toàn hơn thiếu; PR đối chiếu từng dòng.)

K2.g — Test (k=7)

  1. Mig71_BackfillsApprovalGroup1_CountEqualsSumOfFolders — mọi plan cũ group==1.
  2. Create_SamePeSameGroup_409_SamePeOtherGroup_201 — rào mới 2 chiều (DoD "phiếu nhóm 2 CÙNG PE → 201").
  3. Lines_UniqueTriple_DupCatalog_Throws_DiffCatalog_Ok — cùng (plan,supplier,catalogA) ×2 fail; +catalogB pass (năng lực mới).
  4. Submit_LineWrongGroup_409_LegacyNullCatalog_Pass — validator K2.d 2 chiều.
  5. Finalize_FreezesAllLines_NoNullApprovedAmount — sau DaDuyet mọi line ApprovedAmount != null (:303-304).
  6. TransitionViaVariableSite_CannotReachDaDuyetWithoutFreeze — răng site-:342 (theo phương án (i)/(ii) đã chọn).
  7. Sum_PeReferenceAmount_InvariantAcrossMigAndSplit — Σ trước/sau + tách dòng mồi 0.

K2.h — Checklist acceptance K2

  • Mig 71 đủ 3 file; sqlcmd … "SELECT SUM(PeReferenceAmount) FROM ContractSigningPlanLines" trước & sau update → bằng nhau (ghi 2 số vào PR).
  • sqlcmd … "SELECT COUNT(*) FROM ContractSigningPlans WHERE ApprovalGroup=1" == tổng phiếu hiện có; COUNT(tất cả) == Σ COUNT 8 nhóm.
  • curl create phiếu nhóm 2 trên PE đã có phiếu nhóm 1 → 201; lặp nhóm 1 → 409.
  • curl list ?approvalGroup=2 → chỉ phiếu nhóm 2; DTO có approvalGroup.
  • Grep Phase\s*=[^=] toàn ContractSigningPlan* → liệt đủ site vào PR (đối chiếu bảng K2.b).
  • dotnet test +7, khai k=7; bảng K2.f dán vào PR từng dòng (bài S119 disposition TỪNG DÒNG, không số tổng).

K3 8 workflow KHKK-N1..N8 + designer + validator pin-đúng-nhóm

PRE-gate: [OG-2] "Đúng đội hình cũ" — nhưng PHẢI verify đội hình QT-DN-V2-001 bản LIVE trước seed (SSH+sqlcmd prod chết khi SQL-client load — S134/S148 ⇒ đo qua API prod GET /api/approval-workflows-v2 bằng curl + token admin; Dev làm fallback, flag rõ trong PR nếu prod bất khả).

K3.⚠️ MÂU THUẪN SPEC↔CODE phải xử TRƯỚC (finding chính của lane)

Spec §④ OG-3 ghi "K3 0 code mới cho nhánh này — CHỈ config workflow" (tái dùng level-finalize V2). Nhưng code THẬT: fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx:151-152KHKK_EARLY_FINALIZE_NOTE = 'Chưa hỗ trợ cho quy trình Kế hoạch ký kết HĐ — backend chưa xử lý đường "duyệt sớm", tick vào sẽ KHÔNG có tác dụng.' (Designer KHOÁ 2 ô duyệt-sớm cho type-10) + ContractSigningPlanWorkflowService.cs chỉ có finalize thường :278 (KHÔNG có nhánh AllowApproverFinalize; MEMORY-verified :44 comment không port CeoApprovalThreshold). Cột AllowApproverFinalize trên ApprovalWorkflowLevels CÓ SẴN (Mig 58 — dùng chung bảng, 0 mig) — cái thiếu là NHÁNH SERVICE + mở khoá Designer. ⇒ K3 PHẢI gồm 3 việc code (không phải 0): (a) port nhánh duyệt-sớm vào ContractSigningPlanWorkflowService — approver tại level AllowApproverFinalize=true tick "kết thúc tại đây" → nhảy thẳng choke-point :278 (BẮT BUỘC đi qua ApplyApprovedAmounts :303-304 — nối răng K2.b, không tự gán Phase tay #81-EXT) + ghi Changelog ContextNote marker "Duyệt KẾT THÚC tại %" (khuôn PE Mig 61/#79); (b) +cột runtime ContractSigningPlans.EndedByLevelFinalize bit NOT NULL DEFAULT 0 — display đọc RUNTIME không suy CONFIG (né tái diễn #78) — gắn vào Mig 71 item 6 (K2.a) nếu K2 chưa land; nếu đã land → Mig 72 AddColumn riêng; (c) fe-admin GỠ khoá 2 ô + note :151-152 cho type-10 (không gỡ = config-lie NGƯỢC: BE hỗ trợ mà UI bảo không).

K3.a — Seed SeedKhkkApprovalWorkflowsAsync (khuôn DbInitializer.cs:171-217)

  • Idempotent PER-CODE (KHÔNG per-type như khuôn :174-176 — vì 8 code cùng type): if (await db.ApprovalWorkflows.AnyAsync(w => w.Code == code)) continue;
  • Fail-soft thiếu user: FindByEmailAsync từng email, null → LogWarning + return KHÔNG seed (mirror :179-183), boot không chết.
  • 8 workflow: Code = "KHKK-N1".."KHKK-N8", ApplicableType = 10 (ContractSigningPlan), Version=1, IsActive=true, IsUserSelectable=true (picker lọc isUserSelectable — KhkkCreatePage.tsx:56-66), ActivatedAt=UtcNow.
  • Tên VN (từ transcribe): N1 "KHKK — Nhóm 1: Thiết bị, máy móc, vật tư phụ (A1)" · N2 "…Nhóm 2: Kết cấu, hạ tầng, phá dỡ + Dịch vụ hỗ trợ thi công (A2+A3)" · N3 "…Nhóm 3: Hoạt động văn phòng (A4)" · N4 "…Nhóm 4: Vật tư (B1)" · N5 "…Nhóm 5: Thầu phụ xây dựng (B2)" · N6 "…Nhóm 6: Thầu phụ MEP (B3)" · N7 "…Nhóm 7: BCH công trường (C1+C2+C3)" · N8 "…Nhóm 8: CEO — HĐ chưa ủy quyền (5.2)".
  • Đội hình GIỐNG NHAU cả 8 (OG-2 "đội hình cũ"; ký-nháy KHÁC nhau nằm ở TEXT catalog K1 — QĐ-4 2-tầng, KHÔNG nằm ở Levels): Step 1 "Phòng Cung ứng" (Dept Code PRO) — C1 Order=1 OR-of-4 · C2 Order=2 ×1 Step 2 "Phòng KSCP" (Dept CCM) — C1 Order=1 OR-of-4 · C2 Order=2 ×1 AllowApproverFinalize=true (QĐ-9: a Chương quyết kết-thúc-hay-lên-CEO) Step 3 "Ban Giám đốc" (Dept BOD) — C1 Order=1 ×1
  • Bảng resolve email (đối chiếu DbInitializer.cs:1613-1632 — 14 user Solutions thật):
    Vai Tên roster Email seed Nguồn
    PRO C1 Phượng phuong.nguyen@solutions.com.vn (Nguyễn Thị Bích Phượng) :1614
    PRO C1 Duy duy.nguyen@solutions.com.vn (Nguyễn Văn Duy) :1617
    PRO C1 Long long.chau@solutions.com.vn (Châu Tạ Kim Long — ⚠️ ĐỪNG nhầm long.nguyen@ HRA/ISO :1629) :1616
    PRO C1 Thanh thanh.lethanh@solutions.com.vn (Lê Thanh Bình) :1615
    PRO C2 Trà tra.bui@solutions.com.vn (Bùi Lê Thủy Trà, TP Cung ứng) :1613
    CCM C1 Nguyên nguyen.ho@solutions.com.vn (Hồ Thị Nữ Nguyên) :1623
    CCM C1 Lưu luu.tran@solutions.com.vn (Trần Xuân Lưu) :1622
    CCM C1 Trưởng ⚠️ truong.le@solutions.com.vn (Lê Trần Đăng Trường, NV CCM) — ⚠️ roster ghi "Trưởng", DB có "Trường"(Lê :1626) + "Trình"(tring.le@ :1625): xác nhận qua verify-LIVE QT-DN-V2-001, lệch thì hỏi owner :1626
    CCM C1 Binh binh.le@solutions.com.vn (Lê Văn Bình) :1621
    CCM C2 Chương chuong.phan@solutions.com.vn (Phan Văn Chương, TP KSCP) :1620
    BGĐ Trường truong.nguyen@solutions.com.vn (Nguyễn Văn Trường, CEO) :1632

K3.b — Validator phiếu-pin-đúng-nhóm (map Code↔ApprovalGroup = CONVENTION parse suffix)

  • QUYẾT: convention ^KHKK-N([1-8])$ — 0 mig, 0 cột mới. Mở rộng ContractSigningPlanScope.EnsureWorkflowTypeAsync (Features.cs:350 create + :458-462 re-pin UpdateDraft — 2 call-site cùng helper = sửa 1 chỗ) thành EnsureWorkflowTypeAndGroupAsync(db, awId, approvalGroup, ct): nếu workflow.Code match pattern → group parse PHẢI == plan.ApprovalGroup, lệch → ConflictException("Quy trình KHKK-N{x} không thuộc nhóm duyệt N{y} của phiếu.") (DoD: phiếu nhóm 3 pin nhóm 5 → 409); Code KHÔNG match pattern (vd QT-KHKK-V2-001 — DEFAULT_CODE_BY_TYPE ApprovalWorkflowsV2Page.tsx:170 gợi ý admin tự tạo) → pass + LogWarning (không chặn phiếu UAT cũ/quy trình tay). Phương án cột ApprovalWorkflows.ApprovalGroup tường minh hơn = defer, chỉ nâng khi owner cần chặn cứng quy trình tay.

K3.c — 8 menu-con designer + resolvePath + route per-code (fe-admin ONLY — designer không có ở fe-user, S155)

  • MenuKeys.cs +8 const AwV2_KhkkN1..AwV2_KhkkN8NGOÀI All (0 policy mới, tiền lệ HopDongCung/leaf Pe_*); +8 MenuItem row con node ApprovalWorkflowsV2 (DbInitializer.cs:1801 vùng seed menu — QĐ-7 CHỪA subtree này khi thu gọn admin), Label "Nhóm n — <tên ngắn>", Order n.
  • fe-admin Layout.tsx resolvePath vùng :166-172 (map AwV2_* 3-code hiện tại) +8 entry AwV2_KhkkN{n}/system/approval-workflows-v2/ContractSigningPlan?code=KHKK-N{n} — leaf lạ KHÔNG map = rớt im lặng #50, phải đủ 8.
  • ApprovalWorkflowsV2Page.tsx: deep-link type ContractSigningPlan ĐÃ MỞ (:155-163 TYPE_CODE_TO_INT slot 10 :147); THÊM đọc ?code= → filter danh sách definition đúng Code + prefill ô "Mã quy trình" = code đó (thay gợi ý :164-171). Mở designer nhóm n load đúng KHKK-N{n} (DoD l3-F10).
  • mirror fe-admin/src/lib/menuKeys.ts +8 const.

K3.d — Test (k=5)

  1. SeedKhkk_8Workflows_Idempotent — chạy ×2 → đúng 8 row type-10, mỗi Code 1 (đếm-8).
  2. SeedKhkk_Shape_PerWorkflow — 3 Steps; PRO Order1×4+Order2×1; CCM Order1×4+Order2×1 có AllowApproverFinalize=true đúng level Chương; BOD ×1.
  3. SeedKhkk_MissingUser_SkipWarn_NoBootCrash — thiếu 1/11 email → skip + warn.
  4. CreateOrRepin_WrongGroupCode_409_CustomCode_PassWarn — K3.b 3 chiều (đúng/lệch/code-lạ).
  5. KhkkEarlyFinalize_ChuongTick_DaDuyet_FreezeApplied_RuntimeFlagSet — nhánh (a) K3.⚠️: qua choke-point, ApprovedAmount không null, EndedByLevelFinalize=1, marker Changelog ghi.

K3.e — Checklist acceptance K3

  • Verify-LIVE đội hình: curl prod GET /api/approval-workflows-v2 (hoặc Dev + flag) — đối chiếu 11 email bảng K3.a TRƯỚC khi commit seed; ô ⚠️ "Trưởng" chốt được thì ghi vào PR, không thì hỏi owner.
  • Restart API ×2 → sqlcmd … "SELECT Code FROM ApprovalWorkflows WHERE ApplicableType=10 AND Code LIKE 'KHKK-N%'"đúng 8, không nhân.
  • fe-admin sidebar: node ApprovalWorkflowsV2 đếm được 8 menu-con; click "Nhóm 3" → designer load đúng KHKK-N3.
  • curl create phiếu group=3 pin KHKK-N5409; pin KHKK-N3 → 201; duyệt trọn trạm 1 phiếu → DaDuyet (DoD §③-K3).
  • Chương tick kết-thúc-tại-cấp → phiếu DaDuyet + mọi line có ApprovedAmount + cờ runtime =1; ô Designer duyệt-sớm type-10 hết khoá (note :151-152 gỡ).
  • dotnet test +5, khai k=5.

Thứ tự commit + phụ thuộc chéo

Tuyến tính K1 → K2 → K3; seed-86 tách chờ OG-6 (không chặn nhánh sau).

  1. Commit K1-code [CLAUDE] Domain+Infra+App: add ContractCatalogEntry + Mig 70 + CQRS + FE kind — entity/config/DbSet ×2 interface + Mig 70 3-file + ContractCatalogFeatures.cs + extend CatalogsController + FE ×2 KIND_CONFIG + menu row + menuKeys mirror + test K1 {3,4,5}. Restart local verify bảng rỗng OK. ⚠️ update SolutionErp.slnx KHÔNG cần (không .csproj mới) — chỉ file .cs mới trong project sẵn.
  2. Commit K1-seed (SAU [OG-6] anh gật transcribe): SeedContractCatalogAsync 86-row + test K1 {1,2}. Restart ×2 → 86 idempotent. Prod: deploy xong seed tự chạy (UNGATED) — không cần tay.
  3. Commit K2 [CLAUDE] App+Infra: KHKK ApprovalGroup + Line catalog + freeze răng (Mig 71)Mig 71 gồm cả item 6 EndedByLevelFinalize (forward-provision cho K3 — QUYẾT gộp để K3 0-mig; nếu lead tách thì K3 tự mang Mig 72) + rào 2 site + validator cùng-nhóm + list param + DTO + FE types + test K2 ×7. Phụ thuộc K1-code (validator K2.d query bảng catalog — phải CÓ bảng; không cần data 86). Σ PeReferenceAmount đo TRƯỚC/SAU migrate ghi PR.
  4. Commit K3 [CLAUDE] Infra+FE-Admin: seed 8 KHKK workflows + early-finalize + designer per-code — seed 8 + nhánh duyệt-sớm service + validator pin-group + 8 menu-con + Layout +8 + ?code= + gỡ note khoá :151-152 + test K3 ×5. Phụ thuộc K2 (cột ApprovalGroup + EndedByLevelFinalize). PRE: verify-LIVE đội hình (K3.e bước 1) TRƯỚC khi commit.
  5. Mỗi commit qua Gitea CI (~3 phút, path-filter docs-only 0s); prod IIS restart → DbInitializer.MigrateAsync áp mig + seed tự chạy; restart ×2 verify idempotent (khuôn S91). Docs cuối cụm: STATUS.md (Mig 69→71, bảng 96→97, test +17), schema-diagram.md +2 row mig, HANDOFF.md, tick migration-todos.md.

Phụ thuộc chéo tóm tắt: K2.d cần bảng K1 (không cần 86 row — null-pass) · K3.a seed KHÔNG đọc catalog (label hardcode) — chỉ cần K2 cột · K3.⚠️(b) cột runtime NẰM TRONG Mig 71 = K2 phải land trước K3 · seed-86 lơ lửng độc lập chờ OG-6 · K4a (cụm-2) 0 phụ thuộc cụm-1, chạy song song được (l3-F8).

Checklist tổng cụm-1

  • Mig: __EFMigrationsHistory top = Mig 71; Mig 70+71 mỗi cái đủ 3 file (git show --stat); bảng sys.tables = 97.
  • K1: catalog active = 86; phân bố nhóm GROUP BY ApprovalGroup = 5/10/5/23/14/15/13/1; restart ×2 không nhân; CRUD 200/403 đúng vai; tên VN+EN 86/86.
  • K2: COUNT(ContractSigningPlans) == Σ COUNT 8 nhóm (backfill=1 đủ); phiếu nhóm-2-cùng-PE → 201, trùng nhóm → 409; line khác nhóm → 409; SUM(PeReferenceAmount) TRƯỚC == SAU (2 số ghi PR); grep Phase\s*=[^=] liệt đủ site PR; bảng 17-consumer dán PR TỪNG DÒNG.
  • K3: đúng 8 workflow KHKK-N% type-10; sidebar admin 8 menu-con, designer nhóm-n load KHKK-N{n}; pin lệch nhóm → 409; duyệt trọn trạm → DaDuyet; Chương tick kết-thúc → DaDuyet + freeze + cờ runtime; 2 ô Designer type-10 hết khoá.
  • Test: dotnet test SolutionErp.slnx = baseline (STATUS row Tests) + 17 (k khai: K1=5 · K2=7 · K3=5), 0 fail.
  • CI/prod: 4 commit CI PASS; BE-only wave = smoke API (l3-F17, KHÔNG cần byte-verify #77 trừ commit có FE-ship → curl-grep bundle marker); STATUS/schema-diagram/HANDOFF cập nhật.
  • OG còn mở khi land: OG-6 (chặn DUY NHẤT commit K1-seed) · ô ⚠️ "Trưởng" K3.a (chốt qua verify-LIVE hoặc hỏi owner).

END sub-invest-fable-b2-cum1 — TOTAL: K1 6v · K2 8v · K3 6v · 17 test.