From 88e7ecf263f019f4b31d4defb9520704568042a9 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Sat, 1 Aug 2026 10:10:11 +0700 Subject: [PATCH] [CLAUDE] Contract: K4a+K4b SP-002 sidebar 8 nhom x 6 leaf + noi ?group= end-to-end (0-mig) K4a (gate PASS-WITH-FLAGS 4, sub-reviewer-gate-k4a.md): 49 key Khkk_G{2-8} bo-sinh by-construction 3-phia (All/seed/grant) MenuKeys.cs + DbInitializer 49 row + labelBackfill G1 nhan A1 + site-2 Concat + va 7/10/11/13/15 (nhan N2 co "pha do"; comment 1893 -> mo-neo grep). All 64->113, Policies 256->452 (test T2 khoa runtime). Test +7 KhkkGroupMenuSeedTests (T7 revoker-khong-dung-Khkk: probe vi-phan 112->0, gotcha #84). K4b (gate PASS-WITH-FLAGS 4M/5m 0-chan, sub-reviewer-gate-k4b.md): KhkkListPage x2 derive phase+group tu searchParams (leaf->leaf khong F5, navKey reset) + queryKey {group,phase, search,page,deletedView} + KhkkCreatePage preset ?group= + staticMap G1 6-leaf group=1 + regex Khkk_G([1-8])_* fe-user (fe-admin GHI RANG BUOC isAdminHidden) + FLAG-1 lead-fix: GET /deleted +group mirror List (Controller+Query+handler SAU-IDOR) + FE noi 2-nhanh. 2 wave 1 commit CO KHAI: Layout.tsx dinh ca 2 wave khong tach hunk phi-tuong-tac; moi dong deu qua gate rieng. STATUS canonical 5-row dong bo (71/97/452/113/620->621) + bundle :479. No K4c: MAJOR-A test /deleted+group filter + MAJOR-C WfView doc group. Suite 621/0 (45D+576I) + filter 41/41 + npm x2 + dotnet 0E. Co-Authored-By: Claude Fable 5 --- docs/STATUS.md | 12 +- fe-admin/src/components/Layout.tsx | 28 +- fe-admin/src/pages/khkk/KhkkCreatePage.tsx | 24 +- fe-admin/src/pages/khkk/KhkkListPage.tsx | 94 +++- fe-admin/src/types/khkk.ts | 25 + fe-user/src/components/Layout.tsx | 51 ++- fe-user/src/pages/khkk/KhkkCreatePage.tsx | 24 +- fe-user/src/pages/khkk/KhkkListPage.tsx | 94 +++- fe-user/src/types/khkk.ts | 25 + .../ContractSigningPlansController.cs | 5 +- .../ContractSigningPlanFeatures.cs | 9 +- .../SolutionErp.Domain/Identity/MenuKeys.cs | 42 ++ .../Persistence/DbInitializer.cs | 79 +++- .../Application/KhkkGroupMenuSeedTests.cs | 431 ++++++++++++++++++ 14 files changed, 880 insertions(+), 63 deletions(-) create mode 100644 tests/SolutionErp.Infrastructure.Tests/Application/KhkkGroupMenuSeedTests.cs diff --git a/docs/STATUS.md b/docs/STATUS.md index b8b667e..39e37bf 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -3,7 +3,7 @@ > **Update rule:** trước khi bắt đầu 1 task → ghi row `🔥 In Progress`. Xong → `✅ Recently Done`. > **Tiering rule (S40):** chỉ giữ **state hiện tại + 3 session gần nhất** ở file này. Session cũ hơn → `docs/changelog/sessions/`. Full history pre-S40 → `docs/_archive/STATUS-preS40-fullhistory.md`. (Tránh over-context — xóa double, không cắt nội dung.) -**🔥 CURRENT (S161→S162 + S164/S165 2026-07-31 — phiên-LOGIC **L9**: chuỗi SP-002 B0→B3 spec ĐÓNG BĂNG + **B4 K1+K2 SHIP prod-verified + K3 code-xong-gate-đóng** (K4a→K8 + checkbox-opt-out-màn-duyệt = lần `/tiep` sau); lineage L8 giữ dưới):** Mig **71** (K1 Mig70 `AddContractCatalogEntries` danh mục 86 dòng + K2 Mig71 `AddKhkkApprovalGroupCatalogAndFinalizeRuntime` — phiếu KHKK +nhóm duyệt N1-N8 backfill=1 + Line +hạng-mục + unique 3-cột) · **97 bảng** · **614 test PASS** (45D+569I, đo tươi @S165 sau K3) · gotcha **85** · counter **39** · roster 23 · **MenuItems prod 142 GIỮ** (key `KeHoachKyKet` sinh **policy** không sinh menu-row — H1 bác nghi-vấn stale) · Policies **256** · Menu keys **64** (+`ContractCatalog` K1 [4 policy chưa endpoint tiêu thụ — khai #82] · +8 `AwV2_KhkkN1..N8` K3 [designer 8 nhóm, All=64 máy-đếm]) · bundle prod → **canonical ở `:479`, KHÔNG chép số ở đây** (B1). 🔴 *Cặp `DXoYDy7R`/`o8KDluD1` từng ghi ở dòng này **chưa bao giờ là trạng-thái cuối** — `e5123ff` ship thêm 6 file FE-src SAU snapshot; đo live @S164: admin `B43R6Y17` · user `NLI5umBg`.* Contracts prod **0**. +**🔥 CURRENT (S161→S162 + S164/S165 2026-07-31 — phiên-LOGIC **L9**: chuỗi SP-002 B0→B3 spec ĐÓNG BĂNG + **B4 K1+K2 SHIP prod-verified + K3 code-xong-gate-đóng** (K4a→K8 + checkbox-opt-out-màn-duyệt = lần `/tiep` sau); lineage L8 giữ dưới):** Mig **71** (K1 Mig70 `AddContractCatalogEntries` danh mục 86 dòng + K2 Mig71 `AddKhkkApprovalGroupCatalogAndFinalizeRuntime` — phiếu KHKK +nhóm duyệt N1-N8 backfill=1 + Line +hạng-mục + unique 3-cột) · **97 bảng** · **620 test PASS** (45D+575I, đo tươi @S166 sau K4a-test +6 `KhkkGroupMenuSeedTests`) · gotcha **85** · counter **40** (tick S166; H24 deep 15/15 ĐÃ TRẢ @S166 giữa-phiên — light=deep=40) · roster 23 · **MenuItems prod 142 GIỮ** (key `KeHoachKyKet` sinh **policy** không sinh menu-row — H1 bác nghi-vấn stale) · Policies **452** · Menu keys **113** (@S166 K4a +49 `Khkk_G*` bộ-sinh — canonical bảng `:470-471`, test T2 khoá drift; lineage 64/256 @K3: +`ContractCatalog` K1 [4 policy chưa endpoint tiêu thụ — khai #82] · +8 `AwV2_KhkkN1..N8`) · bundle prod → **canonical ở `:479`, KHÔNG chép số ở đây** (B1). 🔴 *Cặp `DXoYDy7R`/`o8KDluD1` từng ghi ở dòng này **chưa bao giờ là trạng-thái cuối** — `e5123ff` ship thêm 6 file FE-src SAU snapshot; đo live @S164: admin `B43R6Y17` · user `NLI5umBg`.* Contracts prod **0**. ① **5 wave KHKK SHIP (window-3, S161):** W1 `a8027c0` (Mig 69, 7 bảng, 89→96) · W2 `6cbc6ad` (CRUD+Dossier 15 policy) · W3 `df52fa0` (duyệt V2 3-trạm, test-before RED→GREEN) · W6 `761d818` (đường-ống HĐ V2) · W7 `3119cb9` (bản-cứng **1-MỐC** theo owner: form điền đủ → duyệt nếu có → upload bản cứng = xong). Mỗi wave: gate 5-anchor → hmw → test → reviewer → F-0 → cicd 3-chân. 3 quyết owner: mã `KHKK/{YYYY}/{Seq:D3}` · **bỏ "MB"** · GĐ4 1-mốc. ② **Cây 4-folder GĐ (window-4, S162) — `bfc7b79`, cicd PASS 5/5 Run #432:** lệnh owner *"Tạo folder: Duyệt NCC / Kế hoạch Hợp đồng. / Duyệt Hợp đồng / Hợp đồng cứng."* + AskUser chốt **DƯỚI TỪNG GÓI THẦU** + **áp CẢ trang GĐ khác**. 3 file MỚI ×2 app (`PipelineStageFolders` · `PipelineTreePanel` · `usePipelineStages`) + 3 trang ×2 app, **SHA-pair 6/6**, thuần FE 0-mig. designer SHIP-READY (tự soi ảnh bắt 4 lỗi build-không-báo) → reviewer PASS-WITH-FLAGS 8 → lead vá F-1/3/4/6/7 + F-8. 🔴 **CÒN NỢ: trang GĐ3 chưa làm** (owner nói "CẢ", lead giao 3/4 — lead-gap FLAG-3) · F-2 liên-danh N-HĐ chờ W5 · F-5 layout 19rem chờ owner nghiệm thu prod. ③ **Bookend-close 5 vòng, 10 vai:** H1 `PASS_WITH_FLAGS` 10 finding · H2 **`GATE-FAIL` 4** (⭐ `ctx-verifier` chạy thật mà **chưa bao giờ tự APPEND** diary — S153+S158 đều lead-seed; `cicd-monitor` khai "verbatim→archive" thứ `git log -S` chứng **chưa từng vào archive**) · cặp H24 **20 FLAG** (stale 16 = **SÀN** + gap 4, `gap-carry-dropped` **streak 8**) · trio memory **MIXED** (headroom 165.767 tok đơn-điệu giảm 3 mốc · light 9/6 OVERDUE · baseline `measured` 17/23) · orphan run-folder **3 xác nhận** (2 thiếu THẬT + 1 dương-giả TÊN). @@ -462,13 +462,13 @@ monthly re-tier mega-line + ef-core SKILL Mig→59 + 3 vocab-fork (2026-07-01 du | Metric | Value | Note | |---|---|---| -| Migrations | **69** | **+S161: Mig 69 `AddContractSigningPlans`** (`20260729122015` — W1 KHKK: 7 bảng MỚI cho Kế hoạch ký kết HĐ, commit `a8027c0` cicd PASS 6/6; SQL tables 89→96). **+S155: Mig 68 `AddPeAllowApproverDelete`** (`20260727033522` — PE cho phép **approver xóa phiếu ngay ở màn duyệt**: cờ `AllowApproverDelete` **per-NGƯỜI** trên `ApprovalWorkflowLevel` [owner chốt (5)] + lệnh sửa-quy-trình-tại-chỗ; **AddColumn no new table [89 giữ]**, reversible Down. Chia 2 ĐỢT theo lệnh anh — §F.0 chứng minh quyền xóa **không tới được phiếu đang treo** nếu thiếu lệnh sửa tại chỗ. Đợt 1 `b1bae77` CICD **PASS 6/6**; đợt 2 `4464a46` NO-MIG [nút xóa + menu 3 mục + màn "Đã xóa"] CICD **PASS 6/6** verify @S156 [endpoint 401⟂control-404 · smoke 8/8 · **D3 giao-tập-ID = 0** 37 sống ⟂ 9 xóa · bundle rotate ×2 + 4/4 byte-marker]; +`f4494cf` fix menu sáng nhầm PASS. prod-verified `__EFMigrationsHistory` top + `sys.tables`=89). +S133: Mig 67 `AddPeApprovedBudgetSnapshot` (PE **freeze ngân sách phiếu DaDuyet** — UAT anh Kiệt 07-16 R1 "phiếu đã duyệt bất biến, ngân sách live chỉ cho phiếu chưa duyệt": **11 AddColumn snapshot** lên PE [`ApprovedBudgetSnapshotAt` + Pro/Ccm `Initial·Adjustment·Note` ×6 + `PrevSubmitted/PrevSelected Total·Count` ×4] chốt tại finalize choke-point qua helper `PeBudgetAccumulator` 4-site + **site-5 admin-override `PurchaseEvaluationWorkflowService.cs:308`** [reviewer catch #81-EXT indirect-assignment — override thẳng → DaDuyet cũng PHẢI snapshot] + seeder site; +`Sql()` backfill OUTER APPLY khớp accumulator cho phiếu DaDuyet cũ — **prod 18/18, chua=0**; display-gate `budgetFrozen`: phiếu DaDuyet đọc snapshot + badge 🔒, phiếu chưa duyệt đọc live [R2 "bản cập nhật gần nhất"]; **no new table [89 giữ]**, reversible Down DropColumn ×11; prod-verified Run #398 `__EFMigrationsHistory`-top + 11 cột; commit `d4fbced`). +S117: Mig 66 `AddPeApprovedPriceRange` (PE +`ApprovedPriceMaxAmount decimal(18,2)?` — feature "duyệt theo khoảng Min–Max" anh Kiệt "chọn cả 2 min và max": source `"ProMinMax"` = Amount(Min)+MaxAmount(Max), bất-biến `MaxAmount != null ⟺ Source=="ProMinMax"` + Min≤Max [guard service `ApplyApprovedPriceOnFinalize` + FluentValidation]; **1 AddColumn nullable no-backfill** [phiếu cũ Source-đơn Max=null nguyên vẹn], reversible Down DropColumn, **no new table [89 giữ]**; HĐ.GiaTri KHÔNG đọc giá chốt [=SUM báo giá NCC được chọn] → 0 tác động HĐ [range = record-of-decision]; prod-verified Run #387 col decimal(18,2) + `__EFMigrationsHistory` top; commit `316a82f`). +S114: Mig 65 `BackfillPeQuoteIsSelectedAndWinnerInvariant` (data-only 2-chiều bất-biến `IsWinner↔IsSelected`, no new table [89 giữ], idempotent, Down no-op mirror Mig 61; multi-NCC per hạng mục anh Kiệt; commit `d436af2`; kèm gotcha #81 delete-writer). +S113: Mig 64 `AddSupplierPublishState` (Supplier +`IsPublic` bit NOT NULL default 0 — cổng công-bố R4 "thiếu Mã NCC→nháp/ẩn"; `Sql("UPDATE Suppliers SET IsPublic=1")` backfill 37/37 prod→public; DropIndex→CreateIndex đổi filtered-unique Code `WHERE [IsDeleted]=0 AND [Code]<>''` [cho nhiều nháp Code='']; **no new table [89 giữ]**, reversible Down; prod-verified Run #497 __EFMigrationsHistory-top + IsPublic-col + 37/37-backfill; gotcha #80 null-Code). +S112: Mig 63 `AddSupplierImportSourceFields` (Supplier +`SourceUpdatedAt datetime2?` + `SourceUpdatedBy nvarchar(200)?` — import-provenance cột 29/30 file Excel "Database NCC"; 2 AddColumn nullable, **no new table [89 giữ]**, reversible Down DropColumn ×2; prod-verified Run #496 `__EFMigrationsHistory` top + 2/2-col present). +S104 (reconcile): Mig 62 `ExpandSupplierFields` (Supplier 9→27 field: 18 AddColumn nullable + 18 DropColumn Down + `SupplierType +CaHai=6` append-only + enum `SupplierStatus` mới + guarded un-cram `Sql()` clear legacy crammed Note 4 seeded rows exact-match; **no new table (89 giữ nguyên — 18 AddColumn/0 CreateTable)**, reversible Down; seed fill-nulls-if-exists 30-field TRUONGGIANG/TANPHU/TGN/DONGDUONG idempotent; commit `778fc98` shipped UN-closed-out → S104 session-start docs-reconcile; prod-verified Run #488 PASS bundle admin `ChSNAJBK`/user `Bn2w1znH`; 440 test re-verified #65-no-bite). +S97: Mig 61 `BackfillEndedByLevelFinalizeFromChangelog` (data-only no-schema — đính chính Mig 60 backfill soi NHẦM bảng `Approvals`→`Changelogs`; set `EndedByLevelFinalize=1` cho phiếu finalize-sớm pre-Mig60 [A/005/A/008 finalize-CCM KHÔNG qua CEO] qua marker ở `PurchaseEvaluationChangelogs.ContextNote`; idempotent SQL-Server-only; prod-verified Run #484: A/005/A/008 0→1 + endsBeforeCeo-flip-True, A/010=0 control-unchanged; gotcha #79). +S97: Mig 60 `AddPeEndedByLevelFinalize` (PE +`EndedByLevelFinalize` bit NOT NULL default 0 — cờ RUNTIME "phiếu ĐÃ kết thúc sớm bằng level-finalize", set-true CHỈ nhánh finalize service; +`Sql()` backfill Phase=7-marker→=1; EndsBeforeCeo display đọc runtime thay config → phiếu-lên-CEO hết báo sai "không qua CEO" [gotcha #78]; AddColumn no new table, prod-verified Run #482 PE-detail endsBeforeCeo=False live). +S87: Mig 58 `AddPeMultiWinnerAndLevelFinalize` (`PurchaseEvaluationSupplier` +`IsWinner` bit multi-winner backfill từ SelectedSupplierId + `ApprovalWorkflowLevel` +`AllowApproverFinalize` bit cấp-KẾT-THÚC-quy-trình AUTO; 2 AddColumn + 1 backfill, no new table) + Mig 59 `AddCcmBudgetPeriodToPurchaseEvaluation` (PE +`CcmBudgetPeriodAmount` decimal(18,2)? — NS kỳ-này cột CCM cho Section B 3-cột; 1 AddColumn nullable, no backfill, no new table). +S77: Mig 57 `AddPeSuggestedPriceNotes` (PE +`ProSuggestedPriceNote` +`CcmSuggestedPriceNote` nvarchar(1000) — ô ghi chú giá đề xuất PRO/CCM giải-thích Min/Max · additive-nullable, 0 backfill, no new table; absolute-set role-gate mirror Mig 54/55). +S76: Mig 56 `AddProBudgetSplitToPeWorkItemBudget` (PeWorkItemBudget +`ProInitialAmount`+`ProAdjustmentAmount` decimal(18,2)? — cột PRO "Ban hành lần đầu"+"V0/hiệu chỉnh" mirror CCM Initial/Adjustment; AddColumn additive + `Sql()` data-migrate `ProInitial=ProEstimate` 4 rows prod gotcha #64; no new table). +S74: Mig 55 `AddCcmNoteToPeWorkItemBudget` (PeWorkItemBudget +CcmNote nvarchar(1000) — "Ghi chú từ CCM" mirror ProNote · additive-nullable, 0 backfill, no new table). +S73: Mig 54 `AddPeSuggestedAndApprovedPrice` (PE +5 cột giá đề xuất PRO Min/Max + CCM + giá chốt ApprovedPriceAmount/Source — additive-nullable, 0 backfill, prod-verified Run #313). +S69: Mig 53 `AddPeUrgentAndCeoApprovalThreshold` (PE +IsUrgentByPro/Ccm cờ gấp + ApprovalWorkflow +CeoApprovalThreshold ngưỡng CCM duyệt-final · 3 AddColumn no new table). Prev S65: Mig 51 `AddDepartmentParentId` (Department.ParentId loose-Guid no-FK — org-tree) + Mig 52 `AddHoSoLinkToPurchaseEvaluation` (PE HoSoLink nvarchar(1000) hyperlink NAS) — **cả 2 AddColumn-only, no new table** (tables giữ 88). Prev Mig 50 `ReplaceBudgetModuleWithPeWorkItemBudgets` (bảng `PeWorkItemBudgets` per-gói-thầu + DROP module Budget cũ + backfill BudgetManual→BudgetPeriod TRƯỚC DropColumn + DROP PE/Contracts.BudgetId; gotcha #63/#64). Prev Mig 49 PE WorkItemId (S57bis) | -| SQL tables | **96** | 🔄 **96 @S161** (cicd `cicd-verify-a8027c0.md:117` prod `sys.tables` 89→96 = +7 bảng Mig 69; mọi note "no-new-table"/"89 giữ" bên dưới là **frozen-in-time** của mig cũ, KHÔNG sửa). re-ground S97 (cicd `sys.tables` is_ms_shipped=0 Run #482 LIVE = **89**, pre==post Mig 60 no-new-table; "88" từ S62 STALE). Prev net 93→88 S62 (Mig 50 drop Budget + create `PeWorkItemBudgets`) | +| Migrations | **71** | 🔄 **71 @S164/S165 (K1/K2 SP-002):** Mig 70 `AddContractCatalogEntries` (danh mục HĐ 86 dòng, `50e6d8c`, cicd 6/6) + Mig 71 `AddKhkkApprovalGroupCatalogAndFinalizeRuntime` (phiếu KHKK +nhóm N1-N8 backfill=1 + Line hạng-mục + unique 3-cột, `0779f2d`, cicd 5/6-khỏe; prod `__EFMigrationsHistory` top + MIGCOUNT=71 @sub-cicd-verify-k3.md §3). **+S161: Mig 69 `AddContractSigningPlans`** (`20260729122015` — W1 KHKK: 7 bảng MỚI cho Kế hoạch ký kết HĐ, commit `a8027c0` cicd PASS 6/6; SQL tables 89→96). **+S155: Mig 68 `AddPeAllowApproverDelete`** (`20260727033522` — PE cho phép **approver xóa phiếu ngay ở màn duyệt**: cờ `AllowApproverDelete` **per-NGƯỜI** trên `ApprovalWorkflowLevel` [owner chốt (5)] + lệnh sửa-quy-trình-tại-chỗ; **AddColumn no new table [89 giữ]**, reversible Down. Chia 2 ĐỢT theo lệnh anh — §F.0 chứng minh quyền xóa **không tới được phiếu đang treo** nếu thiếu lệnh sửa tại chỗ. Đợt 1 `b1bae77` CICD **PASS 6/6**; đợt 2 `4464a46` NO-MIG [nút xóa + menu 3 mục + màn "Đã xóa"] CICD **PASS 6/6** verify @S156 [endpoint 401⟂control-404 · smoke 8/8 · **D3 giao-tập-ID = 0** 37 sống ⟂ 9 xóa · bundle rotate ×2 + 4/4 byte-marker]; +`f4494cf` fix menu sáng nhầm PASS. prod-verified `__EFMigrationsHistory` top + `sys.tables`=89). +S133: Mig 67 `AddPeApprovedBudgetSnapshot` (PE **freeze ngân sách phiếu DaDuyet** — UAT anh Kiệt 07-16 R1 "phiếu đã duyệt bất biến, ngân sách live chỉ cho phiếu chưa duyệt": **11 AddColumn snapshot** lên PE [`ApprovedBudgetSnapshotAt` + Pro/Ccm `Initial·Adjustment·Note` ×6 + `PrevSubmitted/PrevSelected Total·Count` ×4] chốt tại finalize choke-point qua helper `PeBudgetAccumulator` 4-site + **site-5 admin-override `PurchaseEvaluationWorkflowService.cs:308`** [reviewer catch #81-EXT indirect-assignment — override thẳng → DaDuyet cũng PHẢI snapshot] + seeder site; +`Sql()` backfill OUTER APPLY khớp accumulator cho phiếu DaDuyet cũ — **prod 18/18, chua=0**; display-gate `budgetFrozen`: phiếu DaDuyet đọc snapshot + badge 🔒, phiếu chưa duyệt đọc live [R2 "bản cập nhật gần nhất"]; **no new table [89 giữ]**, reversible Down DropColumn ×11; prod-verified Run #398 `__EFMigrationsHistory`-top + 11 cột; commit `d4fbced`). +S117: Mig 66 `AddPeApprovedPriceRange` (PE +`ApprovedPriceMaxAmount decimal(18,2)?` — feature "duyệt theo khoảng Min–Max" anh Kiệt "chọn cả 2 min và max": source `"ProMinMax"` = Amount(Min)+MaxAmount(Max), bất-biến `MaxAmount != null ⟺ Source=="ProMinMax"` + Min≤Max [guard service `ApplyApprovedPriceOnFinalize` + FluentValidation]; **1 AddColumn nullable no-backfill** [phiếu cũ Source-đơn Max=null nguyên vẹn], reversible Down DropColumn, **no new table [89 giữ]**; HĐ.GiaTri KHÔNG đọc giá chốt [=SUM báo giá NCC được chọn] → 0 tác động HĐ [range = record-of-decision]; prod-verified Run #387 col decimal(18,2) + `__EFMigrationsHistory` top; commit `316a82f`). +S114: Mig 65 `BackfillPeQuoteIsSelectedAndWinnerInvariant` (data-only 2-chiều bất-biến `IsWinner↔IsSelected`, no new table [89 giữ], idempotent, Down no-op mirror Mig 61; multi-NCC per hạng mục anh Kiệt; commit `d436af2`; kèm gotcha #81 delete-writer). +S113: Mig 64 `AddSupplierPublishState` (Supplier +`IsPublic` bit NOT NULL default 0 — cổng công-bố R4 "thiếu Mã NCC→nháp/ẩn"; `Sql("UPDATE Suppliers SET IsPublic=1")` backfill 37/37 prod→public; DropIndex→CreateIndex đổi filtered-unique Code `WHERE [IsDeleted]=0 AND [Code]<>''` [cho nhiều nháp Code='']; **no new table [89 giữ]**, reversible Down; prod-verified Run #497 __EFMigrationsHistory-top + IsPublic-col + 37/37-backfill; gotcha #80 null-Code). +S112: Mig 63 `AddSupplierImportSourceFields` (Supplier +`SourceUpdatedAt datetime2?` + `SourceUpdatedBy nvarchar(200)?` — import-provenance cột 29/30 file Excel "Database NCC"; 2 AddColumn nullable, **no new table [89 giữ]**, reversible Down DropColumn ×2; prod-verified Run #496 `__EFMigrationsHistory` top + 2/2-col present). +S104 (reconcile): Mig 62 `ExpandSupplierFields` (Supplier 9→27 field: 18 AddColumn nullable + 18 DropColumn Down + `SupplierType +CaHai=6` append-only + enum `SupplierStatus` mới + guarded un-cram `Sql()` clear legacy crammed Note 4 seeded rows exact-match; **no new table (89 giữ nguyên — 18 AddColumn/0 CreateTable)**, reversible Down; seed fill-nulls-if-exists 30-field TRUONGGIANG/TANPHU/TGN/DONGDUONG idempotent; commit `778fc98` shipped UN-closed-out → S104 session-start docs-reconcile; prod-verified Run #488 PASS bundle admin `ChSNAJBK`/user `Bn2w1znH`; 440 test re-verified #65-no-bite). +S97: Mig 61 `BackfillEndedByLevelFinalizeFromChangelog` (data-only no-schema — đính chính Mig 60 backfill soi NHẦM bảng `Approvals`→`Changelogs`; set `EndedByLevelFinalize=1` cho phiếu finalize-sớm pre-Mig60 [A/005/A/008 finalize-CCM KHÔNG qua CEO] qua marker ở `PurchaseEvaluationChangelogs.ContextNote`; idempotent SQL-Server-only; prod-verified Run #484: A/005/A/008 0→1 + endsBeforeCeo-flip-True, A/010=0 control-unchanged; gotcha #79). +S97: Mig 60 `AddPeEndedByLevelFinalize` (PE +`EndedByLevelFinalize` bit NOT NULL default 0 — cờ RUNTIME "phiếu ĐÃ kết thúc sớm bằng level-finalize", set-true CHỈ nhánh finalize service; +`Sql()` backfill Phase=7-marker→=1; EndsBeforeCeo display đọc runtime thay config → phiếu-lên-CEO hết báo sai "không qua CEO" [gotcha #78]; AddColumn no new table, prod-verified Run #482 PE-detail endsBeforeCeo=False live). +S87: Mig 58 `AddPeMultiWinnerAndLevelFinalize` (`PurchaseEvaluationSupplier` +`IsWinner` bit multi-winner backfill từ SelectedSupplierId + `ApprovalWorkflowLevel` +`AllowApproverFinalize` bit cấp-KẾT-THÚC-quy-trình AUTO; 2 AddColumn + 1 backfill, no new table) + Mig 59 `AddCcmBudgetPeriodToPurchaseEvaluation` (PE +`CcmBudgetPeriodAmount` decimal(18,2)? — NS kỳ-này cột CCM cho Section B 3-cột; 1 AddColumn nullable, no backfill, no new table). +S77: Mig 57 `AddPeSuggestedPriceNotes` (PE +`ProSuggestedPriceNote` +`CcmSuggestedPriceNote` nvarchar(1000) — ô ghi chú giá đề xuất PRO/CCM giải-thích Min/Max · additive-nullable, 0 backfill, no new table; absolute-set role-gate mirror Mig 54/55). +S76: Mig 56 `AddProBudgetSplitToPeWorkItemBudget` (PeWorkItemBudget +`ProInitialAmount`+`ProAdjustmentAmount` decimal(18,2)? — cột PRO "Ban hành lần đầu"+"V0/hiệu chỉnh" mirror CCM Initial/Adjustment; AddColumn additive + `Sql()` data-migrate `ProInitial=ProEstimate` 4 rows prod gotcha #64; no new table). +S74: Mig 55 `AddCcmNoteToPeWorkItemBudget` (PeWorkItemBudget +CcmNote nvarchar(1000) — "Ghi chú từ CCM" mirror ProNote · additive-nullable, 0 backfill, no new table). +S73: Mig 54 `AddPeSuggestedAndApprovedPrice` (PE +5 cột giá đề xuất PRO Min/Max + CCM + giá chốt ApprovedPriceAmount/Source — additive-nullable, 0 backfill, prod-verified Run #313). +S69: Mig 53 `AddPeUrgentAndCeoApprovalThreshold` (PE +IsUrgentByPro/Ccm cờ gấp + ApprovalWorkflow +CeoApprovalThreshold ngưỡng CCM duyệt-final · 3 AddColumn no new table). Prev S65: Mig 51 `AddDepartmentParentId` (Department.ParentId loose-Guid no-FK — org-tree) + Mig 52 `AddHoSoLinkToPurchaseEvaluation` (PE HoSoLink nvarchar(1000) hyperlink NAS) — **cả 2 AddColumn-only, no new table** (tables giữ 88). Prev Mig 50 `ReplaceBudgetModuleWithPeWorkItemBudgets` (bảng `PeWorkItemBudgets` per-gói-thầu + DROP module Budget cũ + backfill BudgetManual→BudgetPeriod TRƯỚC DropColumn + DROP PE/Contracts.BudgetId; gotcha #63/#64). Prev Mig 49 PE WorkItemId (S57bis) | +| SQL tables | **97** | 🔄 **97 @S164** (Mig 70 K1 +1 bảng `ContractCatalogEntries` — prod `sys.tables`=97 @sub-cicd-verify-k1.md; Mig 71 AddColumn-only giữ 97). Cũ: **96 @S161** (cicd `cicd-verify-a8027c0.md:117` prod `sys.tables` 89→96 = +7 bảng Mig 69; mọi note "no-new-table"/"89 giữ" bên dưới là **frozen-in-time** của mig cũ, KHÔNG sửa). re-ground S97 (cicd `sys.tables` is_ms_shipped=0 Run #482 LIVE = **89**, pre==post Mig 60 no-new-table; "88" từ S62 STALE). Prev net 93→88 S62 (Mig 50 drop Budget + create `PeWorkItemBudgets`) | | Master data (prod) | **71 WorkItems PMH-only S59** | 62 Projects + **WorkItems = ĐÚNG 71 mã PMH** (S59: wipe 15 demo + rename format anh Kiệt `MAT-1..16`/`SUB-1..30`/`MEP-SUB-1..9`/`MEP-EQU-1..16`, Name "STT nhóm tên") + Suppliers **37** (S113 cicd-verified prod count, tất cả IsPublic=1 sau Mig 64 backfill — "22 ước" cũ STALE; POST mở any-auth S59 quick-add; import v2 mới landing nháp). Transactional testing data wiped S59 (PE/HĐ/Notif = 0 baseline, phiếu thật từ A/001). Provenance `scripts/master-import-data.generated.md` | | API endpoints | **~253** | +1 S54 `GET /it-tickets/assignable-staff` (capability endpoint); +3 S52 (attendances/report + report/excel + it-tickets/{id}/assign) | | FE pages | **68** | unchanged S54 (ItTicketsPage reassign = in-place 2 app); +1 S52 AttendanceReportPage | -| Policies | **220** | 🔄 **220 @S161** (W1 KHKK: `All` +`KeHoachKyKet` ⇒ 55 × 4 — reviewer F-2 bắt cặp-row-đôi đúng luật dòng dưới) · 🆕 **row MỚI S121** (wave adap-6-broadcast W2 — H24 GAP-3: `governance-detectors.ps1` `Get-StatusValue` resolve canonical TỪ bảng này; **KHÔNG có row ⇒ token `policy` UNRESOLVED ⇒ nửa `policy` của GAP-3 không thể fire** ⇒ detector khai FAIL-LOUD + SKIP, KHÔNG im-lặng-giả-vờ-xanh). 🔴 **DERIVED, KHÔNG độc-lập:** `Policies = |MenuKeys.All| × |MenuKeys.Actions| = 55 × 4 = 220 (@S161; trước W1 = 54 × 4 = 216)` — sinh runtime ở `src/Backend/SolutionErp.Api/Program.cs:82-89` (`foreach menu → foreach action → opts.AddPolicy($"{menu}.{action}")`), `Actions = ["Read","Create","Update","Delete"]` (`MenuKeys.cs:167`). ⇒ **row này BUỘC đổi CÙNG row `Menu keys`**: thêm 1 menu key ⇒ 55 × 4 = 220; sửa `Menu keys` mà quên `Policies` ⇒ detector flag derived-doc thay vì flag chính chỗ sai. Ground S121 bằng đếm mã-nguồn (`MenuKeys.All` = 54 entry, `public const string` = 54, khớp) — KHÔNG chép số từ spec. ⚠️ `.claude/skills/permission-matrix/SKILL.md` ghi **~240 = STALE** ⇒ W4 vá (🔴 chuỗi H24 §2(4) BẮT BUỘC: detector fire → cổng chứng-kiến → RỒI MỚI vá; vá trước = giết chứng-nhân sống) | -| Menu keys | **55** | **+1 S161 `KeHoachKyKet` vào `All` (W1 KHKK — policy `KeHoachKyKet.*` sinh runtime; menu-row prod KHÔNG đổi, 142 giữ)** · +1 S69 `Off_Dashboard` (landing Văn phòng số). Prev re-ground S61 (`MenuKeys.cs` const — Mig 50 gỡ 4 `Bg_*` Budget menu cũ). Prev 57 (S58) | +| Policies | **452** | 🔄 **452 @S166 K4a** = All 113 × 4 (máy khoá: test T2 `KhkkGroupMenuSeedTests` đỏ nếu drift; DELTA vá-2: +4 ContractCatalog K1 · +32 AwV2_KhkkN* K3 · +196 Khkk_G* K4a). Cũ: **220 @S161** (W1 KHKK: `All` +`KeHoachKyKet` ⇒ 55 × 4 — reviewer F-2 bắt cặp-row-đôi đúng luật dòng dưới) · 🆕 **row MỚI S121** (wave adap-6-broadcast W2 — H24 GAP-3: `governance-detectors.ps1` `Get-StatusValue` resolve canonical TỪ bảng này; **KHÔNG có row ⇒ token `policy` UNRESOLVED ⇒ nửa `policy` của GAP-3 không thể fire** ⇒ detector khai FAIL-LOUD + SKIP, KHÔNG im-lặng-giả-vờ-xanh). 🔴 **DERIVED, KHÔNG độc-lập:** `Policies = |MenuKeys.All| × |MenuKeys.Actions| = 55 × 4 = 220 (@S161; trước W1 = 54 × 4 = 216)` — sinh runtime ở `src/Backend/SolutionErp.Api/Program.cs:82-89` (`foreach menu → foreach action → opts.AddPolicy($"{menu}.{action}")`), `Actions = ["Read","Create","Update","Delete"]` (`MenuKeys.cs:167`). ⇒ **row này BUỘC đổi CÙNG row `Menu keys`**: thêm 1 menu key ⇒ 55 × 4 = 220; sửa `Menu keys` mà quên `Policies` ⇒ detector flag derived-doc thay vì flag chính chỗ sai. Ground S121 bằng đếm mã-nguồn (`MenuKeys.All` = 54 entry, `public const string` = 54, khớp) — KHÔNG chép số từ spec. ⚠️ `.claude/skills/permission-matrix/SKILL.md` ghi **~240 = STALE** ⇒ W4 vá (🔴 chuỗi H24 §2(4) BẮT BUỘC: detector fire → cổng chứng-kiến → RỒI MỚI vá; vá trước = giết chứng-nhân sống) | +| Menu keys | **113** | 🔄 **113 @S166 K4a** (đo RUNTIME `MenuKeys.All` — test T2; +49 `Khkk_G{2-8}`+42-leaf bộ-sinh by-construction K4a · +8 `AwV2_KhkkN1..N8` K3 · +1 `ContractCatalog` K1 trên nền 55). Cũ: **+1 S161 `KeHoachKyKet` vào `All` (W1 KHKK — policy `KeHoachKyKet.*` sinh runtime; menu-row prod KHÔNG đổi, 142 giữ)** · +1 S69 `Off_Dashboard` (landing Văn phòng số). Prev re-ground S61 (`MenuKeys.cs` const — Mig 50 gỡ 4 `Bg_*` Budget menu cũ). Prev 57 (S58) | | Tests | **590 PASS** | 45 Domain + 545 Infra · 🔄 **+28 @S161** (5 wave KHKK: W1 566 → W2 574 → W3 580 → W6 587 → W7 590; đo tươi @S162 `dotnet test` 45+545, gate CI Run #432 khớp). ~~45 Domain + 517 Infra~~ · 0 fail / 0 skip · **S155 +30** (PE xóa-by-approver: `PeDeleteByApproverTests.cs` + T26/T27 — 🔴 **T27 khoá CẢ LỚP lỗi** *entity-có-cờ-mà-DTO-không-có* bằng reflection [mọi cờ `Allow*` trên entity phải có mặt trên DTO], **fault-inject xác nhận có răng**: tiêm bug ⇒ ĐỎ, khôi phục ⇒ xanh — không phải guard tuyên-bố suông; T26 giữ ĐỎ có chủ ý vì bug production thật, lead vá bằng đảo thứ tự chứ KHÔNG nới assert. Số 562 cross-check 2 nguồn độc-lập: log CI run #417 + `dotnet test` local @S156) · **S147 +4** (`ProjectCodeNormalizationTests` — Trim khi lưu + kiểm-trùng bỏ-dấu-cách; 🔴 **ô canonical này STALE 528/483I suốt S147→S148**, header `:6` và session-log S147 đều ghi 532/487I từ đầu — closeout S147 vá header mà quên ô canonical; H24 `lead-view-auditor` bắt @S148, lead verify 3-nguồn rồi vá) · **S134 +8** (`PePendingAccumulationTests` C1-C8 lũy-kế-TẠM-TÍNH: C1 ChoDuyet+winner vào delta-selected + PriorPes · C2 TuChoi/DaDuyet loại sạch · C3 Nháp list-only KHÔNG cộng tiền [D1] + TraLai vào delta-submitted [D2] · C4 peer-window CreatedAt · C5 delta âm signed · **C6 complementary-no-double-count exact-numbers [ComputeAsync bất động khi thêm peer non-terminal]** · C7 frozen→pending 0/null · C8 D4 changelog `[S134 pending-prior]` positive+contrast) · **S133 +11** (`PeBudgetFreezeTests` freeze-NS-at-DaDuyet: **T0 repro test-before RED→GREEN** [phiếu DaDuyet phải giữ số cũ sau `UpdatePeBudgetPro` — RED bắt 999tr @:184 pre-fix, phiếu đưa tới DaDuyet QUA service finalize mirror `PeApproverFinalizeTests` KHÔNG seed thẳng Phase] + **T1-T9** spec-coverage [snapshot các nhánh finalize + display-gate + 2 terminal-guard chặn sửa NS phiếu đã kết thúc (cả Admin) + backfill] + **site-5 AdminOverride** [reviewer catch — override thẳng → DaDuyet cũng chốt snapshot]) · **S118 +8** (PRO-perm scope-isolation **+4** `ProcurementMasterAccessSeedTests` [reflection no-prod-touch: upgrade-path Suppliers R+C+U + Master-Read · **scope-isolation** DeptManager-riêng vẫn revoke=false · insert-path no-Delete · idempotent] · ReportsController authz **+4** `AuthorizePolicyRegressionTests` [dashboard/export require Reports.Read · my-dashboard KHÔNG action-policy · class-level [Authorize] intact]) · **S117 +6** (PE endsBeforeCeo pointer-aware **+1** `PeFinalizeProjectionTests.ListPe_EndsBeforeCeo_ChoDuyet_PointerAware_PastFinalizeIsFalse` [ChoDuyet con-trỏ QUA cấp finalize → endsBeforeCeo=false, test-before RED→GREEN + EF-subquery translate smoke] · duyệt-theo-khoảng ProMinMax **+5** `PeApprovedPriceFinalizeTests` [happy set Min+Max · min==max boundary lock strict-`>` · thiếu-Max→Conflict not-finalized · min>max→Conflict · single-source+Max→Conflict wire-bug + regression Max=null] · harness `InvokeApply` 4→5 args fix TargetParameterCountException) · **S116 +9** (PE nhập giá âm: T1 ngân-sách-bất-biến khi quote-âm · T2 winnerQuoteTotal signed-sum · T3 submit-guard all-zero-blocked/pure-neg+net-zero-submittable · T4 CCM-net-threshold) · **S114 +9** (PE multi-NCC per hạng mục: `PeSelectWinnerClearTests` delete-invariant +4 [DeleteQuote/DeleteDetail re-derive IsWinner **test-before RED→GREEN** + guard-no-over-clear + UpsertQuote-update-branch coverage] + impl-lane +5 [per-hạng-mục-total · giaTri-single-partial · threshold-crossing · derive-invariant]) · **S113 +19** (Supplier import v2: `SupplierImportV2DedupTests` 13 [dedup MST-primary+Code-backstop T1-T7 · draft-on-import IsPublic · all-or-nothing · LayoutValid re-bake real-token · **null-Code→nháp FIXED** flipped-from-characterization] + `SupplierPublishAndListTests` 6 [publish-guard Code-required→409 · unpublish · list-filter published · DTO IsPublic]) · **S112 +18** (Supplier `SupplierExcelImportServiceTests` +10 [case-collation dedup có-RĂNG SQLite-BINARY-collation-nên-chứng-dedup-ở-SERVICE · fill-nulls-safe · all-or-nothing-abort · parser abs-cell-index/#REF!-null/header-fingerprint · Type-lạ→NCC · Status-emoji-strip] · PE `PeSubmitGuardAndBypassTests` 4-update→A1 [minOwn subordinate-only, pointer-stop] + 8-new [A1-happy · non-contiguous · EDGE-5 non-admin-throw/admin-pass/system-pass · opinion-history log/none]) · Prev 440: **S96 +6** (PE `PeApproverFinalizeTests` +2 [#6 finalize-opt-out→advance-CEO · #7 stale-noti-clear ExecuteUpdate] · `PeSuggestedPriceSetterAuthzTests` +4 [price-lock phase-guard after-submit — PRO/CCM ChoDuyet/DaDuyet→Conflict, Admin-override; +sửa SeedPeAsync default ChoDuyet→DangSoanThao spec-change]) · **S92 +3** (`AdminOnlyModulesRevokeTests` ẩn 5 nhóm menu chỉ Admin — non-Admin revoked-4-cờ / Admin untouched / Pe_* preserved) · **S91 +9** (ChangePassword self-service BE+test) · **S89 +3** (`PeDraftVisibilityTests` IDOR nháp riêng-tư) · **S88 +17** (D1 financials/CEO-notify/HoSoLink null-safe +11 · D2 create-contract multi-winner +6) · **S87 +25** (Mig 58 net +18: `PeMultiWinnerTests` sum + `PeSelectWinnerClearTests` multi-update + `PeSubmitGuardAndBypass` budget=0 + `PeApproverFinalizeTests` 5 auto-finalize [từng 7, −2 khi opt-in→auto rework] + `PeCcmThresholdFinalize`/`PeWorkItemBudget` collateral; Mig 59 +7 `PeCcmBudgetPeriodSetterAuthzTests` role-gate Admin|CCM/budget=0/null-clear) · **S86 +3** (`PeApproverNotifyExcludesDrafterTests` — notify "cần duyệt" loại NGƯỜI TẠO phiếu: drafter-excluded · co-approver-vẫn-nhận · drafter-vẫn-nhận-chuông-kết-quả) · **S85 +18** (PE CV PRO C5 hủy/đổi NCC winner +10 · urgent drafter-self-unset +5 · workflow-preserve bug-class S42 +3 `PeUpdateDraftWorkflowPreserveTests`) · **S83 +2** (PE require-workflow: `PeWorkItemGuardTests` ApprovalWorkflowId `NotEmpty` ×2 validator + submit-guard rewrite test 7/13 `PeSubmitGuardAndBypass` V1-submit-deprecated, no-count-change) · **S77 +10** (+7 `PeSuggestedPriceSetterAuthzTests` ghi chú PRO/CCM Mig 57 absolute-set/role-gate/null-clear/max-1000 · +3 `PeUrgentToggleAuthzTests` rewrite cờ-gấp **BẤT ĐỐI XỨNG** SET=function role / UNSET=function+DeptManager, plain-PRO-unset Forbidden) · **S76 +5** (`PeWorkItemBudgetTests` PRO split: set ProInitial+ProAdjust gồm âm · validator ProInitial≥0/ProAdjust-cho-âm · full=proFull khi CCM empty · full=CCM khi CCM present) · **S74 +5** (`PeWorkItemBudgetTests` §4b CcmNote: set CCM/Admin · null-clear absolute-set · non-priv Forbidden+no-mutate · all-3-persist) · **S73 +28** (PE opt-in finalize spec 6→11 + 10 giá-chốt `PeApprovedPriceFinalizeTests` + 13 setter-authz `PeSuggestedPriceSetterAuthzTests`) · **S69 +20** (Office-golive OfficeModulePermissionSeed 6 + PE PeCcmThresholdFinalize 5 + PeUrgentToggleAuthz 9) · **S67 +23 HRM test-after** (DepartmentTreeTests 8 cycle-guard/rollup + PeHoSoLinkTests 9 absolute-set + HrmProfilePermissionSeedTests 6 reflection-seed) → 286 · S61 +22 `PeWorkItemBudgetTests` −14 `BudgetPolicyTests` −1 → 263 · S60 +14 `PeSubmitGuardAndBypassTests` +2 spec → 256 (Domain 58→45 do drop Budget module tests) | | Gotchas | **85** | **+S162 #85** (menu-key OR-gate FE ≠ policy per-action BE: cây 4-folder GĐ gate `['Khkk_List','Khkk_G1','KeHoachKyKet'].some(can)` NHƯNG endpoint gắn `[Authorize(Policy="KeHoachKyKet.Read")]` [`ContractSigningPlansController.cs:36`] + `MenuPermissionHandler.cs:40` so khớp **CHÍNH XÁC 1 key** — KHÔNG kế-thừa cha, KHÔNG OR key con ⇒ role `Khkk_List=1 ∧ KeHoachKyKet=0`: FE mở cổng → API **403** → folder GĐ2 hiện "—" dưới **MỌI** gói thầu, claim "gate trước fetch ⇒ 0 rác 403" **vỡ**. Bẫy: luật "OR nhiều key" **ĐÚNG ở tầng HIỂN-THỊ** [`GetMyMenuTreeQuery.cs:61-96` trả node khi con `CanRead`] nhưng **SAI ở tầng API-authz** — mặt thứ 2 của #82, lần này **menu-hiện RỘNG hơn API-cho** [#82 là chiều ngược]. Fix = gate đúng key policy CỦA endpoint; **mở controller đọc attribute TRƯỚC khi wire gate**, đừng suy từ cây menu. Reviewer bắt **trước ship**; Dev 13/13 role đều `Contracts=1 ∧ KeHoachKyKet=1` nên **chưa lộ** — lớp "chưa vỡ vì dữ-liệu hiện tại may mắn", vá ngay không đợi triệu chứng). **+S159 #84** (revoker-2-chiều: sửa data tay trên prod bị code-path ungated @restart ĐẢO NGƯỢC im lặng — grant menu SQL tay bị revoker [S92] lật 447/494 row; fix = code-first seeder + gỡ nhánh + guard 2-vế + khuôn 3-chân-kiềng; liên-quan #75/#76 chiều seeder). **+S133 #81-EXT** (instance 2 indirect-assignment, KHÔNG số mới: snapshot-NS-at-DaDuyet hook đặt ở finalize choke-point NHƯNG 2 site gán Phase TRỰC TIẾP bypass — admin-override `PurchaseEvaluationWorkflowService.cs:308` `= targetPhase` + seeder `DbInitializer:1323` `= current` → phiếu vào DaDuyet qua 2 đường này KHÔNG snapshot [frozen=0, display rơi về live = đúng bug đang vá]; reviewer adversarial bắt pre-commit, em-main + 3 invocation implement đều sót — cùng bài enumeration-writer #81; meta: field-đích có hook/derive/snapshot ⇒ grep MỌI assignment-site kể cả override/seeder). +1 S126 (**#83** số AGGREGATE session-cumulative tự-lão-hoá — audit COUNTS đổi bởi CHÍNH spawn review sau đó [2/2/0→4/4/0]; outward mời-reproduce = bảng per-lane theo run-id, KHÔNG số tổng; reviewer-gate bắt, em-main-solo sót). +1 S118 (**#82** menu-flag grant ≠ quyền API thật: display-layer [Permission `CanRead`→`GetMyMenuTree` FE-menu] ⟂ API-authz-layer [controller `[Authorize(Policy=)]`] = 2 tầng ĐỘC-LẬP; grant `Suppliers` R+C+U cho PRO nhưng `PUT/DELETE /suppliers` gate `[Authorize(Roles="Admin,CatalogManager")]` → PRO vẫn 403 sửa/xóa NCC [chỉ Publish/Import theo `Suppliers.Update` là ăn]; ngược lại `ReportsController` `[Authorize]` trần → mọi authed-user đọc `dashboard` tài-chính-HĐ ngược S92; fix gate `[Authorize(Policy="Reports.Read")]` per-method + grep controller authz TRƯỚC khi tin hiệu-lực grant; reviewer adversarial bắt, em-main-solo tin menu-flag=quyền thì sót; liên-quan #44). +1 S114 (**#81** derived-invariant re-derive ở write/select NHƯNG SÓT DELETE writer [DeleteQuote/DeleteDetail cascade] → xóa quote-selected cuối của NCC → `Supplier.IsWinner` kẹt stale [Any(IsSelected)=false] → `CreateContract Where(IsWinner)` gom NCC phantom → **HĐ giaTri=0**; fix helper `PeWinnerInvariant.ReSyncAfterQuoteRemovalAsync` gọi sau SaveChanges 2 handler; **enumeration-writer-trong-spec KHÔNG đáng tin** → grep MỌI `.Remove(`/`.Add(`/`=field` chạm entity chứa field-nguồn; chỉ **verify-adversarial-refute** bắt [invest/review/implement đều sót]; cardinality S87/S88 lên tầng mutation-path). +1 S113 (**#80** null-forgiving `!` che NRE trên external-parsed data: `SupplierExcelImportService.NewSupplier` gán `Code = r.Code!.Trim()` → NullReferenceException khi parser trả `null` cho ô cột-4 rỗng [ĐÚNG path R4 "thiếu Mã NCC → nháp"] → confirm nổ 500 CẢ batch dù preview classify New-draft KHÔNG lỗi; sibling `IsPublic=!IsNullOrWhiteSpace(row.Code)` null-safe nhưng NewSupplier thì KHÔNG; empty-string `""` chạy OK nhưng FE round-trip blank→JSON `null` → nổ. Fix `(r.Code ?? "").Trim()`. **Build 0/0 KHÔNG bắt** [`!` tắt nullable-warning]; chỉ test-before-critical bắt. Lesson: `!` null-forgiving trên dữ-liệu-parse-ngoài = mìn, dùng `?? default`). +1 S97 (**#79** backfill soi NHẦM bảng — transition-marker "[Duyệt KẾT THÚC]" nằm ở `PurchaseEvaluationChangelogs.ContextNote` KHÔNG phải `PurchaseEvaluationApprovals` → Mig 60 backfill match 0, phiếu finalize-CCM giữ flag=0 hiện sai "Đã được CEO duyệt"; fix Mig 61 query đúng bảng. PE 2 bảng lịch-sử tách vai [Approvals=ai-duyệt record :745 · Changelogs=chuyển-phase+marker LogTransition :1073]; verify marker-count prod trước khi tin backfill). +1 S97 (**#78** display-flag suy CONFIG thay RUNTIME → nói dối khi instance lệch config: `endsBeforeCeo` từ "workflow CÓ cấp finalize" thay "phiếu NÀY finalize" → phiếu lên CEO báo sai "không qua CEO"; nặng sau S97 default-flip opt-in; fix field runtime `EndedByLevelFinalize` Mig 60. Flag = "instance ĐÃ làm-gì" KHÔNG "config CÓ THỂ"). +1 S96 (**#77** SPA deploy bundle cache-lag: team thấy UI CŨ dù đã deploy + Ctrl+F5 — index.html no-cache ĐÚNG-chuẩn + JS hash-immutable nhưng browser giữ tab-SPA-mở/cache-local → verify bundle prod **BYTE-LEVEL** (curl + grep string-fix trong `/assets/index-.js`) phân-biệt cache-vs-deploy; **incognito = golden-test**; no-service-worker nên clear-site-data đủ). +2 S91 (**#76** deploy render config từ `.example` KHÔNG phải file-gitignored → prod thiếu setting tới khi điền tay · **#75** seed ungated per-code RESURRECT data đã-wipe mỗi restart → gate sau env-flag `DemoSeed:Disabled` + verify-by-restart-thật). +1 S90 (**#74** Chrome-MCP screenshot kẹt SPA-SignalR `document_idle` → PowerShell `CopyFromScreen` + Win32 lọc-process-`chrome.exe`). +2 S85 (**#72** lệch giờ +7h — DateTime lưu UTC nhưng serialize thiếu 'Z' → FE `new Date()`=local → `UtcDateTimeJsonConverter` global ép UTC-with-Z · **#73** UpdateDraft absolute-set CLEAR field SÓT khỏi pattern null-safe = bug-class S42 → guard `if (request.X is not null)`). +1 S78 **#71** (thêm enum value vào entity DÙNG-CHUNG → pollute UI/guard phân-loại theo PROXY-predicate `supplierId===null` thay vì enum tường minh; file-khi-duyệt purpose=5 lẫn "Bảng so sánh" + false-pass submit-guard → grep mọi field-proxy predicate loại value mới + build-verify TỪNG app; em-main grep no-leak bắt trước deploy). +1 S76 **#70** (FE absolute-set echo field anh-em từ server-snapshot + `invalidate()` fire-and-forget → cửa-sổ stale-echo mất-dữ-liệu khi lưu 2 ô liên-tiếp; guard `useIsFetching` gate nút Lưu tới khi refetch land; reviewer workflow bắt, impl self-review sót). +1 S72 **#69** FE bundle-hash non-deterministic + `deploy.yml` rebuild-FE-unconditional → bundle rotate kể cả commit governance (SPA-fallback-200 trap; cicd Run #312). +2 S69: **#67** Tailwind v4 accent palette thiếu stop (teal/violet/amberx/greenx chỉ 50/100/500/600/700; dùng -300 → teal/violet rơi DEFAULT Tailwind sai-hệ, amberx/greenx drop hẳn — build PASS, phải soi dist CSS) · **#68** IDE TS diagnostic giữa background-agent/workflow = snapshot dở-dang (TS server bắn mỗi save), chỉ tin build SẠCH sau-cùng (2× session này: re-skin + PE-FE đều false-alarm). Prev +1 S68 **#66** Tailwind v4 rule element thô `h1-h4{color:#0b1220}` viết NGOÀI `@layer` thắng utility `text-white` → heading render đen; fix ĐIỂM `text-white!` (important, grep dist confirm), KHÔNG move `@layer` (load-bearing ~30+ heading toàn app). Prev +1 S65 **#65** build csproj con (vd `SolutionErp.Api.csproj`) ≠ `dotnet build SolutionErp.slnx` (gồm tests) → miss test-compile khi đổi chữ ký record command (CreateDepartmentCommand +ParentId) → CI CS7036 FAIL-gated Run #291 (deploy chặn, prod nguyên — test-gate làm đúng việc). Fix: build full slnx trước push BE signature-change. Prev +2 S61: **#63** EF scaffold tự sinh `RenameColumn` SAI-semantics khi drop+add cùng type (test xanh không bắt — SQLite EnsureCreated không replay migration) · **#64** `dotnet ef database update` áp Design-DB 0-rows ≠ Dev-DB → data-migrate `Sql()` chạy thật lần đầu trên prod. Prev +2 S59 (#61 sqlcmd `-f 65001` · #62 rename natural-key UPDATE trước deploy) | | User memory | **31** | +S84 `feedback_derived_flush_execution` (derived→canonical flush = em-main execution KHÔNG gate user-confirm; monitor INFORM-only ≠ cần-anh-duyệt; chỉ ĐỔI-con-số/policy = quyền anh). +S79 `feedback_canonical_spec_over_broadcast` (adap pull canonical-command/§P đừng phỏng-theo broadcast + report-before-stamp dogfood + residual own-diary OK). +S75 `feedback_harness11_engine` (engine governance tự-bảo-trì + gotcha #30-via-Edit + multi-workflow-verify + git-add-specific lesson). re-ground S71 (H1 disk-count: 25 `feedback_*` + 3 `project_*`; STATUS "21" stale từ S64). +S71 `feedback_harness10_run_trace` (run-trace + 3 lesson). Prev +1 S64 `feedback_harness7_outward_writing_quality`; re-grounded S54 base 19 | @@ -476,7 +476,7 @@ monthly re-tier mega-line + ef-core SKILL Mig→59 + 3 vocab-fork (2026-07-01 du | Sub-agents | **23** | **+S153 bộ-ba vòng Ctx** (adopt thư `0df10df4` — 🔄 **OWNER-VETO @S153 hub-form 3-vai**: bản đầu (38)(a) 1-vai OWNER-DELEGATED→LEAD-DECIDED, **anh bác cùng ngày** chốt trực tiếp *"Build đủ 3 vai như hub"* qua AskUser = owner chốt THẬT cho quyết này; 🔄 **RENAME cùng phiên theo lệnh anh** *"đặt tên giống hub và sisters để dễ audit"* — tên khai-sinh `ctx-pause-check`/`ctx-reload` → **hub-form `ctx-curator`/`ctx-verifier`** [đo đĩa 3 nhà: hub = curator/verifier/audit · VIPIX = check/reload/chain · hub≠sister ⇒ SE theo HUB-form, tiền lệ rename S149 lead-*]): **`ctx-curator`** vai-1 KIỂM nháp @mỗi-`/pause` (carve-out `pause.md §1` mirror hub O-1(a)) · **`ctx-verifier`** vai-2 ĐỐI-CHIẾU+NẠP-LẠI @mỗi-`/tiep` (dòng `LEAD-PHAI-NHAC-LAI:` = bằng-chứng nội-dung) · **`ctx-audit`** vai-3 SOI-CHUỖI @2-bookend (kiểm-vết vai-1/2, hồi-tố = fallback phiên 1-cửa). Nấc: ctx-audit `file-land + registry-surfaced @S153` cho CẢ 3 vai ctx [🔄 vá @S153 FLAG-6: bản đầu chỉ cấp nấc này cho `ctx-audit` trong khi `hmw.js:42` + writeGuard + README `:27`/`:199`/`:200` chứng 2 vai mới ĐẠT CÙNG nấc — khai THIẾU vẫn là lệch] — spawn-probe ALIVE cả 3 = phiên sau restart T9; hmw VALID_ROLES 20→**23** + writeGuard :228/:235; ⚠️ **roster 23 > `hmw_width.cap` 20** ⇒ full-roster-sweep LOUD-skip 3 lane cuối — số cap = quyền anh, O-7 (câu (40) đang treo). **🔄 RENAME @S149 (anh ratify "OK đồng ý"): 5 vai đổi tên đồng-bộ sister — `tooling-harvest-audit`→`ring1-audit` · `h24-audit`→`ring2-audit` · `sleep-audit`→`ring4-audit` · `lead-view-auditor`→`lead-stale-auditor` · `lead-omission-auditor`→`lead-gap-auditor` (ring* = 2 sister TRÙNG tuyệt đối; lead-* = hub-form, VIPIX-form là prefix). Nấc **NÂNG @cùng-phiên: `registry-listed-in-session`** — harness list 5 tên MỚI + gỡ 5 tên CŨ NGAY TRONG PHIÊN S149 (không restart), **TRÁI phép đo S120/S121 no-hot-reload** (môi trường có thể đã đổi — đây là data-point ĐO ĐƯỢC, không suy). Spawn-probe vẫn CẦN để chứng instantiate (list ≠ spawn được — đúng 0.6b). Tên cũ trong narrative/runs/history = vết lịch-sử, CỐ Ý giữ.** **+S145 🔬 `ring1-audit` (18th sub — round-KIỂM vòng tooling/harvest, C4/C4b adap dot-11 khoản ① `harness-dong-du-vong:51`, owner-decision [2] TÁCH mỗi-vòng-1-con-KHÔNG-gộp). Vai-KIỂM ĐỘC-LẬP đọc OUTPUT H1 `tooling-auditor` (4-mặt freshness) + H2 `harvest-curator` (5-trục integrity), **KHÔNG re-chạy đo**: verdict per-claim ĐẠT/TRƯỢT + falsify ≥2 + **tự tái-dựng ≥1 số CÓ-THỂ-SAI** (đếm `.claude/agents/*.md` vs roster-count H1 · N-harvested/M-spawned) + **thách-CLEAN** (H1 PASS/H2 GATE-0 → mở 1 skill "fresh"+1 sub "harvested" xem thật). 🔴 no-self-exempt ≠ H1/H2 (m-3 S139). **AUTO fire-after-measure @session-end §L.b(g-bis)** — owner-chốt S145 KHÔNG consent-gate (KHÁC trio/H24), fail-safe NO-OP nếu H1/H2 không chạy phiên đó (CẤM ĐẠT-ảo); tuần-tự C4b (invocation SAU con-đo, mtime chứng). Propose-only, backstop = git-diff commit-gate (KHÔNG dựa thiếu-tool `[carry:tools-whitelist-no-teeth]`). `model: opus`+`effort: max`+`memory: project`+`maxTurns: 16`. **Vai #1/3 round-KIỂM** — `ring2-audit` (vòng soi-lead) + `ring4-audit` (vòng nén-ngủ) land dần incremental (§6 broadcast báo-nấc-per-vòng, owner S145). 🔴 **Nấc = executed-file / verified-pending-restart** — registry Agent-tool KHÔNG hot-reload (`agents/README` §Memory discipline — anchor không-số-dòng, vá cite-dòng-trống @S151 per H1 F-3), spawn-probe alive cần **restart CLI**; run-thử-thật @session-end phiên sau. Design-doc: `runs/2026-07-22-S144-adap-dot-11/C4-design-3-vai-kiem.md`.** **+S145 🔬 `ring2-audit` (19th sub — round-KIỂM vòng soi-lead H24, vai #2/3 C4/C4b). Đọc OUTPUT `lead-stale-auditor` + `lead-gap-auditor` (🔴 KIỂM 2 con-đo H24, **KHÔNG soi lead trực-tiếp**) → verdict per-FLAG + tự tái-dựng ≥1 số (flag-count · mở 1 FLAG đối-chứng source · `class_repeat` `.session-counter.json`) + **thách-CLEAN** (H24 báo ít/0 FLAG ⇒ tự soi 1 surface tìm FLAG sót — CLEAN "lead ổn" là ca nguy nhất, khớp H24 §2(1) phép-thử-nhanh). Verify vai H24 KHÔNG tự chế class ngoài enum ĐÓNG `flag_classes`. no-self-exempt ≠ lead-stale/lead-gap (m-3; tên mới @rename S149). AUTO wired `session-end §L.b(j)(ii-bis)` sau cặp H24; fail-safe NO-OP nếu H24 không chạy (nhịp/consent). `maxTurns: 16`.** **+S145 🔬 `ring4-audit` (20th sub — round-KIỂM vòng nén-ngủ, vai #3/3 C4/C4b). Đọc OUTPUT `/sleep-recovery-memory-l2` (`.gist.md` mới + Fidelity verdict + claim giảm-byte) → verdict per-claim + **tự ĐO LẠI byte** archive gốc vs gist (đối-chứng claim) + **thách-CLEAN** (Fidelity 5P/0F ⇒ mở 1 gist đối-chứng `.md` gốc xem có giữ 4-field load-bearing hay nén quá tay) + kiểm bất-biến gist-ADDITIVE (gốc byte-identical). no-self-exempt độc-lập lượt nén (m-3). AUTO wired `sleep-recovery-memory-l2.md Phase 4.5` sau nén; fail-safe NO-OP nếu 0 gist tươi. `maxTurns: 16`.** **+S141/S142 🧪🧭🧮 `harness-eval` + `harness-refine` + `harness-audit` (15th–17th sub — bộ-ba đồng-kiểm HỆ MEMORY, adopt AI_INFRA khuôn 5-câu `58e28bae` khối B, wave adap đợt-9/10; land S141 persona+seed+`hmw.js` VALID_ROLES 14→17, wiring nghi-thức S142 vòng-2 sau khi vòng-1 chết giữa chừng). Read-only INFORM-only propose-only, frontmatter **không khai Write/Edit** — 🔴 nhưng đo S143: **runtime VẪN cấp** (append cuối tool-list, 9/9 vai read-only; nhân-chứng `reviewer` đã Write+Edit thật) ⇒ đây là **tuyên-bố ý-định**, KHÔNG phải chặn-bằng-thiếu-tool; containment thật = single-writer + git-diff commit-gate · `model: opus`+`effort: max`+`memory: project`+`maxTurns: 18`. Trục = **CHÍNH HỆ MEMORY**, chạy **TUẦN TỰ** eval→refine→audit (🔴 CẤM `parallel()` — con KIỂM chạy song song sẽ chấm bản CŨ ⇒ verdict vô-nghĩa): `harness-eval` = con **ĐO** (chỉ trả số nguyên-văn từ instrument, CẤM số-từ-trí-nhớ) ⟂ `harness-refine` = con **ĐỀ-XUẤT** (4 lớp reinject/promote/archive/distill, bắt buộc có mục "đã cân-nhắc→BÁC") ⟂ `harness-audit` = con **KIỂM** (verdict ĐẠT/TRƯỢT per-claim + re-chạy ≥2 số load-bearing; PIN run-folder đích-danh, CẤM tự tìm "run mới nhất"). 🔴 **KHÔNG gộp** với H1 tooling-freshness / H2 harvest-integrity / H24 lead-surface / reviewer diff-code — 4 trục khác nhau. Nghi-thức 2-đầu-phiên **AUTO 1 vòng/phiên GIỮA 2 bookend** (`session-start §2.1.9(a)` canonical + `session-end §L.b(k)(i)` trỏ): owner chốt @S151 — em-main IN 1 dòng trạng-thái rồi CHẠY, KHÔNG chờ gật [🔄 vá @S153 FLAG-4 HIGH: ô này còn khai "consent-gated + anh gật mới chạy" sau khi owner (32) đã lật AUTO, và ngược chính `:466` cùng file; trio đã chạy THẬT 3 vòng S151/S152/S153]; skip → ghi vết WAL `trio: skipped S` (vết-2-lớp, chuỗi skip nhìn thấy được). Budget seed đo TƯƠI `measure-agent-memory.ps1`: eval 1547B · refine 1616B · audit 1513B (l2_*=0 = SEED-STATE đúng, chưa chạy lần nào). 🔸 **Nấc trung-thực = executed-file / verified-pending-restart** — 3 vai CHƯA spawn-probe lần nào (cần restart CLI + anh consent; KHÔNG được đọc thành verified-runtime).** **+S121 👁 `lead-stale-auditor` + `lead-gap-auditor` (13th+14th sub — H24 lead-self-audit, adopt broadcast `2026-07-14-Governance-harness-24`, anh chốt S119 `owner-decisions-15-07-2026`, land S121 wave-W2 `hmw.js` VALID_ROLES 12→14 CÙNG COMMIT roster+STATUS [R3-M2: lệch landing ⇒ C2/B3 flag mọi derived ghi "14" là stale-vs-12 ⇒ detector BÃO trên chính thay đổi của H24]). 2 monitor INFORM-only propose-only, cookie-cutter frontmatter monitor sẵn (`model: inherit` lúc-land [S124 re-pin → `opus`+`effort:max` cùng toàn roster] · `memory: project` · `maxTurns: 18` · 8 tool read-only, **NO Write/Edit**). Trục: `lead-stale-auditor` = **view LỆCH source** (5 class `view-*`) ⟂ `lead-gap-auditor` = **cái BỊ THIẾU** (6 class `gap-*` — +`gap-incident-unrecorded` @S143 owner-duyệt) — 🔴 **KHÔNG gộp** vào tooling-auditor(H1 tooling-freshness)/harvest-curator(H2 harvest-integrity): H24 §2(1) *"gộp là mất tiêu-điểm; cái ta cần là cặp mắt CHỈ dành cho lead"*. Class-flag = **enum ĐÓNG** `lead_self_audit.flag_classes` ở `.claude/agent-memory/memory-budget.json` (11 class = 5+6, vai CẤM tự chế — mở enum là quyền owner). Nhịp = `h24_cadence` cùng file (single-source H24 §2(3) — doc TRỎ key, KHÔNG chép số ⇒ 🔄 **đã GỠ digits `{6/15/3}` @S153** theo `ring2-audit` R-3: chép số ngay cạnh câu cấm-chép-số là **B1-copy** [số vẫn KHỚP canonical nên KHÔNG phải stale — F-6 chống vu-oan]; đọc số sống ở `memory-budget.json → h24_cadence`); 🔄 **HÌNH B @S149**: cặp soi-lead spawn **VÔ-ĐIỀU-KIỆN 2 bookend mỗi phiên** — `light_every` hạ thành **stats-only** (KHÔNG còn cổng bắn), `deep_every` GIỮ cổng nhịp [vá @S153 FLAG-5: ô này còn mô-tả bắn thuần-cadence; đối-chứng hành-vi = cặp chạy @counter 27 với light 1/6 CHƯA tới hạn]; counter `.claude/governance/.session-counter.json` (content-based `last_ticked_head`, KHÔNG blind-increment — hook `wal-flush.ps1` auto-commit `agent-memory/` mỗi turn sẽ làm hỏng đếm mù; đặt ngoài **4 path** hook CÓ CHỦ-ĐÍCH — *hook nới từ 3 lên 4 @S146 khi thêm `.claude/sessions/`; mệnh-đề "governance/ nằm ngoài" đã RE-VERIFY sau khi nới, vẫn đúng*). ✅ **verified-runtime S122** (spawn-probe toàn-roster-KHI-ĐÓ SỐNG post-restart — **N=14 tại thời-điểm S122**, chứng runtime, §Recently-Done S122; 🔸 +3 trio S141 CHƯA nằm trong phép đo này) **+ chạy THẬT S124** (dogfood 2-vai đầu-tiên, owner-triggered @counter 3) **+ S137** (light-audit consent OVERDUE 8/6 → 2 FLAG view-stale-status + 1 FLAG gap-decision-sunk). 🔸 Residual THẬT giữ: đường `hmw.js→agentType` cho 2 vai H24 chưa exercise (Agent-tool path đã chứng; workflow-path chưa) [S137 view-audit lật ⚠️→✅ khớp source, giữ residual]. 🔸 Từ-vựng "view"/"omission" chọn có chủ-đích tránh va-chạm: `governance-detectors.ps1:15` đã sở-hữu `derived-staleness`, và "GAP" quá-tải (`session-end` build-gap · `memory-selfimprove-audit.ps1` CRITERIA+GAP).** **+S107 📄 `office-document` (12th sub — Office document WRITE specialist: docx·xlsx·pptx·pdf + form-engine; floor OD1–OD10; VALID_ROLES hmw.js 9→10; anh giao trực tiếp S107 "Sub chính" Office; ✅ **verified-runtime S107 CÙNG PHIÊN** — registry hot-reload agent MỚI giữa phiên [S27 lesson thu hẹp còn EDIT-agent/command] + spawn-test PASS OD1/OD4/OD5 template thật, model self-report `claude-fable-5` [data-point #5 flip-8]; finding: 2 template `wwwroot/templates` 0-placeholder mẫu-blank).** **worker-tier-pin (owner-decision S124 2026-07-15 — thay all-inherit H8 S66, trước nữa two-tier H4; sub TOÀN-ROSTER pin cứng KHÔNG theo lead; H23-precedence ĐO S126: spawn-param THẮNG hard-pin ⇒ `/fable-real` SỐNG). Lead = **DEFAULT Opus 5 MAX** (thư `9c909007` @S149 (re-stamp 07-25 G-024a; pre `59ce1d0f`); **anh chốt ô canonical hiển-thị DEFAULT-fleet @S151** — *"cần gọi Fable tao chọn đầu mỗi phiên hoặc gọi bằng command fable-real"* ⇒ Fable = anh `/model` đầu phiên HOẶC per-invocation `/fable-real`; runtime thật = BƯỚC 0.6 probe mỗi phiên, KHÔNG ghi sổ mỗi flip — hết class stale-2-flip ring1 bắt @S150). 🧊 Lineage owner-choice/flip-chain FROZEN tới flip-8 (flip-9 S119 Opus-4.8 owner-choice + các lượt /model sau = per-session không enumerate, anti-churn S106):** em main Fable 5 (1M) Max (✅ **Fable RESTORED S98 2026-07-02** — lead self-report `claude-fable-5` + settings `claude-fable-5[1m]`; fallback-Opus H5 S62→S97 kết thúc, H5.6 hoàn tất) ⚠️ **S101 2026-07-03: runtime lead = Opus 4.8** (lead + 2 monitor + WF2 reviewer inherit đều self-report `claude-opus-4-8`; settings vẫn pin `claude-fable-5[1m]` → **Fable unavailable phiên này → H5-fallback tự-kích, revert-free, KHÔNG sửa frontmatter**; "Fable RESTORED S98" = point-in-time KHÔNG phải steady-state — inherit-chain resolve theo availability runtime; ✅ **S102 2026-07-06: Fable VỀ LẠI runtime** — lead + 2 monitor inherit self-report `claude-fable-5` → H5-fallback S101 kết thúc, pattern availability per-session xác nhận [S98 Fable → S101 Opus → S102 Fable → **S103 Opus** → **S104 Opus** (2026-07-06 Fable outage tiếp — S104 lead + 2 monitor spawn self-report `claude-opus-4-8` ×3, H5-fallback revert-free) → ✅ **S105 Fable** (2026-07-07 Fable VỀ lần 3 — lead + 2 monitor self-report `claude-fable-5` ×3, runtime-verified) → ⚠️ **S106 Opus** (2026-07-07 Fable outage lại — lead runtime self-report `claude-opus-4-8`, H5-fallback revert-free; flip-7) → ✅ **S107 Fable** (2026-07-10 Fable VỀ lần 4 — lead + 2 monitor self-report `claude-fable-5` ×3, runtime-verified; flip-8)], BƯỚC 0.6 probe mỗi phiên; per-session KHÔNG steady-state. 🔴 **[§Sub-agents = CANONICAL single-owner flip-chain — S106 gộp per anh-confirm; derived `agents/README §Architecture` · `session-start BƯỚC 0.6` · `session-end §commit-format` trỏ về đây [🔄 vá @S153 FLAG-1: 3 cite số-dòng cũ `:3`/`:37`/`:113` đo lại SAI cả 3 (đúng là `:5`/`:48`/`:315`) — vá bằng BỎ số theo luật đã áp @S151, không ĐỔI số], KHÔNG tự enumerate mỗi flip → chống churn 4-file/flip]**) · **TOÀN roster `model: opus` + `effort: max`** (worker-tier-pin S124; **A2 đo S126:** lane no-tier resolve `claude-opus-4-8` DÙ lead=Fable ⇒ pin ăn runtime, hết inherit-lead; số vai = cột giữa của chính row này — KHÔNG chép lại vào narrative, đó là cách row-14 và narrative-12 lệch nhau từ S121 tới S122; lineage: 7 demoted flip S66 → all-inherit S66–S123 [office-document S107 inherit-born] → pin S124) · effort Max. 10 product/quality + monitor INFORM-only (H1 · H2 · 2×H24 S121 · 3×trio-memory S141 · 3×round-KIỂM `ring1-audit`/`ring2-audit`/`ring4-audit` S145) — **liệt-kê NHÓM, số vai = cột giữa của chính row này**. Nấc H8 = **runtime-verified S98 (2026-07-02)** — spawn-test inherit-chain ×2 monitor self-report `claude-fable-5` (cả đội tự lên Fable khi lead restore, 0 sửa frontmatter — đúng thiết kế all-inherit). Prev two-tier H4 runtime-verified S57bis (spawn-test 2 chiều). | | RAG chunks | 🟢 **service UP** · 🔴 **SE index STALE** | 🔴 **TÁCH 2 TRỤC @S159 — 1 nhãn nhị-phân DOWN/alive che mất trục thứ hai.** ① **service-liveness = UP**: đo LIVE 2026-07-29 `list_projects` → **7/7 project chunk > 0** (`solution_erp` **2449** · vipix_multisite 7090 · dh_y_duoc 15440 · namgroup 6106 · ashico 4742 · vipix_ai_infra 4176 · benhvien_aau 3024; chỉ `shared_global` = 0); **query THẬT** `search_code` → 1 hit **có ruột** score 7.156 ⇒ collection đọc được, **không phải vỏ rỗng** (metadata một mình KHÔNG đủ — có thể là registry-cache). ② **SE index-freshness = KÉM**: `last_indexed_at` = **2026-05-29** trong khi **5 sibling re-index 2026-07-29 08:21** cùng một lượt. **Lật thẳng DOWN→alive sẽ CHÔN trục ②.** 🧊 **Nhãn "DOWN từ S153" ĐÚNG LÚC GHI** (`33e7f68`, 07-28 20:50, 3 phép đo khớp, `chunk_count=0` cả 7 project) — service **hồi phục sau ~12h** ⇒ class `view-stale-status`, **KHÔNG phải lead ghi ẩu**; đừng quy lỗi cho lượt viết vốn có bằng chứng đúng. 🔸 **Caveat giữ (H1 F-4 → ring1 T-2):** H1 tuyên `last_indexed_at` "không đáng tin" (2447→2449 nhúc nhích), `ring1` **BÁC** bằng nội-dung — coi là **đáng tin**; muốn chắc tuyệt đối thì re-index rồi xem field có nhảy không. Re-index = AI_INFRA op (cần VOYAGE_API_KEY). *(FLAG-2 `view-stale-status` HIGH — `lead-stale-auditor` @S159, `ring2` tái-dựng 8/8 số. Lead trước đó chỉ vá `:6`, sót đúng ô canonical này = **canonical-poison**, cùng lớp khác site.)* | -**Bundle hash live (prod):** admin **`B43R6Y17`** · user **`NLI5umBg`** — CSS **frozen** `BZ5F-g1w`/`yNZDmVNz` (logic-only fingerprint #69). **Đo LIVE bằng curl 2026-07-31 @S164** (`/tiep` window-2, vá mục 3). **Nguồn rotate = push `e5123ff`**, commit gắn nhãn `Docs: S162 closeout` **nhưng ship 6 file FE-src THẬT** (`PipelineStageFolders.tsx` · `PipelineTreePanel.tsx` · `usePipelineStages.ts` ×2 app) ⇒ rotate **real-src, KHÔNG phải #69-benign**; scope commit sai convention (đáng lẽ `FE-Admin`+`FE-User`, không phải `Docs`). 🔴 **Hại đo được — LỆCH 2 BỀ MẶT, không phải 1:** ô này đứng ở cặp S157 qua **2 đời deploy**, VÀ dòng `:6` ghi cặp S162 `DXoYDy7R`/`o8KDluD1` **chưa bao giờ là trạng-thái cuối** (snapshot chụp sau `bfc7b79`, trong khi `e5123ff` còn ship FE tiếp) ⇒ **cả 2 bề mặt owner đều lệch prod cùng lúc**. Đúng ca *"closeout-push kế có non-md sẽ rotate tiếp"* mà chính ô này đã tiên-liệu (precedent S99-#485 / S107) — tiên-liệu đúng, cơ-chế bắt vẫn không có. **Control-âm:** cặp S157 `8-QYcMOg`/`DptYR4wL` nay trả `text/html` **900 B** admin / **876 B** eoffice ⇒ bundle cũ đã chết. ⚠️ **CHƯA neo run-số CI** — lead chỉ curl 3 phép (2 hash live + control-âm), **không đọc Gitea Actions**; cần `cicd-monitor` xác nhận Run # + test-gate trước khi coi ô này đủ bằng-chứng. 🧊 *Mốc trước S157:* admin **`8-QYcMOg`** · user **`DptYR4wL`** (đo LIVE @S157 2026-07-28, **Run #419** `6250f39` — cicd PASS 9/9 + lead tự re-curl độc lập khớp từng byte, marker 2/2 mỗi app; **control-âm**: 2 hash CŨ `index-BK8w3QxU.js`/`index-DYnxSI0T.js` nay trả `text/html` **900 B admin / 876 B eoffice** ⇒ bundle cũ đã chết trên prod. 3 phép đo độc-lập: cicd-verify · lead re-curl · ring2-audit). 🔴 **Ô này từng đứng ở cặp `BK8w3QxU`/`DYnxSI0T` sau khi prod đã rotate** — `git show 3b6fabc -- docs/STATUS.md` chỉ có 2 hunk, **0 hunk chạm dòng này**; hại đo được: `cicd-verify-s157-diadiem.md:58` dùng chính ô này làm baseline ⇒ lượt deploy kế hoặc báo "ĐỔI" giả hoặc che một lần không-rotate (lead-stale-auditor FLAG-1 HIGH @S158). 🧊 *Mốc trước:* admin `BK8w3QxU` · user `DYnxSI0T` (S156 CI run 531 `f4494cf`; `Last-Modified` 14:41:15/14:42:10 +07 **nằm trong cửa-sổ deploy** — ship-proof, không phải hash-delta suông. Byte-verify 4/4 marker: `allowApproverDelete` 8/2 · `by-approver` 1/1 · `deleted=1` 1/1 · `view=approved` 1/1. ⚠️ **Không chứng minh hồi-tố** được bundle của riêng `4464a46` đã rotate lúc 14:12 — `f4494cf` đè lên sau 30 phút, không ai chụp snapshot khoảng giữa). 🧊 *Mốc trước:* admin `B2j1LQ-v` · user `C3wbJegk` (S147 CI #407 — re-stamp @S152: FLAG-2 lead-stale bắt ô này đứng S134 qua 5 nhãn trong khi `:6`/`:465`/HANDOFF đều S147; ring2 verify 2 lệnh độc-lập). 🧊 *Chuỗi rotate lịch-sử giữ nguyên:* (S134 — **`6664a61`** PE lũy kế TẠM TÍNH vs CHÍNH XÁC [0 mig — `ComputePendingAsync` complementary + DTO 5-field + FE sub-dòng amber + banner màn duyệt + D4 audit-note]; **Run #399** GO 5/5 5m27s gate-**528**-EXACT-từ-log rotate ×2 admin js `3NCdyODe`→**`BVCukFP5`** css `B--dUHcm`→**`CBzUlgOA`** / eoffice js `CKsoiNCI`→**`DoxyEOxh`** css `BuntB63r`→**`BHsBUA8e`** [FE-src đổi thật 6 file ⇒ rotate expected; CSS rotate = amber badge utilities, benign #69]; real-size 1.659.037/1.565.367B + LM 14:31:34/14:32:28+07 in-window; **byte-verify #77** `pendingPriorPes`=2 + `Tạm tính`=3 + `phiếu trước cùng gói thầu CHƯA duyệt`=2 PRESENT cả 2 js [control old-hash 900B text/html 0-hit — ct=text/html là discriminator sạch hơn size-919b]; Mig 67 top FROZEN + 89 bảng [0-mig by-construction]; smoke 4×200 + PE 401; lead spot-check độc-lập post-verdict: 4/4 hash khớp + marker PRESENT). **Prev S133** — admin **`3NCdyODe`** · user **`CKsoiNCI`** (S133 — **`d4fbced`** PE freeze NS phiếu DaDuyet [Mig 67 snapshot 11 cột + backfill 18/18 + FE 2-app badge 🔒 SHA-mirror `5534addb` + display-gate `budgetFrozen`]; **Run #398** GO 5/5 gate-520 rotate ×2 admin →**`3NCdyODe`** [CSS `B--dUHcm`] / user →**`CKsoiNCI`** [CSS `BuntB63r`] — prev `yCkX-2Bp`/`DKSD4p9k` @Run #397 S131-S132 [dòng-live lag S117→S133, hoist per precedent S104/S107]; **CSS cũng rotate** = badge sinh utility-class mới, benign #69; **byte-verify #77** `budgetFrozen`=1 + VN-string 🔒=1 PRESENT [old-control 0]; Mig 67 prod history-top + 11 cột + backfill 18/18 chua=0 + sys.tables **89**; smoke 4×200 + PE 401; `/tiep` S134 re-verify độc-lập 4/4: mig-top + health-200 + bundle-hash ×2 + filter 11/11). **Prev S117** — admin **`cFI7ih4a`** · user **`MxgooVZw`** (S117 — **`316a82f`** PE endsBeforeCeo pointer-aware [display-fix BE-only, phiếu chờ CEO hết báo "Kết thúc" sai] + duyệt-theo-khoảng Min–Max [Mig 66 + FE 2-app ProMinMax radio thứ 5]; **Run #387** PASS 5m36s rotate ×2 admin `Dd55jBpj`→`cFI7ih4a` / user `DVDr1UQ4`→`MxgooVZw` CSS-frozen [logic-only fingerprint]; **byte-verify #77** `ProMinMax`/`duyệt theo khoảng`/`cả Min và Max`/`Min–Max` PRESENT [bundle-cũ=0]; Mig 66 prod col decimal(18,2) + `__EFMigrationsHistory`-top + sys.tables **89**; smoke 200×3 · PE 401-secured; **#53-garble ×2** [invest-lane-1 + cicd-monitor] → SendMessage-resume + em-main curl cross-verify khớp). **Prev S116** — admin **`Dd55jBpj`** · user **`DVDr1UQ4`** (S116 — **`88368fd`** PE cho nhập giá ÂM ô báo giá [FE 2-app nút ± toggle + submit-gate `>0`→`!==0` + helper `hasRealSelectedQuote` 2-mirror `:201`/`:235`; BE submit-guard `:206` `Sum()<=0`→`All(==0)`; +9 test 486→495; **NO migration**]; **Run 386** PASS 5m28s rotate ×2 admin `D51OYyGV`→`Dd55jBpj` / user `BVdssm5S`→`DVDr1UQ4` CSS-frozen [logic-only fingerprint]; **byte-verify #77** `Đã nhập giá chào thầu`/`hoàn tiền NCC`/`phát sinh giảm`/`Đơn vị được chọn chưa có giá chào thầu` PRESENT [bundle-cũ=0]; smoke 200×3 · PE 401-secured · 89 bảng; cicd bắt bẫy snapshot-while-CI-running→poll-success TRƯỚC re-snapshot). **Prev S114** — admin **`D51OYyGV`** · user **`BVdssm5S`** (S114 — **`d436af2`** PE multi-NCC per hạng mục [Mig 65 backfill 2-chiều + BE re-key 5 financial→IsSelected + FE per-cell grid Detail×NCC 2-app SHA `ae3788e9`]; bundle rotate ×2 admin `acprMqfB`→`D51OYyGV` / user `Ck3BTDdA`→`BVdssm5S`; **byte-verify** `detailId`+`select-winner`+`hạng mục` PRESENT [bundle 1.65MB real-size]; smoke health/admin/eoffice 3/3 200 + endpoint POST select-winner bogus-401-live; **cicd garble #53 ×5 → em-main-solo curl-verify**; mig applied [API-healthy=migrations-ran-startup]). **Prev S113** — admin **`acprMqfB`** · user **`Ck3BTDdA`** (S113 — **Run #497** `5fa11b5` Supplier import v2 [Mig 64 publish/draft + dedup-MST + template + file mẫu] FE 2-app real-src rotate; JS admin `Cixlt_MA`→`acprMqfB` / user `BhBOG1dn`→`Ck3BTDdA` + CSS admin `Ds-E5neu`→`CfunQHbd` / user `Beb0Bb1h`→`DGqqHQXS`; ship-proof real-size 1.66/1.56MB + LM-in-window + **byte-verify** `Tải file mẫu`×1/`Đã công bố`×3/`Nháp (ẩn)`×4/`import/template`×1/`published`×5 PRESENT; Mig 64 prod 37/37-backfill sys.tables **89**; smoke health/admin/eoffice 200 + `import/template`-401-live + `suppliers?published=true`-401-live bogus-404-control). **Prev S112** — admin **`Cixlt_MA`** · user **`BhBOG1dn`** (S112 — **Run #496** `e100ef0` Supplier Excel-import Phase B FE 2-app [`SupplierImportDialog` NEW cả fe-admin+fe-user] + PE sign-off code-only; **real fe-src rotate — kết thúc #489→#495 non-determinism-freeze**; JS admin `BVyH-FY7`→`Cixlt_MA` / user `EDsZebvn`→`BhBOG1dn` + CSS admin `Bbtesa6B`→`Ds-E5neu` / user `BmuPz_Dy`→`Beb0Bb1h`; ship-proof real-size + Last-Modified-in-window + `"Database NCC"` marker PRESENT ×1; Mig 63 prod 2/2-col sys.tables **89**; smoke health/admin/eoffice 200 + PE-endpoint 200 + import-route preview-415/confirm-401-live bogus-404). **Prev S107** — admin **`BVyH-FY7`** · user **`EDsZebvn`** (S107 — **Run #489** `21387cd` +office-document 12th sub governance-only; **rotate benign #69-two-mode** [0 fe-src — Vite non-determinism], CSS frozen `Bbtesa6B`/`BmuPz_Dy`, real-size 1.64/1.55MB + Last-Modified-in-window 09:32/09:33 = ship-proof, Mig 62/89 frozen, smoke 200×3; hoist @S107-closeout per H1 flag D-BUNDLE-lặp — ⚠️ closeout-push kế có non-md sẽ rotate tiếp → Run #490 record nằm ở cicd MEMORY [authoritative], hoist ở bundle đầu phiên sau per precedent S99-#485). **Prev S104** — admin **`ChSNAJBK`** · user **`Bn2w1znH`** (S104 — **Run #488** `778fc98` Mig 62 Supplier expand 30-field; real-src-rotate FE 2-app SHA-mirror [form 4-section + Status select + `PathLink` 3 NAS-link], **sys.tables 89 pre==post no-new-table**, prod-verified marker grep-HIT, smoke 200×3; S104-shipped nhưng dòng-live `:27` lag tới S106 → hoist per H1 flag D-BUNDLE). **Prev S97** — admin **`mEGqlNWc`** · user **`Dnrp4PU1`** (S97 — **Run #484** `fb2dfa9` Mig 61 data-fix backfill; bundle rebuild **#69-benign** [0 FE-src change, review-workflow cicd re-snapshot LIVE → `CeTA7Z82`/`CGaAyXen` #483 đã bị #484 rebuild rotate]; backfill A/005/A/008 flag 0→1 prod-verified). **Prev #483** — admin **`CeTA7Z82`** · user **`CGaAyXen`** (S97 — **Run #483** `9e12e80` badge "✅ Đã được CEO duyệt" phiếu đi-hết-lên-CEO [PeFinalizeChip single-source + 6 call-site 2-app, đối xứng chip "⚑ Kết thúc·X"]; FE-only tận-dụng endsBeforeCeo runtime, no BE/mig; JS rotate CSS-frozen #69; byte-#77 "Đã được CEO duyệt" PRESENT cả 2 app + prev-badge additive no-regression; Mig 60 frozen; smoke 200×3 + PE-list 200). **Prev #482** — admin **`DR9q2VUy`** · user **`Bgz8u4E5`** (S97 — **Run #482** `8037fa0` endsBeforeCeo config→RUNTIME [Mig 60 `EndedByLevelFinalize`]: **Mig 60 applied prod** (history-top + col-default-0 EXISTS + backfill=0 UAT-no-marker + sys.tables **89** pre==post); **EF-ternary SQL-Server LIVE-confirmed** GET /purchase-evaluations 200 items=9 + PE-detail `31faef0d` 200 **endsBeforeCeo=False phase=7** = runtime-path chạy = **bug fixed LIVE** (NOT 500); JS rotate 2-app PeWorkflowPanel gate-diff, CSS frozen #69-REFINE; byte-#77 `endsBeforeCeo` PRESENT in-bundle; smoke 200×3. reviewer PASS 0-issue + test 440. **Prev #481** — admin **`Cf0o8dx8`** · user **`x8rppRAK`** (S97 — **Run #481** `1c8fb6a` PE finalize 2-fix [badge "không trình tới"→"Kết thúc tại {cấp}" + đảo default checkbox opt-in]; JS rotate real-FE 2-app `PeWorkflowPanel` diff, CSS frozen `Bbtesa6B`/`BmuPz_Dy` #69-REFINE logic-only; **byte-level #77** NEW `Kết thúc tại`+helper PRESENT / OLD `không trình tới`+`Đã BỎ tick` ABSENT; real-size 1.64/1.54MB; Mig 59/88-table frozen; smoke 200×3. FE-only mirror SHA `bcf812fd`, 0 BE/mig/test). **Prev S96** — admin **`COYAX-yQ`** · user **`0MU-kb4N`** (S96 — **Run #480** `0c2f9f6` price-lock; chuỗi 3 PE deploy #478 `e657faa` checkbox-opt-out → #479 `36cbf8b` chuông-stale-clear (BE-only) → #480 `0c2f9f6` khóa-giá-sau-duyệt, cicd PASS each ~5m; **FE JS rotate THẬT** (PeWorkflowPanel #478 + PeDetailTabs #480, 2-app) real-size 1.64/1.54MB vs 919b SPA-trap, CSS frozen #69-REFINE; Mig 59 frozen 0-mig; +Run #477 `ba4b8e0` NCC seed no-mig prod 21 NCC. **Verify byte-level:** prod `0MU-kb4N` CHỨA checkbox-fix ("BỎ tick"/"Đã BỎ tick") + "✅-static" cũ MẤT → checkbox "ko-được" = browser-cache KHÔNG deploy-fail). **Prev S95** — admin **`0T6vVJST`** · user **`BGXEVZO6`** (S95 — **Run #476** `18a17f5` H17 governance-only deploy; bundle rotate benign #69 CSS-frozen, prod 200×3, Mig 59 unchanged; cicd re-snapshot LIVE — pre-deploy `CkZ1MjC1`/`_-gpX7_Y`, mega-line S93 `CDn40vpV`/`CoPmPBg7` đã bị deploy trung-gian rotate từ S94). **Prev S85** — admin **`aKuNip19`** · user **`BUsL0HeQ`** (S85 — **Run #341** `49ac086` lệch-giờ TZ serialize UTC-with-Z; FE frozen #339+#341 BE-only. Chuỗi ~10 deploy #334→#341 buổi UAT anh Kiệt CV PRO: #334 `5a61254` batch CV PRO 6-fix → #335-#338 6 vòng UAT → #339 `1263c0d` workflow-preserve bug-class S42 → #341 `49ac086` TZ; #342 `d4f6021` docs+cicd BE-frozen, bundle giữ-nguyên gotcha #69-REFINE). **Prev S78** — admin **`CsJetgZH`** · user **`BVS0ApIm`** (S78 — **Run #330** `7886fd0` PE attach-file khi duyệt; bundle ROTATE từ S77 `BqKD3Y23`/`Cn-i349D` dù NO migration enum-only — gotcha #69 non-determ rebuild; cicd ship-proof byte-stable + fake-hash control). **Prev S77** — admin **`BqKD3Y23`** · user **`Cn-i349D`** (S77 — **Run #329** `e823694` banner Trả-lại; cuối chuỗi 10 deploy #320→#329 cùng ngày, cicd PASS each: #320 pill `DsSg6RRz`-era → #321 focus → #322 revert → #323 Mig57+notes `CPm4LTqm`/`BWJUAqEI` → #324 số-âm-đỏ `C6fx-0ea`/`N3sW4Div` → #325 indent `BhnNMucS`/`B1VebpXc` → #326 cờ-gấp-asymmetric `DwXqn37C`/`COXMCv7E` → #327 decouple `B0gboSAg`/`DbDg7pM-` → #328 notify (BE-only, FE frozen) → #329 banner **`BqKD3Y23`**/**`Cn-i349D`**). **Prev S76** — admin **`jOqxW4-p`** · user **`DbsznVvR`** (S76 — **Run #319** `21d1f4e` bảng lưới `` ngân sách viền-ô; chuỗi S76 cùng ngày: #318 `e33481e` feature ma-trận-3-cột + badge quyền-NS `BhFDF9IJ`/`BAkuRl3C` → #319 **`jOqxW4-p`**/**`DbsznVvR`**, cicd PASS each, smoke 3×200, Mig 56 applied prod #318). **Prev S74** — admin **`BYF5vIMJ`** · user **`CB-tiRxd`** (S74 — **Run #315** `8655ebf` Mig 55 CcmNote, cicd PASS ~4m54s: Mig 55 applied prod CcmNote nvarchar(1000) nullable · sys.tables 88 · smoke 4×200 · 0 regression). **Prev S73** — admin **`Bv3jUCNo`** · user **`BWlMBQz6`** (Run #314 `6aa4dcb` FE empty-candidates guard fix; chuỗi 2 deploy: #313 `1d86abc` feature `OlNyG9OD`/`DSzSLVtL` → #314 **`Bv3jUCNo`**/**`BWlMBQz6`**, cicd PASS each). **Prev S72** — admin **`fc_xkNpJ`** · user **`DP-tBcg0`** (Run #312 governance commit `18fced6`; bundle ROTATE do **non-deterministic hash + deploy rebuild-FE-unconditional** (gotcha #69) — 0 FE source change, prod state/DB GIỮ NGUYÊN). **Prev S69** — admin **`BgNCjwsG`** · user **`CBvh0vtf`** (Run #308 PE cờ gấp + ngưỡng CCM; chuỗi 4 deploy session: #305 Văn phòng số foundation → #306 re-skin 10 page → #307 Office golive (BE-only) `Wt54PHYl`/`B99fMU6X` → #308 **`BgNCjwsG`**/**`CBvh0vtf`**). **Prev S68** — admin **`CNUv1jxY`** · user **`CpOskeS1`** (S68 — Run #304 `37752eb` fix tên đen→trắng; chuỗi #303 `6983609` `D532XZKG`/`CuFaBoWt` → #304 **`CNUv1jxY`**/**`CpOskeS1`**, cicd PASS each). **Prev S67** — admin `CcrZqfht`/user `DniDFUB_` (S67 — 6 deploy Run #297→#302; admin rotate chuỗi #298`xkSz9BfE`→#300`PxiZQkaw`→#301`I1fpLeYw`→#302**`CcrZqfht`** · user #297`BumgrwCJ`→#300`B36hGoKd`→#301`DrQYkzh0`→#302**`DniDFUB_`**; #299 tests BE-only cả 2 frozen, cicd PASS each). **Prev S65** — admin `BDwV5d0X`/user `DbVv6rsf` (Run #293-295). Prev S62 admin `0xKYGhhf`/user `C81ZdG9G` (Run #286). **Prev S59:** admin `B1DtNT9C` · user `D6uF3Mln` (Run **#282** `792c030` FINAL — bỏ nút Thêm hạng mục; ships kèm `80b64dd` gỡ Điều khoản TT, #281 cancelled-benign ancestor-verified). Chuỗi S59 cùng ngày: #280 `69997da` (`BKy_8OO9`/`XcZ6PRyA`, ships kèm `f21c55d` table-fixed #279-cancelled) · #278 `9c330d2` self-approve+quick-add-NCC (`BSh2fG2X`/`D22KfpPc`, authz probe 4/4) · #277 `faed59f` SearchableSelect (`ex7Tc92G`/`DzUeSk96`) · #276 `c869d26` rename 71 PMH (`BBA0KSWu`/`DzdTI18G`) · #275 `bbd1554` dọn demo WorkItems (FROZEN BE-only) · #274 `0eafcd3` tree 4 tầng (`DuU7OTym`/`DWyeTzf3`) · #273 `56882ac` wipe + tree v1 (`R9uGRxvw`/`DikfX1RD`). Prev S58: Run #386 `3ebaf84` admin `DMm9rtNA`/user `BUkOMn_Y` (chi tiết session log S58). +**Bundle hash live (prod):** admin **`1yiNV4VH`** · user/eoffice **`DoULfmdT`** — **đo LIVE @S166 ~10:0x 01/08 bởi gate-K4a (post-deploy F-1 `7a903cf`, run #437 — 2 css khớp chứng đo-đúng-site)**; K4a+K4b push kế tiếp SẼ rotate tiếp ⇒ **nguồn cập nhật ô này = cicd-verify lượt kế, đừng tin ô này quá 1 deploy** (bài "sạch lúc HH:MM" — ô này đã chết-ngay-lúc-viết 1 lần trong chính S166). 🧊 Lineage: K3 #436 admin `mySTlx42`/css `Cf3iCOOv` · eoffice `CZAYiWWa` FROZEN-hợp-lệ (diff toàn comment, cicd §5 dự-đoán-trước). 🧊 Lineage cũ (S162-S164, hash `B43R6Y17`/`NLI5umBg` đã chết): **Nguồn rotate = push `e5123ff`**, commit gắn nhãn `Docs: S162 closeout` **nhưng ship 6 file FE-src THẬT** (`PipelineStageFolders.tsx` · `PipelineTreePanel.tsx` · `usePipelineStages.ts` ×2 app) ⇒ rotate **real-src, KHÔNG phải #69-benign**; scope commit sai convention (đáng lẽ `FE-Admin`+`FE-User`, không phải `Docs`). 🔴 **Hại đo được — LỆCH 2 BỀ MẶT, không phải 1:** ô này đứng ở cặp S157 qua **2 đời deploy**, VÀ dòng `:6` ghi cặp S162 `DXoYDy7R`/`o8KDluD1` **chưa bao giờ là trạng-thái cuối** (snapshot chụp sau `bfc7b79`, trong khi `e5123ff` còn ship FE tiếp) ⇒ **cả 2 bề mặt owner đều lệch prod cùng lúc**. Đúng ca *"closeout-push kế có non-md sẽ rotate tiếp"* mà chính ô này đã tiên-liệu (precedent S99-#485 / S107) — tiên-liệu đúng, cơ-chế bắt vẫn không có. **Control-âm:** cặp S157 `8-QYcMOg`/`DptYR4wL` nay trả `text/html` **900 B** admin / **876 B** eoffice ⇒ bundle cũ đã chết. ⚠️ **CHƯA neo run-số CI** — lead chỉ curl 3 phép (2 hash live + control-âm), **không đọc Gitea Actions**; cần `cicd-monitor` xác nhận Run # + test-gate trước khi coi ô này đủ bằng-chứng. 🧊 *Mốc trước S157:* admin **`8-QYcMOg`** · user **`DptYR4wL`** (đo LIVE @S157 2026-07-28, **Run #419** `6250f39` — cicd PASS 9/9 + lead tự re-curl độc lập khớp từng byte, marker 2/2 mỗi app; **control-âm**: 2 hash CŨ `index-BK8w3QxU.js`/`index-DYnxSI0T.js` nay trả `text/html` **900 B admin / 876 B eoffice** ⇒ bundle cũ đã chết trên prod. 3 phép đo độc-lập: cicd-verify · lead re-curl · ring2-audit). 🔴 **Ô này từng đứng ở cặp `BK8w3QxU`/`DYnxSI0T` sau khi prod đã rotate** — `git show 3b6fabc -- docs/STATUS.md` chỉ có 2 hunk, **0 hunk chạm dòng này**; hại đo được: `cicd-verify-s157-diadiem.md:58` dùng chính ô này làm baseline ⇒ lượt deploy kế hoặc báo "ĐỔI" giả hoặc che một lần không-rotate (lead-stale-auditor FLAG-1 HIGH @S158). 🧊 *Mốc trước:* admin `BK8w3QxU` · user `DYnxSI0T` (S156 CI run 531 `f4494cf`; `Last-Modified` 14:41:15/14:42:10 +07 **nằm trong cửa-sổ deploy** — ship-proof, không phải hash-delta suông. Byte-verify 4/4 marker: `allowApproverDelete` 8/2 · `by-approver` 1/1 · `deleted=1` 1/1 · `view=approved` 1/1. ⚠️ **Không chứng minh hồi-tố** được bundle của riêng `4464a46` đã rotate lúc 14:12 — `f4494cf` đè lên sau 30 phút, không ai chụp snapshot khoảng giữa). 🧊 *Mốc trước:* admin `B2j1LQ-v` · user `C3wbJegk` (S147 CI #407 — re-stamp @S152: FLAG-2 lead-stale bắt ô này đứng S134 qua 5 nhãn trong khi `:6`/`:465`/HANDOFF đều S147; ring2 verify 2 lệnh độc-lập). 🧊 *Chuỗi rotate lịch-sử giữ nguyên:* (S134 — **`6664a61`** PE lũy kế TẠM TÍNH vs CHÍNH XÁC [0 mig — `ComputePendingAsync` complementary + DTO 5-field + FE sub-dòng amber + banner màn duyệt + D4 audit-note]; **Run #399** GO 5/5 5m27s gate-**528**-EXACT-từ-log rotate ×2 admin js `3NCdyODe`→**`BVCukFP5`** css `B--dUHcm`→**`CBzUlgOA`** / eoffice js `CKsoiNCI`→**`DoxyEOxh`** css `BuntB63r`→**`BHsBUA8e`** [FE-src đổi thật 6 file ⇒ rotate expected; CSS rotate = amber badge utilities, benign #69]; real-size 1.659.037/1.565.367B + LM 14:31:34/14:32:28+07 in-window; **byte-verify #77** `pendingPriorPes`=2 + `Tạm tính`=3 + `phiếu trước cùng gói thầu CHƯA duyệt`=2 PRESENT cả 2 js [control old-hash 900B text/html 0-hit — ct=text/html là discriminator sạch hơn size-919b]; Mig 67 top FROZEN + 89 bảng [0-mig by-construction]; smoke 4×200 + PE 401; lead spot-check độc-lập post-verdict: 4/4 hash khớp + marker PRESENT). **Prev S133** — admin **`3NCdyODe`** · user **`CKsoiNCI`** (S133 — **`d4fbced`** PE freeze NS phiếu DaDuyet [Mig 67 snapshot 11 cột + backfill 18/18 + FE 2-app badge 🔒 SHA-mirror `5534addb` + display-gate `budgetFrozen`]; **Run #398** GO 5/5 gate-520 rotate ×2 admin →**`3NCdyODe`** [CSS `B--dUHcm`] / user →**`CKsoiNCI`** [CSS `BuntB63r`] — prev `yCkX-2Bp`/`DKSD4p9k` @Run #397 S131-S132 [dòng-live lag S117→S133, hoist per precedent S104/S107]; **CSS cũng rotate** = badge sinh utility-class mới, benign #69; **byte-verify #77** `budgetFrozen`=1 + VN-string 🔒=1 PRESENT [old-control 0]; Mig 67 prod history-top + 11 cột + backfill 18/18 chua=0 + sys.tables **89**; smoke 4×200 + PE 401; `/tiep` S134 re-verify độc-lập 4/4: mig-top + health-200 + bundle-hash ×2 + filter 11/11). **Prev S117** — admin **`cFI7ih4a`** · user **`MxgooVZw`** (S117 — **`316a82f`** PE endsBeforeCeo pointer-aware [display-fix BE-only, phiếu chờ CEO hết báo "Kết thúc" sai] + duyệt-theo-khoảng Min–Max [Mig 66 + FE 2-app ProMinMax radio thứ 5]; **Run #387** PASS 5m36s rotate ×2 admin `Dd55jBpj`→`cFI7ih4a` / user `DVDr1UQ4`→`MxgooVZw` CSS-frozen [logic-only fingerprint]; **byte-verify #77** `ProMinMax`/`duyệt theo khoảng`/`cả Min và Max`/`Min–Max` PRESENT [bundle-cũ=0]; Mig 66 prod col decimal(18,2) + `__EFMigrationsHistory`-top + sys.tables **89**; smoke 200×3 · PE 401-secured; **#53-garble ×2** [invest-lane-1 + cicd-monitor] → SendMessage-resume + em-main curl cross-verify khớp). **Prev S116** — admin **`Dd55jBpj`** · user **`DVDr1UQ4`** (S116 — **`88368fd`** PE cho nhập giá ÂM ô báo giá [FE 2-app nút ± toggle + submit-gate `>0`→`!==0` + helper `hasRealSelectedQuote` 2-mirror `:201`/`:235`; BE submit-guard `:206` `Sum()<=0`→`All(==0)`; +9 test 486→495; **NO migration**]; **Run 386** PASS 5m28s rotate ×2 admin `D51OYyGV`→`Dd55jBpj` / user `BVdssm5S`→`DVDr1UQ4` CSS-frozen [logic-only fingerprint]; **byte-verify #77** `Đã nhập giá chào thầu`/`hoàn tiền NCC`/`phát sinh giảm`/`Đơn vị được chọn chưa có giá chào thầu` PRESENT [bundle-cũ=0]; smoke 200×3 · PE 401-secured · 89 bảng; cicd bắt bẫy snapshot-while-CI-running→poll-success TRƯỚC re-snapshot). **Prev S114** — admin **`D51OYyGV`** · user **`BVdssm5S`** (S114 — **`d436af2`** PE multi-NCC per hạng mục [Mig 65 backfill 2-chiều + BE re-key 5 financial→IsSelected + FE per-cell grid Detail×NCC 2-app SHA `ae3788e9`]; bundle rotate ×2 admin `acprMqfB`→`D51OYyGV` / user `Ck3BTDdA`→`BVdssm5S`; **byte-verify** `detailId`+`select-winner`+`hạng mục` PRESENT [bundle 1.65MB real-size]; smoke health/admin/eoffice 3/3 200 + endpoint POST select-winner bogus-401-live; **cicd garble #53 ×5 → em-main-solo curl-verify**; mig applied [API-healthy=migrations-ran-startup]). **Prev S113** — admin **`acprMqfB`** · user **`Ck3BTDdA`** (S113 — **Run #497** `5fa11b5` Supplier import v2 [Mig 64 publish/draft + dedup-MST + template + file mẫu] FE 2-app real-src rotate; JS admin `Cixlt_MA`→`acprMqfB` / user `BhBOG1dn`→`Ck3BTDdA` + CSS admin `Ds-E5neu`→`CfunQHbd` / user `Beb0Bb1h`→`DGqqHQXS`; ship-proof real-size 1.66/1.56MB + LM-in-window + **byte-verify** `Tải file mẫu`×1/`Đã công bố`×3/`Nháp (ẩn)`×4/`import/template`×1/`published`×5 PRESENT; Mig 64 prod 37/37-backfill sys.tables **89**; smoke health/admin/eoffice 200 + `import/template`-401-live + `suppliers?published=true`-401-live bogus-404-control). **Prev S112** — admin **`Cixlt_MA`** · user **`BhBOG1dn`** (S112 — **Run #496** `e100ef0` Supplier Excel-import Phase B FE 2-app [`SupplierImportDialog` NEW cả fe-admin+fe-user] + PE sign-off code-only; **real fe-src rotate — kết thúc #489→#495 non-determinism-freeze**; JS admin `BVyH-FY7`→`Cixlt_MA` / user `EDsZebvn`→`BhBOG1dn` + CSS admin `Bbtesa6B`→`Ds-E5neu` / user `BmuPz_Dy`→`Beb0Bb1h`; ship-proof real-size + Last-Modified-in-window + `"Database NCC"` marker PRESENT ×1; Mig 63 prod 2/2-col sys.tables **89**; smoke health/admin/eoffice 200 + PE-endpoint 200 + import-route preview-415/confirm-401-live bogus-404). **Prev S107** — admin **`BVyH-FY7`** · user **`EDsZebvn`** (S107 — **Run #489** `21387cd` +office-document 12th sub governance-only; **rotate benign #69-two-mode** [0 fe-src — Vite non-determinism], CSS frozen `Bbtesa6B`/`BmuPz_Dy`, real-size 1.64/1.55MB + Last-Modified-in-window 09:32/09:33 = ship-proof, Mig 62/89 frozen, smoke 200×3; hoist @S107-closeout per H1 flag D-BUNDLE-lặp — ⚠️ closeout-push kế có non-md sẽ rotate tiếp → Run #490 record nằm ở cicd MEMORY [authoritative], hoist ở bundle đầu phiên sau per precedent S99-#485). **Prev S104** — admin **`ChSNAJBK`** · user **`Bn2w1znH`** (S104 — **Run #488** `778fc98` Mig 62 Supplier expand 30-field; real-src-rotate FE 2-app SHA-mirror [form 4-section + Status select + `PathLink` 3 NAS-link], **sys.tables 89 pre==post no-new-table**, prod-verified marker grep-HIT, smoke 200×3; S104-shipped nhưng dòng-live `:27` lag tới S106 → hoist per H1 flag D-BUNDLE). **Prev S97** — admin **`mEGqlNWc`** · user **`Dnrp4PU1`** (S97 — **Run #484** `fb2dfa9` Mig 61 data-fix backfill; bundle rebuild **#69-benign** [0 FE-src change, review-workflow cicd re-snapshot LIVE → `CeTA7Z82`/`CGaAyXen` #483 đã bị #484 rebuild rotate]; backfill A/005/A/008 flag 0→1 prod-verified). **Prev #483** — admin **`CeTA7Z82`** · user **`CGaAyXen`** (S97 — **Run #483** `9e12e80` badge "✅ Đã được CEO duyệt" phiếu đi-hết-lên-CEO [PeFinalizeChip single-source + 6 call-site 2-app, đối xứng chip "⚑ Kết thúc·X"]; FE-only tận-dụng endsBeforeCeo runtime, no BE/mig; JS rotate CSS-frozen #69; byte-#77 "Đã được CEO duyệt" PRESENT cả 2 app + prev-badge additive no-regression; Mig 60 frozen; smoke 200×3 + PE-list 200). **Prev #482** — admin **`DR9q2VUy`** · user **`Bgz8u4E5`** (S97 — **Run #482** `8037fa0` endsBeforeCeo config→RUNTIME [Mig 60 `EndedByLevelFinalize`]: **Mig 60 applied prod** (history-top + col-default-0 EXISTS + backfill=0 UAT-no-marker + sys.tables **89** pre==post); **EF-ternary SQL-Server LIVE-confirmed** GET /purchase-evaluations 200 items=9 + PE-detail `31faef0d` 200 **endsBeforeCeo=False phase=7** = runtime-path chạy = **bug fixed LIVE** (NOT 500); JS rotate 2-app PeWorkflowPanel gate-diff, CSS frozen #69-REFINE; byte-#77 `endsBeforeCeo` PRESENT in-bundle; smoke 200×3. reviewer PASS 0-issue + test 440. **Prev #481** — admin **`Cf0o8dx8`** · user **`x8rppRAK`** (S97 — **Run #481** `1c8fb6a` PE finalize 2-fix [badge "không trình tới"→"Kết thúc tại {cấp}" + đảo default checkbox opt-in]; JS rotate real-FE 2-app `PeWorkflowPanel` diff, CSS frozen `Bbtesa6B`/`BmuPz_Dy` #69-REFINE logic-only; **byte-level #77** NEW `Kết thúc tại`+helper PRESENT / OLD `không trình tới`+`Đã BỎ tick` ABSENT; real-size 1.64/1.54MB; Mig 59/88-table frozen; smoke 200×3. FE-only mirror SHA `bcf812fd`, 0 BE/mig/test). **Prev S96** — admin **`COYAX-yQ`** · user **`0MU-kb4N`** (S96 — **Run #480** `0c2f9f6` price-lock; chuỗi 3 PE deploy #478 `e657faa` checkbox-opt-out → #479 `36cbf8b` chuông-stale-clear (BE-only) → #480 `0c2f9f6` khóa-giá-sau-duyệt, cicd PASS each ~5m; **FE JS rotate THẬT** (PeWorkflowPanel #478 + PeDetailTabs #480, 2-app) real-size 1.64/1.54MB vs 919b SPA-trap, CSS frozen #69-REFINE; Mig 59 frozen 0-mig; +Run #477 `ba4b8e0` NCC seed no-mig prod 21 NCC. **Verify byte-level:** prod `0MU-kb4N` CHỨA checkbox-fix ("BỎ tick"/"Đã BỎ tick") + "✅-static" cũ MẤT → checkbox "ko-được" = browser-cache KHÔNG deploy-fail). **Prev S95** — admin **`0T6vVJST`** · user **`BGXEVZO6`** (S95 — **Run #476** `18a17f5` H17 governance-only deploy; bundle rotate benign #69 CSS-frozen, prod 200×3, Mig 59 unchanged; cicd re-snapshot LIVE — pre-deploy `CkZ1MjC1`/`_-gpX7_Y`, mega-line S93 `CDn40vpV`/`CoPmPBg7` đã bị deploy trung-gian rotate từ S94). **Prev S85** — admin **`aKuNip19`** · user **`BUsL0HeQ`** (S85 — **Run #341** `49ac086` lệch-giờ TZ serialize UTC-with-Z; FE frozen #339+#341 BE-only. Chuỗi ~10 deploy #334→#341 buổi UAT anh Kiệt CV PRO: #334 `5a61254` batch CV PRO 6-fix → #335-#338 6 vòng UAT → #339 `1263c0d` workflow-preserve bug-class S42 → #341 `49ac086` TZ; #342 `d4f6021` docs+cicd BE-frozen, bundle giữ-nguyên gotcha #69-REFINE). **Prev S78** — admin **`CsJetgZH`** · user **`BVS0ApIm`** (S78 — **Run #330** `7886fd0` PE attach-file khi duyệt; bundle ROTATE từ S77 `BqKD3Y23`/`Cn-i349D` dù NO migration enum-only — gotcha #69 non-determ rebuild; cicd ship-proof byte-stable + fake-hash control). **Prev S77** — admin **`BqKD3Y23`** · user **`Cn-i349D`** (S77 — **Run #329** `e823694` banner Trả-lại; cuối chuỗi 10 deploy #320→#329 cùng ngày, cicd PASS each: #320 pill `DsSg6RRz`-era → #321 focus → #322 revert → #323 Mig57+notes `CPm4LTqm`/`BWJUAqEI` → #324 số-âm-đỏ `C6fx-0ea`/`N3sW4Div` → #325 indent `BhnNMucS`/`B1VebpXc` → #326 cờ-gấp-asymmetric `DwXqn37C`/`COXMCv7E` → #327 decouple `B0gboSAg`/`DbDg7pM-` → #328 notify (BE-only, FE frozen) → #329 banner **`BqKD3Y23`**/**`Cn-i349D`**). **Prev S76** — admin **`jOqxW4-p`** · user **`DbsznVvR`** (S76 — **Run #319** `21d1f4e` bảng lưới `
` ngân sách viền-ô; chuỗi S76 cùng ngày: #318 `e33481e` feature ma-trận-3-cột + badge quyền-NS `BhFDF9IJ`/`BAkuRl3C` → #319 **`jOqxW4-p`**/**`DbsznVvR`**, cicd PASS each, smoke 3×200, Mig 56 applied prod #318). **Prev S74** — admin **`BYF5vIMJ`** · user **`CB-tiRxd`** (S74 — **Run #315** `8655ebf` Mig 55 CcmNote, cicd PASS ~4m54s: Mig 55 applied prod CcmNote nvarchar(1000) nullable · sys.tables 88 · smoke 4×200 · 0 regression). **Prev S73** — admin **`Bv3jUCNo`** · user **`BWlMBQz6`** (Run #314 `6aa4dcb` FE empty-candidates guard fix; chuỗi 2 deploy: #313 `1d86abc` feature `OlNyG9OD`/`DSzSLVtL` → #314 **`Bv3jUCNo`**/**`BWlMBQz6`**, cicd PASS each). **Prev S72** — admin **`fc_xkNpJ`** · user **`DP-tBcg0`** (Run #312 governance commit `18fced6`; bundle ROTATE do **non-deterministic hash + deploy rebuild-FE-unconditional** (gotcha #69) — 0 FE source change, prod state/DB GIỮ NGUYÊN). **Prev S69** — admin **`BgNCjwsG`** · user **`CBvh0vtf`** (Run #308 PE cờ gấp + ngưỡng CCM; chuỗi 4 deploy session: #305 Văn phòng số foundation → #306 re-skin 10 page → #307 Office golive (BE-only) `Wt54PHYl`/`B99fMU6X` → #308 **`BgNCjwsG`**/**`CBvh0vtf`**). **Prev S68** — admin **`CNUv1jxY`** · user **`CpOskeS1`** (S68 — Run #304 `37752eb` fix tên đen→trắng; chuỗi #303 `6983609` `D532XZKG`/`CuFaBoWt` → #304 **`CNUv1jxY`**/**`CpOskeS1`**, cicd PASS each). **Prev S67** — admin `CcrZqfht`/user `DniDFUB_` (S67 — 6 deploy Run #297→#302; admin rotate chuỗi #298`xkSz9BfE`→#300`PxiZQkaw`→#301`I1fpLeYw`→#302**`CcrZqfht`** · user #297`BumgrwCJ`→#300`B36hGoKd`→#301`DrQYkzh0`→#302**`DniDFUB_`**; #299 tests BE-only cả 2 frozen, cicd PASS each). **Prev S65** — admin `BDwV5d0X`/user `DbVv6rsf` (Run #293-295). Prev S62 admin `0xKYGhhf`/user `C81ZdG9G` (Run #286). **Prev S59:** admin `B1DtNT9C` · user `D6uF3Mln` (Run **#282** `792c030` FINAL — bỏ nút Thêm hạng mục; ships kèm `80b64dd` gỡ Điều khoản TT, #281 cancelled-benign ancestor-verified). Chuỗi S59 cùng ngày: #280 `69997da` (`BKy_8OO9`/`XcZ6PRyA`, ships kèm `f21c55d` table-fixed #279-cancelled) · #278 `9c330d2` self-approve+quick-add-NCC (`BSh2fG2X`/`D22KfpPc`, authz probe 4/4) · #277 `faed59f` SearchableSelect (`ex7Tc92G`/`DzUeSk96`) · #276 `c869d26` rename 71 PMH (`BBA0KSWu`/`DzdTI18G`) · #275 `bbd1554` dọn demo WorkItems (FROZEN BE-only) · #274 `0eafcd3` tree 4 tầng (`DuU7OTym`/`DWyeTzf3`) · #273 `56882ac` wipe + tree v1 (`R9uGRxvw`/`DikfX1RD`). Prev S58: Run #386 `3ebaf84` admin `DMm9rtNA`/user `BUkOMn_Y` (chi tiết session log S58). **Phase:** ✅ Phase 10 COMPLETE · ✅ **Phase 11 product backlog ĐÓNG TRỌN** · ✅ **Văn phòng số (E-Office) golive S69** (foundation PURO + re-skin 10 page + public read+create 16-key allow-list mọi role) prod-verified · ✅ **PE cờ gấp PRO/CCM + CCM duyệt-final theo ngưỡng (Mig 53 S69)** prod-verified · ✅ **PE ngân sách MA TRẬN 3 cột Dự án/PRO/CCM (bảng lưới `
`) + badge quyền-NS theo role trong Workflow Designer & flow Duyệt NCC (Mig 56 S76)** prod-verified · ✅ **PE UX batch S77 (10 deploy)** — cờ GẤP pill mọi danh sách+inbox · focus/inline+overlay decouple+"Xem mở rộng" · ô ghi chú giá đề xuất PRO/CCM (Mig 57) · số phân cách + số-âm-đỏ-ngoặc + indent mục con · cờ gấp GẮN=NV/GỠ=Trưởng phòng · chuông báo người duyệt · banner Trả-lại — prod-verified · ✅ **PE đính kèm file khi DUYỆT (S78, Run #330)** — người duyệt tải file lên lúc duyệt thay vì Trả lại; migration-free reuse-enum `ApprovalAttachment=5` — prod-verified · 🚫 Phase 9 Ops blocked (anh main coordinate — anh dừng). > ⚠️ **Count drift fixed S40:** endpoints ~223→**211**, FE pages 53→**65**, menu keys 85→**~53**. Tables **84 confirmed correct** (DbSet 77 + Identity 7). 3 số "khó fake" (mig/gotcha/git) luôn đúng. Cause: số "incremented mỗi session" over/under-count optimistic — re-ground định kỳ. diff --git a/fe-admin/src/components/Layout.tsx b/fe-admin/src/components/Layout.tsx index dd1ba03..202360d 100644 --- a/fe-admin/src/components/Layout.tsx +++ b/fe-admin/src/components/Layout.tsx @@ -32,12 +32,17 @@ function resolvePath(key: string): string | null { // [W2 KHKK S161] GĐ2 hết skeleton — re-point trang thật (gotcha #50: thiếu route = // sidebar drop SILENT). WfView phía admin trỏ Designer type-10 (fe-admin CỐ Ý không có // matrix page — chốt 2026-07-27; admin "Luồng duyệt" = dựng/sửa quy trình). + // [K4b S166 — spec K4b-7] 5 leaf trỏ `/khkk/*` mang `group=1` tường minh, mirror + // fe-user (trang coi "vắng group" = xem tất cả, nên thiếu ở đây là nhóm 1 nói dối). + // 🔴 `Khkk_WfView` CỐ Ý KHÔNG mang `group`: bên admin nó trỏ Designer quy trình, + // không phải trang danh sách — Designer không đọc tham số này (fe-user mới đi + // `/khkk/workflow-matrix?type=10&group=1`). Lệch có chủ đích, không mirror mù. Khkk_WfView: '/system/approval-workflows-v2/ContractSigningPlan', - Khkk_List: '/khkk/list', - Khkk_Create: '/khkk/create', - Khkk_Pending: '/khkk/list?filter=ChoDuyet', - Khkk_Approved: '/khkk/list?filter=DaDuyet', - Khkk_Deleted: '/khkk/list?view=deleted', // [S159-đợt5 FLAG-1] thiếu là leaf drop SILENT (gotcha #50) + Khkk_List: '/khkk/list?group=1', + Khkk_Create: '/khkk/create?group=1', + Khkk_Pending: '/khkk/list?group=1&filter=ChoDuyet', + Khkk_Approved: '/khkk/list?group=1&filter=DaDuyet', + Khkk_Deleted: '/khkk/list?group=1&view=deleted', // [S159-đợt5 FLAG-1] thiếu là leaf drop SILENT (gotcha #50) // [W7 KHKK S161] GĐ4 hết skeleton — 7 leaf `Hdc_*` re-point trang thật // `/hard-copies?type=N` (N = ContractType int, mirror TYPE_CODE_TO_INT ở trên). // gotcha #50: thiếu/để sai route ở đây = leaf DROP IM LẶNG khỏi sidebar. @@ -191,6 +196,19 @@ function resolvePath(key: string): string | null { // [S57] BỎ ẩn "Danh mục" (Master/Catalogs + Cấu hình HRM re-parent vào đây) — gom // master data về 1 chỗ cho CẢ admin lẫn nhân viên (đảo S29 hide). Admin nay quản // master trực tiếp trên fe-admin; write vẫn khóa Admin/CatalogManager ở BE controller. +// 🔴 [K4a — S166, vá-8] RÀNG BUỘC NGƯỢC với `resolvePath` bên trên — đọc trước khi +// sửa bộ lọc này. fe-user có nhánh regex `^Khkk_G([1-8])_(WfView|List|Create|Pending| +// Approved|Deleted)$`; fe-admin CỐ Ý KHÔNG có (admin không có `WorkflowMatrixViewPage` +// — lead chốt 2026-07-27, giữ lệch). Hệ quả: 42 leaf `Khkk_G{2..8}_*` mà BE seed sẽ +// rơi vào `resolvePath → null` ⇒ MenuLeaf trả null ⇒ DROP IM LẶNG (gotcha #50). +// ⚠️ ĐO ĐĨA 2026-08-01: hàm này CHỈ ẩn `Ct_*` — `KeHoachKyKet` KHÔNG hề nằm trong tập +// ẩn, tức ràng buộc trên KHÔNG phải chuyện tương lai mà ĐANG hiệu lực: sidebar admin +// hiện root KHKK, 7 nhóm con, và 42 leaf của nhóm 2..8 im lặng biến mất (nhóm 1 sống +// nhờ 6 dòng `Khkk_*` không-infix trong `staticMap`). Đây là trạng thái ĐÃ KHAI, chấp +// nhận có chủ đích ở lượt K4a — KHÔNG phải bug mới phát sinh. +// ⇒ Ai muốn admin thấy đủ 8 nhóm (hoặc ngược lại, thêm `KeHoachKyKet` vào tập ẩn rồi +// sau đó gỡ ra) thì PHẢI nới regex `Khkk_G*` trong `resolvePath` TRƯỚC — và nới thì +// phải quyết luôn `WfView` bên admin trỏ đâu, vì Designer không nhận `?group=`. function isAdminHidden(key: string): boolean { return key.startsWith('Ct_') } diff --git a/fe-admin/src/pages/khkk/KhkkCreatePage.tsx b/fe-admin/src/pages/khkk/KhkkCreatePage.tsx index 1506a67..e1cc937 100644 --- a/fe-admin/src/pages/khkk/KhkkCreatePage.tsx +++ b/fe-admin/src/pages/khkk/KhkkCreatePage.tsx @@ -14,7 +14,7 @@ // không phân biệt được "PE không đủ điều kiện" với "PE đã dùng hết nhóm". import { useState, type FormEvent } from 'react' import { useMutation, useQuery } from '@tanstack/react-query' -import { useNavigate } from 'react-router-dom' +import { useNavigate, useSearchParams } from 'react-router-dom' import { CheckCircle2, FileCheck, FileText, Layers, Plus, Save, Settings2, X } from 'lucide-react' import { toast } from 'sonner' import { PageHeader } from '@/components/ui/PageHeader' @@ -57,11 +57,31 @@ function firstFreeGroup(taken: number[]): number | null { export function KhkkCreatePage() { const navigate = useNavigate() + const [searchParams] = useSearchParams() + + // [K4b S166 — vá-6] Vào từ leaf "Thao tác" của một nhóm (`/khkk/create?group={n}`) + // hoặc từ nút "Lập kế hoạch" trên màn danh sách đang lọc nhóm ⇒ ô chọn nhóm mở sẵn + // đúng nhóm đó. Không có tham số (vào thẳng /khkk/create) thì giữ nguyên default cũ. + // Chỉ nhận 1..8, rác coi như không có — ô chọn không được phép mang giá trị ngoài dải. + const groupParam = searchParams.get('group') + const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null + const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('') const [approvalWorkflowId, setApprovalWorkflowId] = useState('') - const [approvalGroup, setApprovalGroup] = useState(KHKK_APPROVAL_GROUP_DEFAULT) + const [approvalGroup, setApprovalGroup] = useState(presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT) const [ghiChu, setGhiChu] = useState('') + // Cùng lý do với `KhkkListPage` (vá-5): 8 leaf "Thao tác" dùng chung route + // `/khkk/create`, nhảy leaf→leaf không dựng lại component nên giá trị khởi tạo ở + // trên KHÔNG chạy lại. Đồng bộ lại ngay trong render khi tham số nhóm đổi. + // 🔴 Chỉ đổi khi CHÍNH THAM SỐ URL đổi — nếu so với `approvalGroup` thì người dùng + // tự chọn nhóm khác sẽ bị kéo ngược về nhóm của URL, không bấm đi đâu được. + const [lastPresetGroup, setLastPresetGroup] = useState(presetGroup) + if (presetGroup !== lastPresetGroup) { + setLastPresetGroup(presetGroup) + if (presetGroup !== null) setApprovalGroup(presetGroup) + } + // Phiếu PE đủ điều kiện: Phase=DaDuyet VÀ có ≥1 NCC trúng thầu (BE lọc — khớp // ĐÚNG 2 rào còn lại của Create nên picker không mời bấm vào thứ chắc chắn 409). // [K2 S164] Vị ngữ "chưa có kế hoạch sống" ĐÃ BỎ (`Features.cs:1291-1295`). diff --git a/fe-admin/src/pages/khkk/KhkkListPage.tsx b/fe-admin/src/pages/khkk/KhkkListPage.tsx index 9ef293a..bb61d43 100644 --- a/fe-admin/src/pages/khkk/KhkkListPage.tsx +++ b/fe-admin/src/pages/khkk/KhkkListPage.tsx @@ -3,13 +3,20 @@ // KpiCard làm bộ lọc + bảng + phân trang). File MIRROR SHA256 identical với // fe-admin counterpart. // -// URL vào từ menu (Layout.tsx staticMap — chỗ thứ 4 của Pattern 16-bis): -// Khkk_List → /khkk/list -// Khkk_Pending → /khkk/list?filter=ChoDuyet -// Khkk_Approved → /khkk/list?filter=DaDuyet -// Khkk_Deleted → /khkk/list?view=deleted +// URL vào từ menu (Layout.tsx — staticMap cho nhóm 1, regex `Khkk_G{n}_*` cho nhóm +// 2..8; đây là "chỗ thứ 4" của Pattern 16-bis): +// Khkk_List / Khkk_G{n}_List → /khkk/list?group={n} +// Khkk_Pending / Khkk_G{n}_Pending → /khkk/list?group={n}&filter=ChoDuyet +// Khkk_Approved / Khkk_G{n}_Approved → /khkk/list?group={n}&filter=DaDuyet +// Khkk_Deleted / Khkk_G{n}_Deleted → /khkk/list?group={n}&view=deleted // 🔴 Chip lọc chỉ đổi STATE, KHÔNG ghi lại URL — giữ nguyên URL của leaf đang đứng // nên menu không sáng nhầm (bug UAT S155 khi URL leaf trùng URL user tự lọc). +// +// [K4b S166] 🔴 8 nhóm dùng CHUNG một route ⇒ mọi thứ lấy từ URL phải ĐỌC LẠI MỖI +// RENDER, không chụp một lần lúc mount: đi từ leaf nhóm 2 sang leaf nhóm 5 là đổi +// query chứ không đổi route, React Router không dựng lại component nên `useState(...)` +// khởi tạo một lần sẽ giữ nguyên bộ lọc cũ (trước K4b phải F5 mới đúng — vá-5). +// Bộ lọc người dùng bấm được giữ ở lớp override và tự nhả ra khi URL đổi (xem dưới). import { useMemo, useState, type ReactNode } from 'react' import { useQuery } from '@tanstack/react-query' import { useNavigate, useSearchParams } from 'react-router-dom' @@ -29,6 +36,7 @@ import { KHKK_PHASE_BADGE, KHKK_PHASE_LABELS, KhkkPhase, + khkkGroupMenuLabel, type KhkkListItemDto, type KhkkPhaseValue, type PagedResult, @@ -58,17 +66,50 @@ function formatDate(iso: string): string { export function KhkkListPage() { const navigate = useNavigate() const [searchParams] = useSearchParams() - const initialFilter = searchParams.get('filter') - const deletedView = searchParams.get('view') === 'deleted' - const [phase, setPhase] = useState( - initialFilter ? (FILTER_TO_PHASE[initialFilter] ?? null) : null, - ) + // ---- [K4b S166] 3 trục DANH TÍNH đọc thẳng từ URL, KHÔNG chụp một lần lúc mount ---- + // Cả 8 nhóm dùng CHUNG một route `/khkk/list`; bấm leaf này sang leaf kia chỉ đổi + // query, React Router KHÔNG remount ⇒ giá trị khởi tạo của `useState` không chạy lại. + // Đó là lý do trước K4b bấm leaf→leaf phải F5 mới thấy đúng (vá-5). + const urlFilter = searchParams.get('filter') + const urlPhase: KhkkPhaseValue | null = urlFilter ? (FILTER_TO_PHASE[urlFilter] ?? null) : null + const deletedView = searchParams.get('view') === 'deleted' + // `?group=` — chỉ nhận 1..8; rác/thiếu ⇒ null = XEM TẤT CẢ NHÓM (không tự đoán nhóm 1, + // vì "vắng tham số" và "nhóm 1" là hai ý khác nhau; leaf nhóm 1 nay mang group=1 tường minh). + const groupParam = searchParams.get('group') + const group = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null + + // ---- Bộ lọc do NGƯỜI DÙNG bấm (6 chip KpiCard) đè lên bộ lọc của URL ---- + // 🔴 3 trạng thái, KHÔNG gộp được: `undefined` = chưa bấm chip ⇒ nghe theo URL; + // `null` = user chủ động chọn "Tất cả"; số = user chọn một trạng thái. + // Gộp `undefined` với `null` thì chọn "Tất cả" sẽ bị URL kéo ngược về filter cũ. + const [chipPhase, setChipPhase] = useState(undefined) const [search, setSearch] = useState('') const [page, setPage] = useState(1) + // Đổi leaf ⇒ trả bộ lọc chip về cho URL và về trang 1 (đang đứng trang 5 của nhóm 2 + // mà nhảy sang nhóm 5 thì trang 5 gần như chắc chắn rỗng). Điều chỉnh state NGAY + // TRONG RENDER theo khuôn React "reset state khi input đổi" — dùng `useEffect` sẽ + // vẽ một lượt bằng dữ liệu cũ rồi mới sửa (nháy). `search` CỐ Ý giữ lại: đang tìm + // một mã rồi đổi nhóm để dò tiếp là thao tác có thật. + const navKey = `${group ?? ''}|${urlFilter ?? ''}|${deletedView ? 'del' : ''}` + const [lastNavKey, setLastNavKey] = useState(navKey) + if (navKey !== lastNavKey) { + setLastNavKey(navKey) + setChipPhase(undefined) + setPage(1) + } + const phase: KhkkPhaseValue | null = chipPhase !== undefined ? chipPhase : urlPhase + const list = useQuery({ - queryKey: ['khkk-list', { phase, search, page, deletedView }], + // 🔴 [vá-3] `group` PHẢI nằm trong khoá cache. Thiếu nó thì 8 leaf "Danh sách" của + // 8 nhóm dùng CHUNG một ô cache ⇒ mở nhóm 5 lại thấy dữ liệu nhóm 2 trong khoảnh + // khắc trước khi refetch, và tệ hơn là không refetch nếu còn fresh (sai CÂM, không + // có lỗi nào nổ). Trục `?filter=` đã nằm sẵn trong khoá dưới dạng ĐÃ GIẢI MÃ là + // `phase` (`FILTER_TO_PHASE` đơn ánh, và `phase` cũng chính là thứ gửi lên server) + // ⇒ khoá này phủ đủ cả hai trục vá-3 nhắc; thêm `filter` thô nữa chỉ tách đôi ô + // cache cho hai request y hệt nhau. + queryKey: ['khkk-list', { group, phase, search, page, deletedView }], queryFn: async () => { // Phiếu xóa mềm bị HasQueryFilter che ở list thường — chỉ endpoint riêng // `/deleted` thấy (mirror PE `/purchase-evaluations/deleted`, S155). @@ -76,6 +117,11 @@ export function KhkkListPage() { return (await api.get>(url, { params: { phase: deletedView ? undefined : (phase ?? undefined), + // [K4b S166 — FLAG-1 ĐÃ NỐI CẢ 2 NHÁNH] `/deleted` nay nhận `group` mirror + // List (lead vá BE cùng lượt: Controller + `ListDeletedContractSigningPlansQuery` + // + handler lọc SAU vế IDOR). Gửi thẳng cho cả hai — lọc là việc server, + // total/số-trang vì thế nói thật ở cả màn sống lẫn màn đã-xóa. + group: group ?? undefined, search: search.trim() || undefined, page, pageSize: PAGE_SIZE, @@ -115,17 +161,29 @@ export function KhkkListPage() {
: } accent="brand" actions={ deletedView ? undefined : ( - @@ -151,7 +209,9 @@ export function KhkkListPage() { accent={c.accent} active={phase === c.value} onClick={() => { - setPhase(c.value) + // Ghi vào lớp override, KHÔNG ghi vào URL — giữ URL của leaf đang đứng + // để sidebar không sáng nhầm mục khác (bài S155, xem ghi chú đầu file). + setChipPhase(c.value) setPage(1) }} /> diff --git a/fe-admin/src/types/khkk.ts b/fe-admin/src/types/khkk.ts index 1ffd79b..78f9c2c 100644 --- a/fe-admin/src/types/khkk.ts +++ b/fe-admin/src/types/khkk.ts @@ -126,6 +126,12 @@ export const KHKK_APPROVAL_GROUPS = [1, 2, 3, 4, 5, 6, 7, 8] as const // đề xuất", **[OG-6] anh soát SAU trên UI**). Cùng bộ chữ này K4a sẽ seed làm LABEL menu // ⇒ nếu K4a land nhãn khác thì SỬA Ở ĐÂY (1 const, 2 app), đừng vá rải trong page. // Rút gọn có chủ đích: bỏ số thứ tự đầu dòng của bảng (ô select đã có "N{n}"). +// +// [K4a S166 — acceptance-5 single-source] ĐÃ ĐỐI CHIẾU TỪNG KÝ TỰ với nhãn BE seed +// (`DbInitializer.cs:1960` cho nhóm 1 + dict `khkkGroupLabels` `:2127-2136` cho nhóm +// 2..8, cùng `labelBackfill:2235`): 8/8 nhóm khớp theo quan hệ `"{n}. " + chuỗi dưới đây`. +// ⇒ K4a KHÔNG đẻ map thứ hai. Bản đầy đủ (có số thứ tự) lấy qua `khkkGroupMenuLabel(n)` +// ngay bên dưới — DERIVE chứ không chép, để sửa chữ vẫn chỉ đụng đúng 1 chỗ này. export const KHKK_APPROVAL_GROUP_LABELS: Record = { 1: 'Thiết bị, máy móc, vật tư phụ (A1)', 2: 'Kết cấu, hạ tầng, phá dỡ & DV hỗ trợ thi công (A2-A3)', @@ -137,6 +143,25 @@ export const KHKK_APPROVAL_GROUP_LABELS: Record = { 8: 'HĐ chưa ủy quyền — CEO (5.2)', } +// [K4a S166] Nhãn nhóm ĐẦY ĐỦ = đúng chuỗi BE ghi vào `MenuItems.Label` khi seed. +// 🔴 ĐÂY KHÔNG PHẢI NGUỒN CỦA SIDEBAR. Sidebar render nhãn từ payload `/api/menus/me` +// (bảng `MenuItems`, admin còn đổi được qua `displayLabel` — Mig 27), nên đừng ai +// tưởng sửa chuỗi ở đây thì sidebar đổi theo. Hàm này phục vụ chỗ FE tự vẽ tên nhóm +// mà không đi qua menu: cây 8 thư mục nhóm (K5), tiêu đề trang khi vào từ leaf nhóm, +// màn xem luồng duyệt theo nhóm (WfView). +// ⚠️ Ràng buộc ngầm: BE đang đặt nhãn theo khuôn "{n}. {tên}". Nếu BE bỏ tiền tố số thì +// hàm này lệch một cách CÂM (không đỏ build) — lúc đó sửa chính hàm này, đừng vá page. +export function khkkGroupMenuLabel(n: number): string { + const label = KHKK_APPROVAL_GROUP_LABELS[n] + return label ? `${n}. ${label}` : `Nhóm ${n}` +} + +// [K4a S166 — O-2] Mã quy trình duyệt của nhóm n. BE khớp bằng regex `^KHKK-N([1-8])$` +// (`ContractSigningPlanFeatures.cs:420`, rào 409 "phiếu nhóm 3 pin quy trình nhóm 5"); +// 8 workflow do `DbInitializer.SeedKhkkGroupWorkflowsAsync` seed, ApplicableType=10. +// Gom về 1 hàm để FE không rải chuỗi `KHKK-N` ở nhiều nơi rồi lệch nhau. +export const khkkGroupWorkflowCode = (n: number) => `KHKK-N${n}` + export interface KhkkListItemDto { id: string maKeHoach: string | null diff --git a/fe-user/src/components/Layout.tsx b/fe-user/src/components/Layout.tsx index 272320f..45c211a 100644 --- a/fe-user/src/components/Layout.tsx +++ b/fe-user/src/components/Layout.tsx @@ -56,10 +56,14 @@ function resolvePath(key: string): string | null { const staticMap: Record = { // [W2 KHKK S161] GĐ2 hết skeleton — 6 leaf re-point trang thật (gotcha #50: // thiếu route = sidebar drop SILENT; filter đi qua query param, page map FILTER_TO_PHASE). - Khkk_List: '/khkk/list', - Khkk_Create: '/khkk/create', - Khkk_Pending: '/khkk/list?filter=ChoDuyet', - Khkk_Approved: '/khkk/list?filter=DaDuyet', + // [K4b S166 — spec K4b-7] 6 leaf nhóm 1 mang `group=1` TƯỜNG MINH. Chúng là 6 key + // cũ KHÔNG-infix (S159, cấm đổi tên) nên không đi qua regex `Khkk_G{n}_*` bên dưới; + // thiếu `group=1` ở đây thì "Danh sách" của nhóm 1 lại hiện phiếu của cả 8 nhóm — + // đúng một mục nói dối giữa 8 mục nói thật. Trang coi "vắng group" = xem tất cả. + Khkk_List: '/khkk/list?group=1', + Khkk_Create: '/khkk/create?group=1', + Khkk_Pending: '/khkk/list?group=1&filter=ChoDuyet', + Khkk_Approved: '/khkk/list?group=1&filter=DaDuyet', // [W7 KHKK S161] GĐ4 hết skeleton — 7 leaf `Hdc_*` re-point trang thật // `/hard-copies?type=N` (N = ContractType int, mirror TYPE_CODE_TO_INT ở trên). // gotcha #50: thiếu/để sai route ở đây = leaf DROP IM LẶNG khỏi sidebar. @@ -79,8 +83,8 @@ function resolvePath(key: string): string | null { KeHoachKyKet: '/khkk/list', // [W2 S161] root GĐ2 hết placeholder — về danh sách phiếu HopDongCung: '/dashboard', // [W2 S161] 2 leaf còn lại — WfView đi alias matrix type=10 (App.tsx, không đẻ page mới) - Khkk_WfView: '/khkk/workflow-matrix?type=10', - Khkk_Deleted: '/khkk/list?view=deleted', + Khkk_WfView: '/khkk/workflow-matrix?type=10&group=1', + Khkk_Deleted: '/khkk/list?group=1&view=deleted', // [Plan CA Hotfix 1 S29 2026-05-22] 4 master + 4 catalog leaf moved từ // fe-admin → fe-user. resolvePath PHẢI có route mapping nếu không // MenuLeaf line 238 `if (!path) return null` → sidebar drop silent. @@ -181,6 +185,41 @@ function resolvePath(key: string): string | null { if (action === 'Approved') return `/purchase-evaluations?type=${typeInt}&phase=7&view=approved` if (action === 'Deleted') return `/purchase-evaluations?type=${typeInt}&deleted=1` } + + // ===================== [K4a — S166] Sidebar KHKK 8 NHÓM DUYỆT ====================== + // `Khkk_G{n}_{action}` — 42 leaf của nhóm 2..8 (BE `MenuKeys.KhkkLeaf` :77, sinh từ + // `KhkkGroupNumbers = [2..8]` × `KhkkLeafActions` 6 hành động). Đây là "chỗ thứ 4" + // Pattern 16-bis: BE seed 42 row mà thiếu nhánh này thì `resolvePath` trả null ⇒ + // MenuLeaf nhánh `if (!path) return null` (grep chuỗi đó — số dòng trượt theo edit) ⇒ 42 leaf DROP IM LẶNG (gotcha #50). + // + // 🔴 NHÓM 1 KHÔNG đi qua đây: 7 key cũ KHÔNG-infix (`Khkk_G1` + `Khkk_WfView` … + // `Khkk_Deleted`, seed S159) nằm trong `staticMap` bên trên và CẤM đổi tên + // (đổi = mồ côi permission-row đã cấp — bài S155). Vì thế regex dưới về mặt dữ + // liệu chỉ ăn n = 2..8; để dải `[1-8]` theo spec là CỐ Ý — rộng hơn tập seed một + // nấc, vô hại hôm nay và đúng sẵn nếu sau này nhóm 1 chuyển sang khuôn infix. + // + // 🔴 `?group=n` là DANH TÍNH điều hướng, không phải bộ lọc tuỳ hứng: nó KHÔNG nằm + // trong `TRANSIENT_QUERY_KEYS` (grep tên hằng — đừng neo số dòng) nên 6 leaf cùng nhóm và cùng leaf khác nhóm + // đều phân biệt được khi so khớp active (bài `view=approved` S155 ngay khối trên). + // Tên tham số `filter` / `view` / `group` lấy ĐÚNG từ `KhkkListPage` (grep + // `get('filter')` / `get('view')` / `get('group')` trong file đó — đừng neo số dòng, + // chính wave K4b đã làm số cũ trượt) — sai một chữ ở đây là leaf trỏ tới trang mở + // được nhưng lọc SAI, hỏng câm chứ không đỏ build. + // + // 🔴 fe-admin CỐ Ý KHÔNG nới regex này (lead chốt 2026-07-27, giữ lệch): fe-admin + // không có `WorkflowMatrixViewPage` (`App.tsx` bên đó chỉ có /khkk/list|create|:id) + // nên `WfView` bên admin sẽ là link chết; "Luồng duyệt" phía admin trỏ Designer. + // Khai lệch có chủ đích — xem ghi chú ràng buộc ngược tại `isAdminHidden` fe-admin. + const khkkMatch = key.match(/^Khkk_G([1-8])_(WfView|List|Create|Pending|Approved|Deleted)$/) + if (khkkMatch) { + const [, group, action] = khkkMatch + if (action === 'WfView') return `/khkk/workflow-matrix?type=10&group=${group}` + if (action === 'List') return `/khkk/list?group=${group}` + if (action === 'Create') return `/khkk/create?group=${group}` + if (action === 'Pending') return `/khkk/list?group=${group}&filter=ChoDuyet` + if (action === 'Approved') return `/khkk/list?group=${group}&filter=DaDuyet` + if (action === 'Deleted') return `/khkk/list?group=${group}&view=deleted` + } return null } diff --git a/fe-user/src/pages/khkk/KhkkCreatePage.tsx b/fe-user/src/pages/khkk/KhkkCreatePage.tsx index 1506a67..e1cc937 100644 --- a/fe-user/src/pages/khkk/KhkkCreatePage.tsx +++ b/fe-user/src/pages/khkk/KhkkCreatePage.tsx @@ -14,7 +14,7 @@ // không phân biệt được "PE không đủ điều kiện" với "PE đã dùng hết nhóm". import { useState, type FormEvent } from 'react' import { useMutation, useQuery } from '@tanstack/react-query' -import { useNavigate } from 'react-router-dom' +import { useNavigate, useSearchParams } from 'react-router-dom' import { CheckCircle2, FileCheck, FileText, Layers, Plus, Save, Settings2, X } from 'lucide-react' import { toast } from 'sonner' import { PageHeader } from '@/components/ui/PageHeader' @@ -57,11 +57,31 @@ function firstFreeGroup(taken: number[]): number | null { export function KhkkCreatePage() { const navigate = useNavigate() + const [searchParams] = useSearchParams() + + // [K4b S166 — vá-6] Vào từ leaf "Thao tác" của một nhóm (`/khkk/create?group={n}`) + // hoặc từ nút "Lập kế hoạch" trên màn danh sách đang lọc nhóm ⇒ ô chọn nhóm mở sẵn + // đúng nhóm đó. Không có tham số (vào thẳng /khkk/create) thì giữ nguyên default cũ. + // Chỉ nhận 1..8, rác coi như không có — ô chọn không được phép mang giá trị ngoài dải. + const groupParam = searchParams.get('group') + const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null + const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('') const [approvalWorkflowId, setApprovalWorkflowId] = useState('') - const [approvalGroup, setApprovalGroup] = useState(KHKK_APPROVAL_GROUP_DEFAULT) + const [approvalGroup, setApprovalGroup] = useState(presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT) const [ghiChu, setGhiChu] = useState('') + // Cùng lý do với `KhkkListPage` (vá-5): 8 leaf "Thao tác" dùng chung route + // `/khkk/create`, nhảy leaf→leaf không dựng lại component nên giá trị khởi tạo ở + // trên KHÔNG chạy lại. Đồng bộ lại ngay trong render khi tham số nhóm đổi. + // 🔴 Chỉ đổi khi CHÍNH THAM SỐ URL đổi — nếu so với `approvalGroup` thì người dùng + // tự chọn nhóm khác sẽ bị kéo ngược về nhóm của URL, không bấm đi đâu được. + const [lastPresetGroup, setLastPresetGroup] = useState(presetGroup) + if (presetGroup !== lastPresetGroup) { + setLastPresetGroup(presetGroup) + if (presetGroup !== null) setApprovalGroup(presetGroup) + } + // Phiếu PE đủ điều kiện: Phase=DaDuyet VÀ có ≥1 NCC trúng thầu (BE lọc — khớp // ĐÚNG 2 rào còn lại của Create nên picker không mời bấm vào thứ chắc chắn 409). // [K2 S164] Vị ngữ "chưa có kế hoạch sống" ĐÃ BỎ (`Features.cs:1291-1295`). diff --git a/fe-user/src/pages/khkk/KhkkListPage.tsx b/fe-user/src/pages/khkk/KhkkListPage.tsx index 9ef293a..bb61d43 100644 --- a/fe-user/src/pages/khkk/KhkkListPage.tsx +++ b/fe-user/src/pages/khkk/KhkkListPage.tsx @@ -3,13 +3,20 @@ // KpiCard làm bộ lọc + bảng + phân trang). File MIRROR SHA256 identical với // fe-admin counterpart. // -// URL vào từ menu (Layout.tsx staticMap — chỗ thứ 4 của Pattern 16-bis): -// Khkk_List → /khkk/list -// Khkk_Pending → /khkk/list?filter=ChoDuyet -// Khkk_Approved → /khkk/list?filter=DaDuyet -// Khkk_Deleted → /khkk/list?view=deleted +// URL vào từ menu (Layout.tsx — staticMap cho nhóm 1, regex `Khkk_G{n}_*` cho nhóm +// 2..8; đây là "chỗ thứ 4" của Pattern 16-bis): +// Khkk_List / Khkk_G{n}_List → /khkk/list?group={n} +// Khkk_Pending / Khkk_G{n}_Pending → /khkk/list?group={n}&filter=ChoDuyet +// Khkk_Approved / Khkk_G{n}_Approved → /khkk/list?group={n}&filter=DaDuyet +// Khkk_Deleted / Khkk_G{n}_Deleted → /khkk/list?group={n}&view=deleted // 🔴 Chip lọc chỉ đổi STATE, KHÔNG ghi lại URL — giữ nguyên URL của leaf đang đứng // nên menu không sáng nhầm (bug UAT S155 khi URL leaf trùng URL user tự lọc). +// +// [K4b S166] 🔴 8 nhóm dùng CHUNG một route ⇒ mọi thứ lấy từ URL phải ĐỌC LẠI MỖI +// RENDER, không chụp một lần lúc mount: đi từ leaf nhóm 2 sang leaf nhóm 5 là đổi +// query chứ không đổi route, React Router không dựng lại component nên `useState(...)` +// khởi tạo một lần sẽ giữ nguyên bộ lọc cũ (trước K4b phải F5 mới đúng — vá-5). +// Bộ lọc người dùng bấm được giữ ở lớp override và tự nhả ra khi URL đổi (xem dưới). import { useMemo, useState, type ReactNode } from 'react' import { useQuery } from '@tanstack/react-query' import { useNavigate, useSearchParams } from 'react-router-dom' @@ -29,6 +36,7 @@ import { KHKK_PHASE_BADGE, KHKK_PHASE_LABELS, KhkkPhase, + khkkGroupMenuLabel, type KhkkListItemDto, type KhkkPhaseValue, type PagedResult, @@ -58,17 +66,50 @@ function formatDate(iso: string): string { export function KhkkListPage() { const navigate = useNavigate() const [searchParams] = useSearchParams() - const initialFilter = searchParams.get('filter') - const deletedView = searchParams.get('view') === 'deleted' - const [phase, setPhase] = useState( - initialFilter ? (FILTER_TO_PHASE[initialFilter] ?? null) : null, - ) + // ---- [K4b S166] 3 trục DANH TÍNH đọc thẳng từ URL, KHÔNG chụp một lần lúc mount ---- + // Cả 8 nhóm dùng CHUNG một route `/khkk/list`; bấm leaf này sang leaf kia chỉ đổi + // query, React Router KHÔNG remount ⇒ giá trị khởi tạo của `useState` không chạy lại. + // Đó là lý do trước K4b bấm leaf→leaf phải F5 mới thấy đúng (vá-5). + const urlFilter = searchParams.get('filter') + const urlPhase: KhkkPhaseValue | null = urlFilter ? (FILTER_TO_PHASE[urlFilter] ?? null) : null + const deletedView = searchParams.get('view') === 'deleted' + // `?group=` — chỉ nhận 1..8; rác/thiếu ⇒ null = XEM TẤT CẢ NHÓM (không tự đoán nhóm 1, + // vì "vắng tham số" và "nhóm 1" là hai ý khác nhau; leaf nhóm 1 nay mang group=1 tường minh). + const groupParam = searchParams.get('group') + const group = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null + + // ---- Bộ lọc do NGƯỜI DÙNG bấm (6 chip KpiCard) đè lên bộ lọc của URL ---- + // 🔴 3 trạng thái, KHÔNG gộp được: `undefined` = chưa bấm chip ⇒ nghe theo URL; + // `null` = user chủ động chọn "Tất cả"; số = user chọn một trạng thái. + // Gộp `undefined` với `null` thì chọn "Tất cả" sẽ bị URL kéo ngược về filter cũ. + const [chipPhase, setChipPhase] = useState(undefined) const [search, setSearch] = useState('') const [page, setPage] = useState(1) + // Đổi leaf ⇒ trả bộ lọc chip về cho URL và về trang 1 (đang đứng trang 5 của nhóm 2 + // mà nhảy sang nhóm 5 thì trang 5 gần như chắc chắn rỗng). Điều chỉnh state NGAY + // TRONG RENDER theo khuôn React "reset state khi input đổi" — dùng `useEffect` sẽ + // vẽ một lượt bằng dữ liệu cũ rồi mới sửa (nháy). `search` CỐ Ý giữ lại: đang tìm + // một mã rồi đổi nhóm để dò tiếp là thao tác có thật. + const navKey = `${group ?? ''}|${urlFilter ?? ''}|${deletedView ? 'del' : ''}` + const [lastNavKey, setLastNavKey] = useState(navKey) + if (navKey !== lastNavKey) { + setLastNavKey(navKey) + setChipPhase(undefined) + setPage(1) + } + const phase: KhkkPhaseValue | null = chipPhase !== undefined ? chipPhase : urlPhase + const list = useQuery({ - queryKey: ['khkk-list', { phase, search, page, deletedView }], + // 🔴 [vá-3] `group` PHẢI nằm trong khoá cache. Thiếu nó thì 8 leaf "Danh sách" của + // 8 nhóm dùng CHUNG một ô cache ⇒ mở nhóm 5 lại thấy dữ liệu nhóm 2 trong khoảnh + // khắc trước khi refetch, và tệ hơn là không refetch nếu còn fresh (sai CÂM, không + // có lỗi nào nổ). Trục `?filter=` đã nằm sẵn trong khoá dưới dạng ĐÃ GIẢI MÃ là + // `phase` (`FILTER_TO_PHASE` đơn ánh, và `phase` cũng chính là thứ gửi lên server) + // ⇒ khoá này phủ đủ cả hai trục vá-3 nhắc; thêm `filter` thô nữa chỉ tách đôi ô + // cache cho hai request y hệt nhau. + queryKey: ['khkk-list', { group, phase, search, page, deletedView }], queryFn: async () => { // Phiếu xóa mềm bị HasQueryFilter che ở list thường — chỉ endpoint riêng // `/deleted` thấy (mirror PE `/purchase-evaluations/deleted`, S155). @@ -76,6 +117,11 @@ export function KhkkListPage() { return (await api.get>(url, { params: { phase: deletedView ? undefined : (phase ?? undefined), + // [K4b S166 — FLAG-1 ĐÃ NỐI CẢ 2 NHÁNH] `/deleted` nay nhận `group` mirror + // List (lead vá BE cùng lượt: Controller + `ListDeletedContractSigningPlansQuery` + // + handler lọc SAU vế IDOR). Gửi thẳng cho cả hai — lọc là việc server, + // total/số-trang vì thế nói thật ở cả màn sống lẫn màn đã-xóa. + group: group ?? undefined, search: search.trim() || undefined, page, pageSize: PAGE_SIZE, @@ -115,17 +161,29 @@ export function KhkkListPage() {
: } accent="brand" actions={ deletedView ? undefined : ( - @@ -151,7 +209,9 @@ export function KhkkListPage() { accent={c.accent} active={phase === c.value} onClick={() => { - setPhase(c.value) + // Ghi vào lớp override, KHÔNG ghi vào URL — giữ URL của leaf đang đứng + // để sidebar không sáng nhầm mục khác (bài S155, xem ghi chú đầu file). + setChipPhase(c.value) setPage(1) }} /> diff --git a/fe-user/src/types/khkk.ts b/fe-user/src/types/khkk.ts index 1ffd79b..78f9c2c 100644 --- a/fe-user/src/types/khkk.ts +++ b/fe-user/src/types/khkk.ts @@ -126,6 +126,12 @@ export const KHKK_APPROVAL_GROUPS = [1, 2, 3, 4, 5, 6, 7, 8] as const // đề xuất", **[OG-6] anh soát SAU trên UI**). Cùng bộ chữ này K4a sẽ seed làm LABEL menu // ⇒ nếu K4a land nhãn khác thì SỬA Ở ĐÂY (1 const, 2 app), đừng vá rải trong page. // Rút gọn có chủ đích: bỏ số thứ tự đầu dòng của bảng (ô select đã có "N{n}"). +// +// [K4a S166 — acceptance-5 single-source] ĐÃ ĐỐI CHIẾU TỪNG KÝ TỰ với nhãn BE seed +// (`DbInitializer.cs:1960` cho nhóm 1 + dict `khkkGroupLabels` `:2127-2136` cho nhóm +// 2..8, cùng `labelBackfill:2235`): 8/8 nhóm khớp theo quan hệ `"{n}. " + chuỗi dưới đây`. +// ⇒ K4a KHÔNG đẻ map thứ hai. Bản đầy đủ (có số thứ tự) lấy qua `khkkGroupMenuLabel(n)` +// ngay bên dưới — DERIVE chứ không chép, để sửa chữ vẫn chỉ đụng đúng 1 chỗ này. export const KHKK_APPROVAL_GROUP_LABELS: Record = { 1: 'Thiết bị, máy móc, vật tư phụ (A1)', 2: 'Kết cấu, hạ tầng, phá dỡ & DV hỗ trợ thi công (A2-A3)', @@ -137,6 +143,25 @@ export const KHKK_APPROVAL_GROUP_LABELS: Record = { 8: 'HĐ chưa ủy quyền — CEO (5.2)', } +// [K4a S166] Nhãn nhóm ĐẦY ĐỦ = đúng chuỗi BE ghi vào `MenuItems.Label` khi seed. +// 🔴 ĐÂY KHÔNG PHẢI NGUỒN CỦA SIDEBAR. Sidebar render nhãn từ payload `/api/menus/me` +// (bảng `MenuItems`, admin còn đổi được qua `displayLabel` — Mig 27), nên đừng ai +// tưởng sửa chuỗi ở đây thì sidebar đổi theo. Hàm này phục vụ chỗ FE tự vẽ tên nhóm +// mà không đi qua menu: cây 8 thư mục nhóm (K5), tiêu đề trang khi vào từ leaf nhóm, +// màn xem luồng duyệt theo nhóm (WfView). +// ⚠️ Ràng buộc ngầm: BE đang đặt nhãn theo khuôn "{n}. {tên}". Nếu BE bỏ tiền tố số thì +// hàm này lệch một cách CÂM (không đỏ build) — lúc đó sửa chính hàm này, đừng vá page. +export function khkkGroupMenuLabel(n: number): string { + const label = KHKK_APPROVAL_GROUP_LABELS[n] + return label ? `${n}. ${label}` : `Nhóm ${n}` +} + +// [K4a S166 — O-2] Mã quy trình duyệt của nhóm n. BE khớp bằng regex `^KHKK-N([1-8])$` +// (`ContractSigningPlanFeatures.cs:420`, rào 409 "phiếu nhóm 3 pin quy trình nhóm 5"); +// 8 workflow do `DbInitializer.SeedKhkkGroupWorkflowsAsync` seed, ApplicableType=10. +// Gom về 1 hàm để FE không rải chuỗi `KHKK-N` ở nhiều nơi rồi lệch nhau. +export const khkkGroupWorkflowCode = (n: number) => `KHKK-N${n}` + export interface KhkkListItemDto { id: string maKeHoach: string | null diff --git a/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs b/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs index 77c7f08..cc9e45b 100644 --- a/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs @@ -55,16 +55,17 @@ public class ContractSigningPlansController( public async Task>> Inbox(CancellationToken ct) => Ok(await mediator.Send(new GetMyContractSigningPlanInboxQuery(), ct)); - /// Màn "Đã xoá" — CHỈ XEM. + /// Màn "Đã xoá" — CHỈ XEM. [K4b S166] +`group` mirror List `:42` (8 leaf Đã-xóa per-nhóm). [HttpGet("deleted")] [Authorize(Policy = "KeHoachKyKet.Read")] public async Task>> ListDeleted( + [FromQuery(Name = "group")] int? group = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? search = null, [FromQuery] bool sortDesc = true, CancellationToken ct = default) - => Ok(await mediator.Send(new ListDeletedContractSigningPlansQuery + => Ok(await mediator.Send(new ListDeletedContractSigningPlansQuery(group) { Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct)); /// Picker màn tạo: phiếu Duyệt NCC đã duyệt, có NCC trúng thầu, chưa có kế hoạch sống. diff --git a/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs b/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs index c7ecdb6..87fd611 100644 --- a/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs +++ b/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs @@ -862,7 +862,10 @@ public class GetMyContractSigningPlanInboxQueryHandler(IApplicationDbContext db, } /// Màn "Đã xoá" — CHỈ XEM (mirror `ListDeletedPurchaseEvaluationsQuery`). -public record ListDeletedContractSigningPlansQuery +// [K4b S166 — gate-K4b FLAG-1] +ApprovalGroup mirror list SỐNG (`:756-757`): 8 leaf "Đã xóa" +// per-nhóm trỏ `?group=n` mà endpoint này không đọc ⇒ 8 URL trả CÙNG tập mọi-nhóm; lọc client-side +// thì total/số-trang nói dối (server đếm trên tập chưa lọc) ⇒ phải lọc server-side như list sống. +public record ListDeletedContractSigningPlansQuery(int? ApprovalGroup = null) : PagedRequest, IRequest>; public class ListDeletedContractSigningPlansQueryHandler(IApplicationDbContext db, ICurrentUser currentUser) @@ -889,6 +892,10 @@ public class ListDeletedContractSigningPlansQueryHandler(IApplicationDbContext d && userWfIds.Contains(p.ApprovalWorkflowId.Value))); } + // [K4b S166] Lọc nhóm — mirror list sống `:756-757`, SAU vế IDOR (lọc chỉ thu hẹp). + if (request.ApprovalGroup is not null) + q = q.Where(p => p.ApprovalGroup == request.ApprovalGroup); + if (!string.IsNullOrWhiteSpace(request.Search)) { var kw = request.Search.Trim(); diff --git a/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs b/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs index f3c7b5f..2feae50 100644 --- a/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs +++ b/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs @@ -46,6 +46,43 @@ public static class MenuKeys public const string KeHoachKyKet = "KeHoachKyKet"; public const string HopDongCung = "HopDongCung"; + // ============================================================ + // [K4a — S166] Sidebar KHKK 8 NHÓM DUYỆT × 6 leaf. Danh mục SOL-CCM-SP-002 + // (Mig 70) chia 86 dòng thành 8 nhóm duyệt; mỗi nhóm 1 workflow `KHKK-N{n}` + // type-10 (K3) ⇒ sidebar cũng tách 8 nhóm. + // - NHÓM 1 GIỮ NGUYÊN 7 key CŨ KHÔNG-infix (`Khkk_G1` + `Khkk_WfView` … + // `Khkk_Deleted`, seed từ S159 — xem SeedMenuTreeAsync). Đổi tên key = + // mồ côi permission-row đã cấp (bài S155) ⇒ CẤM đổi. + // - NHÓM 2..8 dùng khuôn CÓ infix: `Khkk_G{n}` + `Khkk_G{n}_{action}`. + // 🔴 49 key (7 group + 42 leaf) VÀO `All` ⇒ sinh thêm 196 policy + // `Khkk_G*.{Read,Create,Update,Delete}` (registered-unused: endpoint KHKK + // vẫn CHỈ dùng `KeHoachKyKet.*` — gotcha #85, đừng OR key con vào gate) + // ⇒ 2 số canonical `docs/STATUS.md` đổi: Menu keys +49 · Policies +196 + // (Policies = |All| × |Actions|, DERIVED — khai DELTA, không neo mốc tuyệt đối). + // 🔴 Vào `All` thì BẮT BUỘC có menu-row tương ứng trong `SeedMenuTreeAsync` + // (thiếu row = Permission FK nổ ngay lần boot đầu — invariant T6 K1). + // `KhkkGroupMenuKeys` dưới là NGUỒN CHUNG cho cả `All` LẪN vòng seed + // menu-row + vòng grant site-2 ⇒ 3 phía khớp by-construction, KHÔNG mirror tay. + // ⚠️ 2 KHUÔN menu-key cùng sống trong file này (đọc 1 khuôn đừng tưởng khuôn kia sai): + // (a) `Ct_*` / `Pe_*` leaf CỐ Ý NGOÀI `All` — sinh động qua factory, Admin + // grant qua seeder riêng (xem chú thích tại PurchaseEvaluationApproved). + // (b) catalog / `AwV2_*` / `Khkk_G*` TRONG `All` — Admin tự có row qua + // loop `MenuKeys.All` ở SeedAdminPermissionsAsync. K4a đi khuôn (b). + // ============================================================ + public static readonly int[] KhkkGroupNumbers = [2, 3, 4, 5, 6, 7, 8]; + public static readonly string[] KhkkLeafActions = + ["WfView", "List", "Create", "Pending", "Approved", "Deleted"]; + + public static string KhkkGroup(int n) => $"Khkk_G{n}"; + public static string KhkkLeaf(int n, string action) => $"Khkk_G{n}_{action}"; + + // 49 key theo thứ tự: group n → 6 leaf của n → group n+1 → … + // (khai báo TRƯỚC `All` vì static field initializer chạy theo thứ tự văn bản). + public static readonly string[] KhkkGroupMenuKeys = KhkkGroupNumbers + .SelectMany(n => new[] { KhkkGroup(n) } + .Concat(KhkkLeafActions.Select(a => KhkkLeaf(n, a)))) + .ToArray(); + public static string ContractTypeGroup(string typeCode) => $"Ct_{typeCode}"; public static string ContractTypeList(string typeCode) => $"Ct_{typeCode}_List"; public static string ContractTypeCreate(string typeCode) => $"Ct_{typeCode}_Create"; @@ -200,6 +237,11 @@ public static class MenuKeys OffDatXe, OffItTicket, OffChamCong, OffAttendanceReport, HrmDashboard, // Phase 10.3-10.4 — G-O5/G-O6/G-P1/G-H3 + P11-E report Personal, // [S57] Cá nhân (Puro grouping — Chấm công re-parent) KeHoachKyKet, // [W1 Mig 69 — S161] GĐ2 KHKK: const :41 vào All ⇒ sinh policy KeHoachKyKet.{Read,Create,Update,Delete} + // [K4a — S166] 49 key sidebar KHKK nhóm 2..8 (7 group + 42 leaf) — nguồn sinh + // `KhkkGroupMenuKeys` (dùng chung với seed menu-row + grant site-2). 7 key nhóm 1 + // KHÔNG-infix (`Khkk_G1`/`Khkk_WfView`…) CỐ Ý ngoài `All` như từ S159 — giữ nguyên + // trạng thái cũ, quyền của chúng do site-2/site-3 phát. + .. KhkkGroupMenuKeys, System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows, ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22 // [K3 nhịp-1 — S164/S165] 8 leaf Designer cho 8 nhóm duyệt KHKK (+8 key ⇒ +32 policy). diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs index e4e8c94..86817b1 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs @@ -1953,7 +1953,11 @@ public static class DbInitializer // [S159-đợt3] skeleton menu GĐ2/GĐ4 (owner vẽ: "viết hoa + phân list như // Duyệt NCC" / "thêm Sub đánh số như Duyệt Hợp đồng"). Root CÓ CON ⇒ render // MenuGroup uppercase tự động. Leaves → /coming-soon (staticMap ×2 app). - ("Khkk_G1", "1. Kế hoạch ký kết HĐ (NCC-TP)", MenuKeys.KeHoachKyKet, 1, "FileCheck"), + // [K4a — S166] Nhóm 1 trong 8 NHÓM DUYỆT (danh mục SP-002): nhãn skeleton S159 + // "1. Kế hoạch ký kết HĐ (NCC-TP)" → nhãn nhóm A1. Key `Khkk_G1` GIỮ NGUYÊN; + // DB đang chạy hội tụ qua `labelBackfill` bên dưới (dòng này chỉ ăn với DB trống). + // 7 nhóm còn lại (G2..G8) seed ở vòng lặp phía dưới (sau vòng `PeWf_*`). + ("Khkk_G1", "1. Thiết bị, máy móc, vật tư phụ (A1)", MenuKeys.KeHoachKyKet, 1, "FileCheck"), // [S159-đợt5 owner] đủ 6 leaf "y chang Duyệt NCC" (WfView đầu + Deleted cuối). ("Khkk_WfView", "Luồng duyệt", "Khkk_G1", 1, "Network"), ("Khkk_List", "Danh sách", "Khkk_G1", 2, "List"), @@ -2092,8 +2096,8 @@ public static class DbInitializer tree.Add((MenuKeys.PurchaseEvaluationList(code), "Danh sách", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "List")); tree.Add((MenuKeys.PurchaseEvaluationCreate(code), "Thao tác", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "Plus")); // [S155 đợt 2] Nhãn ở đây CHỈ có tác dụng cho DB TRỐNG. Với DB đang chạy, upsert - // `:1893-1905` gặp key đã tồn tại thì chỉ update `Order` rồi `continue` — KHÔNG - // đụng Label ⇒ rename "Duyệt" → "Đang duyệt" phải qua `labelBackfill` bên dưới. + // `:2162-2179` (grep `existingItems.TryGetValue`) gặp key đã tồn tại thì chỉ update + // `Order` rồi `continue` ⇒ rename "Duyệt" → "Đang duyệt" phải qua `labelBackfill`. tree.Add((MenuKeys.PurchaseEvaluationPending(code), "Đang duyệt", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "CheckCircle2")); // 2 mục MỚI (owner chốt 2026-07-27) — key mới ⇒ đi nhánh INSERT bình thường. tree.Add((MenuKeys.PurchaseEvaluationApproved(code), "Đã duyệt", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "CheckCheck")); @@ -2108,6 +2112,50 @@ public static class DbInitializer tree.Add((MenuKeys.PeWorkflowTypeLeaf(code), label, MenuKeys.PeWorkflows, peWfOrder++, "FileCheck")); } + // [K4a — S166] Sidebar KHKK 8 NHÓM DUYỆT × 6 leaf — 49 row MỚI cho nhóm 2..8 + // (nhóm 1 = 7 row CŨ KHÔNG-infix nằm trong tree initializer trên: `Khkk_G1` + + // `Khkk_WfView`…`Khkk_Deleted`, key GIỮ NGUYÊN — đổi = mồ côi permission-row, S155). + // Nhãn 8 nhóm = bảng owner đã soát `danh-muc-sp002-transcribe.md:22-29` (nguồn: danh + // mục SOL-CCM-SP-002 Mig 70 chia 86 dòng thành 8 nhóm duyệt). Nhãn/icon/Order của 6 + // leaf MIRROR Y nhóm 1 (Luồng duyệt/Danh sách/Thao tác/Đang duyệt/Đã duyệt/Đã xóa). + // 🔴 Key sinh bằng CHÍNH `MenuKeys.KhkkGroup/KhkkLeaf` + duyệt CHÍNH `KhkkGroupNumbers`/ + // `KhkkLeafActions` ⇒ mọi key trong `MenuKeys.All` đều có menu-row by-construction + // (thiếu row = Permission FK nổ lần boot đầu — invariant T6 K1). Thêm nhóm/action mà + // quên nhãn ⇒ KeyNotFoundException NGAY lúc seed (fail-fast, hơn là FK nổ về sau). + // ⚠️ Đổi NHÃN sau khi đã seed: BẮT BUỘC thêm entry vào `labelBackfill` bên dưới + + // restart — sửa chữ tại đây VÔ TÁC DỤNG trên DB đang chạy (upsert chỉ re-set Order). + var khkkGroupLabels = new Dictionary + { + [2] = "2. Kết cấu, hạ tầng, phá dỡ & DV hỗ trợ thi công (A2-A3)", + [3] = "3. Hoạt động văn phòng (A4)", + [4] = "4. Vật tư (B1)", + [5] = "5. Thầu phụ xây dựng (B2)", + [6] = "6. Thầu phụ MEP (B3)", + [7] = "7. BCH công trường (C1-C3)", + [8] = "8. HĐ chưa ủy quyền — CEO (5.2)", + }; + var khkkLeafMeta = new Dictionary + { + ["WfView"] = ("Luồng duyệt", "Network"), + ["List"] = ("Danh sách", "List"), + ["Create"] = ("Thao tác", "Plus"), + ["Pending"] = ("Đang duyệt", "CheckCircle2"), + ["Approved"] = ("Đã duyệt", "CheckCheck"), + ["Deleted"] = ("Đã xóa", "Trash2"), + }; + foreach (var n in MenuKeys.KhkkGroupNumbers) + { + var groupKey = MenuKeys.KhkkGroup(n); + // Order nhóm = n (nhóm 1 đang Order 1 ⇒ 8 nhóm xếp 1..8 dưới root KeHoachKyKet). + tree.Add((groupKey, khkkGroupLabels[n], MenuKeys.KeHoachKyKet, n, "FileCheck")); + var khkkLeafOrder = 1; + foreach (var action in MenuKeys.KhkkLeafActions) + { + var (leafLabel, leafIcon) = khkkLeafMeta[action]; + tree.Add((MenuKeys.KhkkLeaf(n, action), leafLabel, groupKey, khkkLeafOrder++, leafIcon)); + } + } + var existingItems = await db.MenuItems.ToDictionaryAsync(m => m.Key); var added = 0; var reordered = 0; @@ -2146,7 +2194,7 @@ public static class DbInitializer [MenuKeys.PeWorkflowTypeLeaf("DuyetNccPhuongAn")] = "Duyệt NCC và Giải pháp", // [S155 đợt 2] 🔴 Rename "Duyệt" → "Đang duyệt" (owner, ảnh menu 2026-07-27). // BẮT BUỘC đi qua đây: sửa chữ trong `tree.Add(...)` là VÔ TÁC DỤNG trên DB đang - // chạy vì upsert `:1893-1905` chỉ update `Order` cho key đã tồn tại. + // chạy vì upsert `:2162-2179` (grep `existingItems.TryGetValue`) chỉ re-set `Order`. [MenuKeys.PurchaseEvaluationPending("DuyetNcc")] = "Đang duyệt", [MenuKeys.PurchaseEvaluationPending("DuyetNccPhuongAn")] = "Đang duyệt", // [S159] 🔴 UAT anh Kiệt: đánh số 01.-07. nhóm HĐ (verbatim "01 . HĐ thầu phụ"). @@ -2180,6 +2228,11 @@ public static class DbInitializer [MenuKeys.ContractTypeGroup("MuaBan")] = "05. HĐ Mua bán", [MenuKeys.ContractTypeGroup("NguyenTacNcc")] = "06. HĐ Nguyên tắc NCC", [MenuKeys.ContractTypeGroup("NguyenTacDv")] = "07. HĐ Nguyên tắc Dịch vụ", + // [K4a — S166] Nhóm 1 KHKK đổi nhãn skeleton S159 ("1. Kế hoạch ký kết HĐ + // (NCC-TP)") → nhãn nhóm duyệt A1, khớp bảng 8 nhãn owner đã soát. CHỈ đổi + // Label, KHÔNG đổi key ⇒ 0 row mới, 0 permission-row mồ côi. 7 nhóm G2..G8 là + // key MỚI nên đi nhánh INSERT của upsert, không cần entry backfill. + ["Khkk_G1"] = "1. Thiết bị, máy móc, vật tư phụ (A1)", }; var updatedLabels = 0; foreach (var (key, expectedLabel) in labelBackfill) @@ -2282,6 +2335,9 @@ public static class DbInitializer // Cá nhân (Personal) khỏi MỌI role TRỪ Admin. Menu tự ẩn cả 2 app // (GetMyMenuTree lọc CanRead). Chạy SAU grant seed để revoke thắng. // Mở lại sau golive: gỡ prefix khỏi revoke + thêm lại vào InReviewScope. + // [K4a — S166] Đã soi revoker cho 49 key KHKK mới: filter chỉ bắt prefix `Hrm*`/`Off*` + // + key `Personal` ⇒ **0 match `Khkk_*`** ⇒ 49 row CanRead vừa cấp KHÔNG bị lật. Phép + // thử của khẳng định này = restart API ×2 rồi đếm lại row `Khkk%` (acceptance A5). await RevokeTemporarilyHiddenModulesAsync(db, roleManager, logger); // [S118 2026-07-14 — anh Kiệt FDC] CHẠY SAU revoke để THẮNG: NGOẠI-LỆ role Procurement @@ -2370,11 +2426,24 @@ public static class DbInitializer MenuKeys.CatalogServices, MenuKeys.CatalogWorkItems, MenuKeys.Departments, MenuKeys.Projects, MenuKeys.Suppliers, }; + // [S159] 7 key nhóm 1 (không-infix) + [K4a S166] 49 key nhóm 2..8 (7 group + 42 leaf, + // sinh từ `MenuKeys.KhkkGroupMenuKeys` — CÙNG bộ sinh với `MenuKeys.All` và vòng seed + // menu-row ⇒ không mirror tay). CanRead mọi role, skip-existing. + // 🔴 LÝ DO GIỮ site này (đính chính lý-do sai đang lưu hành "group-key cần row để + // render"): group KHÔNG cần row CanRead của chính nó — `GetMyMenuTreeQuery.cs:96` + // `HasAccess(n) = n.CanRead || n.Children.Any(HasAccess)`. Cái BẮT BUỘC là 42 LEAF + // phải có row CanRead cho non-Admin: site-1 (loop MenuKeys.All) CHỈ cấp Admin, còn + // site-3 `SeedKeHoachKyKetAccessAsync` CHỈ đụng root + 6 leaf nhóm 1. Group vẫn liệt + // kê ở đây để có hàng tường minh trên ma trận Phân quyền (admin tự bật/tắt). + // ⚠️ CanRead là ĐỦ cho 42 leaf mới, KHÔNG cần nhét chúng vào site-3: mọi endpoint KHKK + // gate bằng policy `KeHoachKyKet.{Read,Create,Update,Delete}` (gotcha #85 — gate FE + // cũng phải dùng đúng key đó, KHÔNG OR key con), và cờ GHI của root do site-3 phát + // (13/13 role C+U, Delete = Drafter+Admin). Leaf chỉ là điểm điều hướng. static IEnumerable KhkkKeys() => new[] { "Khkk_G1", "Khkk_WfView", "Khkk_List", "Khkk_Create", "Khkk_Pending", "Khkk_Approved", "Khkk_Deleted", - }; + }.Concat(MenuKeys.KhkkGroupMenuKeys); static IEnumerable HdcKeys() => MenuKeys.ContractTypeCodes.Select(c => "Hdc_" + c); diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/KhkkGroupMenuSeedTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/KhkkGroupMenuSeedTests.cs new file mode 100644 index 0000000..010d224 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/KhkkGroupMenuSeedTests.cs @@ -0,0 +1,431 @@ +using System.Reflection; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using SolutionErp.Domain.Identity; +using SolutionErp.Infrastructure.Persistence; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Application; + +// [K4a — S166 2026-08-01] Test-after cho sidebar KHKK 8 NHÓM DUYỆT (danh mục SOL-CCM-SP-002 +// Mig 70 chia 86 dòng thành 8 nhóm; mỗi nhóm 1 workflow `KHKK-N{n}` type-10 — K3). +// +// BE đã land: `MenuKeys.KhkkGroupNumbers/KhkkLeafActions/KhkkGroupMenuKeys` (49 key spread vào +// `All`) + vòng seed 49 menu-row trong `SeedMenuTreeAsync` + entry `labelBackfill["Khkk_G1"]` +// + site-2 grant `KhkkKeys().Concat(MenuKeys.KhkkGroupMenuKeys)`. +// +// KHÔNG nhân bản test đã có: `ContractCatalogTests.MenuKey_ContractCatalog_...` (T6 lane K1) +// đã assert `MenuKeys.All.Except(menuKeysInDb).BeEmpty()` sau `SeedMenuTreeAsync` ⇒ invariant +// "mọi key ∈ All có menu-row" (thiếu row = Permission FK nổ lúc boot) TỰ ĐỘNG phủ 49 key mới. +// File này đo phần T6 KHÔNG đo: +// T1 hình bộ sinh (49 key, group-trước-6-leaf, đúng chuỗi ký tự `Khkk_G{n}_{action}`) +// T2 `All` — chứa đủ 49 + KHÔNG key đôi + số tổng runtime + nhóm 1 CỐ Ý đứng ngoài +// T3 seed ×2 idempotent — 56 row họ Khkk (7 nhóm-1 + 49 mới), 0 nhân đôi, hình cây đúng +// T4 6 leaf nhóm 2..8 MIRROR nhãn/icon/Order của nhóm 1 (đo DB↔DB, không bảng hardcode) +// T5 labelBackfill đổi NHÃN mà KHÔNG thêm row + đối chứng key KHÔNG có entry thì KHÔNG tự lành +// T6 site-2 phát CanRead cho 49 key mới (read-only) + đối chứng Pe_* cùng lượt được CanCreate +// +// Harness: seeder là `private static` → REFLECTION (KHÔNG sửa prod đổi visibility). +// `SeedMenuTreeAsync(ApplicationDbContext, ILogger)` +// `SeedAllRolesReviewReadPermissionsAsync(ApplicationDbContext, RoleManager, ILogger)` +// Fixture `IdentityFixture` (cần RoleManager cho site-2) — KHÔNG chế fixture mới. +public class KhkkGroupMenuSeedTests +{ + // ── 49 key kỳ vọng, VIẾT TAY (KHÔNG gọi lại `MenuKeys.KhkkGroup/KhkkLeaf` — gọi lại là + // tautology: bộ sinh sai chuỗi thì kỳ vọng cũng sai theo và test xanh vô nghĩa). ──────── + private static readonly string[] Expected49 = + { + "Khkk_G2", "Khkk_G2_WfView", "Khkk_G2_List", "Khkk_G2_Create", "Khkk_G2_Pending", "Khkk_G2_Approved", "Khkk_G2_Deleted", + "Khkk_G3", "Khkk_G3_WfView", "Khkk_G3_List", "Khkk_G3_Create", "Khkk_G3_Pending", "Khkk_G3_Approved", "Khkk_G3_Deleted", + "Khkk_G4", "Khkk_G4_WfView", "Khkk_G4_List", "Khkk_G4_Create", "Khkk_G4_Pending", "Khkk_G4_Approved", "Khkk_G4_Deleted", + "Khkk_G5", "Khkk_G5_WfView", "Khkk_G5_List", "Khkk_G5_Create", "Khkk_G5_Pending", "Khkk_G5_Approved", "Khkk_G5_Deleted", + "Khkk_G6", "Khkk_G6_WfView", "Khkk_G6_List", "Khkk_G6_Create", "Khkk_G6_Pending", "Khkk_G6_Approved", "Khkk_G6_Deleted", + "Khkk_G7", "Khkk_G7_WfView", "Khkk_G7_List", "Khkk_G7_Create", "Khkk_G7_Pending", "Khkk_G7_Approved", "Khkk_G7_Deleted", + "Khkk_G8", "Khkk_G8_WfView", "Khkk_G8_List", "Khkk_G8_Create", "Khkk_G8_Pending", "Khkk_G8_Approved", "Khkk_G8_Deleted", + }; + + // 7 key nhóm 1 KHÔNG-infix, seed từ S159 — CẤM đổi (đổi key = mồ côi permission-row, bài S155). + private const string Group1GroupKey = "Khkk_G1"; + private static readonly string[] Group1LeafKeys = + { "Khkk_WfView", "Khkk_List", "Khkk_Create", "Khkk_Pending", "Khkk_Approved", "Khkk_Deleted" }; + + // Nhãn nhóm 1 sau K4a (entry `labelBackfill` mới) và nhãn skeleton S159 mà nó thay thế. + private const string Group1LabelAfterK4a = "1. Thiết bị, máy móc, vật tư phụ (A1)"; + private const string Group1LabelSkeletonS159 = "1. Kế hoạch ký kết HĐ (NCC-TP)"; + + // ── Reflection helper (mirror `ContractCatalogTests.InvokePrivateSeedAsync`) ──────────── + private static async Task InvokePrivateSeedAsync( + TestApplicationDbContext db, string methodName, RoleManager? rm = null) + { + var mi = typeof(DbInitializer).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static); + mi.Should().NotBeNull($"DbInitializer.{methodName} phải tồn tại (private static) — đổi signature thì cập nhật test"); + var args = rm is null + ? new object[] { db, NullLogger.Instance } + : new object[] { db, rm, NullLogger.Instance }; + await (Task)mi!.Invoke(null, args)!; + } + + private static Task SeedMenuAsync(TestApplicationDbContext db) + => InvokePrivateSeedAsync(db, "SeedMenuTreeAsync"); + + // Đếm họ Khkk bằng so-khớp Ordinal TRONG BỘ NHỚ (LIKE của SQLite case-insensitive ASCII — + // không muốn phép đếm phụ thuộc collation của provider). + private static async Task> KhkkKeysInDbAsync(TestApplicationDbContext db) + { + var keys = await db.MenuItems.AsNoTracking().Select(m => m.Key).ToListAsync(); + return keys.Where(k => k.StartsWith("Khkk", StringComparison.Ordinal)).OrderBy(k => k, StringComparer.Ordinal).ToList(); + } + + private static Task RowAsync(TestApplicationDbContext db, string key) + => db.MenuItems.AsNoTracking().FirstOrDefaultAsync(m => m.Key == key); + + // ===================================================================================== + // T1 — HÌNH BỘ SINH: đúng 49 key, đúng THỨ TỰ group-trước-6-leaf, đúng chuỗi ký tự. + // `Equal` (thứ tự có nghĩa) chứ không `BeEquivalentTo`: nếu ai đổi `SelectMany` + // thành "7 group trước rồi 42 leaf sau" thì vòng seed menu vẫn chạy nhưng cây menu + // đổi hình ⇒ phải ĐỎ. Kèm phép kiểm liền-kề tường minh (G{n} đứng NGAY TRƯỚC + // G{n}_WfView) để thông điệp lỗi chỉ đúng thuộc tính bị vỡ. + // ===================================================================================== + [Fact] + public void KhkkGroupMenuKeys_Is49Keys_GroupImmediatelyFollowedBySixLeaves() + { + MenuKeys.KhkkGroupMenuKeys.Should().HaveCount(49, + "7 nhóm (2..8) × (1 group + 6 leaf) = 49 — số này quyết định delta `Menu keys` + `Policies` ở docs/STATUS.md"); + + MenuKeys.KhkkGroupMenuKeys.Should().Equal(Expected49, + "bộ sinh phải ra ĐÚNG chuỗi `Khkk_G{{n}}` / `Khkk_G{{n}}_{{action}}` theo thứ tự group → 6 leaf; " + + "sai 1 ký tự là menu-row lệch key với `MenuKeys.All` ⇒ Permission FK nổ lúc boot"); + + // Liền-kề tường minh: mỗi group đứng ngay trước leaf `_WfView` của chính nó. + foreach (var n in MenuKeys.KhkkGroupNumbers) + { + var idx = Array.IndexOf(MenuKeys.KhkkGroupMenuKeys, $"Khkk_G{n}"); + idx.Should().BeGreaterThanOrEqualTo(0, $"phải có key nhóm Khkk_G{n}"); + MenuKeys.KhkkGroupMenuKeys[idx + 1].Should().Be($"Khkk_G{n}_WfView", + $"leaf đầu của nhóm {n} phải đứng NGAY SAU key nhóm — vòng seed menu dựa vào hình này"); + } + + MenuKeys.KhkkLeafActions.Should().Equal( + new[] { "WfView", "List", "Create", "Pending", "Approved", "Deleted" }, + "6 action khớp Y 6 leaf nhóm 1 (owner: 'y chang Duyệt NCC') — thêm/bớt action là đổi 2 số canonical"); + } + + // ===================================================================================== + // T2 — `MenuKeys.All`: chứa đủ 49 + KHÔNG key đôi + số tổng ĐO ĐƯỢC (Policies = |All|×4, + // DERIVED ⇒ số này là nguồn của 2 row canonical docs/STATUS.md). + // ⭐ Vế cuối = guard thật: nếu ai thêm số 1 vào `KhkkGroupNumbers` thì `Khkk_G1` vào + // `All` VÀ vòng seed thêm `tree.Add("Khkk_G1")` lần thứ hai (row cứng đã có ở + // initializer) ⇒ 2 entry cùng PK trong 1 lần SaveChanges = nổ lúc boot DB trống. + // ===================================================================================== + [Fact] + public void MenuKeys_All_ContainsThe49_NoDuplicate_AndKeepsGroup1Outside() + { + MenuKeys.All.Should().Contain(Expected49, + "49 key phải nằm trong All — ngoài All thì Program.cs không sinh policy và Admin không được grant qua loop All"); + + MenuKeys.All.Should().OnlyHaveUniqueItems( + "key đôi trong All ⇒ SeedAdminPermissionsAsync chèn Permission trùng (RoleId, MenuKey) ⇒ nổ UNIQUE"); + + MenuKeys.All.Should().HaveCount(113, + "số ĐO runtime tại S166 sau K4a (+49). `Policies` = |All| × |Actions| = 113 × 4 = 452 — " + + "đổi số ở đây BẮT BUỘC đổi 2 row canonical `Menu keys` / `Policies` trong docs/STATUS.md"); + + MenuKeys.KhkkGroupNumbers.Should().Equal(new[] { 2, 3, 4, 5, 6, 7, 8 }, + "nhóm 1 CỐ Ý ngoài bộ sinh — 7 key nhóm 1 không-infix đã có row cứng trong SeedMenuTreeAsync"); + + foreach (var g1 in Group1LeafKeys.Append(Group1GroupKey)) + MenuKeys.All.Should().NotContain(g1, + $"{g1} (nhóm 1) giữ nguyên trạng thái NGOÀI All từ S159 — kéo vào All sẽ sinh menu-row trùng PK lúc seed"); + } + + // ===================================================================================== + // T3 — SEED ×2 IDEMPOTENT: 56 row họ Khkk (7 nhóm-1 + 49 mới), lần 2 KHÔNG nhân đôi, + // và hình cây đúng (7 group mới treo dưới root `KeHoachKyKet`, Order = n, mỗi group + // đúng 6 con). Đếm tổng MenuItems trước/sau lần 2 để bắt cả trường hợp seeder nhân + // row Ở CHỖ KHÁC (không chỉ họ Khkk). + // ===================================================================================== + [Fact] + public async Task SeedMenuTree_RunTwice_Has56KhkkRows_NoDuplication_AndCorrectTreeShape() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + await SeedMenuAsync(db); + var khkkAfter1 = await KhkkKeysInDbAsync(db); + var totalAfter1 = await db.MenuItems.AsNoTracking().CountAsync(); + + khkkAfter1.Should().HaveCount(56, + "7 row nhóm 1 (S159, không-infix) + 49 row nhóm 2..8 (K4a) — thiếu row nào là menu mất mục / Permission FK nổ"); + khkkAfter1.Should().Contain(Expected49, "49 key mới phải có menu-row thật"); + khkkAfter1.Should().Contain(Group1LeafKeys.Append(Group1GroupKey), + "7 row nhóm 1 KHÔNG được mất khi thêm 8-nhóm"); + + await SeedMenuAsync(db); // ⭐ lần 2 + var khkkAfter2 = await KhkkKeysInDbAsync(db); + var totalAfter2 = await db.MenuItems.AsNoTracking().CountAsync(); + + khkkAfter2.Should().Equal(khkkAfter1, "idempotent — chạy lại KHÔNG thêm/bớt row họ Khkk"); + totalAfter2.Should().Be(totalAfter1, "idempotent trên TOÀN bảng menu, không riêng họ Khkk"); + + // Hình cây: 7 group mới dưới root KeHoachKyKet, Order = số nhóm (xếp 1..8 cùng nhóm 1). + var g1 = await RowAsync(db, Group1GroupKey); + g1!.ParentKey.Should().Be(MenuKeys.KeHoachKyKet); + g1.Order.Should().Be(1, "nhóm 1 giữ Order 1 — 8 nhóm xếp 1..8 dưới root"); + + foreach (var n in MenuKeys.KhkkGroupNumbers) + { + var group = await RowAsync(db, $"Khkk_G{n}"); + group.Should().NotBeNull($"Khkk_G{n} phải có row"); + group!.ParentKey.Should().Be(MenuKeys.KeHoachKyKet, $"nhóm {n} treo dưới root Kế hoạch ký kết HĐ"); + group.Order.Should().Be(n, $"Order nhóm = {n} (8 nhóm xếp 1..8 theo danh mục SP-002)"); + group.Label.Should().NotBeNullOrWhiteSpace($"nhóm {n} phải có nhãn tiếng Việt (bảng 8 nhãn owner đã soát)"); + + var children = await db.MenuItems.AsNoTracking() + .Where(m => m.ParentKey == group.Key).Select(m => m.Key).ToListAsync(); + children.Should().HaveCount(6, $"nhóm {n} đúng 6 leaf (WfView/List/Create/Pending/Approved/Deleted)"); + } + + // 8 nhãn nhóm phải KHÁC NHAU — copy-paste sót 1 dòng dictionary là 2 nhóm cùng tên trên sidebar. + var groupLabels = new List { g1.Label }; + foreach (var n in MenuKeys.KhkkGroupNumbers) + groupLabels.Add((await RowAsync(db, $"Khkk_G{n}"))!.Label); + groupLabels.Should().OnlyHaveUniqueItems("8 nhóm duyệt = 8 nhãn phân biệt trên sidebar"); + } + + // ===================================================================================== + // T4 — 6 leaf nhóm 2..8 MIRROR Y nhóm 1 (nhãn + icon + Order). Đo DB↔DB: lấy row nhóm 1 + // làm mốc thay vì bảng hardcode ⇒ đổi nhãn leaf mà quên đồng bộ 7 nhóm kia là ĐỎ. + // Chống vacuous: chốt trước rằng 6 nhãn nhóm 1 PHÂN BIỆT (nếu chúng trùng nhau hết + // thì phép so "khớp nhãn" đúng ở mọi thế giới). + // ===================================================================================== + [Fact] + public async Task SeedMenuTree_NewGroupLeaves_MirrorGroup1_LabelIconOrder() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + await SeedMenuAsync(db); + + var g1Leaves = new List(); + foreach (var key in Group1LeafKeys) + { + var row = await RowAsync(db, key); + row.Should().NotBeNull($"{key} (leaf nhóm 1, S159) phải còn — K4a không được đụng key cũ"); + g1Leaves.Add(row!); + } + + g1Leaves.Select(x => x.Label).Should().OnlyHaveUniqueItems( + "6 nhãn leaf nhóm 1 phải phân biệt — nếu trùng nhau, phép so mirror bên dưới đúng vô nghĩa"); + + for (var i = 0; i < MenuKeys.KhkkLeafActions.Length; i++) + { + var action = MenuKeys.KhkkLeafActions[i]; + var mirror = g1Leaves[i]; + mirror.Key.Should().Be($"Khkk_{action}", + "thứ tự KhkkLeafActions phải khớp thứ tự 6 leaf nhóm 1 — lệch thứ tự là so nhầm cặp"); + + foreach (var n in MenuKeys.KhkkGroupNumbers) + { + var leaf = await RowAsync(db, $"Khkk_G{n}_{action}"); + leaf.Should().NotBeNull($"Khkk_G{n}_{action} phải có row"); + leaf!.ParentKey.Should().Be($"Khkk_G{n}", "leaf treo dưới đúng nhóm của nó"); + leaf.Label.Should().Be(mirror.Label, $"nhãn leaf {action} nhóm {n} phải Y nhóm 1 ({mirror.Label})"); + leaf.Icon.Should().Be(mirror.Icon, $"icon leaf {action} nhóm {n} phải Y nhóm 1"); + leaf.Order.Should().Be(mirror.Order, $"Order leaf {action} nhóm {n} phải Y nhóm 1 (1..6)"); + } + } + } + + // ===================================================================================== + // T5 — labelBackfill: đổi NHÃN nhóm 1 mà KHÔNG thêm row (gotcha #11 — sửa chữ trong + // `tree.Add` là vô tác dụng trên DB đang chạy vì upsert chỉ re-set Order). + // ⭐ MA TRẬN cùng-ruột-khác-1-biến: bôi hỏng nhãn của CẢ HAI key trong cùng 1 lần, + // rồi seed lại — `Khkk_G1` (CÓ entry backfill) tự lành, `Khkk_G2` (KHÔNG có entry) + // GIỮ NGUYÊN chữ hỏng. Khác biệt duy nhất = sự có mặt trong `labelBackfill` ⇒ chứng + // chính cơ chế backfill làm việc, không phải upsert chính "tiện tay" ghi đè Label. + // ===================================================================================== + [Fact] + public async Task SeedMenuTree_LabelBackfill_HealsGroup1Label_WithoutAddingRows_ControlKeyStaysDirty() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + await SeedMenuAsync(db); + var countBefore = await db.MenuItems.AsNoTracking().CountAsync(); + (await RowAsync(db, Group1GroupKey))!.Label.Should().Be(Group1LabelAfterK4a, + "DB trống: nhãn A1 đến thẳng từ tree initializer"); + + // Bôi hỏng nhãn 2 key: G1 = nhãn skeleton S159 (ca THẬT trên prod), G2 = chuỗi lạ (đối chứng). + const string dirtyG2 = "ZZZ nhãn hỏng đối chứng"; + var g1 = await db.MenuItems.FirstAsync(m => m.Key == Group1GroupKey); + var g2 = await db.MenuItems.FirstAsync(m => m.Key == "Khkk_G2"); + g1.Label = Group1LabelSkeletonS159; + g2.Label = dirtyG2; + await db.SaveChangesAsync(CancellationToken.None); + + // Sanity: mutation ĐÃ land (nếu không, "sau seed nhãn đúng" xanh vì chưa từng hỏng). + (await RowAsync(db, Group1GroupKey))!.Label.Should().Be(Group1LabelSkeletonS159); + (await RowAsync(db, "Khkk_G2"))!.Label.Should().Be(dirtyG2); + + await SeedMenuAsync(db); // ⭐ lần 2 — labelBackfill chạy + + (await RowAsync(db, Group1GroupKey))!.Label.Should().Be(Group1LabelAfterK4a, + "entry labelBackfill[\"Khkk_G1\"] phải kéo nhãn skeleton S159 về nhãn nhóm A1 trên DB đang chạy"); + + (await RowAsync(db, "Khkk_G2"))!.Label.Should().Be(dirtyG2, + "ĐỐI CHỨNG: Khkk_G2 KHÔNG có entry backfill ⇒ upsert chính chỉ re-set Order, KHÔNG ghi đè Label " + + "(gotcha #11). Đổi nhãn G2..G8 sau khi đã seed thì BẮT BUỘC thêm entry backfill."); + + var countAfter = await db.MenuItems.AsNoTracking().CountAsync(); + countAfter.Should().Be(countBefore, "đổi nhãn KHÔNG được sinh row mới (đổi key mới sinh row + mồ côi permission)"); + } + + // ===================================================================================== + // T6 — SITE-2 (`SeedAllRolesReviewReadPermissionsAsync`): role thường có CanRead trên 49 + // key mới (điểm điều hướng của sidebar). Cờ ghi = false vì nhánh non-Pe là read-only. + // ⭐ Chống vacuous bằng ĐỐI CHỨNG CÙNG LƯỢT: cùng role đó, key `Pe_DuyetNcc_List` + // phải có CanCreate=true (nhánh isPe) ⇒ "C/U/D=false trên Khkk" không phải vì seeder + // không chạy, mà vì seeder PHÂN BIỆT 2 họ key. + // Chạy SeedMenuTree trước = smoke FK: mọi key trong grant-set phải có menu-row thật. + // ===================================================================================== + [Fact] + public async Task SeedAllRolesReviewRead_GrantsReadOnNew49KhkkKeys_ReadOnly_WithPeContrast() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var rm = fix.Services.GetRequiredService>(); + + var admin = new Role { Id = Guid.NewGuid(), Name = AppRoles.Admin }; + var normal = new Role { Id = Guid.NewGuid(), Name = AppRoles.Drafter }; + (await rm.CreateAsync(admin)).Succeeded.Should().BeTrue(); + (await rm.CreateAsync(normal)).Succeeded.Should().BeTrue(); + + await SeedMenuAsync(db); + await InvokePrivateSeedAsync(db, "SeedAllRolesReviewReadPermissionsAsync", rm); + + var rows = (await db.Permissions.AsNoTracking() + .Where(p => p.RoleId == normal.Id).ToListAsync()) + .ToDictionary(p => p.MenuKey); + + // Mục được đề bài gọi tên (leaf MỚI) — nêu riêng để thông điệp lỗi nói đúng chỗ. + rows.Should().ContainKey("Khkk_G2_List", "leaf mới phải được site-2 phát quyền cho role thường"); + rows["Khkk_G2_List"].CanRead.Should().BeTrue( + "menu hiện ⟺ CanRead=true (GetMyMenuTree lọc CanRead, KHÔNG admin-bypass cho role thường)"); + + foreach (var key in Expected49) + { + rows.Should().ContainKey(key, $"{key} phải có Permission row cho role thường (site-2 grant)"); + var row = rows[key]; + row.CanRead.Should().BeTrue($"{key}: role thường phải đọc được mục sidebar"); + row.CanCreate.Should().BeFalse($"{key}: nhánh non-Pe là read-only (cờ GHI của KHKK do site-3 phát trên root)"); + row.CanUpdate.Should().BeFalse($"{key}: read-only"); + row.CanDelete.Should().BeFalse($"{key}: read-only"); + } + + // 7 key nhóm 1 vẫn được phát (K4a `.Concat` mở rộng, KHÔNG thay thế). + foreach (var key in Group1LeafKeys.Append(Group1GroupKey)) + rows[key].CanRead.Should().BeTrue($"{key} (nhóm 1) phải giữ CanRead sau khi K4a nối thêm 49 key"); + + // ⭐ ĐỐI CHỨNG cùng lượt seed: họ Pe_* đi nhánh isPe ⇒ CanCreate=true. + var peList = MenuKeys.PurchaseEvaluationList("DuyetNcc"); + rows[peList].CanCreate.Should().BeTrue( + "chứng seeder CÓ chạy và CÓ phân biệt 2 họ key — nếu không, mọi assert 'false' bên trên đúng vô nghĩa"); + } + + // ===================================================================================== + // T7 — [finding D, gate K4a @S166] ⭐ BẤT-BIẾN AN NINH-DỮ-LIỆU: `RevokeTemporarilyHidden- + // ModulesAsync` KHÔNG được đụng họ `Khkk_*`. + // Đây là ca ĐÃ VỠ THẬT trên prod, không phải giả định: 2026-07-29 grant 38 key bằng + // SQL tay bị CHÍNH revoker này lật ở app-restart kế (Run #423 — 447/494 row rơi, + // 11/13 role về 0; gotcha #84). Thêm 1 prefix vào bộ lọc revoke = 49 row Khkk vừa cấp + // biến mất Ở LẦN RESTART SAU, im lặng, không test nào đỏ ⇒ Fact này bịt đúng lỗ đó. + // Khuôn 2-vế mirror `AdminOnlyModulesRevokeTests` (StillHidden ∧ Reopened). + // + // 🔴 BẪY VACUOUS ĐÃ CHỐNG (đọc từ mã `:2546-2551`): thiếu role Admin thì revoker + // RETURN SỚM (no-op) ⇒ mọi assert "Khkk còn nguyên" xanh vì revoker CHƯA TỪNG CHẠY. + // Nên test này (a) seed Admin, (b) có CONTROL DƯƠNG: cùng lượt đó, key `Off_*` và + // `Personal` của CHÍNH 2 role kia PHẢI bị hạ hết cờ. Control dương đỏ ⇔ phép đo hỏng. + // Đo trên 2 role non-Admin (khẳng định là "mọi role", 1 role không đủ nói) + kiểm + // Admin được loại trừ. + // ===================================================================================== + [Fact] + public async Task Revoke_DoesNotTouch_KhkkGroups() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var rm = fix.Services.GetRequiredService>(); + + // Admin BẮT BUỘC tồn tại, nếu không revoker return sớm và test thành vô nghĩa. + var admin = new Role { Id = Guid.NewGuid(), Name = AppRoles.Admin }; + var role1 = new Role { Id = Guid.NewGuid(), Name = AppRoles.Drafter }; + var role2 = new Role { Id = Guid.NewGuid(), Name = AppRoles.DeptManager }; + foreach (var r in new[] { admin, role1, role2 }) + (await rm.CreateAsync(r)).Succeeded.Should().BeTrue(); + + await SeedMenuAsync(db); + await InvokePrivateSeedAsync(db, "SeedAllRolesReviewReadPermissionsAsync", rm); + + // CONTROL DƯƠNG: 2 key NẰM TRONG tập revoke (Off* + Personal) — site-2 không phát + // 2 key này nên cấp tay. Có menu-row thật từ SeedMenuTreeAsync ⇒ FK ok. + var revokedSetKeys = new[] { MenuKeys.OffDanhBa, MenuKeys.Personal }; + foreach (var roleId in new[] { admin.Id, role1.Id, role2.Id }) + foreach (var key in revokedSetKeys) + db.Permissions.Add(new Permission + { + RoleId = roleId, MenuKey = key, + CanRead = true, CanCreate = false, CanUpdate = false, CanDelete = false, + }); + + // Mô phỏng admin tick thêm cờ GHI cho 1 mục Khkk trên ma trận Phân quyền — revoker + // (nếu bắt nhầm `Khkk_*`) sẽ xoá luôn cờ này, không chỉ CanRead. + const string tickedKey = "Khkk_G5_Create"; + var ticked = await db.Permissions.FirstAsync(p => p.RoleId == role1.Id && p.MenuKey == tickedKey); + ticked.CanCreate = true; + await db.SaveChangesAsync(CancellationToken.None); + + await InvokePrivateSeedAsync(db, "RevokeTemporarilyHiddenModulesAsync", rm); + + var after = (await db.Permissions.AsNoTracking().ToListAsync()) + .ToDictionary(p => (p.RoleId, p.MenuKey)); + + // ── VẾ 1 (CONTROL DƯƠNG): revoker THẬT SỰ đã chạy và có hiệu lực trên non-Admin ── + foreach (var roleId in new[] { role1.Id, role2.Id }) + foreach (var key in revokedSetKeys) + { + var row = after[(roleId, key)]; + row.CanRead.Should().BeFalse( + $"{key} thuộc tập ẩn (Hrm*/Off*/Personal) ⇒ PHẢI bị hạ cờ. Vế này ĐỎ = revoker không chạy " + + "(vd Admin chưa seed) ⇒ mọi khẳng định 'Khkk còn nguyên' bên dưới vô giá trị"); + row.CanCreate.Should().BeFalse(); + row.CanUpdate.Should().BeFalse(); + row.CanDelete.Should().BeFalse(); + } + + // Admin được loại trừ khỏi revoke (chứng bộ lọc `RoleId != adminRoleId` còn sống). + after[(admin.Id, MenuKeys.OffDanhBa)].CanRead.Should().BeTrue( + "Admin KHÔNG bị revoke — nếu vế này đỏ thì revoker đang quét quá tay, kết luận về Khkk cũng đáng ngờ"); + + // ── VẾ 2 (BẤT-BIẾN CẦN BẢO VỆ): 56 key họ Khkk GIỮ NGUYÊN quyền trên MỌI role ── + var allKhkkKeys = Expected49.Concat(Group1LeafKeys).Append(Group1GroupKey).ToArray(); + allKhkkKeys.Should().HaveCount(56); + + var survivors = 0; + foreach (var roleId in new[] { role1.Id, role2.Id }) + foreach (var key in allKhkkKeys) + { + after.Should().ContainKey((roleId, key), + $"{key}: revoke chỉ HẠ CỜ, KHÔNG xoá row — mất row là hỏng nặng hơn cả gotcha #84"); + after[(roleId, key)].CanRead.Should().BeTrue( + $"{key} KHÔNG thuộc Hrm*/Off*/Personal ⇒ revoker phải THA. Đỏ ở đây = đúng ca prod Run #423 " + + "(grant xong bị lật ở restart kế) tái diễn cho sidebar KHKK 8 nhóm"); + survivors++; + } + + // Nói bằng ĐƠN VỊ của sự cố thật (đếm row sống sót) — 56 key × 2 role non-Admin. + survivors.Should().Be(112, "toàn bộ row Khkk của 2 role non-Admin phải sống qua revoke"); + + after[(role1.Id, tickedKey)].CanCreate.Should().BeTrue( + $"{tickedKey}: cờ GHI admin tự tick trên ma trận Phân quyền cũng không được revoker chạm"); + } +}