32 KiB
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-01…CEO-01 — UNIQUE 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ônSeedRealMasterDataAsync:2970dòdb.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à hitIgnoreQueryFiltersđầ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
Mã→Code ·Tên VN→TenVi ·Tên EN→TenEn · heading section (A1..C3, 5.2)→GroupCode · cột→ Nnheading→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
InitializeAsynccạnhSeedRealMasterDataAsync(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ạnhCatalogsFeatures.cshiệ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;ApprovalGroupInclusiveBetween(1,8); dup-Code active-set → ConflictException — so exactCode ==, filtered-unique cho phép trùng với row đã xoá mềm) ·DeleteContractCatalogEntryCommandsoft-delete (Line K2 giữ FK Restrict chặn hard-delete; soft-delete OK vì Line denormTenHangMuc). - Controller: EXTEND
CatalogsController+5 actionGET/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_CONFIGRecord<Kind,...>theo:kindURL — khuôn S29/S35). - Thêm kind
contract-catalogvào KIND_CONFIG ×2 app: columns {Code, TenVi, TenEn, GroupCode, ApprovalGroup (badge N1..N8), SignerRole, InitialsChain, IsActive, SortOrder}; form create/edit đủ field; endpointapi/catalogs/contract-catalog. Duplicate 2 app CÓ CHỦ ĐÍCH (CLAUDE.md). - Menu leaf: +const
MenuKeys.ContractCatalog(NGOÀIAll— khuôn leafPe_*/tiền lệHopDongCungNGOÀI All = 0 policy mới; hiển thị đi đường Permission row) + 1 MenuItem row con groupCatalogstrongSeedMenuTreeAsync+ grant admin (SeedAdminPermissionsAsync loop) + mirrorfe-{admin,user}/src/lib/menuKeys.ts. Route:kind=contract-catalog— 0 route file mới.
K1.e — Test (k=5)
Seed_ContractCatalog_86Rows_Idempotent— chạy seed ×2 → count active == 86, không nhân row.Seed_SkipsSoftDeletedCode_NoResurrect— soft-deleteA1-01→ re-seed → active == 85, row không hồi sinh (#75/#76 có răng).Create_DuplicateActiveCode_Throws409_ButAllowsReuseOfSoftDeletedCode— dup active → Conflict; Code trùng row IsDeleted=1 → OK (filtered #57 2 chiều).CreateOrUpdate_ApprovalGroupOutOfRange_FailsValidation— 0 và 9 fail, 1 và 8 pass.Delete_IsSoft_ListExcludes_RowRemains— Delete → list 85, row DB IsDeleted=1.
K1.f — Checklist acceptance K1
dotnet ef migrations add AddContractCatalogEntries …→ đủ 3 file trongPersistence/Migrations/;git statusshow 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)
ContractSigningPlans+ApprovalGroup int NOT NULL, defaultValue: 1— AddColumn với default tự backfill mọi row hiện có = QĐ-6 backfill=1 (khuônEvaluationTypel3-F5; KHÔNG cầnSql()riêng — default áp cả Design-DB 0-row lẫn prod-DB có-row, né #64 lệch môi trường).ContractSigningPlanLines+CatalogEntryId uniqueidentifier NULL— loose-Guid KHÔNG FK vật lý (QUYẾT: mirror y Mig 49PE.WorkItemId"convention PE: ProjectId/SelectedSupplierId đều loose"; chính Line.cs:15SupplierIdđã loose cố ý — thêm FK thật là phá khuôn module) +IX_ContractSigningPlanLines_CatalogEntryId.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ônSignedByFullNamedenorm Mig 26).- Đổi UNIQUE: DropIndex unique cũ trên (
ContractSigningPlanId,SupplierId) (tên EF-sinh — lấy đúng từContractSigningPlanLineConfiguration.csvùng :24-26 khi build) → CreateIndex UNIQUE (ContractSigningPlanId,SupplierId,CatalogEntryId) filteredWHERE [IsDeleted]=0.- ⚠️ Bẫy NULL-unique SQL Server: trong UNIQUE index, NULL == NULL ⇒ 2 row cùng (Plan,Supplier) đều
CatalogEntryId NULLsẽ 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êmAND CatalogEntryId IS NOT NULLvào filter — làm vậy là thả trôi vô hạn dup (Plan,Supplier,null).
- ⚠️ Bẫy NULL-unique SQL Server: trong UNIQUE index, NULL == NULL ⇒ 2 row cùng (Plan,Supplier) đều
- 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-20—PeReferenceAmount= 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:304—line.ApprovedAmount ??= line.ProposedAmount;foreach:303chạy tại finalize;:290comment 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|WinnerQuoteTotaltrongContractSigningPlan*= 1 hit label (Features.cs:162DTO 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 LIVEPeWorkItemBudgets; 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\.Phasevì object-initializerFeatures.cs:373không có dấu chấm, thoát lưới hẹp):Site Gán Freeze? Features.cs:373Phase = 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-304WorkflowService.cs:342= targetPhase(gán qua BIẾN — đúng class bẫy:39comment)🔴 PHẢI XỬ: nếu nhánh này reachable với targetPhase == DaDuyet(admin-override/return-path) thì hook:304bị bypass ⇒ hoặc (i) gọiApplyApprovedAmountsngay trước:342khitargetPhase == 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 đọcApprovedAmount+ badge khoá, trước đó đọcProposedAmount; 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 Σ:711trô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(validatorInclusiveBetween(1,8)); create gánplan.ApprovalGroup = request.ApprovalGroup. - Site 2 picker
Features.cs:1201-1202(query PE-eligible): bỏ loại-trừ PE-đã-có-phiếu → trả kèmexistingGroups: 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. FEKhkkCreatePage×2 app +types/khkk.tsDTO picker. - Auto-gen Lines
:389giữ per-winner, mồiCatalogEntryId = 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? = null→if (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· FEtypes/khkk.tsmirror ×2 app.- Bất-biến Σ PeReferenceAmount: Mig 71 KHÔNG đụng data tiền ⇒
SELECT SUM(PeReferenceAmount) FROM ContractSigningPlanLinesTRƯỚ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ồiPeReferenceAmount = 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)
Mig71_BackfillsApprovalGroup1_CountEqualsSumOfFolders— mọi plan cũ group==1.Create_SamePeSameGroup_409_SamePeOtherGroup_201— rào mới 2 chiều (DoD "phiếu nhóm 2 CÙNG PE → 201").Lines_UniqueTriple_DupCatalog_Throws_DiffCatalog_Ok— cùng (plan,supplier,catalogA) ×2 fail; +catalogB pass (năng lực mới).Submit_LineWrongGroup_409_LegacyNullCatalog_Pass— validator K2.d 2 chiều.Finalize_FreezesAllLines_NoNullApprovedAmount— sau DaDuyet mọi line ApprovedAmount != null (:303-304).TransitionViaVariableSite_CannotReachDaDuyetWithoutFreeze— răng site-:342(theo phương án (i)/(ii) đã chọn).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ànContractSigningPlan*→ 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-152 — KHKK_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:
FindByEmailAsynctừ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" (DeptCCM) — C1 Order=1 OR-of-4 · C2 Order=2 ×1AllowApproverFinalize=true(QĐ-9: a Chương quyết kết-thúc-hay-lên-CEO) Step 3 "Ban Giám đốc" (DeptBOD) — 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):1614PRO C1 Duy duy.nguyen@solutions.com.vn(Nguyễn Văn Duy):1617PRO C1 Long long.chau@solutions.com.vn(Châu Tạ Kim Long — ⚠️ ĐỪNG nhầmlong.nguyen@HRA/ISO:1629):1616PRO C1 Thanh thanh.lethanh@solutions.com.vn(Lê Thanh Bình):1615PRO C2 Trà tra.bui@solutions.com.vn(Bùi Lê Thủy Trà, TP Cung ứng):1613CCM C1 Nguyên nguyen.ho@solutions.com.vn(Hồ Thị Nữ Nguyên):1623CCM C1 Lưu luu.tran@solutions.com.vn(Trần Xuân Lưu):1622CCM 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:1626CCM C1 Binh binh.le@solutions.com.vn(Lê Văn Bình):1621CCM C2 Chương chuong.phan@solutions.com.vn(Phan Văn Chương, TP KSCP):1620BGĐ 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ộngContractSigningPlanScope.EnsureWorkflowTypeAsync(Features.cs:350create +:458-462re-pin UpdateDraft — 2 call-site cùng helper = sửa 1 chỗ) thànhEnsureWorkflowTypeAndGroupAsync(db, awId, approvalGroup, ct): nếuworkflow.Codematch 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 (vdQT-KHKK-V2-001— DEFAULT_CODE_BY_TYPEApprovalWorkflowsV2Page.tsx:170gợi ý admin tự tạo) → pass + LogWarning (không chặn phiếu UAT cũ/quy trình tay). Phương án cộtApprovalWorkflows.ApprovalGrouptườ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_KhkkN8— NGOÀIAll(0 policy mới, tiền lệHopDongCung/leafPe_*); +8 MenuItem row con nodeApprovalWorkflowsV2(DbInitializer.cs:1801vù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.tsxresolvePath vùng:166-172(mapAwV2_*3-code hiện tại) +8 entryAwV2_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 typeContractSigningPlanĐÃ MỞ (:155-163TYPE_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 đúngKHKK-N{n}(DoD l3-F10).- mirror
fe-admin/src/lib/menuKeys.ts+8 const.
K3.d — Test (k=5)
SeedKhkk_8Workflows_Idempotent— chạy ×2 → đúng 8 row type-10, mỗi Code 1 (đếm-8).SeedKhkk_Shape_PerWorkflow— 3 Steps; PRO Order1×4+Order2×1; CCM Order1×4+Order2×1 cóAllowApproverFinalize=trueđúng level Chương; BOD ×1.SeedKhkk_MissingUser_SkipWarn_NoBootCrash— thiếu 1/11 email → skip + warn.CreateOrRepin_WrongGroupCode_409_CustomCode_PassWarn— K3.b 3 chiều (đúng/lệch/code-lạ).KhkkEarlyFinalize_ChuongTick_DaDuyet_FreezeApplied_RuntimeFlagSet— nhánh (a) K3.⚠️: qua choke-point,ApprovedAmountkhô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-N5→ 409; pinKHKK-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-152gỡ). 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).
- 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+ extendCatalogsController+ FE ×2 KIND_CONFIG + menu row + menuKeys mirror + test K1 {3,4,5}. Restart local verify bảng rỗng OK. ⚠️ updateSolutionErp.slnxKHÔNG cần (không .csproj mới) — chỉ file .cs mới trong project sẵn. - Commit K1-seed (SAU [OG-6] anh gật transcribe):
SeedContractCatalogAsync86-row + test K1 {1,2}. Restart ×2 → 86 idempotent. Prod: deploy xong seed tự chạy (UNGATED) — không cần tay. - Commit K2
[CLAUDE] App+Infra: KHKK ApprovalGroup + Line catalog + freeze răng (Mig 71)— Mig 71 gồm cả item 6EndedByLevelFinalize(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. - 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ộtApprovalGroup+EndedByLevelFinalize). PRE: verify-LIVE đội hình (K3.e bước 1) TRƯỚC khi commit. - 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, tickmigration-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:
__EFMigrationsHistorytop = Mig 71; Mig 70+71 mỗi cái đủ 3 file (git show --stat); bảngsys.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); grepPhase\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 loadKHKK-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.