From af5f5be83786ab6b01849a3ec01cea066b0316e6 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Mon, 10 Aug 2026 19:46:33 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20Workflow:=20W3=20seed=207=20quy=20tr?= =?UTF-8?q?=C3=ACnh=20HD-*=20sao=20khu=C3=B4n=20KHKK=20(roster=20builder?= =?UTF-8?q?=20chung,=20finalize=3Dfalse=20c=C3=B3=20khai)=20+=207=20key=20?= =?UTF-8?q?AwV2=5FHd*=20by-construction=20+=20menu-row=20T6=20+=202=20row?= =?UTF-8?q?=20canonical=20STATUS;=20test=20T2/T6=20kho=C3=A1=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/STATUS.md | 4 +- .../ContractApprovalWorkflowFamily.cs | 63 +++++ .../SolutionErp.Domain/Identity/MenuKeys.cs | 26 ++ .../Persistence/DbInitializer.cs | 267 ++++++++++++++---- .../Application/AwV2DesignerMenuKeyTests.cs | 244 ++++++++++++++++ .../Application/KhkkGroupMenuSeedTests.cs | 13 +- 6 files changed, 555 insertions(+), 62 deletions(-) create mode 100644 src/Backend/SolutionErp.Domain/Contracts/ContractApprovalWorkflowFamily.cs create mode 100644 tests/SolutionErp.Infrastructure.Tests/Application/AwV2DesignerMenuKeyTests.cs diff --git a/docs/STATUS.md b/docs/STATUS.md index 18e8e1f..9db24fd 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -470,8 +470,8 @@ monthly re-tier mega-line + ef-core SKILL Mig→59 + 3 vocab-fork (2026-07-01 du | 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 | **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) | +| Policies | **480** | 🔄 **480 @S187 W3** = All 120 × 4 (DELTA: **+28** = 7 `AwV2_Hd*` × 4 actions — 7 quy trình duyệt HĐ theo LOẠI, YC-023/YC-024). Đo RUNTIME, không suy từ số cũ: nạp `SolutionErp.Domain.dll` vừa build → `MenuKeys.All.Length × MenuKeys.Actions.Length` = 120 × 4 (`Distinct()` = 120 ⇒ 0 key trùng). 🧊 **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 | **120** | 🔄 **120 @S187 W3** (đo RUNTIME `MenuKeys.All` bằng cách nạp DLL vừa build, KHÔNG đếm mắt: `ALL=120 · DISTINCT=120 · HD_KEYS=7`). DELTA **+7** = `AwV2_Hd{TP,GK,NCC,DV,MB,NTNCC,NTDV}` — 7 leaf Designer cho 7 quy trình duyệt HĐ theo LOẠI (YC-023/YC-024), sinh by-construction từ `ContractApprovalWorkflowFamily.Members` qua `MenuKeys.ContractWorkflowMenuKeys` ⇒ CÙNG bộ sinh với vòng seed menu-row trong `SeedMenuTreeAsync` (invariant T6: key vào `All` mà thiếu menu-row = Permission FK nổ lần boot đầu). 🧊 **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 | **668 PASS** | **45 Domain + 623 Infra** · 🔄 **668 @S185 — ĐO BẰNG CÁCH CHẠY THẬT ×2 nguồn độc-lập** (`dotnet test SolutionErp.slnx` @/tiep-verify + `ring2-audit` tự chạy đối-chứng cùng ngày): Domain 45 · Infra 623 · 0 fail 0 skip. Lineage: +15 KHKK-mirror @S182 (14 spec + T15) + +8 `UploadDuyet_*/UploadCanCu_*` YC-016 @S183. 🧊 **645 @S179** (45D+600I — ô này từng kẹt 645 tới S185 trong khi suite đã 668: stale-F3 canonical-poison, máy lấy ô này làm thước in 6 flag khuyên vá về số SAI — vá @S185). 🔴 Không lấy số từ doc: `lead-stale-auditor` FLAG-4 bắt ô này còn **644**, mà 644 đang là **THƯỚC** cho 6 flag derived-stale của detector ⇒ canonical sai thì máy khuyên vá về số sai (class canonical-poison S148/S166). 🔸 Breakdown cũ ghi "599 Infra" cũng lệch — **600**. 🧊 **644 @S168 #444** (+5 Fact Lines-editor) · 🧊 **639 @S168-sáng** (đo tươi local 45D+594I sau K7 Δ+17; chuỗi K lineage: 597 K1 → 614 K3 → 622 K4c @S167 KHỚP CI #439 tách-phần → **639** K7 — gate `639=45+594` KHỚP cicd-K7 dòng-1) · 🧊 **+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 | **88** | 🔄 **88 @S185** — **+S185 #88** (sự cố prod 2 tầng: disk C: đầy → 2 DB RECOVERY_PENDING → Disk-Cleanup "free space" **NÉN NTFS đúng file .mdf/.ldf đang đóng** → SET ONLINE fail `Msg 5118`, login prod 500 "transient failure UseSqlServer" + Gitea push 502/unpacker CÙNG GỐC; fix OFFLINE-từng-DB → backup copy → restart-service-nhả-handle → `compact /U` 4 file + folder → ONLINE, `Recovery is complete` **0 mất data** [58 PE · 21 user · 97 bảng khớp]; luật: Msg 5118 ⇒ check `compact` TRƯỚC khi nghĩ restore + CẤM nén cây SQL DATA; kèm phát hiện **1433 phơi Internet, `sa` bị brute-force** — chờ anh vá firewall). 🧊 87 @S179 flush — đo đĩa `docs/gotchas.md` max anchor = **87** (`lead-stale-auditor` FLAG-5 + máy HIGH `canonical-itself-stale`; #87 sinh ở S177 vá lệch 2 tầng). 🧊 **+S168 #86** (2-push-sát → Gitea auto-CANCEL run đầu cùng-branch: CANCELLED ≠ fail — verifier đọc run CUỐI của range + bằng-chứng artifact-level; side-effect @startup chỉ 1-startup cho cả cụm push; ca thật `aaed699` ship qua #442) · **+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 | **60** | 🔄 **re-ground S179** — đo lại ĐÚNG phương-pháp dòng này tự khai: `ls .claude/auto-memory/*.md | wc -l` = **61**, trừ 1 index `MEMORY.md` = **60** (`lead-stale-auditor` FLAG-9; máy MÙ trục này — không có cross-check). 🔴 Ô này trôi lại chỉ sau **9 nhãn phiên** kể từ lần re-ground trước ⇒ đây là ô **rụng nhanh**, đừng tin số cũ. 🧊 re-ground S170 — đếm đĩa 60 file trừ 1 index = 59 (số "31" stale từ S84; lead-stale FLAG @S169, ring2 verify, vá @S170 SAU khi ring chấm — đúng thứ-tự H24 §2(4) fire→chứng-kiến→vá). +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 | diff --git a/src/Backend/SolutionErp.Domain/Contracts/ContractApprovalWorkflowFamily.cs b/src/Backend/SolutionErp.Domain/Contracts/ContractApprovalWorkflowFamily.cs new file mode 100644 index 0000000..c8bebd9 --- /dev/null +++ b/src/Backend/SolutionErp.Domain/Contracts/ContractApprovalWorkflowFamily.cs @@ -0,0 +1,63 @@ +namespace SolutionErp.Domain.Contracts; + +// [S187 W3 — YC-023/YC-024] HỌ MÃ QUY TRÌNH DUYỆT HỢP ĐỒNG (ApprovalWorkflow V2, ApplicableType=3). +// +// Owner chốt @S188: (80) trục = **7 LOẠI HĐ** (không phải 8 nhóm duyệt SP-002) · (81) **PA-A** = +// mỗi loại MỘT mã quy trình riêng, 0 cột mới trong bảng quy trình (khuôn KHKK `KHKK-N{n}` đã qua UAT). +// +// 🔴 ĐÂY LÀ NHÀ DUY NHẤT của 7 mã này ở production. Trước W3 chúng chỉ tồn tại trong test +// (`UpdateAwDefinitionTests.cs:1573` `ContractFamilyCodes`) — test giữ bản riêng CÓ CHỦ ĐÍCH +// (test không được lấy hằng của code làm kỳ vọng, không thì nó đo chính nó). Mọi chỗ KHÁC cần +// "loại HĐ này đi quy trình mã nào" thì gọi `CodeFor`, ĐỪNG gõ lại chuỗi: +// • `DbInitializer.SeedContractTypeWorkflowsAsync` — seed 7 quy trình; +// • `MenuKeys.ContractWorkflowMenuKeys` — sinh 7 key Designer `AwV2_Hd*` (by-construction); +// • W4 (nhịp sau) — rào auto-pin/re-pin so `Contract.Type` ↔ mã đã pin. +// +// ⚠️ ĐẶT Ở DOMAIN, không phải Application, vì `MenuKeys` (Domain/Identity) phải sinh key TỪ đây — +// Domain không thấy được Application. Đây cũng là lý do KHÔNG nhét vào nhà KHKK +// (`ContractSigningPlanWorkflowGuards`, tầng Application): 2 họ khác trục, khác tầng, khác vòng đời. +// +// 🔴 MÃ ASCII HOA, KHÔNG DẤU (FLAG-6): admin tạo version mới qua Designer phải lọt validator +// `^[A-Za-z0-9._-]+$` (`ApprovalWorkflowV2AdminFeatures.cs:274-276`) — seeder không đi qua +// validator nhưng vòng đời "clone tạo v02" thì CÓ ⇒ mã mang "Đ"/dấu là kẹt ở lần sửa đầu tiên. +// Đường ghi còn chuẩn hoá `Trim().ToUpperInvariant()` (`:348`) ⇒ mã ở đây phải sẵn dạng HOA, +// nếu không thì bản seed và bản admin-tạo là HAI chuỗi khác nhau trên SQLite (so BINARY). +public static class ContractApprovalWorkflowFamily +{ + /// Tiền tố chung của cả họ. Tách hằng để `KeyToken` cắt đúng độ dài, không gõ số 3. + public const string CodePrefix = "HD-"; + + /// 7 thành viên — thứ tự = thứ tự `ContractType` (1..7), cũng là thứ tự hiện trên sidebar. + /// `ShortName` dùng cho CẢ tên quy trình ("Duyệt Hợp đồng — {ShortName}") LẪN nhãn menu + /// Designer ⇒ 2 chỗ không thể lệch chữ. + public static readonly (ContractType Type, string Code, string ShortName)[] Members = + [ + (ContractType.HopDongThauPhu, "HD-TP", "Thầu phụ"), + (ContractType.HopDongGiaoKhoan, "HD-GK", "Giao khoán"), + (ContractType.HopDongNhaCungCap, "HD-NCC", "Nhà cung cấp"), + (ContractType.HopDongDichVu, "HD-DV", "Dịch vụ"), + (ContractType.HopDongMuaBan, "HD-MB", "Mua bán"), + (ContractType.HopDongNguyenTacNCC, "HD-NT-NCC", "Nguyên tắc NCC"), + (ContractType.HopDongNguyenTacDichVu, "HD-NT-DV", "Nguyên tắc Dịch vụ"), + ]; + + /// 7 mã, đúng thứ tự `Members`. + public static readonly string[] Codes = Members.Select(m => m.Code).ToArray(); + + /// Mã quy trình của một loại HĐ, `null` nếu loại nằm ngoài họ (enum nở thêm về sau). + public static string? CodeFor(ContractType type) + { + foreach (var m in Members) + if (m.Type == type) return m.Code; + return null; + } + + /// QUY TẮC ĐẶT KEY MENU (khai một lần, dùng mọi nơi): bỏ tiền tố `HD-`, bỏ mọi dấu `-`, + /// GIỮ NGUYÊN hoa/thường của phần còn lại ⇒ `HD-TP`→`TP`, `HD-NT-NCC`→`NTNCC`. + /// `MenuKeys` ghép thành `AwV2_Hd{token}` (⇒ `AwV2_HdTP`, `AwV2_HdNTNCC`). + /// Suy TỪ mã chứ không khai thêm cột: mã đổi thì key đổi theo, không có đường lệch câm. + public static string KeyToken(string code) => + code.StartsWith(CodePrefix, StringComparison.Ordinal) + ? code[CodePrefix.Length..].Replace("-", string.Empty) + : code.Replace("-", string.Empty); +} diff --git a/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs b/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs index 2feae50..ee4443f 100644 --- a/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs +++ b/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs @@ -1,3 +1,5 @@ +using SolutionErp.Domain.Contracts; + namespace SolutionErp.Domain.Identity; // Nguồn duy nhất (single source of truth) cho menu key — dùng ở cả BE (policy) và seed. @@ -133,6 +135,27 @@ public static class MenuKeys public const string ApprovalWorkflowKhkkN7 = "AwV2_KhkkN7"; public const string ApprovalWorkflowKhkkN8 = "AwV2_KhkkN8"; + // [S187 W3 — YC-023/YC-024] 7 leaf Designer cho 7 QUY TRÌNH DUYỆT HỢP ĐỒNG theo LOẠI HĐ + // (owner @S188: (80) trục 7-loại · (81) PA-A mỗi loại 1 mã). Workflow `HD-{TOKEN}` type-3 + // do `DbInitializer.SeedContractTypeWorkflowsAsync` seed; mã + tên loại ở NHÀ DUY NHẤT + // `ContractApprovalWorkflowFamily` (Domain/Contracts). + // 🔴 7 key này VÀO `All` (T3 — quyết mức-lead, tiền lệ K3-N1 `:123`) ⇒ sinh 28 policy + // AwV2_Hd*.{Read,Create,Update,Delete} ⇒ 2 số canonical `docs/STATUS.md` đổi: + // Menu keys +7 · Policies +28 (DERIVED |All|×|Actions| — khai DELTA, không neo mốc tuyệt đối). + // Như 8 key KHKK: registered-unused ở tầng endpoint (controller AwV2 vẫn gác bằng + // `ApprovalWorkflowsV2.*`) — đừng OR key con vào gate, gotcha #85. + // 🔴 Vào `All` thì BẮT BUỘC có menu-row trong `SeedMenuTreeAsync` — thiếu row = Permission + // FK nổ ngay lần boot đầu (invariant T6; test `ContractCatalogTests` `:445` bắt đúng ca này). + // Mảng dưới là NGUỒN CHUNG cho cả `All` LẪN vòng seed menu-row ⇒ 2 phía khớp + // by-construction, KHÔNG mirror tay (khuôn K4a `KhkkGroupMenuKeys`). + public static string ApprovalWorkflowContractLeaf(string keyToken) => $"AwV2_Hd{keyToken}"; + + // Khai báo TRƯỚC `All` — static field initializer chạy theo THỨ TỰ VĂN BẢN + // (đặt sau `All` thì `All` nhận mảng null, hỏng câm lúc boot). + public static readonly string[] ContractWorkflowMenuKeys = ContractApprovalWorkflowFamily.Members + .Select(m => ApprovalWorkflowContractLeaf(ContractApprovalWorkflowFamily.KeyToken(m.Code))) + .ToArray(); + // [S61 Mig 50] Module Ngân sách cũ (Budgets + Bg_List/Bg_Create/Bg_Pending) // đã XÓA — thay bằng "Ngân sách gói thầu" nhúng trong phiếu PE // (PeWorkItemBudgets per cặp Dự án × Hạng mục). Menu/Permission rows cũ @@ -247,6 +270,9 @@ public static class MenuKeys // [K3 nhịp-1 — S164/S165] 8 leaf Designer cho 8 nhóm duyệt KHKK (+8 key ⇒ +32 policy). ApprovalWorkflowKhkkN1, ApprovalWorkflowKhkkN2, ApprovalWorkflowKhkkN3, ApprovalWorkflowKhkkN4, ApprovalWorkflowKhkkN5, ApprovalWorkflowKhkkN6, ApprovalWorkflowKhkkN7, ApprovalWorkflowKhkkN8, + // [S187 W3] 7 leaf Designer cho 7 quy trình duyệt HĐ theo LOẠI (+7 key ⇒ +28 policy). + // Bộ-sinh từ `ContractApprovalWorkflowFamily` — dùng CHUNG với vòng seed menu-row. + .. ContractWorkflowMenuKeys, ]; public static readonly string[] Actions = ["Read", "Create", "Update", "Delete"]; diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs index 8d5bc9c..68d7a83 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs @@ -170,6 +170,13 @@ public static class DbInitializer // Drafter không pick được quy trình lúc tạo phiếu KHKK. Idempotent per-Code. await SeedKhkkGroupWorkflowsAsync(db, userManager, logger); + // [S187 W3 — YC-023/YC-024] 7 quy trình duyệt HĐ theo LOẠI (`HD-*`, + // ApplicableType=Contract=3). Cùng lý do ungated + idempotent per-Code như KHKK + // ngay trên (gotcha #51). Đặt SAU KHKK vì dùng chung `ProCcmBodApprovalRoster` + // và cùng khuôn 3 trạm — đọc 2 seeder cạnh nhau thấy ngay chỗ chúng LỆCH + // (cờ finalize: KHKK true / HĐ false). + await SeedContractTypeWorkflowsAsync(db, userManager, logger); + await WarnDefaultAdminPasswordAsync(userManager, logger); } @@ -546,13 +553,25 @@ public static class DbInitializer // Cấp không còn ai ⇒ bỏ cấp (Order được đánh LẠI liên tục 1,2,… để không vi phạm // ràng buộc sequential của Designer); Bước không còn cấp nào ⇒ bỏ bước; workflow // 0 bước vẫn TẠO (giữ mã cho phiếu pin + admin bổ sung người qua Designer) + log. - private static async Task SeedKhkkGroupWorkflowsAsync( - ApplicationDbContext db, UserManager userManager, ILogger logger) - { - // Roster VERIFY LIVE trên prod 2026-07-31 (bản QT-DN-V2-001 IsActive=1) — - // bước × phòng × [Cấp 1 OR-of-N] × [Cấp 2] × cờ finalize của Cấp 2. - var roster = new (string StepName, string DeptCode, string[] Level1, string[] Level2, bool Level2Finalize)[] - { + // ĐỘI HÌNH 3 TRẠM DÙNG CHUNG — nguồn DUY NHẤT của "ai duyệt, trạm nào, thứ tự nào". + // + // Roster VERIFY LIVE trên prod 2026-07-31 (bản QT-DN-V2-001 IsActive=1) — + // bước × phòng × [Cấp 1 OR-of-N] × [Cấp 2] × cờ finalize của Cấp 2. + // + // 🔴 [S187 W3] NÂNG từ biến cục bộ của seeder KHKK lên field dùng chung, vì họ quy trình + // HĐ (`SeedContractTypeWorkflowsAsync`, type-3) phải "SAO KHUÔN KHKK" theo owner (83). + // Chép mảng lần thứ hai = 2 bản 11 email đi lệch nhau theo thời gian (đúng bug-class mà + // `ContractSigningPlanWorkflowGuards:28-32` đang chống) ⇒ dùng CHUNG để 2 họ khớp + // by-construction. Đo 2026-08-10 trên Dev: 8/8 workflow `KHKK-N*` có chữ ký cấu trúc + // GIỐNG HỆT nhau (MD5 `7EB6CD21…` trên tập bước/phòng/cấp/email/cờ) ⇒ "khuôn chung" là + // mô tả ĐO ĐƯỢC, không phải giả định. + // ⚠️ Muốn 2 họ KHÁC người nhau thì TÁCH thành 2 mảng đặt cạnh nhau — ĐỪNG sửa tại chỗ, + // sửa ở đây là đổi luôn họ kia mà không ai thấy. (Chỉnh người cho MỘT quy trình đang chạy + // là việc của admin trên Designer, không phải của seeder: seeder idempotent per-Code nên + // sau lần seed đầu nó không đụng vào DB nữa.) + private static readonly (string StepName, string DeptCode, string[] Level1, string[] Level2, bool Level2Finalize)[] + ProCcmBodApprovalRoster = + [ ("Phòng Cung Ứng", "PRO", new[] { @@ -576,15 +595,27 @@ public static class DbInitializer // 🔴 AllowApproverFinalize=true (owner OG-3/OG-9: a Chương quyết lên CEO hay // kết thúc). Nhánh service port ở nhịp sau CÙNG WAVE trước push nên không // phải config-lie #78. + // 🔴 [S187 W3] Cờ này CHỈ ăn cho họ KHKK (type-10). Họ HĐ (type-3) truyền + // `applyLevel2Finalize: false` — xem chú thích tại `SeedContractTypeWorkflowsAsync`: + // máy duyệt type-3 (`ContractWorkflowService.ApproveV2Async`) KHÔNG có nhánh + // finalize (đo: 0 hit `AllowApproverFinalize`; control dương PE `:870` + KHKK + // `ContractSigningPlanWorkflowService.cs:286` đều CÓ) ⇒ seed `true` cho HĐ sẽ + // khoe trên Designer một quyền mà engine không thi hành = đúng #78. true), ("Ban Giám đốc", "BOD", new[] { "truong.nguyen@solutions.com.vn" }, // Nguyễn Văn Trường (CEO) Array.Empty(), false), - }; + ]; - // Resolve email → UserId MỘT lượt (11 email, dùng lại cho cả 8 workflow). + // Resolve email → UserId MỘT lượt cho cả roster (dùng lại cho MỌI workflow của họ). + // Thiếu user ⇒ ghi warning + bỏ đúng slot đó, KHÔNG throw (seeder chạy lúc boot). + private static async Task> ResolveRosterApproversAsync( + UserManager userManager, + (string StepName, string DeptCode, string[] Level1, string[] Level2, bool Level2Finalize)[] roster, + string seeder, ILogger logger) + { var usersByEmail = new Dictionary(StringComparer.OrdinalIgnoreCase); var missingEmails = new List(); foreach (var email in roster.SelectMany(r => r.Level1.Concat(r.Level2)) @@ -596,13 +627,83 @@ public static class DbInitializer } if (missingEmails.Count > 0) logger.LogWarning( - "SeedKhkkGroupWorkflowsAsync: {Count} email người duyệt KHÔNG có user — bỏ qua slot đó: {Emails}", - missingEmails.Count, string.Join(", ", missingEmails)); + "{Seeder}: {Count} email người duyệt KHÔNG có user — bỏ qua slot đó: {Emails}", + seeder, missingEmails.Count, string.Join(", ", missingEmails)); + return usersByEmail; + } - var deptIdByCode = (await db.Departments.ToListAsync()) + private static async Task> LoadDepartmentIdsByCodeAsync(ApplicationDbContext db) => + (await db.Departments.ToListAsync()) .GroupBy(d => d.Code) .ToDictionary(g => g.Key, g => (Guid?)g.First().Id); + // Dựng 3 trạm PRO → CCM → BGĐ theo `ProCcmBodApprovalRoster` vào MỘT workflow. + // Cấp không còn ai ⇒ bỏ cấp (Order đánh LẠI liên tục 1,2,… để không vi phạm ràng buộc + // sequential của Designer); Bước không còn cấp nào ⇒ bỏ bước + log. + // `applyLevel2Finalize=false` ⇒ ép cờ finalize của Cấp 2 về false cho MỌI trạm (họ nào mà + // engine chưa có nhánh finalize thì truyền false — xem chú thích cờ trong roster). + private static void AddProCcmBodStepsToWorkflow( + ApprovalWorkflow wf, + IReadOnlyDictionary usersByEmail, + IReadOnlyDictionary deptIdByCode, + bool applyLevel2Finalize, + string seeder, string code, ILogger logger) + { + var stepOrder = 1; + foreach (var r in ProCcmBodApprovalRoster) + { + var step = new ApprovalWorkflowStep + { + ApprovalWorkflow = wf, + Order = stepOrder, + Name = r.StepName, + DepartmentId = deptIdByCode.GetValueOrDefault(r.DeptCode), + }; + + var levelOrder = 1; + foreach (var (emails, finalize) in new[] + { (r.Level1, false), (r.Level2, applyLevel2Finalize && r.Level2Finalize) }) + { + var approverIds = emails + .Where(usersByEmail.ContainsKey) + .Select(e => usersByEmail[e]) + .ToArray(); + if (approverIds.Length == 0) continue; // cấp rỗng → bỏ, KHÔNG chừa lỗ Order + + foreach (var approverId in approverIds) + { + step.Levels.Add(new ApprovalWorkflowLevel + { + Step = step, + Order = levelOrder, // N row cùng Order = OR-of-N trong 1 Cấp + Name = $"Cấp {levelOrder}", + ApproverUserId = approverId, + AllowApproverFinalize = finalize, + }); + } + levelOrder++; + } + + if (step.Levels.Count == 0) + { + logger.LogWarning( + "{Seeder}: {Code} bỏ Bước \"{Step}\" — 0 người duyệt resolve được", + seeder, code, r.StepName); + continue; + } + + wf.Steps.Add(step); + stepOrder++; + } + } + + private static async Task SeedKhkkGroupWorkflowsAsync( + ApplicationDbContext db, UserManager userManager, ILogger logger) + { + var usersByEmail = await ResolveRosterApproversAsync( + userManager, ProCcmBodApprovalRoster, nameof(SeedKhkkGroupWorkflowsAsync), logger); + var deptIdByCode = await LoadDepartmentIdsByCodeAsync(db); + var seeded = 0; for (var n = 1; n <= 8; n++) { @@ -624,51 +725,12 @@ public static class DbInitializer ActivatedAt = DateTime.UtcNow, }; - var stepOrder = 1; - foreach (var r in roster) - { - var step = new ApprovalWorkflowStep - { - ApprovalWorkflow = wf, - Order = stepOrder, - Name = r.StepName, - DepartmentId = deptIdByCode.GetValueOrDefault(r.DeptCode), - }; - - var levelOrder = 1; - foreach (var (emails, finalize) in new[] { (r.Level1, false), (r.Level2, r.Level2Finalize) }) - { - var approverIds = emails - .Where(usersByEmail.ContainsKey) - .Select(e => usersByEmail[e]) - .ToArray(); - if (approverIds.Length == 0) continue; // cấp rỗng → bỏ, KHÔNG chừa lỗ Order - - foreach (var approverId in approverIds) - { - step.Levels.Add(new ApprovalWorkflowLevel - { - Step = step, - Order = levelOrder, // N row cùng Order = OR-of-N trong 1 Cấp - Name = $"Cấp {levelOrder}", - ApproverUserId = approverId, - AllowApproverFinalize = finalize, - }); - } - levelOrder++; - } - - if (step.Levels.Count == 0) - { - logger.LogWarning( - "SeedKhkkGroupWorkflowsAsync: {Code} bỏ Bước \"{Step}\" — 0 người duyệt resolve được", - code, r.StepName); - continue; - } - - wf.Steps.Add(step); - stepOrder++; - } + // [S187 W3] Vòng dựng trạm CŨ (inline) đã nâng nguyên xi thành `AddProCcmBodStepsToWorkflow`. + // `applyLevel2Finalize: true` ⇒ biểu thức trong builder là `true && r.Level2Finalize` + // = ĐÚNG bằng `r.Level2Finalize` của dòng cũ ⇒ hành vi KHKK không đổi một li. + AddProCcmBodStepsToWorkflow( + wf, usersByEmail, deptIdByCode, applyLevel2Finalize: true, + nameof(SeedKhkkGroupWorkflowsAsync), code, logger); if (wf.Steps.Count == 0) logger.LogWarning( @@ -686,6 +748,76 @@ public static class DbInitializer } } + // ============================================================================ + // [S187 W3 — YC-023/YC-024] 7 QUY TRÌNH DUYỆT HỢP ĐỒNG theo LOẠI HĐ + // ============================================================================ + // Owner @S188: (80) trục = 7 LOẠI HĐ · (81) PA-A mỗi loại MỘT mã riêng, 0 cột mới · + // (83) đội hình SAO KHUÔN KHKK, anh tự chỉnh lại trong Admin Designer sau. + // Mã + tên loại: `ContractApprovalWorkflowFamily` (Domain/Contracts) — NHÀ DUY NHẤT. + // `ApplicableType = Contract` (=3) ⇒ Drafter pick được ở màn tạo HĐ (dropdown lọc + // `IsUserSelectable && IsActive` theo type). + // + // INFRASTRUCTURE seed — NOT gated DemoSeed (gotcha #51): prod cần đủ 7 quy trình ngay + // deploy đầu, thiếu thì HĐ loại đó không pin được quy trình nào. + // Idempotent PER-CODE (`HD-*`) — CÙNG lý do như KHKK: 7 workflow DÙNG CHUNG 1 type nên + // check theo `ApplicableType` sẽ chặn 6 loại còn lại ngay khi loại đầu vừa tạo (và ở đây + // còn tệ hơn: type-3 ĐÃ có sẵn `QT-HD-V2-001` sample từ S29 ⇒ check-by-type = seed 0 cái). + // + // 🔴 CỜ `AllowApproverFinalize` CỐ Ý KHÔNG SAO NGUYÊN XI (`applyLevel2Finalize: false`): + // roster KHKK bật finalize ở Cấp 2 trạm CCM, nhưng máy duyệt type-3 + // (`ContractWorkflowService`) đo ngày 2026-08-10 có **0 hit** `AllowApproverFinalize` + // (control dương: PE `PurchaseEvaluationWorkflowService.cs:870` CÓ · KHKK + // `ContractSigningPlanWorkflowService.cs:286` CÓ ⇒ thước sống, 0-hit là vắng THẬT). + // Seed `true` khi engine không thi hành = Designer khoe một quyền không có thật — + // đúng gotcha #78 (config-lie). Owner bật lại bằng 1 click trong Designer khi máy + // type-3 có nhánh finalize. TRẠM/NGƯỜI/THỨ TỰ/OR-of-N giữ NGUYÊN theo (83). + private static async Task SeedContractTypeWorkflowsAsync( + ApplicationDbContext db, UserManager userManager, ILogger logger) + { + var usersByEmail = await ResolveRosterApproversAsync( + userManager, ProCcmBodApprovalRoster, nameof(SeedContractTypeWorkflowsAsync), logger); + var deptIdByCode = await LoadDepartmentIdsByCodeAsync(db); + + var seeded = 0; + foreach (var (_, code, shortName) in ContractApprovalWorkflowFamily.Members) + { + if (await db.ApprovalWorkflows.AnyAsync(w => w.Code == code)) continue; + + var wf = new ApprovalWorkflow + { + Code = code, + Version = 1, + ApplicableType = ApprovalWorkflowApplicableType.Contract, + Name = $"Duyệt Hợp đồng — {shortName}", + Description = + $"Quy trình duyệt Hợp đồng loại \"{shortName}\". Đội hình sao khuôn quy trình " + + "duyệt KHKK (3 trạm PRO → CCM → BGĐ). Admin chỉnh người/quyền tại chỗ hoặc tạo " + + "version mới qua Designer.", + IsActive = true, + IsUserSelectable = true, + ActivatedAt = DateTime.UtcNow, + }; + + AddProCcmBodStepsToWorkflow( + wf, usersByEmail, deptIdByCode, applyLevel2Finalize: false, + nameof(SeedContractTypeWorkflowsAsync), code, logger); + + if (wf.Steps.Count == 0) + logger.LogWarning( + "SeedContractTypeWorkflowsAsync: {Code} tạo RỖNG (0 Bước có người) — admin phải thêm người qua Designer", + code); + + db.ApprovalWorkflows.Add(wf); + seeded++; + } + + if (seeded > 0) + { + await db.SaveChangesAsync(); + logger.LogInformation("Seeded {Count} contract-type approval workflows (W3 YC-023)", seeded); + } + } + // Seed 4 master catalogs với defaults cho user nhập liệu Details. Idempotent: // skip per-table nếu đã có row (admin có thể đã thêm/sửa — không clobber). private static async Task SeedCatalogsAsync(ApplicationDbContext db, ILogger logger) @@ -2156,6 +2288,27 @@ public static class DbInitializer } } + // [S187 W3 — YC-023/YC-024] 7 leaf Designer cho 7 quy trình duyệt HĐ theo LOẠI + // (workflow `HD-*` type-3 seed ở `SeedContractTypeWorkflowsAsync`). Cùng cha + // `ApprovalWorkflowsV2` với 8 leaf KHKK ngay trên, Order tiếp ngay sau (KHKK 3..10 + // ⇒ HĐ 11..17). Key CÓ trong `MenuKeys.All` ⇒ Admin được grant tự động qua loop + // `SeedAdminPermissionsAsync`; upsert dưới idempotent per-Key. + // 🔴 Key sinh bằng CHÍNH `MenuKeys.ApprovalWorkflowContractLeaf` trên CHÍNH + // `ContractApprovalWorkflowFamily.Members` — tức CÙNG bộ sinh với + // `MenuKeys.ContractWorkflowMenuKeys` đang nằm trong `All` ⇒ mọi key của họ đều + // có menu-row BY-CONSTRUCTION (invariant T6: key vào `All` mà thiếu menu-row = + // Permission FK nổ ngay lần boot đầu). Thêm loại HĐ thứ 8 chỉ phải sửa 1 nơi. + // ⚠️ Nhãn ở đây chỉ ăn cho DB TRỐNG / key MỚI. Đổi nhãn sau khi đã seed BẮT BUỘC + // thêm entry vào `labelBackfill` bên dưới — upsert chỉ re-set `Order` (gotcha #11). + var awV2ContractOrder = 11; + foreach (var (_, familyCode, shortName) in ContractApprovalWorkflowFamily.Members) + { + var leafKey = MenuKeys.ApprovalWorkflowContractLeaf( + ContractApprovalWorkflowFamily.KeyToken(familyCode)); + tree.Add((leafKey, $"QT Duyệt HĐ — {shortName}", + MenuKeys.ApprovalWorkflowsV2, awV2ContractOrder++, "FileSignature")); + } + var existingItems = await db.MenuItems.ToDictionaryAsync(m => m.Key); var added = 0; var reordered = 0; diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/AwV2DesignerMenuKeyTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/AwV2DesignerMenuKeyTests.cs new file mode 100644 index 0000000..bcc1509 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/AwV2DesignerMenuKeyTests.cs @@ -0,0 +1,244 @@ +using System.Reflection; +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; + +// [W3 — S187 2026-08-10] Khoá DRIFT menu-key/policy cho họ leaf Designer `AwV2_*` +// (dưới root "Quy trình duyệt (Mới)" = `ApprovalWorkflowsV2`). +// +// ĐÍCH của wave W3 (spec `spec-duyet-hd-10-08-2026.md:10` + `sub-plan-review-fable.md §5`): +// thêm **7 key `AwV2_Hd*`** vào `MenuKeys.All` + 7 menu-row trong `SeedMenuTreeAsync`, mirror +// TRỌN khuôn 8 key `AwV2_KhkkN{1..8}` (K3 @S164/S165), cho 7 mã quy trình duyệt HĐ +// `HD-TP · HD-GK · HD-NCC · HD-DV · HD-MB · HD-NT-NCC · HD-NT-DV`. +// Hệ quả SỐ (2 row canonical `docs/STATUS.md`): Menu keys 113 → **120** · Policies 452 → **480** +// (Policies = |All| × |Actions|, DERIVED — khoá ở `KhkkGroupMenuSeedTests` T2, xem §"chồng lấn"). +// +// ── VÌ SAO FILE NÀY KHÔNG VIẾT TÊN KEY RA CHỮ ──────────────────────────────────────────── +// Spec chốt "CÓ sinh 7 key `AwV2_Hd*`" nhưng KHÔNG chốt chuỗi ký tự cụ thể (`AwV2_HdTp`? +// `AwV2_HdThauPhu`? theo `ContractTypeCodes` hay theo token mã `HD-TP`?). Viết thẳng +// `MenuKeys.ApprovalWorkflowHdTp` mà lane BE đặt tên khác ⇒ LỖI BIÊN DỊCH ⇒ sập cả test-project +// ⇒ đỏ vì "test sai", đúng thứ phải tránh. Nên: **0 tham chiếu tới symbol C# chưa tồn tại** — +// họ key mới được PHÁT HIỆN bằng phép trừ trên `MenuKeys.All` (10 key cũ tham chiếu qua const +// CÓ THẬT). Tập rỗng ⇒ đỏ tại vế tiền-đề với thông điệp "chưa có key nào" (= key chưa có). +// 🔄 Cập nhật giữa lượt: lane BE ĐÃ land `MenuKeys.cs` + `ContractApprovalWorkflowFamily.cs` +// (2026-08-10) ⇒ 7 chuỗi key nay ĐỌC ĐƯỢC TỪ MÃ và được ghim VIẾT TAY ở `Expected7` (khoá drift +// tên — xem chú thích tại đó). Vẫn giữ luật "0 symbol mới": file này KHÔNG gọi +// `MenuKeys.ContractWorkflowMenuKeys` / `ContractApprovalWorkflowFamily.KeyToken` — gọi bộ sinh +// làm kỳ vọng là tautology, và tên field của lane khác đổi thì test phải ĐỎ có thông điệp, chứ +// không phải sập biên dịch cả test-project. +// +// ── CHỒNG LẤN đã tra TRƯỚC khi viết (bài S166) ─────────────────────────────────────────── +// • `ContractCatalogTests.cs:445` (T6 lane K1) — `All.Except(menuKeysInDb).BeEmpty()` phủ +// chiều "key ∈ All ⇒ có menu-row" cho TOÀN BỘ `All` ⇒ file này KHÔNG nhân bản, chỉ thêm +// bản gia-cố per-key (thông điệp gọi đúng tên key thiếu) + **chiều NGƯỢC chưa ai đo**. +// • `KhkkGroupMenuSeedTests.cs:131` (T2 lane K4a) — số neo `|All|` DUY NHẤT trong `tests/`; +// update 113 → 120 ở CHÍNH nó (không mở số neo thứ hai ở đây — 1 sự thật 1 chỗ). +// • `grep "AwV2\|KhkkN" tests/` = **0 hit** trước lượt này ⇒ họ 10 leaf Designer đang sống +// CHƯA có test nào. File này khoá cả họ cũ (control dương, XANH hôm nay) lẫn họ mới. +// • Admin grant 4 cờ cho key mới: CỐ Ý KHÔNG viết — by-construction từ loop `MenuKeys.All` +// trong `SeedAdminPermissionsAsync :2299`, và đã có chứng nhân độc lập ở ContractCatalog T6. +// • `RevokeTemporarilyHiddenModulesAsync`: bộ lọc `:2568-2571` = `StartsWith("Hrm"|"Off") || +// == Personal`; site-2 `:2413` CỐ Ý loại `AwV2_*` (admin-only) ⇒ không có ca thật, không +// viết test trang trí. +public class AwV2DesignerMenuKeyTests +{ + // Tiền tố họ leaf Designer. Root `ApprovalWorkflowsV2` = "ApprovalWorkflowsV2" (KHÔNG mang + // tiền tố này) nên phép lọc dưới chỉ bắt LEAF. + private const string DesignerLeafPrefix = "AwV2_"; + + // Khuôn tên bắt buộc của họ mới (spec §5: "+7 key `AwV2_Hd*`"). + private const string ContractDesignerPrefix = "AwV2_Hd"; + + // 10 leaf Designer ĐANG SỐNG — tham chiếu qua const CÓ THẬT (đổi tên const ⇒ lỗi biên dịch + // ngay, đúng chỗ). Vừa là tập trừ để phát hiện họ mới, vừa là CONTROL DƯƠNG. + private static readonly string[] LegacyDesignerLeafKeys = + { + MenuKeys.ApprovalWorkflowDuyetNccV2, + MenuKeys.ApprovalWorkflowDuyetNccPhuongAnV2, + MenuKeys.ApprovalWorkflowKhkkN1, MenuKeys.ApprovalWorkflowKhkkN2, + MenuKeys.ApprovalWorkflowKhkkN3, MenuKeys.ApprovalWorkflowKhkkN4, + MenuKeys.ApprovalWorkflowKhkkN5, MenuKeys.ApprovalWorkflowKhkkN6, + MenuKeys.ApprovalWorkflowKhkkN7, MenuKeys.ApprovalWorkflowKhkkN8, + }; + + // 7 mã quy trình HĐ (lane t0 đã ghim cùng bộ ở `UpdateAwDefinitionTests.cs:1574`). + private static readonly string[] ContractWorkflowCodes = + { "HD-TP", "HD-GK", "HD-NCC", "HD-DV", "HD-MB", "HD-NT-NCC", "HD-NT-DV" }; + + // 7 key kỳ vọng, VIẾT TAY (khuôn `KhkkGroupMenuSeedTests.Expected49`): CỐ Ý **không** gọi + // `MenuKeys.ContractWorkflowMenuKeys` / `ContractApprovalWorkflowFamily.KeyToken` — gọi lại + // bộ sinh là TAUTOLOGY (luật sinh sai chuỗi thì kỳ vọng sai theo, test xanh vô nghĩa). + // Chuỗi dưới ĐỌC TỪ MÃ ĐÃ LAND @S187 W3: mã `HD-{X}` → bỏ tiền tố `HD-`, bỏ mọi `-`, GIỮ + // hoa/thường → ghép `AwV2_Hd{token}` (`ContractApprovalWorkflowFamily.KeyToken`). + // 🔴 Đây là chỗ khoá DRIFT TÊN: đổi luật đặt key (vd "normalize" về `AwV2_Hdtp`) mà không + // đổi dòng này = ĐỎ — đúng ý, vì đổi key sau khi đã seed là mồ côi permission-row đã cấp + // (bài S155) + lệch `fe-*/src/lib/menuKeys.ts` đồng bộ tay. + private static readonly string[] Expected7 = + { + "AwV2_HdTP", "AwV2_HdGK", "AwV2_HdNCC", "AwV2_HdDV", + "AwV2_HdMB", "AwV2_HdNTNCC", "AwV2_HdNTDV", + }; + + private const string ExpectedCount = "7"; + + // Họ MỚI = mọi leaf `AwV2_*` trong `All` TRỪ 10 leaf cũ. + private static string[] ContractDesignerKeysInAll() => + MenuKeys.All + .Where(k => k.StartsWith(DesignerLeafPrefix, StringComparison.Ordinal)) + .Except(LegacyDesignerLeafKeys, StringComparer.Ordinal) + .OrderBy(k => k, StringComparer.Ordinal) + .ToArray(); + + // Seeder `private static` → REFLECTION (KHÔNG sửa prod đổi visibility) — mirror + // `KhkkGroupMenuSeedTests.InvokePrivateSeedAsync`. + private static async Task SeedMenuAsync(TestApplicationDbContext db) + { + var mi = typeof(DbInitializer).GetMethod("SeedMenuTreeAsync", BindingFlags.NonPublic | BindingFlags.Static); + mi.Should().NotBeNull("DbInitializer.SeedMenuTreeAsync phải tồn tại (private static) — đổi signature thì cập nhật test"); + await (Task)mi!.Invoke(null, new object[] { db, NullLogger.Instance })!; + } + + private static async Task> DesignerChildrenAsync(TestApplicationDbContext db) + { + var rows = await db.MenuItems.AsNoTracking() + .Where(m => m.ParentKey == MenuKeys.ApprovalWorkflowsV2).ToListAsync(); + return rows.OrderBy(r => r.Key, StringComparer.Ordinal).ToList(); + } + + // ===================================================================================== + // T1 — `MenuKeys.All`: đúng 7 key Designer HĐ, đúng khuôn tên `AwV2_Hd*`, 10 key cũ NGUYÊN. + // Vế "10 key cũ nguyên" đứng TRƯỚC = CONTROL DƯƠNG cho chính phép trừ: nếu ai đổi tên + // họ `AwV2_KhkkN*` thì tập-trừ sẽ nhặt nhầm chúng vào "họ mới" và mọi kết luận dưới + // thành rác — vế này bắt đúng ca đó trước khi kịp kết luận sai. + // ===================================================================================== + [Fact] + public void MenuKeys_All_HasSevenContractDesignerKeys_AndKeepsTheTenLegacyOnes() + { + // ── CONTROL DƯƠNG: cơ sở của phép trừ còn nguyên ───────────────────────────────── + MenuKeys.All.Should().Contain(LegacyDesignerLeafKeys, + "10 leaf Designer đang sống (2 Duyệt NCC + 8 KHKK nhóm) phải còn trong `All` — mất/đổi tên " + + "là (a) mồ côi permission-row đã cấp (bài S155) và (b) phép trừ tìm 'họ mới' bên dưới hỏng thước"); + + LegacyDesignerLeafKeys.Should().OnlyHaveUniqueItems("10 hằng phải trỏ 10 chuỗi khác nhau"); + + // ── VẾ CHÍNH: họ MỚI phải có đúng 7 thành viên ─────────────────────────────────── + var family = ContractDesignerKeysInAll(); + + family.Should().HaveCount(7, + $"W3 @S187 thêm ĐÚNG {ExpectedCount} leaf Designer cho {ContractWorkflowCodes.Length} loại HĐ " + + $"({string.Join(" · ", ContractWorkflowCodes)}) — mỗi loại 1 quy trình V2 riêng (PA-A). " + + "Đếm được 0 = lane BE chưa land 7 key vào `MenuKeys.All` (spec-change: test + code CÙNG wave, " + + "rules §7). Đếm 6/8 = lệch danh sách. Số này quyết định 2 row canonical `Menu keys` (113→120) " + + "và `Policies` (452→480 = |All|×4) ở docs/STATUS.md"); + + family.Should().OnlyHaveUniqueItems( + "key đôi trong `All` ⇒ SeedAdminPermissionsAsync chèn Permission trùng (RoleId, MenuKey) ⇒ nổ UNIQUE"); + + // ── ĐỦ 7 TÊN, đúng chuỗi ký tự (khoá drift tên — xem chú thích `Expected7`) ────── + family.Should().BeEquivalentTo(Expected7, + $"7 key phải đúng khuôn `{ContractDesignerPrefix}{{token}}` với token = mã bỏ `HD-` và bỏ `-` " + + $"({string.Join(" → ", ContractWorkflowCodes.Select((c, i) => c + "/" + Expected7[i]))}). " + + "Sai 1 ký tự = menu-row lệch key với `MenuKeys.All` ⇒ Permission FK nổ lúc boot; đổi key SAU khi " + + "đã seed = mồ côi permission-row đã cấp (bài S155) + lệch `fe-*/src/lib/menuKeys.ts` (đồng bộ tay)"); + } + + // ===================================================================================== + // T2 — MENU-ROW cho 7 key mới: có row thật, treo đúng cha `ApprovalWorkflowsV2`, và HAI + // CHIỀU khớp nhau với `All`: + // (A) key ∈ All ⇒ có menu-row — thiếu row = Permission FK nổ NGAY lần boot đầu + // (`MenuKeys.cs:125-126`; chiều này `ContractCatalogTests:445` đã phủ toàn cục, + // ở đây gia cố per-key để thông điệp gọi đúng tên key thiếu). + // (B) menu-row `AwV2_*` ⇒ key ∈ All — CHƯA AI ĐO. Row có mà key ngoài `All` thì + // `Program.cs` KHÔNG sinh policy ⇒ mục hiện trên sidebar admin nhưng bấm vào là + // 403/500 (cùng lớp gotcha #85: menu rộng hơn API). + // Thứ tự: seed → CONTROL DƯƠNG trên 10 leaf cũ → tiền-đề 7 → vế mới. Control đứng + // trước để lúc đỏ còn phân biệt được "seeder không chạy" với "7 key chưa có". + // ===================================================================================== + [Fact] + public async Task SeedMenuTree_ContractDesignerLeaves_HaveRowsUnderApprovalWorkflowsV2_AndMatchAllBothWays() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + await SeedMenuAsync(db); + + // ── CONTROL DƯƠNG: seeder CÓ chạy và khuôn assert dưới là thoả được ────────────── + var root = await db.MenuItems.AsNoTracking().FirstOrDefaultAsync(m => m.Key == MenuKeys.ApprovalWorkflowsV2); + root.Should().NotBeNull("root 'Quy trình duyệt (Mới)' phải có row — vế này đỏ = seeder không chạy, đừng đọc các vế sau"); + root!.ParentKey.Should().Be(MenuKeys.System, "root Designer treo dưới Hệ thống"); + + var children = await DesignerChildrenAsync(db); + children.Select(c => c.Key).Should().Contain(LegacyDesignerLeafKeys, + "10 leaf Designer cũ phải có row dưới đúng root — vế này đỏ = hỏng THƯỚC (parent/seeder), " + + "không phải chuyện của 7 key mới"); + + // ── TIỀN-ĐỀ (chống vacuous): không có 7 key thì mọi vòng lặp dưới chạy 0 lần ───── + var family = ContractDesignerKeysInAll(); + family.Should().HaveCount(7, + $"tiền-đề: `MenuKeys.All` phải có {ExpectedCount} key `{ContractDesignerPrefix}*` (W3). " + + "Đếm 0 = lane BE chưa land — mọi assert menu-row bên dưới sẽ đúng-vô-nghĩa nếu bỏ vế này"); + + // ── (A) key ∈ All ⇒ menu-row, đúng cha, có nhãn + icon ────────────────────────── + foreach (var key in family) + { + var row = children.FirstOrDefault(c => c.Key == key); + row.Should().NotBeNull( + $"{key} ∈ MenuKeys.All ⇒ BẮT BUỘC có menu-row trong SeedMenuTreeAsync với ParentKey=" + + $"'{MenuKeys.ApprovalWorkflowsV2}'. Thiếu row = SeedAdminPermissionsAsync chèn Permission " + + "→ FK MenuKey→MenuItem.Key nổ NGAY lần boot đầu (MenuKeys.cs:125-126)"); + row!.Label.Should().NotBeNullOrWhiteSpace($"{key} phải có nhãn tiếng Việt hiện trên sidebar admin"); + row.Icon.Should().NotBeNullOrWhiteSpace($"{key} phải có icon như 10 leaf anh em (sidebar lệch hình nếu trống)"); + } + + // ── (B) CHIỀU NGƯỢC: mọi menu-row họ `AwV2_*` phải nằm trong `All` ────────────── + // Lọc TRONG BỘ NHỚ bằng Ordinal (LIKE của SQLite case-insensitive ASCII — không muốn + // phép đo phụ thuộc collation provider; bài S166). + var allRowKeys = await db.MenuItems.AsNoTracking().Select(m => m.Key).ToListAsync(); + var awV2RowKeys = allRowKeys.Where(k => k.StartsWith(DesignerLeafPrefix, StringComparison.Ordinal)).ToArray(); + + awV2RowKeys.Should().HaveCount(17, + $"10 leaf cũ + {ExpectedCount} leaf HĐ mới. Đếm 10 = 7 row mới chưa được seed; " + + "đếm >17 = seed dư row (mục lạ trên sidebar admin)"); + + awV2RowKeys.Except(MenuKeys.All, StringComparer.Ordinal).Should().BeEmpty( + "menu-row họ `AwV2_*` mà key NGOÀI `MenuKeys.All` ⇒ Program.cs không sinh policy cho key đó ⇒ " + + "mục hiện trên sidebar nhưng endpoint/gate tương ứng 403/500 (cùng lớp gotcha #85). " + + "Vào `All` và seed row là MỘT cặp, không được lệch nửa"); + } + + // ===================================================================================== + // T3 — HÌNH SIDEBAR của cả 17 leaf: NHÃN phân biệt + ORDER phân biệt. + // Khối 7 dòng mới là copy-paste của khối 8 dòng `AwV2_KhkkN*` (`DbInitializer:1988-1995`) + // ⇒ 2 lỗi kinh điển của copy-paste khối: (i) quên sửa 1 nhãn ⇒ 2 mục cùng tên + // "Duyệt HĐ — …" trên sidebar admin ⇒ chọn nhầm Designer ⇒ sửa quy trình của LOẠI HĐ + // KHÁC; (ii) quên tăng Order ⇒ 2 mục cùng Order ⇒ thứ tự anh em bất định giữa các lần + // render (GetMyMenuTree sort theo Order). + // Chống vacuous: tiền-đề 17 leaf đứng trước (tập rỗng thì "phân biệt" đúng ở mọi thế giới). + // ===================================================================================== + [Fact] + public async Task SeedMenuTree_AllDesignerLeaves_HaveDistinctLabelsAndDistinctOrders() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + await SeedMenuAsync(db); + var children = await DesignerChildrenAsync(db); + + children.Should().HaveCount(17, + $"2 leaf Duyệt NCC + 8 leaf KHKK nhóm + {ExpectedCount} leaf HĐ (W3 @S187) = 17 mục Designer. " + + "Đếm 10 = lane BE chưa land 7 menu-row `AwV2_Hd*`"); + + children.Select(c => c.Label).Should().OnlyHaveUniqueItems( + "17 mục Designer = 17 nhãn PHÂN BIỆT. Copy-paste khối 7 dòng mà quên sửa 1 nhãn ⇒ 2 mục cùng " + + "tên trên sidebar admin ⇒ mở nhầm Designer của loại HĐ khác (sửa quy trình sai loại)"); + + children.Select(c => c.Order).Should().OnlyHaveUniqueItems( + "17 mục = 17 Order PHÂN BIỆT. Trùng Order ⇒ thứ tự anh em bất định (GetMyMenuTree sort theo " + + "Order) ⇒ sidebar đổi chỗ giữa các lần render"); + } +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/KhkkGroupMenuSeedTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/KhkkGroupMenuSeedTests.cs index 010d224..17b7053 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Application/KhkkGroupMenuSeedTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Application/KhkkGroupMenuSeedTests.cs @@ -118,6 +118,10 @@ public class KhkkGroupMenuSeedTests // ⭐ 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. + // 🔄 [W3 — S187 2026-08-10] SỐ NEO update 113 → 120: wave W3 thêm 7 leaf Designer + // `AwV2_Hd*` (7 loại HĐ — spec `spec-duyet-hd-10-08-2026.md:10` §5). Đây là số neo + // |All| DUY NHẤT trong `tests/` (1 sự thật 1 chỗ) — hình dạng họ key mới khoá riêng ở + // `AwV2DesignerMenuKeyTests`, KHÔNG mở số neo thứ hai ở đó. // ===================================================================================== [Fact] public void MenuKeys_All_ContainsThe49_NoDuplicate_AndKeepsGroup1Outside() @@ -128,9 +132,12 @@ public class KhkkGroupMenuSeedTests 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.All.Should().HaveCount(120, + "ĐÍCH sau W3 @S187 = 113 (số ĐO runtime tại S166 sau K4a) + 7 leaf Designer `AwV2_Hd*` cho " + + "7 loại HĐ (HD-TP · HD-GK · HD-NCC · HD-DV · HD-MB · HD-NT-NCC · HD-NT-DV). " + + "`Policies` = |All| × |Actions| = 120 × 4 = 480 — đổi số ở đây BẮT BUỘC đổi 2 row canonical " + + "`Menu keys` / `Policies` trong docs/STATUS.md CÙNG COMMIT. " + + "Đo được 113 = lane BE chưa land 7 key (spec-change: test + code CÙNG wave, rules §7)"); 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");