Compare commits

...

8 Commits

Author SHA1 Message Date
15349e8ee0 [CLAUDE] Contract: K3 SP-002 8 workflow nhom KHKK-N1-8 + port level-finalize + designer (0-mig) + 5 test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m10s
Seed 8 workflow type-10 theo ROSTER LIVE prod (QT-DN-V2-001, lead verify tay
@SQLEXPRESS: 3 tram PRO->CCM->CEO, 11 nguoi, resolve EMAIL, idempotent per-Code;
co Finalize CHI cap chuong.phan). Port nhanh level-finalize mirror PE :866-876
(EndedByLevelFinalize K2-de-K3-tieu + ApplyApprovedValuesOnFinalize truoc Phase;
opt-out applyLevelFinalize 3-tang interface/service/controller, default true).
Sweep ep-false va-3: GO CREATE ep-false Finalize type-10 (OG-9 supersede PHA-VO
S155); GIU PUT-Conflict (luat versioning CHUNG - spec liet nham, khai lech);
GIU ep-false SkipToFinal type-10 (K3-test O-1 chong config-lie mới) + FE disable
o SkipToFinal cho KHKK (gate-K3 F-2). Rao (v) pin-dung-nhom regex KHKK-N[1-8].
+8 MenuKeys AwV2_KhkkN* (All 56->64, policies 224->256 may-dem) + 8 menu-row +
resolvePath fe-admin phep-am Khkk_ 0-hit. 5 test K3 (T-a..T-e, anti-tautology).
Gate K3 PASS-WITH-FLAGS 8f/0-blocker (sub-reviewer-gate-k3.md): F-1 checkbox
opt-out man duyet = KHAI HANDOFF dau-viec-#1 lan sau (tram CEO tam thoi qua
Designer bo co); F-2 va; F-3 dong truoc do. Suite 614/0 (45D+569I). K3-BE 2 luot
engine-exit -> che nho + lead-solo (bai: task-hep-Write-som song).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 18:21:39 +07:00
d6cb5469b3 wal: flush 20260731T1803 2026-07-31 18:03:05 +07:00
2967c43b5c wal: flush 20260731T1743 2026-07-31 17:43:01 +07:00
9a69dae859 wal: flush 20260731T1738 2026-07-31 17:38:48 +07:00
a46a0a27a6 wal: flush 20260731T1733 2026-07-31 17:33:30 +07:00
003ce99faf wal: flush 20260731T1721 2026-07-31 17:21:52 +07:00
07cc86c2f0 wal: flush 20260731T1720 2026-07-31 17:20:31 +07:00
f851f1015f wal: flush 20260731T1708 2026-07-31 17:08:29 +07:00
27 changed files with 1487 additions and 94 deletions

View File

@ -33,17 +33,39 @@ chain:
SAI-MỘT-PHẦN: 8 khai báo ĐÃ có call-site — diagnostics = snapshot giữa-Write #68; chỗ dở thật =
ListPage → vá badge + cột Tổng-duyệt vá-8 + colSpan 8; cp 4-file fe-user; build ×2 PASS DORfkFnc/
qYI2yszp; SHA-pair 5/5 + panel control-dương giữ baseline). Artifact sub-implfe-k2.md ĐẦY tự lane ghi
[!] reviewer-gate K2 đang chạy (sub-reviewer-gate-k2.md) → PASS thì commit-push K2 → K3
[ ] MANDATE còn lại (🔴 owner @PAUSE-2 "chạy 1 mạch ko ngừng"): K2(FE+test sau BE)→gate→push → K3 →
K4a→K4b→K5→K4c→K6 → K7 → K8-prep (form 14 người fail-closed = điểm dừng hợp lệ duy nhất).
Mỗi wave: build+test+reviewer-gate rồi lăn; DỪNG chỉ khi FAIL cứng. OG-6 = soát-SAU (HẾT chặn push).
[x] gate K2 PASS-WITH-FLAGS 10f (sub-reviewer-gate-k2.md; #53 ×1 → resume; phán vá-5-thắng-nền ĐÚNG +
bác 3 nghi-vấn lead-solo). Xử: F-1 guard đếm-so-khớp (bịt hạng-mục-xoá-mềm/Guid-rác) + 2 test bịt
(xoá-mềm-chặn + reflection ?group=) · F-2/F-5/F-7/F-8/F-9 comment-honest (lý-do-thật filtered-index
khả-thi, thì-tương-lai K4b, Down-data-dependent, default-0-SQL-thô) · F-3 STATUS 607→609 · F-4 SKILL 97.
Suite 609/0 (45D+564I) · npm ×2 (comment-only ⇒ bundle hash giữ) · SHA-pair ✓
[x] commit K2 0779f2d 23-file PUSH (lần 1 FAIL PS5.1-quote-trap → -F file) → cicd-K2 PARTIAL 5/6
deploy-KHỎE (run #435 · gate-CI 609 tách-phần · Mig71 prod-top+restart-proof · bundle 4/4 rotate ·
smoke 6/6 ?group= wired; mục-4 backfill/Σ = CHÂN-LÝ-RỖNG prod-0-phiếu — đo lại khi K8 đẻ phiếu thật.
cicd #53 return-garble tiếp, artifact trọn)
[!] K3 CHẺ NHỎ sau 2 lượt engine-process-exit (p1 wf_a04124a7 + p2 Agent — cả 2 0-delta-0-artifact;
bài: task-đọc-nhiều-trước-Write chết, task-hẹp-Write-sớm sống). Bước-0 LEAD: prod=localhost\SQLEXPRESS,
QT-DN-V2-001 LIVE, ROSTER 3-trạm 11 người @sub-implbe-k3.md §0, chuong.phan giữ cờ.
ĐANG CHẠY: nhịp-1-BE lane (seed 8 wf + 8 MenuKeys — đã append §1 kế-hoạch + khai L1-không-khuôn-type-10
→mirror-ContractV2 + L2-ép-false-không-cản-seeder) ∥ K3-FE lane (resolvePath AwV2_KhkkN* + gỡ khóa
Designer + panel ×2). XONG LEAD-SOLO: nhịp-2 port finalize (branch mirror PE :866-876 + opt-out
applyLevelFinalize 3-tầng interface/service/controller + comment (a)(b) supersede-S155 + 2 test-callsite
ct:) + nhịp-3 (gỡ CREATE ép-false :380 [GIỮ PUT-Conflict — luật versioning CHUNG, spec vá-3 liệt nhầm,
lệch KHAI chờ gate soi] + rào (v) pin-đúng-nhóm regex KHKK-N[1-8]).
✅ nhịp-1-BE DONE (seed 8 wf + 8 key; khai L1-mirror-ContractV2 + L2) · ✅ K3-FE DONE (Layout phép-âm
PASS · gỡ khóa duyệt-sớm sạch · Panel pair 336ECCC1F86D ×2 · npm ×2 lead-verified) · ✅ BE trọng-tài
0W/0E + 609/0. [!] K3-test lane ĐANG CHẠY (T-a..T-e finalize/opt-out/pin-nhóm/giữ-cờ)
→ gate K3 → commit-push → cicd → /pause (mandate mới)
[ ] 🔴 MANDATE MỚI (owner in-turn @S165 ~17:3x: "xong nốt thì /pause rồi những đầu việc tiếp theo để
lần tiep sau làm tiếp") — SUPERSEDE "chạy 1 mạch": XONG NỐT K3 (BE p2 đang chạy → FE + test song song
→ gate K3 → commit-push → cicd verify) rồi /pause. K4a→K4b→K5→K4c→K6→K7→K8 = lần /tiep sau.
OG-6 = soát-SAU (giữ). Lưu ý pause tới: trần _mind 84,8% ⇒ PHẢI nén MIND-1 kèm nơi-đi 6 ý trước MIND-3.
[ ] K8 dry-run: gói test SOLUTION-PE-{n} + B0-form 14 người thật (fail-closed) + đo freeze 2-nhánh
[ ] closeout: seed diary vai (C2 AB=∅) · tally garble (+reviewer-gate ×1 @S165 resume-sạch, đếm lại cuối
phiên) · nhãn D MIND-3 + nén MIND-1-kèm-nơi-đi-6-ý (trần 84,8%) · STATUS row S163/S164/S165
next: đợi wf_583be7ad-1a2 (K2-BE) → VERIFY ĐĨA (sub-implbe-k2.md + git status + build/test THẬT) →
phóng K2-FE + K2-test song song → gate K2 → push → lăn K3 theo build-order.
Chết giữa chừng: relaunch-cắt-gọt tiep.md §4 (git status + sub-file cho biết đã tới đâu).
next: đợi K3-BE p2 → VERIFY ĐĨA → phóng K3-FE + K3-test song song → gate K3 → commit-push → cicd
→ /pause (mandate mới). Chết giữa: relaunch-cắt-gọt §4. Lần /tiep sau: K4a (49 menu + grant, spec-cum2
vá-1 route-regex CÙNG K4a) → K4b → K5 → K4c → K6 → K7 → K8-prep-form-14-người.
verify:
ls .claude/workflows/runs/2026-07-31-S164-4gd-khkk-fanout/ | wc -l # 29 @S165 sau gate-K1 (tăng theo lane — đếm tươi, đừng neo; ctx-verifier K8 vá 28→29)

View File

@ -1,16 +1,17 @@
# CI/CD Monitor Agent — Persistent Memory
- **07-31 verify `50e6d8c` S164 K1 SP-002 danh-mục-loại-HĐ (20 file: 11 BE + 6 FE + 1 test + 2 docs, +Mig 70):** **PASS 6/6** — Run **#434** (id=547) success **6m31s** (dài nhất 4-run gần đây: #431/#432/#433 ~5m50; hợp lý vì +Mig+seed-86+8-test — chưa tới ngưỡng escalate). Gate **598 = 45D + 553I** (0 fail/skip) — Domain **GIỮ 45**, Infra **545→553 = +8** ⇒ delta rơi TRỌN vào Infra đúng nơi file test mới nằm (đối-chiếu TÁCH-PHẦN bắt được điều tổng-số giấu). Mig 70 `20260731072441_AddContractCatalogEntries` **TOP prod**, `sys.tables` 96→**97** khớp `grep -c CreateTable`=**1**. 🔑 **TAM-GIÁC 3 NGUỒN ĐỘC-LẬP** cho seed: source-parse `DbInitializer` == sqlcmd prod == **API materialize qua EF** — cả 3 ra `86` + `1:5·2:10·3:5·4:23·5:14·6:15·7:13·8:1` y hệt; nguồn 3 mạnh nhất vì đi trọn Domain→Infra→Api (200, 25.374 B, distinct-code 86). Serilog `Seeded 86 contract catalog entries (S164 SP-002)` @15:52:27 + control-ÂM findstr chuỗi-bịa 0 hit. Bundle **4/4 ROTATE** (admin `B43R6Y17``CKgq0YlH`, user `NLI5umBg``CHMCU35C`) + **4/4 hash CŨ rơi `text/html` 876/900 B** = ghi-đè thật; LM 15:51:06/15:52:07 in-window. Smoke 8/8: `/health` bare **404** (control) · `/health/{ready,live}` 200 · endpoint mới không-token **401** kèm **2 route BỊA 404** ⇒ 401 CÓ NGHĨA. w3wp PID 2644 StartTime 15:52:13 in-window + log dòng-đầu `Applying migrations...` ⇒ đo SAU recycle (né #423). ⚠️ **BÀI MỚI — 0-hit là DƯƠNG-GIẢ tới khi soi format**: grep `Passed!` lần đầu **0 hit** chỉ vì log dùng 2-khoảng-trắng (`Passed! - Failed: 0`); suýt báo "không thấy dòng test". Cùng class: parse seeder lần 1 ra **78** (regex line-anchored, tuple xuống dòng) — vứt số, parse lại ra 86 + cross-check `raw code-literal`=86. ⚠️ **hash CI ≠ hash local build** (local dự-báo `BE7zE81C`/`DSXJJbJq`, CI ra khác) ⇒ CẤM lấy hash local làm mốc kỳ-vọng prod, luôn re-snapshot LIVE. Evidence: `runs/2026-07-31-S164-4gd-khkk-fanout/sub-cicd-verify-k1.md`. Tag `[run-434, pass-6-6, tam-giac-3-nguon, 0-hit-la-duong-gia, gate-598, mig-70, hash-CI-khac-local]`
- **07-31 verify `0779f2d` S164 K2 SP-002 KHKK mang hạng-mục + nhóm duyệt N1-8 (23 file: 9 BE + 8 FE + 3 test + Mig 71 ×3 + 2 ignored-path):** ⚠️ **PARTIAL 5/6** deploy KHỎE, điểm trừ = **phép đo không có răng**, không phải lỗi ship. Run **#435** (id=548) success **6m12s**. Gate **609 = 45D + 564I** (0 fail/skip) — Domain **GIỮ 45**, Infra 553→**564 = +11** khớp TỪNG VẾ kỳ-vọng "9 lane + 2 gate-fix". Mig 71 `20260731085624_AddKhkkApprovalGroupCatalogAndFinalizeRuntime` TOP prod (MIGCOUNT=71), `sys.tables` **97 GIỮ NGUYÊN** khớp `grep -c CreateTable`=**0** (control dương `grep -c AddColumn`=4). 🔴 **BÀI LỚN NHẤT — ACCEPTANCE RƠI VÀO CHÂN-LÝ-RỖNG**: `ContractSigningPlans` prod **0 dòng cả PRE lẫn POST** ⇒ cả 3 acceptance mục 4 (`COUNT(group=1)==COUNT(*)``0==0`; `NOT BETWEEN 1 AND 8`=0; Σ tiền `NULL==NULL`) đều "xanh" mà **0 bit thông tin** — backfill hụt sẽ trông Y HỆT. Tác giả Mig 71 đã khai bẫy này ở comment ("đo local Σ = tập RỖNG hai phía… phép đo prod mới có răng") — nay prod cũng rỗng nốt ⇒ phép đo prod CŨNG hết răng. KHÔNG hạ FAIL (không có gì sai) nhưng CẤM đọc thành PASS. 🔑 **Đối-sách khi acceptance rỗng → thay bằng bằng-chứng KHÔNG-phụ-thuộc-số-dòng**: (a) 4 cột đúng kiểu/null/default qua `INFORMATION_SCHEMA`; (b) **hoán-đổi index chứng bằng LIỆT KÊ TRỌN HỌ 14 index** — 3-cột unique filtered `([IsDeleted]=(0))` CÓ + 2-cột cũ MẤT, vắng-mặt suy từ danh-sách-đầy-đủ nên là vắng-mặt ĐO ĐƯỢC (mạnh nhất mục 4); (c) Σ bất-biến chứng theo **CẤU TRÚC**: grep cột tiền trên Mig 71 ra 2 hit **đều là comment `//` dòng 23-24** ⇒ 0 câu lệnh thực thi, đúng kể cả khi có dữ liệu; (d) EF materialize 200-không-500. 🔑 **MỚI — chứng query-param ĐƯỢC WIRE bằng bind-error**: `?group=abc`**400** (param lạ mà bị nuốt thì ASP.NET trả 200) + khớp nguồn `Controller:42 [FromQuery(Name="group")] int? group`; `?group=1/2/8` chỉ ra `{"items":[],"total":0}` = VACUOUS. Áp bài K1: soi **thân RAW** trước khi tin parser ⇒ rỗng THẬT. Control DƯƠNG đường-ống list: `/api/catalogs/contract-catalog` **86 item** (seed K1 nguyên) ⇒ ống chạy, `items:[]` là bảng rỗng thật. Bundle **4/4 ROTATE** (admin `CKgq0YlH``DVQEwGwf`, user `CHMCU35C``CZAYiWWa`) + hash CŨ **và hash BỊA** cùng rơi `text/html` 900/876 B ⇒ `text/html` thật sự nghĩa "không tồn tại"; LM 17:10:20/17:11:20 in-window. Smoke: `/health/{ready,live}` 200 + bare `/health` **404** control · no-token **401** kèm **2 route BỊA 404**. w3wp **PID 5364 = `SolutionErp-Api`** (map PID→pool bằng `appcmd list wp` vì VPS có 6 w3wp chung VIETREPORT — CẤM đoán PID) StartTime 17:11:25 + Serilog dòng-đầu `Applying migrations...` 17:11:27 ⇒ mọi đo DB từ 17:12+ = SAU recycle. Quan sát cho K4b: `?group=999`**200** (biên API không chặn ngoài-miền 1..8, hiện vô hại); ghost-window `EndedByLevelFinalize` luôn 0 tới khi K3 land = ĐÚNG hiện trạng. Evidence: `runs/2026-07-31-S164-4gd-khkk-fanout/sub-cicd-verify-k2.md`. Tag `[run-435, partial-5-6, chan-ly-rong-vacuous, liet-ke-tron-ho-index, wire-proof-bang-bind-400, gate-609, mig-71, map-PID-pool]`
- _(**07-31 `50e6d8c` S164 K1 SP-002 danh-mục-loại-HĐ +Mig 70 — PASS 6/6**: run #434 6m31s, gate 598, `sys.tables` 96→97. 🔑 **TAM-GIÁC 3 NGUỒN ĐỘC-LẬP** cho seed (source-parse `DbInitializer` == sqlcmd prod == **API materialize qua EF**, cả 3 ra 86 + phân-bố `1:5·2:10·3:5·4:23·5:14·6:15·7:13·8:1`; nguồn 3 mạnh nhất vì đi trọn Domain→Infra→Api). ⚠️ **BÀI: 0-hit là DƯƠNG-GIẢ tới khi soi format** — grep `Passed!` ra 0 chỉ vì log dùng 2-khoảng-trắng; cùng class parse seeder ra 78 (regex line-anchored, tuple xuống dòng) → vứt số, parse lại 86. ⚠️ **hash CI ≠ hash local build** ⇒ CẤM lấy hash local làm mốc kỳ-vọng prod. verbatim → `archive/2026-07.md` via `_INDEX`)_
- _(**07-30 `bfc7b79` S162 cây-4-folder-GĐ FE-ONLY — PASS 5/5**: run #432 5m35s, gate 590. 🔴 **LUẬT CẤM `HEAD~1`** — Stop-hook WAL auto-commit giữa lúc verify làm `HEAD~1` đổi nghĩa ⇒ tao kết SAI "marker vô giá trị"; đo lại bằng SHA tuyệt đối `X^..X` thì 0 hit ⇒ marker hợp lệ. 🔑 **ĐẾM TRỌN HỌ bằng 1 REGEX** `pipeline-[a-z-]{0,24}index` ra đúng 3 phần tử khớp source element-wise (vừa vế DƯƠNG vừa tự chứng regex không quét bừa). ⚠️ suýt FAIL-OAN: nhãn cũ 8/app sống hợp lệ — control ÂM phải ĐỘC-QUYỀN. verbatim → `runs/2026-07-30-S162-cay-4-folder-gd/sub-cicd-monitor-2.md`)_
- **07-30 verify `bfc7b79` S162 cây-4-folder-GĐ (FE-ONLY, 17 file, 12 FE = 6+6 đối xứng ×2 app):** ✅ **PASS 5/5** — Run **#432** (id=545) success **5m35s**; gate **590 = 45D+545I** (0 fail/skip). 0 mig/0 BE ⇒ không chân EF. Bundle **4/4 ROTATE** (admin js `BBQKvUMa``DXoYDy7R` · user js `BV5--mPf``o8KDluD1`, css cả 2 đổi); `index.html` LM 10:09:07 in-window + `Cache-Control:no-cache`. 🔴 **BẪY MỚI — MOVING-TARGET `HEAD~1`:** phiên có Stop-hook WAL **tự commit giữa lúc verify**`HEAD~1` đổi nghĩa từ 'cha commit đích' thành 'chính commit đích' trong ~6 phút. Tao đã kết SAI '2 marker có sẵn ở parent ⇒ vô giá trị'; đo lại bằng SHA tuyệt đối `8eba137` thì **cả 2 = 0 hit** ⇒ marker hợp lệ. **LUẬT: verify commit X luôn neo `X^..X` bằng SHA, CẤM dùng `HEAD~1`** (mọi repo có hook auto-commit). 🔑 **Khuôn MỚI 'ĐẾM TRỌN HỌ bằng 1 REGEX'**: `pipeline-[a-z-]{0,24}index` quét bundle ra **đúng 3 phần tử** `{contract×2, khkk×1, pe×1}` khớp source element-wise ⇒ vừa là vế DƯƠNG, vừa tự chứng regex không quét bừa (mạnh hơn đếm chuỗi rời). Dự-đoán-trước-khi-đo khớp từng đồng: `contract-index`=2 (= số FILE trong diff) · VN `Kế hoạch Hợp đồng`=**1** (source 3 **2 comment**) · raw-UTF8 không escape. ⚠️ **suýt FAIL OAN lần nữa:** `Kế hoạch ký kết HĐ` (nhãn strip cũ) = **8/app** — KHÔNG dùng làm control ÂM được vì synthesis ghi rõ nhãn folder GĐ2 **cố ý khác** nhãn strip; 1-mới + 8-cũ cùng sống = chứng phân-biệt-phiên-bản. Control ÂM: chuỗi-BỊA `pipeline-hardcopy-index`=0 (source cũng 0) + hash BỊA & **hash CŨ cả 2 app** đều `ct=text/html` **876B/900B trùng khít size index.html** ⇒ bundle cũ biến mất = ghi đè thật. Smoke 4×200 + bare `/health` **404** (control). WAL-commit `a07b942` (3 file `.md`) bị `paths-ignore` nuốt ⇒ không đẻ run rác. Evidence: `runs/2026-07-30-S162-cay-4-folder-gd/sub-cicd-monitor-2.md`. Tag `[run-432, pass-5-5, CAM-HEAD~1-neo-SHA, dem-tron-ho-1-regex, control-am-DOC-QUYEN-lan-2, gate-590, fe-only]`
- _(**#427 S161 W1 KHKK `a8027c0` +Mig 69 `AddContractSigningPlans` — PASS 6/6**: gate 566, 5m40s; `sys.tables` **89→96** (+7 khớp 7 CreateTable), MenuItems 142 GIỮ. 🔑 **khuôn ĐO-CỜ không đếm ROW** (row tồn tại sẵn ⇒ đếm row = 0-bit; đo PRE/POST **cờ** 7 key 13/0/0/0→13/13/13/2) + 🔑 **control ÂM NỘI-HỌ: `Khkk_G1` bị seeder CỐ Ý bỏ khỏi tập** ⇒ phải đứng yên, và nó đứng yên ⇒ chứng 'đúng NGƯỜI, đúng TẬP'. Đọc seeder **dự đoán `0 added + 91 upgraded` TRƯỚC khi đo → log prod khớp từng đồng**. verbatim → `archive/2026-07.md` substring:"a8027c0` W1 KHKK")_
- _(**#426 S159 đợt-5 `ea962b1` menu 6-leaf + `GET /contracts/deleted` — PASS 9/9**: gate 562. 🔑 **LUẬT gốc control-ÂM-phải-ĐỘC-QUYỀN** (cứu false-fail: chuỗi brief bảo phải vắng vẫn sống hợp lệ ở `aria-label` ⇒ grep repo-wide TRƯỚC; chuỗi dùng-chung chỉ làm marker DƯƠNG). 🔑 **SEEDER-INSERT delta khớp TOÁN**: MenuItems **119→142 = +23 = 7+7+7+2**. Endpoint mới 401 **kèm route bịa 404** ⇒ 401 CÓ NGHĨA. ⚠️ bẫy phạm-vi query: `Ct_*` đẻ thêm 21 key ⇒ `LEFT(MenuKey,3)` không so được với đợt trước. verbatim → `archive/2026-07.md` substring:"#426 S159 đợt-5")_
- _(**S159 arc đợt-1→4 (#422 `b5799fc` · #423 `1a47a61` · #424 `2a72695` · #425 `a2bbcb9`) + S157 #419 `a52a91e`** — gate 562. 🔴 **ĐÍNH CHÍNH @S162 (H2 F-02 HIGH):** câu cũ ở đây viết *"verbatim → `archive/2026-07.md` via `_INDEX`"* cho CẢ 5 run — **SAI**: `#423`/`#424`/`#419` có trong archive ✅ nhưng **`#422 b5799fc``#425 a2bbcb9` CHƯA TỪNG vào archive** (`git log -S"b5799fc" -- .claude/agent-memory/cicd-monitor/archive/` **RỖNG toàn lịch sử**). Mất **lớp đã chưng cất**, KHÔNG mất nguyên-liệu — còn nguyên ở `runs/2026-07-29-S159-tong-quan-pipeline-menu/cicd-verify-b5799fc.md` (9.930 B) + `cicd-verify-a2bbcb9.md` (9.860 B); re-distill khi retro-harvest folder đó (HANDOFF NEXT-em #2). **Bài: stub tự khai "đã archive" là một CLAIM, phải đo mới được viết** — cùng class với F-03 (trích verbatim chuỗi không tồn tại) trong cùng phiên. Bài-học CÒN DÙNG: 🔴 **#423 PARTIAL 8/9 = phát hiện lớn nhất arc** `RevokeTemporarilyHiddenModulesAsync` UNGATED lật 447/494 row grant SQL tay; tao sót vì **đo TRƯỚC recycle** (query lúc `running` = trạng thái CŨ) → đã nâng thành luật 3-CHÂN-KIỀNG ở 'Recurring bug patterns'; đóng ở #424 (guard 2-vế StillHidden/Reopened, 494/494 sống qua restart ×3). 🔑 #425 **comment-strip dự-đoán-trước-rồi-mới-đo** (source=2 đều trong comment → dự đoán bundle=0 → đúng 0); marker phổ-thông chỉ dùng '≥1' (`Không tìm thấy` bundle 10 > source 3). 🔑 #425 **FROZEN vẫn lành tính nếu LM in-window** (#69 mode-B). 🔑 #422 **marker PHÂN-BIỆT-PHIÊN-BẢN** (cặp chữ cũ/mới sau review: `Toàn trình`=1 & `Pipeline`=0 ⇒ prod chạy bản ĐÃ-FIX); ⚠️ **LỆCH-số phải truy PHẠM-VI-QUERY trước khi hô drift** (`LEFT(,3)` vs `LIKE 'Pe%'`). 🔑 #419 byte-marker **count = SỐ FILE trong diff** ('đúng-BỘI-SỐ' > 'tìm thấy ≥1') + control âm 2 tầng fake-hash & hash-CŨ → `text/html`)_
- _(**S159 arc đợt-1→4 (#422 · #423 · #424 · #425) + S157 #419** — gate 562. 🔴 **#423 PARTIAL 8/9 = phát hiện lớn nhất arc**: `RevokeTemporarilyHiddenModulesAsync` UNGATED lật 447/494 row grant SQL tay; tao sót vì **đo TRƯỚC recycle** → đã nâng thành luật 3-CHÂN-KIỀNG ở 'Recurring bug patterns'; đóng ở #424 (guard 2-vế, 494/494 sống qua restart ×3). 🔑 #425 comment-strip **dự-đoán-trước-rồi-mới-đo**; FROZEN vẫn lành tính nếu LM in-window (#69 mode-B). 🔑 #422 **marker PHÂN-BIỆT-PHIÊN-BẢN** (cặp chữ cũ/mới ⇒ chứng prod chạy bản ĐÃ-FIX); ⚠️ **LỆCH-số phải truy PHẠM-VI-QUERY trước khi hô drift**. 🔑 #419 byte-marker **count = SỐ FILE trong diff**. ⚠️ **ĐÍNH CHÍNH: `#422`/`#425` CHƯA TỪNG vào archive** (mất lớp chưng-cất, KHÔNG mất nguyên-liệu — còn ở `runs/2026-07-29-S159-tong-quan-pipeline-menu/cicd-verify-{b5799fc,a2bbcb9}.md`); **bài: stub tự khai "đã archive" là CLAIM, phải đo mới được viết**. verbatim (#423/#424/#419) → `archive/2026-07.md`)_
- _(**#416 S155 `b1bae77` Workflow duyệt-TẠI-CHỖ +Mig 68 APPLIED-PROD 6/6** [gate 551; FLAG 'mig rớt khỏi commit' loại-trừ 3 lớp — mạnh nhất `GET /approval-workflows-v2` trả cột ×54 **materialize qua EF từ DB thật**] · **#407 S135 `4510cea` mã-dự-án normalize 5/5** [gate 532] · **#399 S134 `6664a61` PE lũy-kế 0-mig 5/5** [gate 528; 🔑 `ct=text/html` discriminator SẠCH HƠN size-919B vì shell-size drift] · **#398 S133 `d4fbced` +Mig 67 budget-freeze 5/5** [gate 520; backfill 18/18; python text=True crash cp1252 → dùng bytes] · verbatim → `archive/2026-07.md` via `_INDEX`)_

View File

@ -71,3 +71,8 @@
- **07-29 verify `a8027c0` W1 KHKK — Mig 69 `AddContractSigningPlans` [S161, 30 file +8987/22]:** ✅ **PASS 6/6** — Run **#427** (id=540) success **5m40s**; gate **566 = 45D+521I** (0 fail/skip, +4 test W1). Mig 69 APPLIED-PROD: MIGTOP `20260729122015_AddContractSigningPlans` · MIGCOUNT 68→**69** · `sys.tables` **89→96 (+7 khớp 7 CreateTable)** · 7 bảng `ContractSigningPlan{s,Lines,DossierItems,LevelOpinions,Attachments,Changelogs,Approvals}`. MenuItems **142→142 GIỮ** (policy-key ⟂ menu-key). 🔑 **KHUÔN ĐO-CỜ (không đếm ROW) — tái dùng cho mọi seeder UPGRADE-if-exists:** row đã tồn tại CanRead-only ⇒ đếm row = 0-bit; đo PRE/POST **cờ** 8 key: 7 key đích 13/0/0/0 → **13/13/13/2**, vế ÂM liệt-kê-toàn-bộ `CanDelete=1` = **đúng 14 row = {Admin,Drafter}×7** (11 role kia D=0), Drafter (non-Admin) C/U/D=1. 🔑 **Control ÂM NỘI-HỌ mạnh nhất từ trước tới nay: `Khkk_G1` bị seeder CỐ Ý bỏ khỏi danh sách 7 key** ⇒ nó phải đứng yên 13/**0/0/0** — và nó đứng yên ⟹ chứng "đúng NGƯỜI làm, đúng TẬP", không phải thứ gì lật bừa cả họ. **Đọc seeder DỰ ĐOÁN `0 added + 91 upgraded` (91=7×13) TRƯỚC khi đo → log prod khớp từng đồng** (`added=0` = đúng đường upgrade, F-S1 đóng). Marker `QT-KHKK-V2-001` grep repo TRƯỚC ⇒ độc quyền 1 site fe-admin ⇒ dự đoán admin=1/user=0, đo đúng; **control ÂM 3 TẦNG**: hash CŨ `D0sXA0fe``ct=text/html` 900B **trùng khít hash BỊA** ⇒ 200 là SPA-fallback, bundle cũ đã bị ghi đè. admin js+css rotate (`M8SDA7Ps`/`C3xzT0H9`); user FROZEN nhưng **LM 20:10:00 in-window**#69 mode-B lành tính. Restart chứng bằng **w3wp pid=4980 StartTime 20:10:05 TRÙNG dòng CI `App pool started`** + DLL mtime 20:07:50. Label F-7 sống: `?applicableType=10``"applicableTypeLabel":"Kế hoạch ký kết HĐ"`, `active:null/history:[]` + sqlcmd `ApplicableType=10`=0 ⇒ chưa dựng WF (đúng W1). Smoke 4 endpoint 200 + **route bịa 404** (chứng 200 CÓ NGHĨA). Durability `Ct_` **637/637** CanRead, root 12, DUPKEYS 0. Evidence: `runs/2026-07-29-S161-khkk-w1-schema/cicd-verify-a8027c0.md`. Tag `[run-427, pass-6-6, do-CO-khong-dem-ROW, control-am-noi-ho-key-bi-bo-sot, du-doan-91-upgraded-khop, control-am-3-tang-bundle, mig69-96-bang, gate-566]`
- _(**#426 S159 đợt-5 `ea962b1` menu 6-leaf + `GET /contracts/deleted` + bỏ hero — PASS 9/9**: gate 562, 5m38s. JS rotate cả 2 app (admin `CC1pGE1F``D0sXA0fe` · user `CLfAftby``Y6dW_5CM` +css `B6bUSFBt``6YIAufJR`); admin-css FROZEN nhưng **LM 14:44:29 in-window** ⇒ lành tính (#69 mode-B, khuôn đợt-4 dùng lại được ngay). 🔑 **CỨU FALSE-FAIL — luật MỚI về control ÂM:** brief bảo `Toàn trình 4 giai đoạn` phải VẮNG; tao grep repo-wide TRƯỚC khi đo → chuỗi vẫn sống hợp lệ ở `PePipelineStrip.tsx:24` **`aria-label`** (cả 2 app, có từ đợt-2) ⇒ **dự đoán bundle=1 chứ không 0**, đo đúng 1. Áp máy móc là FAIL OAN. Thay bằng `Quy trình mua sắm` (eyebrow hero) — `grep -rc` 2 app = **0 hit mọi file** ⇒ ĐỘC QUYỀN ⇒ bundle=0 = chứng hero đã gỡ (kèm `UserDashboardPage` 63.124→50.142 B). ⟹ **LUẬT: trước khi nhận 1 chuỗi làm control ÂM phải grep repo-wide xác nhận nó ĐỘC QUYỀN cho thứ mình muốn chứng vắng; chuỗi dùng-chung chỉ làm marker DƯƠNG.** 🔑 **SEEDER-INSERT delta khớp TOÁN (khuôn mạnh, tái dùng):** đo baseline TRƯỚC deploy rồi so SAU restart — `Ct_%_WfView` 0→**7** · `_Approved` 0→**7** · `_Deleted` 0→**7** · `Khkk_%` 5→**7** · nhãn `Ct_%_Pending`=`Đang%` 0→**7** · Dashboard `%quy tr%` 0→**1** · **MenuItems 119→142 = +23 = 7+7+7+2 khớp từng đồng** · root 12 · DUPKEYS 0 · **21 key mới đủ 13/13 canread** (nhánh reviewKeys skip-existing ăn vì key HOÀN TOÀN MỚI — khác đợt-2/3 nơi row `false` sẵn có không được nâng). **Endpoint mới:** `GET /api/contracts/deleted`**401** + **control route bịa → 404** ⇒ 401 CÓ NGHĨA, không phải catch-all (đừng chỉ đo 401 trần). ⚠️ brief ghi `/contracts/deleted` thiếu prefix — route thật có `[Route("api/contracts")]`. **Durability lần 3:** 38-key **494/494** · control âm Hrm/Off/Personal=0 · `Pe%` 197/185 · `Hdc_` nhãn 7 · 3 root ẩn. ⚠️ **Bẫy phạm-vi mới: `Ct_*` nay đẻ thêm 21 key ⇒ `LEFT(MenuKey,3)='Ct_'` cho ra 59 key, KHÔNG so được với đợt trước — phải NOT LIKE 3 hậu tố mới để giữ đúng tập 38 gốc.** Evidence: `runs/2026-07-29-S159-tong-quan-pipeline-menu/cicd-verify-ea962b1.md`. Tag `[run-426, pass-9-9, control-am-phai-DOC-QUYEN, seeder-delta-khop-toan, 401-voi-control-404, durability-restart-3, query-scope-re-anchor, gate-562]`
- **07-31 verify `50e6d8c` S164 K1 SP-002 danh-mục-loại-HĐ (20 file: 11 BE + 6 FE + 1 test + 2 docs, +Mig 70):** ✅ **PASS 6/6** — Run **#434** (id=547) success **6m31s** (dài nhất 4-run gần đây: #431/#432/#433 ~5m50; hợp lý vì +Mig+seed-86+8-test — chưa tới ngưỡng escalate). Gate **598 = 45D + 553I** (0 fail/skip) — Domain **GIỮ 45**, Infra **545→553 = +8** ⇒ delta rơi TRỌN vào Infra đúng nơi file test mới nằm (đối-chiếu TÁCH-PHẦN bắt được điều tổng-số giấu). Mig 70 `20260731072441_AddContractCatalogEntries` **TOP prod**, `sys.tables` 96→**97** khớp `grep -c CreateTable`=**1**. 🔑 **TAM-GIÁC 3 NGUỒN ĐỘC-LẬP** cho seed: source-parse `DbInitializer` == sqlcmd prod == **API materialize qua EF** — cả 3 ra `86` + `1:5·2:10·3:5·4:23·5:14·6:15·7:13·8:1` y hệt; nguồn 3 mạnh nhất vì đi trọn Domain→Infra→Api (200, 25.374 B, distinct-code 86). Serilog `Seeded 86 contract catalog entries (S164 SP-002)` @15:52:27 + control-ÂM findstr chuỗi-bịa 0 hit. Bundle **4/4 ROTATE** (admin `B43R6Y17``CKgq0YlH`, user `NLI5umBg``CHMCU35C`) + **4/4 hash CŨ rơi `text/html` 876/900 B** = ghi-đè thật; LM 15:51:06/15:52:07 in-window. Smoke 8/8: `/health` bare **404** (control) · `/health/{ready,live}` 200 · endpoint mới không-token **401** kèm **2 route BỊA 404** ⇒ 401 CÓ NGHĨA. w3wp PID 2644 StartTime 15:52:13 in-window + log dòng-đầu `Applying migrations...` ⇒ đo SAU recycle (né #423). ⚠️ **BÀI MỚI — 0-hit là DƯƠNG-GIẢ tới khi soi format**: grep `Passed!` lần đầu **0 hit** chỉ vì log dùng 2-khoảng-trắng (`Passed! - Failed: 0`); suýt báo "không thấy dòng test". Cùng class: parse seeder lần 1 ra **78** (regex line-anchored, tuple xuống dòng) — vứt số, parse lại ra 86 + cross-check `raw code-literal`=86. ⚠️ **hash CI ≠ hash local build** (local dự-báo `BE7zE81C`/`DSXJJbJq`, CI ra khác) ⇒ CẤM lấy hash local làm mốc kỳ-vọng prod, luôn re-snapshot LIVE. Evidence: `runs/2026-07-31-S164-4gd-khkk-fanout/sub-cicd-verify-k1.md`. Tag `[run-434, pass-6-6, tam-giac-3-nguon, 0-hit-la-duong-gia, gate-598, mig-70, hash-CI-khac-local]`
- **07-30 verify `bfc7b79` S162 cây-4-folder-GĐ (FE-ONLY, 17 file, 12 FE = 6+6 đối xứng ×2 app):** ✅ **PASS 5/5** — Run **#432** (id=545) success **5m35s**; gate **590 = 45D+545I** (0 fail/skip). 0 mig/0 BE ⇒ không chân EF. Bundle **4/4 ROTATE** (admin js `BBQKvUMa``DXoYDy7R` · user js `BV5--mPf``o8KDluD1`, css cả 2 đổi); `index.html` LM 10:09:07 in-window + `Cache-Control:no-cache`. 🔴 **BẪY MỚI — MOVING-TARGET `HEAD~1`:** phiên có Stop-hook WAL **tự commit giữa lúc verify**`HEAD~1` đổi nghĩa từ 'cha commit đích' thành 'chính commit đích' trong ~6 phút. Tao đã kết SAI '2 marker có sẵn ở parent ⇒ vô giá trị'; đo lại bằng SHA tuyệt đối `8eba137` thì **cả 2 = 0 hit** ⇒ marker hợp lệ. **LUẬT: verify commit X luôn neo `X^..X` bằng SHA, CẤM dùng `HEAD~1`** (mọi repo có hook auto-commit). 🔑 **Khuôn MỚI 'ĐẾM TRỌN HỌ bằng 1 REGEX'**: `pipeline-[a-z-]{0,24}index` quét bundle ra **đúng 3 phần tử** `{contract×2, khkk×1, pe×1}` khớp source element-wise ⇒ vừa là vế DƯƠNG, vừa tự chứng regex không quét bừa (mạnh hơn đếm chuỗi rời). Dự-đoán-trước-khi-đo khớp từng đồng: `contract-index`=2 (= số FILE trong diff) · VN `Kế hoạch Hợp đồng`=**1** (source 3 **2 comment**) · raw-UTF8 không escape. ⚠️ **suýt FAIL OAN lần nữa:** `Kế hoạch ký kết HĐ` (nhãn strip cũ) = **8/app** — KHÔNG dùng làm control ÂM được vì synthesis ghi rõ nhãn folder GĐ2 **cố ý khác** nhãn strip; 1-mới + 8-cũ cùng sống = chứng phân-biệt-phiên-bản. Control ÂM: chuỗi-BỊA `pipeline-hardcopy-index`=0 (source cũng 0) + hash BỊA & **hash CŨ cả 2 app** đều `ct=text/html` **876B/900B trùng khít size index.html** ⇒ bundle cũ biến mất = ghi đè thật. Smoke 4×200 + bare `/health` **404** (control). WAL-commit `a07b942` (3 file `.md`) bị `paths-ignore` nuốt ⇒ không đẻ run rác. Evidence: `runs/2026-07-30-S162-cay-4-folder-gd/sub-cicd-monitor-2.md`. Tag `[run-432, pass-5-5, CAM-HEAD~1-neo-SHA, dem-tron-ho-1-regex, control-am-DOC-QUYEN-lan-2, gate-590, fe-only]`

View File

@ -141,4 +141,5 @@
- 2026-07-23 · S155 #407 `4510cea` App chuan-hoa ma du-an normalize + PE attach-gate FE both-apps… · PASS 5/5 · `2026-07.md` · substring:"Run #407 `4510cea` (2026-07-23)"
- 2026-07-27 · S155 #416 `b1bae77` Workflow duyet-TAI-CHO + co F6 AllowApproverDelete… · PASS · `2026-07.md` · substring:"Run #416 `b1bae77` (2026-07-27)"
- 2026-07-28 · S157 #419 `a52a91e` PE canh-bao amber o Dia-diem khi du-an thieu Location… · PASS 9/9 · `2026-07.md` · substring:"Run #419 `a52a91e` (2026-07-28)"
- 2026-07-29 · S159 #423 `1a47a61` dot-2 tab strip 4GD + menu 4-GD — PARTIAL 8/9, bat regression revoker an 447/494 row · `2026-07.md` · substring:"Run #423 `1a47a61` (2026-07-29"
- 2026-07-29 · S159 #423 `1a47a61` dot-2 tab strip 4GD + menu 4-GD — PARTIAL 8/9, bat regression revoker an 447/494 row · `2026-07.md` · substring:"Run #423 `1a47a61` (2026-07-29"- 2026-07-30 · S162 #432 `bfc7b79` cay-4-folder-GD FE-ONLY — PASS 5/5, LUAT cam HEAD~1 (neo SHA X^..X) + dem-tron-ho 1 regex · `2026-07.md` · substring:"07-30 verify `bfc7b79` S162"
- 2026-07-31 · S164 #434 `50e6d8c` K1 SP-002 danh-muc loai-HD + Mig 70 — PASS 6/6, tam-giac 3 nguon doc-lap cho seed 86 + bai "0-hit la duong-gia toi khi soi format" · `2026-07.md` · substring:"07-31 verify `50e6d8c` S164 K1"

View File

@ -1,5 +1,7 @@
# Test-Specialist Agent — Persistent Memory
- **S164-K3 (07-31) KHKK level-finalize + pin-đúng-nhóm — 5 test, return SẠCH `[baseline đo tươi 609/0 = 45D+564I KHỚP đề bài · sau: 614/0, Δ+5, 0 hồi quy]`:** +4 vào `ContractSigningPlanApprovalTests.cs` (T-a/b/c finalize + T-e giữ-cờ), +1 vào `ContractSigningPlanGroupAndCatalogTests.cs` (T-d rào (v)) — chọn file thứ 3 thay "file mới" để KHÔNG nhân bản 70 LOC `SeedPeForCreateAsync`+`CreateAsync` đã có sẵn ở đó. **Kỹ thuật chính = MA TRẬN cùng-ruột-khác-1-biến thay cho fault-inject** (không được chạm prod, mà prod đang mang diff lane khác): 1 `SeedFinalizeScenarioAsync` → T-a (cờ+apply=true ⇒ DaDuyet+chốt giá) / T-b (**cùng seed, đổi ĐÚNG `applyLevelFinalize:false`** ⇒ (2,1) advance thường) / T-c (Cấp không cờ + default true ⇒ (0,2)). Bỏ qua cờ ⇒ T-a đỏ · bỏ qua param ⇒ T-b đỏ · `||` thay `&&` ⇒ T-c đỏ. **Điều kiện SỐNG CÒN: đặt cờ ở Bước-2-Cấp-2 của quy trình 3 Bước** — cờ ở cấp cuối thì "kết thúc" xảy ra ở CẢ HAI thế giới ⇒ T-a xanh mà chứng minh 0 (assert `StepCountAsync==3` để chốt). T-d: **CÙNG workflow `KHKK-N5` cho 2 kết quả trái ngược** (phiếu nhóm 3 = 409 · nhóm 5 = qua) + không-đốt-mã (rào (v) `:409-420` đứng TRƯỚC codegen `:428`) có control-dương 001/002/003 tăng thật. Kiểm dữ-liệu chứ không chỉ đọc regex: `DbInitializer.cs:609` sinh đúng `$"KHKK-N{n}"` ⇒ rào neo thật. T-e: 3 vế (tick⇒true · không-tick⇒false · **đối chứng type-1 cũng true** ⇒ ECHO chứ không phải đổi ép-false thành ép-true). **CỐ Ý KHÔNG pin `AllowApproverSkipToFinal==true`** dù mã đang echo: service KHKK 0 lần đọc cờ đó (`:47` khai "không port") ⇒ đó là ứng-viên config-lie #78 CÒN SÓT; pin nó = biến khả-năng-lỗi thành yêu cầu → REPORT lead thay vì test. 0 bug prod. Tag `[s164-k3, level-finalize, ma-tran-cung-ruot-1-bien, co-o-cap-cuoi-lam-rong-phep-do, khong-pin-config-lie]`
- **S161-W2 (07-29) KHKK CRUD 8 test `[hmw wf_7490179a-70b · chết #53 sau khi file LAND (sub-md dừng §0.1) · lead chạy suite tươi 574/0 = 566+8, cả 8 tên đúng]`:** `Application/ContractSigningPlanCrudTests.cs` (7) + extend `Api/AuthorizePolicyRegressionTests.cs` (PIN reflection). 2 PIN spec + 6 acceptance: happy-path Lines=winners + SUM IsSelected đúng NCC (bẫy `w.Id` vs `w.SupplierId`) · second-create 409 **`_AndDoesNotBurnCode`** (mã không đốt khi Conflict — phép đo sequence side-effect) · workflow-guard 2 nhánh Conflict/NotFound · no-winner · **soft-delete-then-recreate + changelog giữ** · owner/phase guard. Phát hiện đắt (sub-md §0.1): **TestApplicationDbContext KHÔNG wire AuditingInterceptor ⇒ `Remove()` = xoá CỨNG ⇒ đo "xoá-tạo-lại" trên xoá-cứng = ĐO NHẦM CƠ CHẾ** (rào 1-PE-1-KH dựa global-filter che row soft-deleted, không phải row biến mất) → dựng fixture riêng CÓ interceptor cho nhóm delete-test. Baseline đo tươi trước khi viết (không tin số đề bài). Tag `[s161-w2, khkk-8-test, khong-dot-ma-khi-409, fixture-interceptor-cho-soft-delete, do-nham-co-che-xoa-cung]`
- **S161 (07-29) W1 KHKK: 4 test schema+codegen `[hmw wf_a95c9909-14b · chết #53 LÚC ĐỢI full-suite (việc thật đã xong) → sub-md 10KB ghi-từng-mục cứu trọn; lead chạy full-suite tươi 566/0 + điền §2/§4 on-behalf]`:** File MỚI `Common/ContractSigningPlanSchemaTests.cs` — 4 test: (1) PIN-spec `BayBang_MoiBang_CoAuditFields_VaQueryFilter` (quét EF model theo NAMESPACE → `BeEquivalentTo` đúng bộ 7 tên [bảng #8 lọt = ĐỎ] + audit-fields + soft-delete assert 2-CHIỀU [BaseEntity phải KHÔNG có IsDeleted] + seed IsDeleted=true chứng filter là BIỂU-THỨC-THẬT); (2) PIN `SigningPlanCodeGenerator_Format_KhkkYearSeqD3` — regex + tuyệt-đối 001→002 + row LastSeq==2 + **đẩy đồng hồ 2027 → KHKK/2027/001 reset** (thiếu vế này `\d{4}` xanh cả khi năm hardcode); (3) #57 xoá-mềm-thêm-lại KHÔNG nổ UNIQUE + vế đối chứng 2-dòng-sống PHẢI DbUpdateException (không có = xanh cả khi index chưa từng tạo); (4) pin slot 10 enum. **Fault-inject 2 chiều trên bảng-kỳ-vọng rồi revert** — chứng `GetDeclaredQueryFilters()` phân biệt 2 thế giới. Learned: (1) **prompt lead ≠ đĩa** — LevelOpinion là AuditableEntity nhưng CỐ Ý KHÔNG filter (lý-do ghi 2 chỗ: site orphan-purge phải thấy row soft-deleted) ⇒ test theo CODE + assert cái-cố-ý thành invariant có răng; (2) EF Core 10: `GetQueryFilter()` obsolete-prone → `(IReadOnlyEntityType).GetDeclaredQueryFilters()`; (3) Serializable trên SQLite = graceful-map, tiền lệ `SqliteDbFixture.cs:36-42` + PE codegen test — KHÔNG chế fixture mới; (4) KHÔNG `Remove()` đo xoá mềm (TestCtx không wire AuditingInterceptor → xoá CỨNG, bài S155). Tag `[s161, w1-4-test, faultinject-2-chieu, test-theo-code-khong-theo-prompt, clock-2027-reset, 53-luc-doi-suite]`

View File

@ -0,0 +1,219 @@
CICD-K2: PARTIAL — 5/6 mục (mục 4 = CHÂN-LÝ-RỖNG, không phải sạch)
# CI/CD verify K2 SP-002 — commit `0779f2d`
> Artifact chống #53 — ghi TỪNG MỤC NGAY KHI ĐO. Vai: cicd-monitor (READ-only). Ngày: 2026-07-31.
> Deploy KHỎE, KHÔNG khuyến nghị rollback. Điểm trừ duy nhất = **phép đo mục 4 không có răng**
> (bảng rỗng), không phải lỗi deploy.
---
## Mục 0 — Push + path-filter (tiền đề)
```
$ git log -1 --format='%H|%s|%ci'
0779f2d45a932ec7c279c191d9ae3fe3505442af|[CLAUDE] Contract: K2 SP-002 phieu KHKK mang hang-muc + nhom duyet N1-8 (Mig 71) + FE x2 app + 11 test|2026-07-31 17:05:27 +0700
$ git log origin/main..HEAD --oneline
(RỖNG — đã push trọn)
```
`git diff --name-only 0779f2d^..0779f2d` = **23 file**: BE 9 · Mig 71 ×3 file (3-file rule đủ) ·
FE 8 (admin ×4 + user ×4 đối xứng) · test 3 · paths-ignore 2 (`docs/STATUS.md`,
`.claude/skills/ef-core-migration/SKILL.md` — KHÔNG chặn vì ≥1 file code).
Neo bằng SHA tuyệt đối `0779f2d^..0779f2d` (LUẬT S162: cấm `HEAD~1`, Stop-hook WAL auto-commit).
## Mục 0b — SNAPSHOT PRE-MIGRATE (đo lúc run #435 còn `running`, 17:07)
```
MIGTOP=20260731072441_AddContractCatalogEntries <- Mig 70 (K1), Mig 71 CHƯA áp
PLANS=0 · LINES=0 · SUMPE=NULL|SUMPROP=NULL · SYSTABLES=97
admin /assets/index-CKgq0YlH.js + /assets/index-DD3xQhNt.css
eoffice /assets/index-CHMCU35C.js + /assets/index-CdXRNT7E.css (khớp mốc K1 #434)
```
🔴 **KHAI TRƯỚC — MỤC 4 SẼ RỖNG, KHÔNG PHẢI SẠCH.** Xem mục 4.
---
## Mục 1 — Gitea Actions run ✅ PASS
| | |
|---|---|
| Run | **#435** (id=548), `0779f2d4` |
| Status | **success** |
| Cửa sổ | created `17:05:33` → updated `17:11:45` = **6m12s** |
| URL | https://git.baocaogiaoduc.vn/vietreport-admin/solution-erp/actions/runs/435 |
Poll 6 vòng (17:08:31 → 17:12:26), running×5 → success. 6m12s ≈ K1 #434 (6m31s) — hợp lý cho
+Mig +11 test, chưa tới ngưỡng escalate.
## Mục 2 — Test gate 609 ✅ PASS (khớp TÁCH-PHẦN, không chỉ khớp tổng)
Log web-UI `.../actions/runs/435/jobs/0/logs` (18.677 B), grep nới `Passed![^\r\n]*`:
```
Passed! - Failed: 0, Passed: 45, Skipped: 0, Total: 45, Du
Passed! - Failed: 0, Passed: 564, Skipped: 0, Total: 564, Du
```
| | Domain | Infra | Tổng |
|---|---|---|---|
| K1 #434 | 45 | 553 | 598 |
| K2 #435 | **45** | **564** | **609** |
| delta | **0** | **+11** | +11 |
Kỳ vọng 609 = 598 + 9 lane + 2 gate-fix ⇒ khớp **từng vế**: Domain đứng yên, +11 rơi TRỌN vào
Infra — đúng nơi 3 file test mới nằm (`ContractSigningPlanApprovalTests` /
`~GroupAndCatalogTests` / `~SchemaTests`). 0 fail, 0 skipped.
Control ÂM: grep chuỗi bịa `ZZZ_KHONG_TON_TAI_9x7` = **0 hit**.
⚠️ Dùng regex nới ngay từ đầu vì bài K1: log dùng **2 khoảng trắng** sau `Passed!`.
## Mục 3 — EF migration prod + chứng-restart ✅ PASS
```
MIGTOP=20260731085624_AddKhkkApprovalGroupCatalogAndFinalizeRuntime <- TOP, đúng Mig 71
MIGCOUNT=71
SYSTABLES=97 (GIỮ NGUYÊN — Mig 71 có 0 CreateTable)
```
`sys.tables` 97 == 97 khớp phép đo nguồn `grep -c "CreateTable"` = **0** (control dương:
`grep -c "AddColumn"` = **4** ⇒ grep có tìm được thứ có thật).
**3-CHÂN-KIỀNG (né bẫy #423 đo-trước-recycle):**
1. *Restart thật:* `appcmd list wp``WP "5364" (applicationPool:SolutionErp-Api)`;
`Get-Process w3wp` → PID **5364 StartTime 17:11:25** ∈ cửa sổ deploy (17:05:3317:11:45).
(5 w3wp còn lại là pool VIETREPORT/Default — đã map PID→pool để không nhận nhầm.)
2. *Dòng ĐẦU Serilog:* `2026-07-31 17:11:27.310 +07:00 [INF] Applying migrations...`
3. *Mọi phép đo DB dưới đây chạy từ 17:12+* ⇒ SAU mốc recycle.
## Mục 4 — Data prod sau migrate ⚠️ PARTIAL — **CHÂN-LÝ-RỖNG**
Số đo nguyên văn (POST-migrate, sau recycle):
```
TOTAL=0 · GROUP1=0 · OUTOFRANGE=0 · LINES=0 · SUMPE=NULL|SUMPROP=NULL
CTRL_SUPPLIERS=60 · CTRL_CATALOG=86 <- control dương: bộ đếm CÓ đếm được
```
| Acceptance yêu cầu | Kết quả | Giá trị bằng chứng |
|---|---|---|
| `COUNT(ApprovalGroup=1) == COUNT(*)` | `0 == 0` ✅ | **RỖNG — 0 bit thông tin** |
| `COUNT(ApprovalGroup NOT BETWEEN 1 AND 8) = 0` | `0` ✅ | **RỖNG** |
| Σ `PeReferenceAmount`/`ProposedAmount` | `NULL\|NULL` (PRE cũng `NULL\|NULL`) | **RỖNG hai phía** |
🔴 `ContractSigningPlans` prod có **0 dòng cả TRƯỚC lẫn SAU**. Ba acceptance "xanh" nhưng
**không mang thông tin nào** về việc backfill có chạy đúng — backfill hụt sẽ trông y hệt.
Chính tác giả Mig 71 đã khai bẫy này ("đo local Σ = tập RỖNG hai phía … phép đo prod mới có
răng"); nay prod cũng rỗng ⇒ **phép đo prod cũng KHÔNG có răng**. Không hạ thành FAIL (không có
gì sai), nhưng CẤM đọc thành PASS.
**Bằng chứng THAY THẾ — không phụ thuộc số dòng (CÓ RĂNG):**
*(a) 4 cột, đúng kiểu/nullability/default (`INFORMATION_SCHEMA.COLUMNS`):*
```
ContractSigningPlans |ApprovalGroup |int |null=NO |def=((0))
ContractSigningPlans |EndedByLevelFinalize|bit |null=NO |def=(CONVERT([bit],(0)))
ContractSigningPlanLines |CatalogEntryId |uniqueidentifier|null=YES|def=-
ContractSigningPlanLines |TenHangMuc |nvarchar(500) |null=YES|def=-
```
*(b) HOÁN-ĐỔI INDEX — chứng bằng **liệt kê TRỌN HỌ** (14 index cả 2 bảng), không bằng grep-rỗng:*
```
✅ CÓ : IX_ContractSigningPlanLines_ContractSigningPlanId_SupplierId_CatalogEntryId
unique=1 | filter=([IsDeleted]=(0)) | cols=ContractSigningPlanId,SupplierId,CatalogEntryId
✅ CÓ : IX_ContractSigningPlanLines_CatalogEntryId (cols=CatalogEntryId)
✅ CÓ : IX_ContractSigningPlans_ApprovalGroup_Phase (cols=ApprovalGroup,Phase)
❌ MẤT: IX_ContractSigningPlanLines_ContractSigningPlanId_SupplierId (unique 2-cột cũ)
```
Vắng-mặt của index cũ suy ra từ **danh sách đầy đủ 14 index có thật**, nên đây là vắng-mặt ĐO
ĐƯỢC chứ không phải "truy vấn trả rỗng đọc thành sạch". Đây là phép đo mạnh nhất của mục 4.
*(c) Bất-biến Σ tiền — chứng theo CẤU TRÚC bằng phép ĐO nguồn:*
`grep -nE "PeReferenceAmount|ProposedAmount|ApprovedAmount"` trên Mig 71 → chỉ **2 hit, cả 2 ở
dòng 23-24 và đều là comment `//`** ⇒ **0 câu lệnh thực thi** chạm cột tiền. `migrationBuilder.Sql`
= đúng **1** câu (dòng 61, backfill `ApprovalGroup=1`). Σ bất biến đúng kể cả khi bảng có dữ liệu.
*(d) Materialize qua EF:* xem mục 6 — `?group=N` trả **200** (không 500) ⇒ cột `ApprovalGroup`
tồn tại & query được xuyên Domain→Infra→Api trên DB thật.
## Mục 5 — Bundle ×2 app (#77) ✅ PASS — 4/4 ROTATE
| app | asset | TRƯỚC (K1 #434) | SAU (K2 #435) |
|---|---|---|---|
| admin | js | `CKgq0YlH` | **`DVQEwGwf`** |
| admin | css | `DD3xQhNt` | **`D050EVYG`** |
| eoffice | js | `CHMCU35C` | **`CZAYiWWa`** |
| eoffice | css | `CdXRNT7E` | **`DX1FRk5n`** |
*Hash CŨ chết (control âm tầng-2 — bundle cũ biến mất khỏi đĩa):*
```
admin /assets/index-CKgq0YlH.js -> 200|text/html|900 (SPA fallback, không phải JS)
eoffice/assets/index-CHMCU35C.js -> 200|text/html|876
admin /assets/index-ZZBIADAT.js -> 200|text/html|900 <- hash BỊA, cùng dấu hiệu
```
Hash bịa cho ra ĐÚNG dấu hiệu của hash cũ ⇒ `text/html` thật sự nghĩa là "file không tồn tại"
(né bẫy SPA-fallback-200 S72; dùng `Content-Type` chứ không neo byte).
*Last-Modified in-window:* admin `10:10:20 GMT` = 17:10:20 +07 · eoffice `10:11:20 GMT` =
17:11:20 +07 — cả hai ∈ (17:05:33, 17:11:45). `Cache-Control: no-cache` cả 2 (hardening S88).
## Mục 6 — Smoke ✅ PASS
| Phép đo | Kỳ vọng | Thực tế |
|---|---|---|
| `/health/ready` | 200 | **200** |
| `/health/live` | 200 | **200** |
| `/health` trần | (control) | **404** ⇒ 200 ở trên có nghĩa |
| `GET /api/contract-signing-plans?group=2` KHÔNG token | 401 | **401** |
| route BỊA `/api/contract-signing-plans-KHONG-TON-TAI?group=2` | 404 | **404** |
| route BỊA 2 `/api/zzz-fake-9x7` | 404 | **404** |
| admin.solutions.com.vn | 200 | **200** |
| eoffice.solutions.com.vn | 200 | **200** |
401 (route có thật, có gác) tương phản 404 ×2 (route không có) ⇒ **401 CÓ NGHĨA**.
*Có token admin* (login `/api/auth/login`, `accessToken` len=468):
```
?group=1 -> 200 {"items":[],"total":0,"page":1,"pageSize":20,"totalPages":0,...}
?group=2 -> 200 (thân y hệt)
?group=8 -> 200 (thân y hệt)
inbox -> 200 deleted -> 200
```
"mọi item `approvalGroup==1`" ⇒ **VACUOUS (0 item)** — không tính là bằng chứng.
⚠️ Đã soi **thân RAW** (không tin bộ parse) đúng bài K1 "0-hit là dương-giả tới khi soi format":
thân thật là `{"items":[],"total":0}` ⇒ rỗng THẬT, không phải lỗi đọc format.
**Chứng `group` ĐƯỢC WIRE (không bị nuốt im lặng):**
```
?group=abc -> 400 <- bind int THẬT; nếu param không tồn tại, ASP.NET bỏ qua query lạ và trả 200
```
Khớp nguồn: `ContractSigningPlansController.cs:42``[FromQuery(Name = "group")] int? group = null`
`:49` truyền vào `ListContractSigningPlansQuery(..., group)`.
**Control DƯƠNG cho đường ống list:** `GET /api/catalogs/contract-catalog`**86 item**
(seed K1 còn nguyên) ⇒ token + serializer + đường ống list CHẠY, nên `items:[]` của KHKK là
bảng rỗng thật, không phải ống vỡ.
---
## Lệch / quan sát (không chặn deploy)
1. **`?group=999` → 200** (không 400). Miền nghiệp vụ là 1..8 nhưng biên API không chặn
ngoài-miền; hiện vô hại (trả rỗng) và mục 4 đã xác nhận 0 dòng ngoài 1..8. Ghi cho K4b cân
nhắc cùng lớp với validator `CatalogEntryId` bắt buộc mà Mig 71 đã hẹn.
2. **Ghost-window `EndedByLevelFinalize`** — tác giả khai trước: K2 đẻ cột, K3 tiêu thụ; tới khi
K3 land cột luôn = 0. Đã xác minh cột tồn tại `bit NOT NULL default 0`; **không** coi
"toàn 0" là dữ liệu hỏng.
3. **Mục 4 rỗng** — xem trên. Khi có phiếu KHKK thật trên prod, cần đo LẠI 3 acceptance đó thì
mới có răng; hiện chưa thể kết luận backfill đúng/sai từ dữ liệu.
## Bảng tổng
| # | Mục | Verdict |
|---|---|---|
| 1 | Gitea run `0779f2d` success | ✅ PASS |
| 2 | Test gate 609 = 45D + 564I | ✅ PASS |
| 3 | Mig 71 TOP + chứng-restart | ✅ PASS |
| 4 | Data prod sau migrate | ⚠️ **PARTIAL — vacuous** (schema/index CÓ răng ✅) |
| 5 | Bundle ×2 app rotate + hash cũ chết | ✅ PASS |
| 6 | Smoke 401/404/200 + group wired | ✅ PASS |
**Không ghi gì lên prod ngoài đọc** (toàn bộ là SELECT / curl GET / login POST lấy token).
END — VERDICT=PARTIAL 5/6 (mục 4 vacuous, deploy khỏe, không rollback)

View File

@ -0,0 +1,100 @@
K3-BE: IN-PROGRESS — bước-0 XONG (lead), §1-8 p2 đang chạy
# sub-implbe-k3 — K3 BACKEND (S165, wave 3/8: 8 workflow nhóm + finalize + sweep)
> 🔴 **KHAI VẾT:** p1 wf_a04124a7-0fe = class *engine-process-exit* (35 tool-use/458s nhưng **0 delta đĩa
> + 0 artifact + return rỗng** — lượt chạy coi như chưa từng tồn tại; #53 tally cả wave). Bước-0 dưới đây
> = **LEAD tự chạy** (ssh + sqlcmd trực tiếp), p2 seed từ bảng ĐÃ VERIFY này, không cần ssh lại.
## §0 [LEAD] FAIL-CLOSED verify đội-hình QT-DN-V2-001 LIVE — PASS
- Instance prod = `localhost\SQLEXPRESS` (named instance — `-S localhost`/`tcp:1433` đều fail; ghi lại cho
mọi lane sau đỡ dò). Windows-auth `-E` qua `ssh vietreport-vps` chạy được.
- `ApprovalWorkflows WHERE Code='QT-DN-V2-001'` = **3 row: 2 archived (IsActive=0) + 1 LIVE (IsActive=1)**,
ApplicableType=**1** (Duyệt NCC/PE — nguồn đội-hình, đúng OG-2 + note owner slot-54 "lấy đúng bộ người
duyệt đang cấu hình ở workflow Duyệt NCC prod"). TotalWf active toàn hệ = 7.
- **ROSTER bản LIVE (nguyên văn sqlcmd, mojibake console đã chuẩn hoá dấu theo email đã biết):**
| Bước | Dept | Cấp | Email | Tên |
|---|---|---|---|---|
| 1 «Phòng 1» | Phòng Cung Ứng (PRO) | 1 (OR-4) | phuong.nguyen@solutions.com.vn | Nguyễn Thị Bích Phượng |
| 1 | Phòng Cung Ứng | 1 | long.chau@solutions.com.vn | Châu Tạ Kim Long |
| 1 | Phòng Cung Ứng | 1 | duy.nguyen@solutions.com.vn | Nguyễn Văn Duy |
| 1 | Phòng Cung Ứng | 1 | binh.lethanh@solutions.com.vn | Bình Lê Thanh |
| 1 | Phòng Cung Ứng | 2 | tra.bui@solutions.com.vn | Bùi Lê Thủy Trà |
| 2 «Phòng 2» | P. Kiểm soát Chi phí (CCM) | 1 (OR-4) | luu.tran@solutions.com.vn | Trần Xuân Lưu |
| 2 | P. Kiểm soát Chi phí | 1 | truong.letrandang@solutions.com.vn | Lê Trần Đăng Trường |
| 2 | P. Kiểm soát Chi phí | 1 | binh.le@solutions.com.vn | Lê Văn Bình |
| 2 | P. Kiểm soát Chi phí | 1 | nguyen.ho@solutions.com.vn | Hồ Thị Nữ Nguyên |
| 2 | P. Kiểm soát Chi phí | 2 | chuong.phan@solutions.com.vn | **Phan Văn Chương** (TP.CCM — giữ cờ finalize OG-3/OG-9) |
| 3 «Phòng 3» | Ban Giám đốc (CEO) | 1 | truong.nguyen@solutions.com.vn | Nguyễn Văn Trường |
- **Vá-11 danh-tính (phân giải bằng dept trên bản LIVE):** `truong.letrandang` (CCM C1) ⟂ `truong.nguyen`
(BGĐ C1 = CEO) — 2 người khác dept, không nhầm; `binh.lethanh` (PRO C1) ⟂ `binh.le` (CCM C1) — khác dept,
không nhầm. Cặp "tring.le"/"thanh.lethanh" trong câu-đố spec KHÔNG xuất hiện trên roster LIVE ⇒ là bẫy
chính-tả từ ảnh, roster email là chân lý.
-**Mô-hình 3 trạm PRO → CCM → CEO** với OR-of-N cấp 1 mỗi trạm + cấp 2 = trưởng phòng. Seed 8 workflow
KHKK-N1..N8 mirror ĐÚNG cấu trúc này (3 Steps × Levels như bảng), resolve user theo EMAIL.
(§1-8 p2 ghi tiếp bên dưới)
---
# §1 [p2] KẾ HOẠCH (ghi TRƯỚC khi code — 3 dòng)
1. **A**`DbInitializer.cs`: thêm `SeedKhkkGroupWorkflowsAsync(db, userManager, logger)` (UNGATED, đặt sau
cụm 4 seeder WorkflowApps V2 ở `InitializeAsync`), mirror khuôn `SeedSampleContractWorkflowV2Async`; resolve
11 email → dict 1 lượt, rồi loop N=1..8 tạo `KHKK-N{n}` (type-10) 3 Steps × Levels theo roster §0.
2. **B**`MenuKeys.cs`: +8 const `AwV2_KhkkN1..N8` + vào `All` (ràng buộc T6: key ∈ All BẮT BUỘC có menu-row)
`DbInitializer.SeedMenuTreeAsync`: +8 row con dưới `ApprovalWorkflowsV2` (order 3..10), idempotent per-Key.
3. **C**`dotnet build SolutionErp.slnx` (0W/0E) + `dotnet test SolutionErp.slnx` full (baseline 609/0) + đếm
`MenuKeys.All` bằng lệnh máy → ghi DELTA.
**Đo TRƯỚC khi sửa (baseline, lệnh máy chạy ở §4):** `MenuKeys.All` hiện = ? (đo ở §4, không đoán).
🔴 **2 lệch phát hiện lúc khảo sát (khai ngay, KHÔNG tự quyết):**
- **L1 — "vùng seed workflow type-10 hiện có (W3 S161)" KHÔNG TỒN TẠI.** `grep ContractSigningPlan
DbInitializer.cs` = **0 hit** (chỉ có `SeedKeHoachKyKetAccessAsync` = seed *quyền*, không phải workflow).
⇒ Không có khuôn type-10 để mirror; tôi mirror khuôn gần nhất **cùng lớp** = `SeedSampleContractWorkflowV2Async`
(`:226`) / `SeedSampleProposalWorkflowV2Async` (`:276`) — cùng cách dựng `ApprovalWorkflow + Steps + Levels`.
- **L2 — `AllowApproverFinalize` bị ÉP `false` server-side cho type-10 ở đường Designer.**
`ApprovalWorkflowV2AdminFeatures.cs:380-381`: `AllowApproverFinalize = typeEnum != ...ContractSigningPlan && l.…`
(W1 S161 F-8 containment). Seeder ghi entity TRỰC TIẾP nên **không bị chặn**, nhưng nếu admin bấm "tạo version
mới" qua Designer thì cờ rơi về false. Tôi vẫn set `true` theo lệnh (OG-3/OG-9) + ghi comment đúng câu lead
giao; **nhịp sau cùng wave** port nhánh service + (nếu owner chốt) gỡ ép-false ở `:381`.
---
# §2 VIỆC A — `SeedKhkkGroupWorkflowsAsync` — DONE
**File:** `src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs` (2 chỗ)
- `:160-171` — gọi trong `InitializeAsync`, **UNGATED** (ngoài `if (!demoSeedDisabled)`), đặt ngay sau cụm
4 seeder WorkflowApps V2 và trước `WarnDefaultAdminPasswordAsync`. Comment nêu gotcha #51.
- `:520-…` — thân method, ngay sau `SeedSampleVehicleBookingWorkflowV2Async`.
**Đã làm đúng lệnh:**
- 8 workflow `KHKK-N1..KHKK-N8`, `ApplicableType = ContractSigningPlan` (=10), `Version=1`,
`IsActive=true`, `IsUserSelectable=true`, `Name = $"Duyệt KHKK — Nhóm {n}"` (em-dash + khoảng trắng
y như lệnh), `ActivatedAt=UtcNow`.
- Idempotent per-Code literal theo lệnh: `if (await db.ApprovalWorkflows.AnyAsync(w => w.Code == code)) continue;`
- 3 Steps Order 1/2/3, Name `"Phòng Cung Ứng"` / `"P. Kiểm soát Chi phí"` / `"Ban Giám đốc"`,
`DepartmentId` = hint theo mã phòng `PRO` / `CCM` / `BOD` (`SeedDepartmentsAsync:2744`; nullable ⇒ phòng
vắng cũng không chặn).
- Levels đúng roster §0: Step1 C1 OR-4 + C2 `tra.bui`; Step2 C1 OR-4 + C2 `chuong.phan`; Step3 C1 `truong.nguyen`.
- `AllowApproverFinalize = true` **CHỈ** ở Step2/Cấp2 (`chuong.phan`) — ghi comment nguyên câu lead giao
(OG-3/OG-9 + "nhánh service port ở nhịp sau CÙNG WAVE trước push nên không phải config-lie #78").
Đặt PER-ROW (khuôn F5/F6) nên chỉ 1 người mang cờ, không phát cho cả Cấp.
- Resolve email→user 1 lượt (11 email `Distinct` OrdinalIgnoreCase) qua `userManager.FindByEmailAsync`;
thiếu ⇒ log warning gộp + **bỏ slot**, KHÔNG throw. Cấp 0 người ⇒ bỏ Cấp; Bước 0 Cấp ⇒ bỏ Bước + warn;
workflow 0 Bước ⇒ VẪN tạo + warn (đúng lệnh).
- Log: `logger.LogInformation("Seeded {Count} KHKK group workflows (K3 SP-002)", seeded)` — chỉ khi `seeded > 0`.
**2 quyết định nhỏ tôi tự chốt (khai để lead soi):**
- ⚠️ **KHÔNG dùng khuôn `AnyAsync(w.ApplicableType == …)`** của 6 seeder mẫu: 8 workflow DÙNG CHUNG type-10
⇒ check-theo-type sẽ chặn 7 nhóm còn lại ngay sau khi N1 vừa tạo. Đây là chỗ khuôn cũ **không** copy được.
- **Order của Cấp đánh LẠI liên tục** (`levelOrder++` chỉ khi cấp có người) thay vì cứng 1/2 — nếu dev-DB thiếu
đúng người Cấp 1 thì workflow còn mỗi Cấp Order=2, vi phạm ràng buộc sequential của Designer
(`ApprovalWorkflowV2AdminFeatures.cs` `HaveSequentialOrders`). Trên prod (đủ 11 người) kết quả **y hệt** 1/2.
**Build chunk A:** `dotnet build src/Backend/SolutionErp.Infrastructure/SolutionErp.Infrastructure.csproj`
**0 Warning / 0 Error**.

View File

@ -0,0 +1,109 @@
# K3-BE: IN-PROGRESS — build · test · roster-live ?
> Sub: implementer-backend-0 (wave K3). Bat dau 2026-07-31.
> ARTIFACT chong #53 — ghi LIEN TUC tung muc, KHONG doi den cuoi.
## Trang thai khoi dong
- git branch: main, HEAD `0779f2d` ([CLAUDE] Contract: K2 SP-002 ... Mig 71)
- git status truoc khi minh Write: 12 M + 2 ?? (khong co file BE code nao dirty) — snapshot ghi o duoi.
```
M .claude/WAL.md
M .claude/agents/README.md
M .claude/auto-memory/MEMORY.md
M .claude/auto-memory/feedback_agent_return_garble_recover.md
M .claude/commands/session-end.md
M .claude/commands/session-start.md
M .claude/governance/.session-counter.json
M .claude/skills/README.md
M .claude/skills/permission-matrix/SKILL.md
M .claude/workflows/runs/2026-07-31-S164-4gd-khkk-fanout/sub-reviewer-gate-k2.md
M CLAUDE.md
M docs/governance/ctx-soft-memory-se-draft.md
?? .claude/auto-memory/feedback_may_rong_ui_hep.md
?? .claude/auto-memory/project_sol_ccm_sp002_signing_matrix.md
?? .claude/workflows/runs/2026-07-31-S164-4gd-khkk-fanout/sub-cicd-verify-k2.md
```
## Tick 0-8 (cap nhat dan)
- [x] 0. FAIL-CLOSED verify doi-hinh LIVE prod (OG-2) — **THAY: workflow LIVE ton tai** (chi tiet duoi)
- [ ] 1. Seed 8 workflow KHKK-N1..N8 type-10
- [ ] 2. Port nhanh LEVEL-FINALIZE vao ContractSigningPlanWorkflowService
- [ ] 3. Sweep ep-false va-3 (>=3 site BE)
- [ ] 4. Validator phieu-pin-dung-nhom (QD-3)
- [ ] 5. Seed AllowApproverFinalize=true cap CCM
- [ ] 6. MenuKeys +8 AwV2_KhkkN1..N8 + menu-row
- [ ] 7. Va-12 comment supersede-S155
- [ ] 8. build 0W/0E + test FULL (baseline 609)
---
# Muc 0 — FAIL-CLOSED verify doi-hinh LIVE (OG-2) — **PASS, workflow LIVE co that**
## Cach do (READ-ONLY, 0 ghi prod)
`ssh vietreport-vps``sqlcmd -S '.\SQLEXPRESS' -d SolutionErp -E` (integrated auth `-E`, KHONG cham password vrapp — khuon S147).
Nhan tieng Viet doc qua `CONVERT(varchar(N), CAST(col AS varbinary(N)), 2)` → hex → decode `utf-16-le` phia client
(khuon cicd-verify-dot2: sqlcmd mangle `Duy?t` khong phan biet duoc dau).
⚠️ 3 lech vs tai lieu, da tu sua khi do (khong bao "khong query duoc"):
1. De-bai ghi `QT-DN-V2-001` **type 3** → LIVE la **ApplicableType=1** (DuyetNcc). Type 3 = `QT-HD-V2-001` (HD). Doi-hinh dung van la QT-DN-V2-001.
2. Bang Identity ten **`Users`** khong phai `AspNetUsers` (rename Mig 1).
3. FK ten **`ApprovalWorkflowLevels.ApprovalWorkflowStepId`** khong phai `StepId`.
## Ban do LIVE — `QT-DN-V2-001` **Version 4** (IsActive=1)
Chung "khong phai archived": 3 version tren prod — v2 `IsActive=0`, v3 `IsActive=0`, **v4 `IsActive=1` + IsUserSelectable=1**.
```
QT-DN-V2-001|2|1|0|0
QT-DN-V2-001|3|1|0|0
QT-DN-V2-001|4|1|1|1 <- LIVE
```
### BANG ROSTER NGUYEN VAN (11 slot, decode tu hex)
| Ver | Buoc | Dept | Cap | Email | Fin | Skp | Bud | Ho ten (LIVE) | Chuc danh (LIVE) |
|---|---|---|---|---|---|---|---|---|---|
| 4 | 1 | PRO | 1 | `binh.lethanh@solutions.com.vn` | 0 | 0 | 1 | Binh Le Thanh | Nhân viên |
| 4 | 1 | PRO | 1 | `duy.nguyen@solutions.com.vn` | 0 | 0 | 1 | Nguyễn Văn Duy | Nhân viên Cung ứng |
| 4 | 1 | PRO | 1 | `long.chau@solutions.com.vn` | 0 | 0 | 1 | Châu Tạ Kim Long | Nhân viên Cung ứng |
| 4 | 1 | PRO | 1 | `phuong.nguyen@solutions.com.vn` | 0 | 0 | 1 | Nguyễn Thị Bích Phượng | Nhân viên Cung ứng |
| 4 | 1 | PRO | 2 | `tra.bui@solutions.com.vn` | 0 | 1 | 1 | Bùi Lê Thủy Trà | TP Cung ứng |
| 4 | 2 | CCM | 1 | `binh.le@solutions.com.vn` | 0 | 0 | 1 | Lê Văn Bình | Nhân viên Kiểm soát Chi phí |
| 4 | 2 | CCM | 1 | `luu.tran@solutions.com.vn` | 0 | 0 | 1 | Trần Xuân Lưu | Nhân viên Kiểm soát Chi phí |
| 4 | 2 | CCM | 1 | `nguyen.ho@solutions.com.vn` | 0 | 0 | 1 | Hồ Thị Nữ Nguyên | Nhân viên Kiểm soát Chi phí |
| 4 | 2 | CCM | 1 | `truong.letrandang@solutions.com.vn` | 0 | 0 | 1 | Lê Trần Đăng Trường | Nhân viên |
| 4 | 2 | CCM | 2 | `chuong.phan@solutions.com.vn` | **1** | 1 | 1 | Phan Văn Chương | TP Kiểm soát Chi phí |
| 4 | 3 | BOD | 1 | `truong.nguyen@solutions.com.vn` | 0 | 1 | 1 | Nguyễn Văn Trường | Tổng Giám đốc (CEO) |
⇒ Hinh dang = **PRO C1 OR-of-4 + C2 ×1 · CCM C1 OR-of-4 + C2 ×1 · BOD C1 ×1** — KHOP y nguyen K3.a nen.
`AllowApproverFinalize=1` **DA CO SAN tren prod** dung o chuong.phan (CCM C2) — tien le song, khong phai co-che moi.
⇒ Ten Buoc tren LIVE la "Phòng 1/2/3" (nhat nheo); K3.a nen dat ten mo-ta ("Phòng Cung ứng"/"Phòng KSCP"/"Ban Giám đốc") — **ten = display-only, danh tinh = Dept + ApproverUserId** ⇒ dung ten nen, khai lech o day.
## VA-11 — DO DANH TINH 2 CAP GAN-TRUNG: **CA 2 DEU LA TRUNG-TAI-KHOAN THAT tren prod**
Phat hien nang: **prod co 2 tai khoan cho CUNG 1 nguoi**, va **workflow LIVE dung tai khoan TAO TAY, khong phai tai khoan seeder**.
| Cap | Tai khoan A (DbInitializer seed) | Tai khoan B (tao tay tren prod) | LIVE workflow dung | Phan giai |
|---|---|---|---|---|
| PRO "Thanh/Bình" | `thanh.lethanh@` — PRO — "Lê Thanh Bình" — *Nhân viên Cung ứng* (`DbInitializer.cs:1617`) | `binh.lethanh@` — PRO — "Binh Le Thanh" (**khong dau**) — *Nhân viên* | **B `binh.lethanh@`** | Cung dept PRO, cung nguoi; chuc danh khac ("Cung ứng" vs tran). Phan giai bang **bang LIVE**: slot Buoc1/Cap1 tro toi B. |
| CCM "Trưởng" | `truong.le@` — CCM — "Lê Trần Đăng Trường" — *Nhân viên Kiểm soát Chi phí* (`DbInitializer.cs:1626`) | `truong.letrandang@` — CCM — "Lê Trần Đăng Trường" (**ten y het**) — *Nhân viên* | **B `truong.letrandang@`** | Ten TRUNG KHIT ⇒ khong the match bang ten. Phan giai bang **bang LIVE**: slot Buoc2/Cap1 tro toi B. |
3 ung vien nhieu loan da loai dut khoat bang **dept**:
- `tring.le@` = "Lê Tự Đăng Trình" (CCM) — **nguoi KHAC han**, KHONG co trong workflow LIVE ⇒ loai.
- `truong.nguyen@` = "Nguyễn Văn Trường" (**BOD**, CEO) — o Buoc 3, khong phai CCM ⇒ khong nham voi `truong.le*`.
- `binh.le@` = "Lê Văn Bình" (**CCM**) vs `binh.lethanh@` (**PRO**) — tach bang dept, ca 2 DEU trong roster nhung khac buoc.
Kiem ton tai + active (prod): **11/11 email = IsActive 1**. 3 dept `PRO`/`CCM`/`BOD` deu `IsDeleted=0`.
### 🔴 QUYET-DINH ky thuat rut ra (khai de owner soi)
Seeder **KHONG the hardcode 1 email duy nhat/slot**: email LIVE (`binh.lethanh@`, `truong.letrandang@`) **khong ton tai tren Dev/local/test** (DbInitializer chi tao `thanh.lethanh@`/`truong.le@`) ⇒ seed se fail-soft bo qua toan bo, Dev 0 workflow.
⇒ Dung **chuoi uu-tien alias/slot**: thu email LIVE TRUOC, khong co thi lui ve email seeder. Prod → khop dung nguoi dang duyet that; Dev/test → khop tai khoan seeder. Ca 2 vang mat moi fail-soft. Moi lan dung alias-du-phong deu `LogWarning` neu ro → co dau vet.
⚠️ Day KHONG phai quyet dinh danh-tinh moi: uu tien 1 = **dung nguoi tren ban LIVE** (OG-2). Chi la duong lui cho moi truong khong co ban sao tay.
⚠️ De-xuat cho owner (NGOAI pham vi wave nay, khong tu lam): don trung 2 cap tai khoan tren prod (vo hieu 1 ben) — hien 1 nguoi 2 tai khoan la nguon nham lan that.
## Muc 0 — VERDICT
**PASS** — workflow LIVE `QT-DN-V2-001` v4 ton tai (IsActive=1, khong archived), roster 11 slot ghi nguyen van o tren,
2 cap gan-trung phan giai xong bang dept + chuc danh + vi tri slot tren ban LIVE. **Duoc phep seed.**
Doi chung nen: prod hien **0** workflow `ApplicableType=10`, **0** Code `KHKK-N%` ⇒ seed lan dau se tao dung 8.
---

View File

@ -0,0 +1,41 @@
K3-FE: DONE — build ×2 PASS · SHA-pair Panel 336ECCC1F86D ×2 (lead chốt trọng-tài — xem đuôi file)
# sub-implfe-k3 — K3-FE 3 việc FE (Layout AwV2_KhkkN*, gỡ khóa duyệt-sớm KHKK, comment supersede Panel)
> Artifact chống #53 — đổ liên tục từng việc. Dòng-1 = verdict (cập nhật cuối).
> Run: 2026-07-31-S164-4gd-khkk-fanout · role: implementer-frontend · lượt K3
## §0 — Trạng thái mở lượt (đo đĩa TRƯỚC khi gõ, bài học S164/K2)
- `fe-admin/src/components/Layout.tsx:170-176``awV2Match` whitelist hiện: `'DuyetNcc' | 'DuyetNccPhuongAn' | 'Contract'`. Chưa có nhánh KhkkN. ✔ khớp đề-bài.
- `Khkk_*` staticMap ĐÃ CÓ SẴN 6 dòng (`:35-40`, từ W2 S161) — đề-bài "CẤM nới họ Khkk_" ⇒ đọc là **cấm THÊM dòng mới**, 6 dòng cũ giữ nguyên byte.
- `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx` 1368 dòng. Lock KHKK sống ở **4 cụm**: `:151` note-const · `:242-259` `levelFlagsPayload(e, lockEarlyFinalize)` + 2 call-site `:783/:815` · `:633-638` `earlyFinalizeLocked` · `:1281-1329` render (banner + 2 checkbox `disabled`/`checked` ép false + 2 `title`). ⇒ gỡ lock = đụng CẢ 4, không phải 3.
- `KhkkWorkflowPanel.tsx:6-9` — câu cần supersede nguyên văn: *"KHÔNG 2 đường kết-thúc-sớm (AllowApproverFinalize / CeoApprovalThreshold — anh phán «PHÁ VỠ» S155; W1 đã rào ở Designer type-10)"*.
- Baseline SHA-pair Panel ×2 = `2B64144EF6E8567D6997C0E19B9686C083080216510A4CD8DA6157A80F584817` (khớp đề-bài `2b64144ef6e8`). ✔
- `tsconfig.app.json:19-21` = `noUnusedLocals` + **`noUnusedParameters`** true ⇒ KHÔNG được để param `lockEarlyFinalize` mồ côi; phải xoá param + sửa 2 call-site.
## §1 — VIỆC 1 Layout.tsx `AwV2_KhkkN{1..8}` — DONE
- `fe-admin/src/components/Layout.tsx:177-182` (+8 dòng, chỉ INSERT, 0 xoá): thêm nhánh
`if (/^KhkkN[1-8]$/.test(code)) return '/system/approval-workflows-v2/' + code` **BÊN TRONG** khối
`awV2Match` sẵn có, đặt SAU whitelist 3 code cũ ⇒ 3 code cũ đi đường cũ, 0 đổi hành vi.
- Regex `^KhkkN[1-8]$` neo 2 đầu ⇒ `KhkkN0`/`KhkkN9`/`KhkkN12` KHÔNG khớp (rơi về `return null`, đúng ý "n=1..8").
- **Phép-âm (đề-bài yêu cầu) — PASS:** `git diff -U0 | grep -E "^\+ *Khkk_[A-Za-z]+: *'"` = **0 hit**.
Kèm **control dương**: cùng regex quét cả file = **6 hit** (6 dòng `Khkk_*` cũ) ⇒ mẫu CÓ RĂNG, 0-hit là
0-hit thật chứ không phải regex trượt. Dòng `Khkk_` duy nhất trong diff là **comment** (mention, không
phải route) ghi rõ lý do CẤM nới.
- `git diff --stat` = `1 file changed, 8 insertions(+)`.
## §2-§4 — [lead on-behalf @S165] lane chết khi đang GHI §2 nhưng CODE ĐÃ LAND ĐỦ (disk-truth)
- Lane die giữa lượt ghi §2 (return "Now build both apps" = ý-định; #53 quen). Đo đĩa SAU khi lane dừng:
- §2 ApprovalWorkflowsV2Page.tsx: `earlyFinalizeLocked` + `KHKK_EARLY_FINALIZE_NOTE` GỠ SẠCH (0 tham chiếu
sót — diagnostics về 0 lỗi, chỉ còn hint FormEvent pre-existing); `levelFlagsPayload` về 1-arg, mọi
caller cập nhật. Khóa duyệt-sớm KHKK HẾT — khớp OG-9 + BE đã gỡ CREATE ép-false.
- §3 KhkkWorkflowPanel.tsx ×2 app: comment supersede cập nhật, SHA-pair `336ECCC1F86D` ×2 IDENTICAL
(baseline cũ `2B64144EF6E8` — đổi có chủ-đích, pair giữ).
- §4 [LEAD trọng-tài]: `npm run build` fe-admin ✓ + fe-user ✓ (0 TS error — noUnusedLocals gate);
BE-side cùng thời điểm: dotnet build 0W/0E + suite 609/0.
END sub-implfe-k3 — VERDICT=DONE (Layout AwV2_KhkkN* phép-âm PASS · gỡ khóa duyệt-sớm · Panel pair giữ ·
build ×2 lead-verified)

View File

@ -29,3 +29,81 @@ REVIEWER-GATE-K2: IN-PROGRESS — chưa chốt
(`MenuKeys.cs` BE cũng không trong diff của wave K2 — chỉ `.claude/skills/permission-matrix/SKILL.md` là doc.)
- (d) 3 test lead vá: `ContractSigningPlanSchemaTests.cs` (+56/-…) + `ContractSigningPlanApprovalTests.cs` (+19) vẫn còn trong diff, file mới `ContractSigningPlanGroupAndCatalogTests.cs` tách riêng ⇒ không bị lane đè.
## §3 `npm run build` ×2 — reviewer tự chạy tươi
| App | Kết quả | Bundle (nguyên văn) | So claim lane |
|---|---|---|---|
| fe-admin | ✅ PASS, `✓ 1961 modules transformed` / `✓ built in 647ms` | `dist/assets/index-DORfkFnc.js 1,722.60 kB │ gzip: 428.08 kB` | KHỚP TỪNG KÝ TỰ |
| fe-user | ✅ PASS, `✓ built in 575ms` | `dist/assets/index-qYI2yszp.js 1,638.80 kB │ gzip: 413.92 kB` | KHỚP TỪNG KÝ TỰ |
Warning cả 2 app = pre-existing (chunk > 500 kB · `INEFFECTIVE_DYNAMIC_IMPORT` `realtime.ts`), 0 TS error.
`noUnusedLocals: true` ⇒ build XANH = phép thử TS6133 có răng (import `KHKK_APPROVAL_GROUP_LABELS` ở ListPage đã có người dùng thật).
## §4 Code-vs-spec K2
**Mig 71 — 6 item ĐỦ, 3-file ĐỦ.** `20260731085624_…FinalizeRuntime.cs` + `.Designer.cs` (2 file `??` mới) + `ApplicationDbContextModelSnapshot.cs` (M) — snapshot có đủ `ApprovalGroup` · `EndedByLevelFinalize` · `CatalogEntryId` · `TenHangMuc(500)` · `HasIndex("ApprovalGroup","Phase")` · `HasIndex("CatalogEntryId")`. Backfill `:61` `UPDATE [ContractSigningPlans] SET [ApprovalGroup] = 1;` **vô điều kiện** đúng chữ vá. Unique `:93-98` 3 cột + `filter: "[IsDeleted] = 0"` đúng #57.
**Đo DB (sqlcmd, `SolutionErp_Dev`) — reviewer tự chạy:** `tables=97` · top mig `20260731085624_AddKhkkApprovalGroupCatalogAndFinalizeRuntime` · 2 cột header + 2 cột line **có mặt** · `sys.indexes is_unique=1` chỉ còn `PK_…``IX_…_ContractSigningPlanId_SupplierId_CatalogEntryId` filter `([IsDeleted]=(0))`**unique cũ 2-cột ĐÃ CHẾT THẬT** (index swap, không phải thêm chồng). `plans=0 · lines_cnt=0` ⇒ Σ tiền local là **control RỖNG** — lane khai đúng độ mạnh, KHÔNG đòi thêm phép đo (xem §6).
**Submit-guard 2 vế — PHÁN vụ "vá-5 THẮNG nền `:126`": lead ĐỌC ĐÚNG ưu tiên.** Chứng bằng chữ:
- `spec-cum1-chi-tiet:4` nguyên văn: *"**12 VÁ dưới đây THẮNG khi xung đột**"* ⇒ luật ưu tiên do CHÍNH spec đặt, không phải lead tự phong.
- `spec-cum1-chi-tiet:12` (vá-5): *"auto-gen line lúc tạo phiếu được mồi null NHƯNG **submit-guard CHẶN null**"*.
- Nền `sub-invest-fable-b2-cum1.md:126`: *"Line `CatalogEntryId == null` (legacy nhóm-1 backfill) → CHO QUA"*.
⇒ Xung đột THẬT, vá thắng, code chọn CHẶN (`WorkflowService.cs:174-176`). Đúng. Kèm điểm cộng: cái GIÁ của lựa chọn (phiếu cũ bị Trả-lại sẽ **kẹt trình** tới khi K4b land) được khai THẲNG trong comment tại chỗ, không giấu.
Vế (ii) khác-nhóm 409 `:178-182` theo đúng code mẫu nền K2.d.
**List `?group=` end-to-end — 5/5 mắt xích có thật:** controller `ContractSigningPlansController.cs:42` `[FromQuery(Name="group")] int? group` → truyền vị trí thứ 5 `:49` → record `ListContractSigningPlansQuery:705 int? ApprovalGroup` → filter `:735-736` (đặt SAU vế IDOR, chỉ thu hẹp) → DTO `ListItemDto:63 ApprovalGroup` → FE `types/khkk.ts:158 approvalGroup: number`. FE **cố ý chưa đẻ ô lọc** (K4b/K5) — khai rõ ở `sub-implfe-k2.md §2` + comment `types/khkk.ts:28-32`. *(bẫy tên tham số → F-6.)*
**Σ×3 + display-gate:** `Features.cs:793-804` — PeReference · Proposed · **+Approved** (`.Sum(l => l.ApprovedAmount) ?? 0m`). Detail `:689` `BudgetFrozen = plan.Phase == DaDuyet`. FE: `KhkkListPage` +cột "Tổng duyệt" (8 `<th>` == 8 `<td>`, `colSpan` 7→**8** ở CẢ 2 chỗ ×2 app — đếm tay khớp) · `KhkkDetailPage:439-446` badge 🔒 qua prop `actions` (`SectionCard` có sẵn prop này, không bịa).
**Phase-guard + T-f:** `EnsureDraftEditable` (`Features.cs:229-237`) có **3 call-site thật** `:518 :928 :982`; K2 KHÔNG dựng guard mồ côi. T-f (`…GroupAndCatalogTests.cs:570-641`) **nối đường ghi THẬT**: dựng `UpdateContractSigningPlanDraftCommandHandler` thật, `ThrowAsync<ConflictException>`, rồi đọc lại DB khẳng định `GhiChu == null` **và** `Changelogs == 0` ⇒ không phải reflection suông, có cả vế "ném rồi thì không ghi gì".
**FE vá-4 + OG-7 — mở file thật, đối chiếu 4 điểm (≥3 yêu cầu):**
1. select nhóm default 1 — `KhkkCreatePage.tsx:62` `useState<number>(KHKK_APPROVAL_GROUP_DEFAULT)`, const `= 1` (`types/khkk.ts:121`). ✅
2. POST tường minh — `:99` `approvalGroup,` trong body (không dựa default ngầm BE). ✅
3. nút "Tạo thêm cho nhóm khác" — `:240-243`, đặt NGOÀI `<button>` PE (bọc `<div key>`, tránh button-lồng-button). ✅
4. hết 8 nhóm = **chữ**`:236` `free === null ? <span className="italic …">Đã dùng hết 8 nhóm duyệt</span> : <Button…>` ⇒ không mời bấm thứ chắc chắn 409. ✅
Kèm: option nhóm đã dùng `disabled` + hậu tố "(đã có kế hoạch)" `:283-287`; nút Lưu `disabled … || groupTaken` `:360`.
**SHA-pair 5/5 — reviewer tự tính lại (`sha256sum`, 12 ký tự đầu):**
`types/khkk.ts 72a71451b98f` · `KhkkCreatePage 677f4ab803b0` · `KhkkDetailPage 1863153b4f99` · `KhkkListPage e892310434f2` · `KhkkWorkflowPanel 2b64144ef6e8`**admin == user cả 5**, và **panel == baseline `2b64144ef6e8`** ⇒ control DƯƠNG đứng: phép đo có phân biệt được "đụng" và "không đụng". 5/5 khớp con số lane tự khai.
## §5 Nghi-vấn lead-solo (adversarial)
**(i) Có đường trình nào né submit-guard không? — KHÔNG.** `grep -rn "\.Phase = \|Phase = ContractSigningPlanPhase" src/Backend` cho đúng 4 site ghi: `Features.cs:428` (`= DangSoanThao`, create) · `WorkflowService.cs:191` (`= ChoDuyet`) · `:295` (`= DaDuyet`, finalize) · `:359` (`= targetPhase`, chỉ nhận TraLai/TuChoi từ switch `:127-136`). **`= ChoDuyet` duy nhất 1 site**, nằm TRONG `SubmitAsync` SAU guard; `SubmitAsync` private, caller duy nhất là switch `case "submit"`. Guard cũng KHÔNG có nhánh `isAdmin` bỏ qua. ⇒ bác nghi-vấn.
**(ii) Backfill=1 vô-điều-kiện có đè nhóm ≠1 không? — KHÔNG, an toàn theo CẤU TRÚC.** `AddColumn` `:47-52` chạy NGAY TRƯỚC `Sql UPDATE` `:61` trong cùng transaction ⇒ tại thời điểm UPDATE **mọi row đều = 0**, không tồn tại row mang nhóm ≠1 để đè. Kịch bản rollback+re-apply: `Down()` `:124-127` **DropColumn** trước ⇒ dữ liệu nhóm mất **do Down**, không do backfill; re-apply set 1 là hành vi đúng của cột vừa tái sinh. ⇒ bác nghi-vấn. *(Dư lượng thật, xem F-9: `defaultValue: 0` để lại DEFAULT constraint 0 trên cột.)*
**(iii) DTO append-CUỐI có vỡ FE cũ không? — KHÔNG, và lane khai ĐỦ.** 4 record đổi (`ListItemDto` +2 · `LineDto` +2 · `DetailDto` +2 · `ApprovedPeAwaitingPlanDto` +1), tất cả append cuối; JSON bind theo TÊN. Construction site positional duy nhất của mỗi record nằm cùng file và đã sửa — `dotnet build 0 Error` chính là phép chứng (thiếu 1 site sẽ là CS7036). FE khai đúng cả chỗ **lệch**: `KhkkDetailDto extends KhkkListItemDto` nhưng BE `DetailDto` KHÔNG có `TotalApprovedAmount` ⇒ FE để `number | null` + ghi lý do tại chỗ (`types/khkk.ts:158-164`).
## §6 Σ tiền — xác nhận độ mạnh khai báo
Lane khai: (a) tầng CẤU TRÚC `grep PeReferenceAmount|ProposedAmount|ApprovedAmount` trong file mig = **0 hit** — reviewer đọc lại file mig, xác nhận 0 hit; (b) tầng PHÉP ĐO local = **control RỖNG** (`plans=0 · lines_cnt=0`, reviewer đo lại bằng sqlcmd, khớp). Lane **tự dán nhãn "control rỗng, không phải bằng chứng có răng"** thay vì dán "PASS". ⇒ **Khai đúng độ mạnh; reviewer KHÔNG đòi phép đo không tồn tại.** Răng thật nằm ở phép đo prod post-deploy (cicd K2).
## §7 FINDING (10) — 0 blocker kỹ-thuật, 1 gate quy-trình
**F-1 [MEDIUM · data-integrity · sửa ở K4b]** `ContractSigningPlanWorkflowService.cs:178-182` — vế "khác nhóm" hỏi SAI câu: `AnyAsync(c => catalogIds.Contains(c.Id) && c.ApprovalGroup != plan.ApprovalGroup)` = "có tồn tại entry nhóm khác không", trong khi điều cần bảo đảm là "MỌI id đều resolve tới entry ĐÚNG nhóm". `ContractCatalogEntryConfiguration.cs:29``HasQueryFilter(x => !x.IsDeleted)``CatalogEntryId`**loose-Guid 0 FK** ⇒ dòng trỏ tới hạng mục **đã xoá mềm** (hoặc Guid rác) **biến mất khỏi tập truy vấn**`Any` = false → **trình QUA**. Vắng-mặt đọc thành sạch. Tiêu chí đóng: đếm `entries.Count(id ∈ ids && ApprovalGroup == plan.ApprovalGroup) == ids.Distinct().Count()`, lệch thì 409 nêu rõ id nào không resolve. *(Chưa khai thác được ở K2 vì chưa có đường gán hạng mục — nhưng K4b mở đường đó, phải đóng cùng lúc.)*
**F-2 [MEDIUM · claim mạnh hơn việc đã làm]** 2 site khai lớp bảo vệ (a)/(1) — *"validator đường gán hạng mục: `CatalogEntryId` BẮT BUỘC — dòng mới qua API không bao giờ mang NULL"* — ở **THÌ HIỆN TẠI**, nhưng lớp đó **CHƯA TỒN TẠI** (K2 không có command ghi Line; validator thuộc K4b). Site: `20260731085624_…FinalizeRuntime.cs:36-40``ContractSigningPlanLineConfiguration.cs:26-31`. Hiện thật **1 lớp** (submit-guard), không phải 2. Đối chứng cùng diff: comment trong `SubmitAsync:180` viết ĐÚNG thì tương lai (*"validator đường-gán K4b là fail-fast tầng trước"*) ⇒ 2 giọng lệch nhau trong cùng một wave. Tiêu chí đóng: đổi 2 site sang thì tương lai + đánh dấu nợ K4b.
**F-3 [MAJOR · gate quy-trình · đóng TRƯỚC/CÙNG commit]** `docs/STATUS.md:6` đang ghi `Mig **70** … **598 test PASS** (45D+553I)`; thực đo sau K2: **Mig 71** · **607 test (45D+562I)** · bảng vẫn **97** (Mig 71 = 0 CreateTable) · MenuKeys/Policies KHÔNG đổi. `docs/STATUS.md` **không nằm trong working-tree diff** ⇒ commit K2 nguyên trạng = drift tức thì. Cùng LỚP với finding đã nêu ở gate K1 (`STATUS.md:6` chưa khai DELTA) — **tái phát, chưa đóng**.
**F-4 [LOW-MEDIUM · doc sẽ đi kèm commit]** `.claude/skills/ef-core-migration/SKILL.md` (uncommitted) ghi `Total: **96 bảng**` và bảng migration **dừng ở Mig 69** — đã bị chính K1 (`50e6d8c`, Mig 70 `+ContractCatalogEntries`) làm sai TRƯỚC khi kịp land; đo thật `sys.tables = 97`. Thiếu row Mig 70 **và** Mig 71. Ai commit K2 sẽ mang con số sai này vào lịch sử.
**F-5 [LOW · neo-số trong chú thích]** `KhkkListPage.tsx:176-177` (×2 app) còn ghi *"bảng **7 cột** cuộn ngang"* trong khi CHÍNH diff này nâng lên **8 cột** (đã sửa đúng `colSpan` 7→8 hai chỗ). Sửa mã mà không sửa số trong chú thích cạnh nó.
**F-6 [LOW · acceptance tự lão hoá + không có test phủ]** Nền `sub-invest-fable-b2-cum1.md` K2.h ghi acceptance `curl list ?approvalGroup=2`, nhưng controller bind `[FromQuery(Name="group")]` ⇒ gõ `?approvalGroup=2` **không lọc gì cả**, trả **toàn bộ** nhóm với HTTP 200 — sai IM LẶNG chứ không 400. Thêm: 9 test mới đo ở tầng **query handler**, **0 test** chạm tên tham số ở controller ⇒ đổi/gõ nhầm tên sẽ không ai bắt. Tiêu chí đóng: sửa chữ acceptance thành `?group=2` (và/hoặc 1 test controller-binding).
**F-7 [MEDIUM · lý-do-thiết-kế nêu SAI]** Rào (PeId, ApprovalGroup) là **check-then-insert không khoá** (`Features.cs:388-395` `AnyAsync` rồi `Add`) ⇒ 2 request đồng thời cùng (PE, nhóm) đều qua rào → **2 phiếu sống cùng nhóm**, làm hỏng đúng bất-biến `COUNT == Σ 8 folder`. Lý do bỏ unique DB được nêu **2 lần** (`ContractSigningPlanConfiguration.cs:33-36` + `Features.cs:390-393`): *"unique index không diễn tả được vế `Phase != TuChoi`"***sai kỹ thuật**: SQL Server cho phép filtered index với so sánh hằng, `UNIQUE (PurchaseEvaluationId, ApprovalGroup) WHERE [Phase] <> 3 AND [IsDeleted] = 0` là hợp lệ và tự nhả chỗ khi phiếu chuyển TuChoi. Không đòi phải dựng index ngay — đòi **sửa lý do**, vì lý do sai sẽ được chép tiếp sang K4b/K5.
**F-8 [LOW · rollback]** `Down()` `:132-137` tạo lại UNIQUE (Plan, Supplier) SAU khi đã DropColumn `CatalogEntryId` ⇒ một khi K4b sinh **nhiều dòng/NCC khác hạng mục** (đúng năng lực K2 mở ra), rollback Mig 71 sẽ **FAIL giữa chừng**. Khai trước rẻ hơn phát hiện lúc cần rollback.
**F-9 [INFO · dư lượng của (ii)]** `AddColumn<int>(… defaultValue: 0)` `:47-52` để lại **DEFAULT constraint = 0** trên cột `ApprovalGroup` ở DB. Mọi đường EF an toàn (entity default `= 1`, handler luôn set tường minh), nhưng INSERT bằng **SQL thô** (seeder tương lai) sẽ ra nhóm **0** — ngoài dải 1..8, rơi NGOÀI cây 8 folder mà không có gì kêu.
**F-10 [INFO · positive validation]** Những chỗ CHỊU được soi: (a) `existingGroups` dùng chung `db.ContractSigningPlans` nên **thừa hưởng global filter `!IsDeleted`** (`Configuration:53`) ⇒ nghi-vấn mở của FE lane §5.3 ("phiếu xoá mềm có rơi khỏi tập không") = **CÓ RƠI**, picker nhất quán với rào Create; (b) consumer của endpoint picker chỉ có `KhkkCreatePage` ×2 app (grep toàn `src/Backend` + `fe-*/src`) ⇒ đổi hình dạng không làm phồng badge/KPI nào khác; (c) auto-gen line không sinh 2 dòng cùng NCC nên unique 3-cột toàn-NULL **không đẻ lỗi 2601 mới** so với unique 2-cột cũ (tập hành vi tương đương ở nhánh NULL).
## §8 Ghi chú phạm vi commit (không tính là finding)
`git status`**10 file governance/doc** (`CLAUDE.md`, `.claude/skills/*`, `.claude/commands/*`, `.claude/auto-memory/*`, `docs/governance/*`, `.session-counter.json`) **không thuộc K2** đang cùng nằm trong working tree. Người commit cần stage CÓ CHỦ ĐÍCH (hoặc khai chúng trong commit message), đừng để `git add -A` gộp im lặng.
END sub-reviewer-gate-k2 — VERDICT=PASS-WITH-FLAGS · 10 finding (F-3 = gate quy-trình phải đóng trước/cùng commit; F-1/F-2/F-7 nợ K4b) · build 0W/0E · test 607/0 (45D+562I) · npm ×2 PASS · SHA-pair 5/5 + control-dương đứng.

View File

@ -0,0 +1,22 @@
REVIEWER-GATE-K3: IN-PROGRESS — 0 finding (đang chạy)
# Reviewer gate K3 — 8 workflow nhóm + port finalize + designer
Ghi-đĩa TRONG LÚC LÀM (chống #53). Dòng-1 sẽ được cập nhật khi có verdict.
## 0. Trạng thái khởi động (đo từ đĩa)
- Branch `main`, HEAD `2967c43` (wal: flush 20260731T1743).
- `git diff --stat`: 25 file M, +908 / -102.
- Untracked: chỉ 2 file `.claude/auto-memory/*` (memory, không phải prod-code).
- 0 file untracked trong `src/` hoặc `tests/`**0 migration mới** (kiểm lại ở §4).
## 1. Build / test (đang chạy)
## 2. Code-vs-spec
## 3. Phán 3 điểm lead tự-quyết
## 4. Ranh giới
## 5. Test lane

View File

@ -0,0 +1,110 @@
K3-TEST: DONE — suite 614/0 (+5)
# K3-TEST — level-finalize + rào pin-đúng-nhóm (S164)
## §0 Nhật ký (ghi TRONG lúc làm, chống #53)
- [t0] Đọc `ContractSigningPlanApprovalTests.cs` (797 dòng) — fixture `KhkkWorkflowFixture` (SQLite in-mem + AuditingInterceptor),
helper: `AddUserAsync` · `SeedWorkflowAsync(f, code, steps[][][], stepOrderStart, stepOrderStride)` ·
`SeedPlanAsync(f, drafter, wfId, maKeHoach, phase, stepIndex, levelOrder, LineSeed[])` ·
`ActAsync(f, actor, planId, action, comment)``svc.TransitionAsync(planId, action, comment, ct:)` ·
`ReadPlanAsync` · `ReadOpinionsAsync` · `LevelIdOfAsync` · `InboxAsync` · `ShouldBeAt` · `ResultShouldMirror`.
File hiện có 8 [Fact] (2 gate-K2 + T1..T6). git status: file đang `M` (K2 đã sửa) — KHÔNG đè.
- [t0] Baseline đề bài: 609/0 (45D + 564I). SẼ ĐO LẠI TƯƠI (không tin số đề bài — bài S161-W2).
- [t1] Đọc `ContractSigningPlanWorkflowService.cs` — nhánh MỚI `:276-289`:
`if (actingLevel.AllowApproverFinalize && applyLevelFinalize)``EndedByLevelFinalize=true` ·
`ApplyApprovedValuesOnFinalize(plan)` (TRƯỚC gán Phase) · `Phase=DaDuyet` · 2 con-trỏ = null · log · return.
Đứng NGAY SAU `UpsertLevelOpinionAsync` ⇒ ý kiến vẫn ghi. Advance thường ở `:293-318`.
`TransitionAsync(planId, action, comment, bool applyLevelFinalize = true, CancellationToken ct = default)`.
- [t1] Đọc rào (v) `ContractSigningPlanFeatures.cs:409-420`: regex `^KHKK-N([1-8])$` trên `w.Code`,
ĐỨNG SAU `EnsureWorkflowTypeAsync` (:407) và **TRƯỚC** `codeGen.GenerateAsync` (:428)
⇒ 409 ở rào (v) KHÔNG được đốt mã (đo được — khuôn S161-W2 `_AndDoesNotBurnCode`).
- [t1] `ApprovalWorkflowV2AdminFeatures.cs:384-386` — ép-false type-10 ĐÃ GỠ ở **POST tạo version**
(echo `l.AllowApproverSkipToFinal` + `l.AllowApproverFinalize`). PUT sửa-tại-chỗ VẪN chặn
(`:795` không gán, `:813-814` dòng THÊM luôn false) — CỐ Ý theo luật versioning ⇒ KHÔNG test PUT.
Handler `CreateAwDefinitionCommandHandler(IApplicationDbContext db)` — 1 dep ⇒ arrange NHẸ,
làm test HÀNH-VI được (không phải rơi xuống phương án "khẳng định không còn biểu thức").
- [t1] Chỗ đặt test (khai lệch so với đề bài):
* T-a/T-b/T-c/T-e → **append CUỐI** `ContractSigningPlanApprovalTests.cs` (dùng `KhkkWorkflowFixture`).
* T-d → **append CUỐI** `ContractSigningPlanGroupAndCatalogTests.cs`: rào (v) cần
`SeedPeForCreateAsync` (:749) + `CreateAsync` (:814) đã có sẵn ở ĐÓ và là `private`.
Đề bài cho "ApprovalTests hoặc file mới"; chọn file thứ 3 để KHÔNG nhân bản ~70 LOC seed PE.
KHÔNG đè test cũ (chỉ chèn trước dấu `}` cuối class).
- [t2] **BASELINE ĐO TƯƠI (không lấy số đề bài):** `dotnet test SolutionErp.slnx`
Domain **45**/0 + Infra **564**/0 = **609**/0. KHỚP số đề bài.
- [t3] Đã viết 5 test + 6 helper. Build test-project: **succeeded, 0 warning 0 error**.
- [t4] Chạy LỌC 5 test mới (`--filter`): **6 matched, 6 PASS, 0 fail** (6 = 5 của tôi + 1 test
nền `Entity_MacDinhNhomMot_...ConCoKetThucSomMacDinhTat` dính chữ "KetThuc").
`--list-tests` xác nhận ĐỦ 5 TÊN trên đĩa (chống claim-mạnh-hơn-việc-đã-làm):
ApproveV2_CapMangCoKetThuc_ChotPhieuNgay_KhongTrinhTiepBuocSau (T-a)
ApproveV2_CapMangCoKetThuc_NhungBoTick_ThiVanTrinhTiepBuocSau (T-b)
ApproveV2_CapKhongMangCo_ThiKhongBaoGioKetThucSom (T-c)
Create_PinQuyTrinhKhacNhomDuyet_ThiBiChan_VaKhongDotMa (T-d)
CreateAwDefinition_LoaiKhkk_GiuNguyenCoKetThucTaiCap_KhongConEpFalse (T-e)
## §1 Vì sao 5 lưới này KHÔNG rỗng (fault-injection = CẤU TRÚC, không cần sửa mã production)
Ma trận T-a/T-b/T-c dùng **CÙNG MỘT bộ seed** (`SeedFinalizeScenarioAsync`), khác đúng 1 biến:
- cài đặt bỏ qua `AllowApproverFinalize` (không bao giờ finalize) ⇒ **T-a ĐỎ**
- cài đặt bỏ qua tham số `applyLevelFinalize` (cờ một mình quyết) ⇒ **T-b ĐỎ** (phiếu ra DaDuyet
thay vì Bước 3) — tương đương phép "gọi handler THẬT với param=null" của S164-W7d2
- điều kiện viết `||` thay `&&` (mặc định true ⇒ mọi lượt duyệt đều chốt) ⇒ **T-c ĐỎ**
- nhánh finalize quên gọi `ApplyApprovedValuesOnFinalize`**T-a ĐỎ** ở khối chốt giá
- finalize quên vế "chỉ phiếu này" ⇒ **T-a ĐỎ** ở phiếu chứng nhân song sinh
Điều kiện để phép đo có nghĩa (đã assert trong test, không chỉ nói mồm): Cấp mang cờ là
**Bước 2 Cấp 2 / quy trình 3 Bước** ⇒ đường THƯỜNG từ (1,2) đi tới (2,1) **chứ không** tới
DaDuyet ⇒ `DaDuyet` của T-a không thể tới từ đường thường. `StepCountAsync == 3` chốt vế này.
T-d: **cùng một quy trình `KHKK-N5`** cho 2 kết quả trái ngược khi chỉ đổi nhóm phiếu (3 = chặn,
5 = qua) ⇒ rào-chặn-tuốt và rào-chưa-bao-giờ-chạy đều bị bắt. Vế "không đốt mã" có
**control dương**: 001/002/003 tăng thật ⇒ codegen sống, nên "001 ngay sau lần 409" là phép đo
thật chứ không phải hằng số.
T-e: 3 vế — Cấp tick ⇒ true · Cấp không tick ⇒ false (chống "bật bừa") · **đối chứng type-1
`DuyetNcc` cũng true** ⇒ chứng cơ chế là ECHO tham số, phân biệt "đã gỡ ép-false" với
"đổi ép-false thành ép-true".
## §2 KẾT QUẢ ĐO (full suite, chạy SAU khi 5 test land)
```
Domain.Tests Failed: 0, Passed: 45 (không đổi)
Infrastructure.Tests Failed: 0, Passed: 569 (564 + 5)
TỔNG 614 / 0 fail (baseline đo tươi 609 ⇒ Δ = +5)
```
`dotnet build` test-project: 0 warning · 0 error. **0 hồi quy** — 564 test Infra cũ nguyên vẹn.
Phạm vi file đã ghi (`git status`, chỉ 3 dòng, KHÔNG chạm production):
```
M tests/.../Application/ContractSigningPlanApprovalTests.cs +346 1
M tests/.../Application/ContractSigningPlanGroupAndCatalogTests.cs + 81 1
M .claude/workflows/runs/2026-07-31-S164-4gd-khkk-fanout/sub-test-k3.md
```
(2 dòng `1` = dấu `}` cuối class bị thay khi chèn thêm; không test cũ nào bị sửa/xoá.)
## §3 BUG PRODUCTION: **KHÔNG CÓ** — 5/5 xanh ngay lượt đầu, mã BE đúng như spec K3 mô tả
## §4 QUAN SÁT giao lead (KHÔNG tự sửa, KHÔNG pin thành test)
**(O-1) Ứng viên config-lie #78 còn sót: `AllowApproverSkipToFinal` cho type-10.**
Khối ép-false vừa gỡ ở `ApprovalWorkflowV2AdminFeatures.cs:376-385` bao **CẢ HAI** cờ, nên nay
`:384` echo `AllowApproverSkipToFinal` cho MỌI type kể cả 10. Nhưng
`ContractSigningPlanWorkflowService.cs:47` khai rõ **"`AllowApproverSkipToFinal` giữ nguyên
không port"**, và grep toàn `src/Backend` xác nhận service KHKK **0 lần đọc** cờ này (chỉ PE
`:810` + Contract `:335` đọc). ⇒ Admin tick "Cấp này nhảy thẳng Cấp cuối" trên quy trình KHKK:
DB lưu `true`, máy KHÔNG làm gì — đúng hình config-lie mà chính lane này vừa trả giá để đóng
cho `AllowApproverFinalize`. Lựa chọn: (a) ép false RIÊNG `SkipToFinal` cho type-10, hoặc
(b) ẩn ô tick đó trên Designer khi type=10, hoặc (c) port nốt nhánh. **CỐ Ý KHÔNG viết test
pin `SkipToFinal == true`** — pin nó là biến một khả-năng-lỗi thành "yêu cầu".
**(O-2) Rào (v) neo vào mã CHÍNH XÁC — đã đối chiếu dữ liệu, KHÔNG chỉ đọc regex.**
`^KHKK-N([1-8])$` neo hai đầu ⇒ `QT-KHKK-N5` (có tiền tố) sẽ **lọt rào trong im lặng**.
Đã kiểm nguồn dữ liệu thật: `DbInitializer.cs:609` sinh `var code = $"KHKK-N{n}";` — KHỚP
tuyệt đối ⇒ rào SỐNG trên 8 workflow seed. Rủi ro còn lại thuần đặt-tên: ai tạo tay quy trình
nhóm mà gõ tiền tố sẽ mất rào mà không có tín hiệu nào. (Không đổi mã — chỉ ghi để lead cân
nhắc đưa mã nhóm vào hằng số dùng chung thay vì literal 2 nơi.)
**(O-3) PUT sửa-tại-chỗ CỐ Ý vẫn chặn đổi cờ** (`:795` không gán · `:813-814` dòng thêm luôn
false) — spec vá-3 xếp nhầm vào "cụm ép-false", lead đã khai lệch trong `sub-implbe-k3.md`.
KHÔNG viết test cho PUT theo đúng quyết định đó.

View File

@ -74,7 +74,7 @@
**(46)** 🔴 **Thước "17,1K" là thước MA — chọn 1 trong 3 ngã** (`harness-refine` A1, nó **cố ý không đặt số** vì số là quyền anh): **(i) WIRE** thêm key `tiers.l1_hot.self_target_bytes` để máy soi được · **(ii) RETIRE** gỡ luật khỏi 5 diary vì thước thật là 25.600 · **(iii) RELABEL** giữ nhưng ghi rõ "advisory". Bằng chứng nó **đang tốn công thật**: `investigator-codebase:58` nén về ~17,1K @S155, **3 ngày sau 20.646 B lại**; áp thước này thì **9/23 vai vượt** trong khi mọi máy in "0 over".
**(47)** `cicd-monitor/archive/_INDEX.md` **19.484/20.480 = 95,14%**, tăng ~81 B/ngày ⇒ **chạm cap ~2026-08-09**. A1@S153 chưa land, `harness-refine` đã kích chốt-dừng ⇒ lead {thi-hành nguyên văn | HỦY ghi rõ}; nếu chọn **nâng cap 20.480** thì là quyền anh.
**(48)** `harvest-curator/MEMORY.md` **19.456 B = 0 byte đổi kể từ S154**; món nợ **tự khai 2 lần** (budget `:192` từ S107 · diary `:5` từ S153) chưa trả lần nào — 3 lối ra đều là số/nhịp của anh.
🔸 **Không chặn W1-W2:** 5 câu thiết kế spec KHKK (Q3 · Q6 · Q11 + O-Q1 · O-Q2) · nguồn thứ 4 `SOL-PRO-SP-001` chưa ai đọc · 2 đường kết thúc sớm W3 (`AllowApproverFinalize` · `CeoApprovalThreshold` — anh từng phán **PHÁ VỠ** @S155) · thứ tự chạy wave.
🔸 **Không chặn W1-W2:** 5 câu thiết kế spec KHKK (Q3 · Q6 · Q11 + O-Q1 · O-Q2) · nguồn thứ 4 `SOL-PRO-SP-001` chưa ai đọc · 2 đường kết thúc sớm W3: `AllowApproverFinalize` **ĐÃ PORT @K3 S165** (OG-9 đảo "PHÁ VỠ" @S155 — owner @S164 verbatim *"chỗ budget chốt chặn là a Chương… bài toán trần budget xem như đóng"*; ⚠️ **gate-K3 F-1 MAJOR — checkbox opt-out màn duyệt CHƯA có UI, đầu việc #1 lần sau**: hiện `?? true` ⇒ MỌI lượt anh Chương bấm Duyệt đều KẾT THÚC phiếu — muốn phiếu lên CEO tạm thời chỉ có cách admin BỎ cờ ở Designer trước; vá = mirror checkbox `PeWorkflowPanel.tsx:778` vào KhkkWorkflowPanel ×2 + DTO expose cờ level) · `CeoApprovalThreshold` KHÔNG BAO GIỜ port (OG-3 human-gate vĩnh viễn) · thứ tự chạy wave.
🔸 **Treo CÓ CHỦ ĐÍCH, không phải quên:** lỗ hổng an ninh HĐ (`ContractWorkflowService.cs:48-66` Reject-trước-guard + controller class-trần) — anh chốt *"chỗ hợp đồng cứ từ từ"*. **Wave 5 chạm HĐ ⇒ phải hỏi lại anh trước khi chạy.**
🧊 *Segment cũ (S153) giữ nguyên bên dưới làm lineage:* 2026-07-26 chiều (**S153** — phiên-logic 6, 1 cửa-sổ: **adopt TRỌN thư Ctx soft-memory `0df10df4` + OWNER-VETO 3-vai + RENAME hub-form + bookend 2 đầu**). Governance-only, 0 prod-code (test 532 giữ, `git diff` src/tests/fe rỗng). Chi tiết = `docs/STATUS.md` dòng CURRENT + log `changelog/sessions/2026-07-26-1600-S153-ctx-adopt-3vai-rename.md`. ⭐ Bài đắt nhất phiên: **GAP-#9** — máy tự-build của SE tái hiện đúng bẫy F-1 mà thư hub kể (closed-mode: *xoá con-trỏ trung thực* → xanh RẺ HƠN *giữ* → trượt ⇒ gradient thưởng đường SAI; vá bằng TRƯỢT). Bài phụ: **đo wall-clock phải gồm nhánh ĐẮT** (ước lệch 2.2-5.4×) · **fixture-hỏng-cho-xanh trông y hệt máy-đúng-cho-xanh** (phải soi RUỘT fixture) · **bẫy parse `]` trong comment** nuốt non array (18 vs 23).

View File

@ -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 danh mục loại HĐ SHIP** gate PASS-WITH-FLAGS; 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** · **607 test PASS** (45D+562I, đo tươi @S165 gate-K2) · 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 **224** · Menu keys **56** (+`ContractCatalog` K1 4 policy sinh ra, giới-hạn khai: chưa endpoint nào tiêu thụ, controller mirror khuôn Catalogs `Roles=Admin` #82) · 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** · **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**.
**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).

View File

@ -173,6 +173,14 @@ function resolvePath(key: string): string | null {
if (code === 'DuyetNcc' || code === 'DuyetNccPhuongAn' || code === 'Contract') {
return `/system/approval-workflows-v2/${code}`
}
// [K3 S164/S165] Họ MỚI `AwV2_KhkkN1..N8` — 8 quy trình duyệt theo NHÓM của
// Kế hoạch ký kết HĐ (mỗi nhóm 1 đội hình 3 trạm PRO → CCM → CEO). Đây là "chỗ
// thứ 4" Pattern 16-bis: BE seed leaf mà thiếu nhánh này thì `resolvePath` trả
// null ⇒ MenuLeaf DROP IM LẶNG khỏi sidebar (gotcha #50).
// 🔴 CHỈ nới họ `AwV2_*` (Designer, fe-admin CÓ trang). KHÔNG nới họ `Khkk_*`
// — fe-admin cố ý KHÔNG có `WorkflowMatrixViewPage` (xem ghi chú khối `Pe_*`
// bên trên); nới ở đó = đẻ leaf chết rơi vào catch-all "chưa được build".
if (/^KhkkN[1-8]$/.test(code)) return `/system/approval-workflows-v2/${code}`
}
return null

View File

@ -4,9 +4,17 @@
// Khuôn: `components/pe/PeWorkflowPanel.tsx` — GIỮ sơ đồ Bước→Cấp ✓/●/○, banner "đến lượt bạn"
// (`blockedByV2Level` :109-114), 3 nút màu (emerald/amber/red), Dialog + ô ý kiến.
// ĐƠN-GIẢN-HOÁ CÓ CHỦ ĐÍCH (spec W3 §②-3): KHÔNG price-picker, KHÔNG ngân sách, KHÔNG file-khi-duyệt,
// KHÔNG 4 return-mode (W3 = TraLai toàn phần), KHÔNG 2 đường kết-thúc-sớm (AllowApproverFinalize /
// CeoApprovalThreshold — anh phán "PHÁ VỠ" S155; W1 đã rào ở Designer type-10). Chốt giá per-Line là
// việc BE làm ở finalize choke-point, KHÔNG phải việc của panel này.
// KHÔNG 4 return-mode (W3 = TraLai toàn phần). Chốt giá per-Line là việc BE làm ở finalize
// choke-point, KHÔNG phải việc của panel này.
//
// 🔴 [K3 S164/S165 — SUPERSEDE] Câu cũ ở đây khai "KHÔNG 2 đường kết-thúc-sớm (AllowApproverFinalize /
// CeoApprovalThreshold — anh phán «PHÁ VỠ» S155; W1 đã rào ở Designer type-10)". KHAI ĐÓ ĐÃ HẾT HIỆU LỰC:
// owner ĐẢO phán quyết S155 (@S164 — "a Chương quyết cái gì cần CEO") ⇒ BE **đã port nhánh finalize cho
// KHKK ở K3 cùng wave**, và Designer type-10 đã GỠ rào (`ApprovalWorkflowsV2Page.tsx` — hằng
// `earlyFinalizeLocked` xoá hẳn, 2 ô tick sống lại). Đừng đọc file này thành "BE không hỗ trợ".
// Panel VẪN không có control riêng cho đường kết-thúc-sớm — nhưng vì lý do KHÁC HẲN: `AllowApproverFinalize`
// là cấu-hình PER-CẤP do admin tick ở Designer, BE đọc lúc duyệt để quyết dừng hay trình tiếp CEO. Người
// duyệt chỉ bấm "Duyệt" như thường; không có gì để panel render thêm.
//
// 🔴 KHÁC PE Ở CHỖ ĐẮT NHẤT: PE nhận `approvalFlow` đã có `status` Done/Current/Pending do BE precompute.
// KHKK BE trả CÂY THÔ (`ContractSigningPlanFeatures.cs:112-117` + `:105-110`) — KHÔNG có `status`.

View File

@ -142,14 +142,23 @@ const LEVEL_ORDERS: LevelOrder[] = [1, 2, 3]
// [W1 KHKK S161] "Kế hoạch ký kết HĐ" = slot 10 của `ApprovalWorkflowApplicableType`
// (`ApprovalWorkflow.cs:66` trước đó dừng ở `TravelRequest = 9`). Đặt TÊN cho số 10
// vì nó dùng ở HAI nơi trong file: map deep-link ngay bên dưới + rào 2 ô "duyệt sớm"
// trong Designer — số trần rải 2 chỗ là kiểu sửa 1 quên 1.
// vì nó dùng ở HAI nơi trong file: map deep-link ngay bên dưới + gợi ý mã quy trình
// mặc định — số trần rải 2 chỗ là kiểu sửa 1 quên 1.
// [K3 S164 — GỠ KHOÁ] Chỗ dùng THỨ BA của hằng này (rào 2 ô "duyệt sớm" trong
// Designer) ĐÃ GỠ, xem khối ghi chú ngay dưới.
const TYPE_INT_CONTRACT_SIGNING_PLAN = 10
// Câu giải thích DÙNG CHUNG cho tooltip 2 ô bị khoá + dòng ghi chú (1 chuỗi, không
// chép 3 ln rồi lệch chữ).
const KHKK_EARLY_FINALIZE_NOTE =
'Chưa hỗ trợ cho quy trình Kế hoạch ký kết HĐ — backend chưa xử lý đường "duyệt sớm", tick vào sẽ KHÔNG có tác dụng.'
// [K3 S164 — SUPERSEDE W1 S161] 🔴 Duyệt sớm ĐÃ MỞ cho KHKK từ K3 (owner đảo phán
// quyết S155 @S164: TP.CCM quyết lên CEO hay kết thúc). Trước đó file này có hằng
// `KHKK_EARLY_FINALIZE_NOTE` + cờ `earlyFinalizeLocked` ép 2 ô "Duyệt thẳng Cấp cuối"
// và "Duyệt là KẾT THÚC" về mờ-và-false cho type 10, vì backend chưa port đường
// finalize. BE đã port nhánh finalize ở K3 CÙNG WAVE ⇒ cờ hết là "cờ chết", khoá lại
// thành khoá SAI (chặn đúng cái owner vừa yêu cầu mở). Gỡ ở CẢ HAI lớp đã rào —
// render (banner + `disabled`) và payload (`levelFlagsPayload` ép false) — vì chỉ gỡ
// 1 lớp thì màn hình và dữ liệu nói hai đằng.
// Banner giải thích đi kèm cũng XOÁ (không đổi chữ): nội dung nó chỉ để cảnh báo
// khoá, hết khoá là hết việc; còn câu supersede mang từ vựng nội bộ ("K3", "S155/
// S164") nên thuộc về comment này, không đẩy lên UI admin.
// FE typeCode → BE int (giống MenuKeys ApplicableType)
const TYPE_CODE_TO_INT: Record<string, number> = {
@ -239,12 +248,11 @@ function makeDefaultLevelEntry(order: LevelOrder, approverUserId: string): EditL
// - 2 cờ ĐỔI-ĐƯỜNG-ĐI (SkipToFinal, Finalize): PUT chỉ ECHO — gửi lệch ⇒ 409
// (ApprovalWorkflowV2AdminFeatures.cs:580-585). Vẫn PHẢI gửi đúng giá trị đang lưu.
//
// [W1 KHKK S161] `lockEarlyFinalize` = quy trình thuộc loại CHƯA hỗ trợ 2 đường
// "duyệt sớm" (hiện chỉ Kế hoạch ký kết HĐ, type 10) ⇒ ép 2 cờ đó về `false` TẠI ĐÂY,
// không ch khoá ô tick ở màn hình: khoá render mà payload vẫn bê giá trị cũ thì
// clone/dữ liệu lạ vẫn đẩy `true` lên BE. Echo của PUT vẫn khớp DB vì mọi bản type 10
// đều do chính màn này tạo ra với 2 cờ = false ⇒ không sinh 409.
function levelFlagsPayload(e: EditLevelEntry, lockEarlyFinalize: boolean) {
// [K3 S164 — GỠ KHOÁ] Tham số thứ 2 `lockEarlyFinalize` (W1 S161, ép 2 cờ "duyệt sớm"
// về `false` cho type 10) ĐÃ BỎ: mọi loại quy trình nay gửi đúng giá trị admin tick.
// Xoá hẳn tham số ch không để nó nằm chết — `noUnusedParameters` bật, và param mồ côi
// ở hàm 2 call-site là mồi cho lượt sau tưởng còn rào.
function levelFlagsPayload(e: EditLevelEntry) {
return {
allowReturnOneLevel: e.allowReturnOneLevel,
allowReturnOneStep: e.allowReturnOneStep,
@ -252,8 +260,8 @@ function levelFlagsPayload(e: EditLevelEntry, lockEarlyFinalize: boolean) {
allowReturnToDrafter: e.allowReturnToDrafter,
allowApproverEditDetails: e.allowApproverEditDetails,
allowApproverEditBudget: e.allowApproverEditBudget,
allowApproverSkipToFinal: lockEarlyFinalize ? false : e.allowApproverSkipToFinal,
allowApproverFinalize: lockEarlyFinalize ? false : e.allowApproverFinalize,
allowApproverSkipToFinal: e.allowApproverSkipToFinal,
allowApproverFinalize: e.allowApproverFinalize,
allowApproverDelete: e.allowApproverDelete,
}
}
@ -630,13 +638,10 @@ function Designer({
const isEdit = editTarget !== null
const source = editTarget ?? cloneFrom
// [W1 KHKK S161 — chặn "cờ chết"] 2 ô "duyệt sớm" (Duyệt thẳng Cấp cuối / Duyệt là
// KẾT THÚC) ĐỔI ĐƯỜNG ĐI của phiếu. Module Kế hoạch ký kết HĐ chưa port 2 đường này
// ở backend ⇒ để ô tick sống = admin tưởng đã bật mà phiếu vẫn chạy y như cũ (cờ
// chết đánh lừa người cấu hình). Khoá ở CẢ HAI lớp: render (disabled + ghi chú) và
// payload (levelFlagsPayload ép false).
const earlyFinalizeLocked = applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN
// [K3 S164 — GỠ KHOÁ] `earlyFinalizeLocked` (W1 S161) ĐÃ BỎ HẲN: 2 ô "duyệt sớm"
// nay sống cho MỌI loại quy trình, kể cả Kế hoạch ký kết HĐ (type 10) — BE đã port
// đường finalize ở K3 cùng wave, và owner chốt TP.CCM là người quyết lên CEO hay
// kết thúc. Lý do đầy đủ ở khối ghi chú đầu file (gần `TYPE_INT_CONTRACT_SIGNING_PLAN`).
const initialSteps: EditStep[] = useMemo(
() => (source ? copyFromDefinition(source) : [makeEmptyStep(1)]),
[source],
@ -780,7 +785,7 @@ function Designer({
name: `Cấp ${e.order}`,
approverUserId: e.approverUserId,
// 9 cờ qua ĐÚNG 1 hàm dùng chung với POST — xem levelFlagsPayload().
...levelFlagsPayload(e, earlyFinalizeLocked),
...levelFlagsPayload(e),
})),
})),
})
@ -812,7 +817,7 @@ function Designer({
// field (KHÔNG spread `...e`) ⇒ thiếu 1 cờ thì TS vẫn build sạch, ô tick
// vẫn hiện, chỉ BE không bao giờ nhận. Từ khi có call-site thứ hai (PUT),
// 9 cờ gom vào levelFlagsPayload() dùng CHUNG — thêm cờ mới chỉ sửa 1 nơi.
...levelFlagsPayload(e, earlyFinalizeLocked),
...levelFlagsPayload(e),
})),
})),
})
@ -1278,51 +1283,32 @@ function Designer({
/>
<span>Cho phép chỉnh sửa Section ngân sách lúc đang duyệt</span>
</label>
{/* [W1 KHKK S161] Ghi chú vì sao 2 ô ngay dưới bị mờ. Chỉ hiện
cho loại quy trình chưa hỗ trợ "duyệt sớm" (type 10 — Kế hoạch
ký kết HĐ); các loại khác giao diện KHÔNG đổi 1 pixel. */}
{earlyFinalizeLocked && (
<div className="col-span-2 rounded border border-slate-200 bg-slate-50 px-1.5 py-1 text-[10.5px] leading-relaxed text-slate-500">
{KHKK_EARLY_FINALIZE_NOTE}
</div>
)}
{/* Mig 31 (S23 t1) — F2 AllowApproverSkipToFinal admin opt-in per-slot.
Approver tick = skip thẳng Cấp cuối khi đang ChoDuyet. */}
<label
className={
earlyFinalizeLocked
? 'col-span-2 flex cursor-not-allowed items-center gap-1 text-[11px] text-slate-400'
: 'col-span-2 flex items-center gap-1 text-[11px] text-slate-700'
}
title={earlyFinalizeLocked ? KHKK_EARLY_FINALIZE_NOTE : undefined}
>
Approver tick = skip thẳng Cấp cuối khi đang ChoDuyet.
[K3 S165 — gate-K3 F-2] KHKK (type-10) CHỈ mở ô "KẾT THÚC" (dưới):
service KHKK CHƯA port nhánh SkipToFinal + BE CREATE vẫn ép-false cờ
này cho type-10 ⇒ ô này DISABLE cho KHKK kẻo tick bị nuốt im lặng
(class "UI nói một đằng DB một nẻo" — đúng thứ W1 S161 cảnh báo). */}
<label className={`col-span-2 flex items-center gap-1 text-[11px] ${applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN ? 'text-slate-400' : 'text-slate-700'}`}>
<input
type="checkbox"
className="h-3 w-3"
// [W1 KHKK S161] Khoá thì hiện KHÔNG tick — khớp đúng cái
// payload gửi lên (levelFlagsPayload ép false), không để màn
// hình nói một đằng dữ liệu một nẻo.
checked={earlyFinalizeLocked ? false : entry.allowApproverSkipToFinal}
disabled={earlyFinalizeLocked}
checked={applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN ? false : entry.allowApproverSkipToFinal}
disabled={applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN}
title={applicableType === TYPE_INT_CONTRACT_SIGNING_PLAN ? 'Kế hoạch ký kết chưa hỗ trợ duyệt vượt cấp — service chưa port nhánh này (K3)' : undefined}
onChange={e => updateField('allowApproverSkipToFinal', e.target.checked)}
/>
<span>Cho phép duyệt thẳng Cấp cuối khi đang duyệt</span>
</label>
{/* [Mig 58 CCM item 4 — anh Kiệt FDC] Duyệt là KẾT THÚC: NV slot
này duyệt xong → DaDuyet luôn, KHÔNG trình tiếp CEO (vd Cấp anh Chương). */}
<label
className={
earlyFinalizeLocked
? 'col-span-2 flex cursor-not-allowed items-center gap-1 text-[11px] text-slate-400'
: 'col-span-2 flex items-center gap-1 text-[11px] text-emerald-700'
}
title={earlyFinalizeLocked ? KHKK_EARLY_FINALIZE_NOTE : undefined}
>
này duyệt xong → DaDuyet luôn, KHÔNG trình tiếp CEO (vd Cấp anh Chương).
[K3 S164] Với Kế hoạch ký kết HĐ đây CHÍNH LÀ ô owner cần: tick cho
Cấp TP.CCM (anh Chương) ⇒ anh ấy quyết kết thúc hay trình tiếp CEO. */}
<label className="col-span-2 flex items-center gap-1 text-[11px] text-emerald-700">
<input
type="checkbox"
className="h-3 w-3"
checked={earlyFinalizeLocked ? false : entry.allowApproverFinalize}
disabled={earlyFinalizeLocked}
checked={entry.allowApproverFinalize}
onChange={e => updateField('allowApproverFinalize', e.target.checked)}
/>
<span>Duyệt KẾT THÚC quy trình (không trình tiếp CEO)</span>

View File

@ -4,9 +4,17 @@
// Khuôn: `components/pe/PeWorkflowPanel.tsx` — GIỮ sơ đồ Bước→Cấp ✓/●/○, banner "đến lượt bạn"
// (`blockedByV2Level` :109-114), 3 nút màu (emerald/amber/red), Dialog + ô ý kiến.
// ĐƠN-GIẢN-HOÁ CÓ CHỦ ĐÍCH (spec W3 §②-3): KHÔNG price-picker, KHÔNG ngân sách, KHÔNG file-khi-duyệt,
// KHÔNG 4 return-mode (W3 = TraLai toàn phần), KHÔNG 2 đường kết-thúc-sớm (AllowApproverFinalize /
// CeoApprovalThreshold — anh phán "PHÁ VỠ" S155; W1 đã rào ở Designer type-10). Chốt giá per-Line là
// việc BE làm ở finalize choke-point, KHÔNG phải việc của panel này.
// KHÔNG 4 return-mode (W3 = TraLai toàn phần). Chốt giá per-Line là việc BE làm ở finalize
// choke-point, KHÔNG phải việc của panel này.
//
// 🔴 [K3 S164/S165 — SUPERSEDE] Câu cũ ở đây khai "KHÔNG 2 đường kết-thúc-sớm (AllowApproverFinalize /
// CeoApprovalThreshold — anh phán «PHÁ VỠ» S155; W1 đã rào ở Designer type-10)". KHAI ĐÓ ĐÃ HẾT HIỆU LỰC:
// owner ĐẢO phán quyết S155 (@S164 — "a Chương quyết cái gì cần CEO") ⇒ BE **đã port nhánh finalize cho
// KHKK ở K3 cùng wave**, và Designer type-10 đã GỠ rào (`ApprovalWorkflowsV2Page.tsx` — hằng
// `earlyFinalizeLocked` xoá hẳn, 2 ô tick sống lại). Đừng đọc file này thành "BE không hỗ trợ".
// Panel VẪN không có control riêng cho đường kết-thúc-sớm — nhưng vì lý do KHÁC HẲN: `AllowApproverFinalize`
// là cấu-hình PER-CẤP do admin tick ở Designer, BE đọc lúc duyệt để quyết dừng hay trình tiếp CEO. Người
// duyệt chỉ bấm "Duyệt" như thường; không có gì để panel render thêm.
//
// 🔴 KHÁC PE Ở CHỖ ĐẮT NHẤT: PE nhận `approvalFlow` đã có `status` Done/Current/Pending do BE precompute.
// KHKK BE trả CÂY THÔ (`ContractSigningPlanFeatures.cs:112-117` + `:105-110`) — KHÔNG có `status`.

View File

@ -209,7 +209,8 @@ public class ContractSigningPlansController(
[Authorize(Policy = "KeHoachKyKet.Update")]
public async Task<ActionResult<ContractSigningPlanTransitionResult>> Transition(
Guid id, [FromBody] ContractSigningPlanTransitionBody body, CancellationToken ct)
=> Ok(await workflow.TransitionAsync(id, body.Action, body.Comment, ct));
=> Ok(await workflow.TransitionAsync(id, body.Action, body.Comment,
body.ApplyLevelFinalize ?? true, ct));
// ========================= Body records =========================
// PUT header: KHÔNG mang `Id` trong body (id lấy từ route) — tránh 2 nguồn sự thật.
@ -223,7 +224,10 @@ public class ContractSigningPlansController(
// `Comment` tuỳ chọn. Tên property PascalCase bind đúng JSON "action"/"comment" —
// ASP.NET Core mặc định camelCase + đọc không phân biệt hoa/thường
// (`Api/Program.cs:33-34` chỉ thêm converter DateTime, KHÔNG đổi naming policy).
// [K3 S165] `ApplyLevelFinalize` — OPT-OUT khuôn PE S96: null/true = auto kết-thúc-tại-cấp
// khi Cấp có `AllowApproverFinalize`; false = người duyệt BỎ tick ⇒ trình tiếp CEO.
public record ContractSigningPlanTransitionBody(
string Action,
string? Comment = null);
string? Comment = null,
bool? ApplyLevelFinalize = null);
}

View File

@ -375,10 +375,17 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
AllowApproverEditBudget = l.AllowApproverEditBudget,
// [W1 S161 — reviewer F-8, F-B3 containment TẦNG BE] type=10 KHKK: 2 đường
// early-finalize anh phán "PHÁ VỠ" (S155), W3 không port code path ⇒ ép false
// server-side (FE đã ép ở levelFlagsPayload — đây chặn curl bypass). PUT
// sửa-tại-chỗ đã có echo-so-BẰNG chặn đổi cờ ⇒ chỉ cần guard ở CREATE.
// 🔄 [K3 S165 — OG-9 SUPERSEDE "PHÁ VỠ" @S155, sweep vá-3] GỠ ép-false
// type-10 cho `AllowApproverFinalize`: owner @S164 đảo phán quyết ("a Chương
// quyết cái gì cần CEO") — service KHKK ĐÃ port nhánh level-finalize cùng
// wave ⇒ cờ hết config-lie #78. Đường bật cờ = TẠO VERSION MỚI (đây);
// PUT sửa-tại-chỗ vẫn chặn đổi cờ (luật versioning CHUNG mọi type — spec
// vá-3 liệt nhầm vào "cụm ép-false", lead giữ + khai lệch sub-implbe-k3.md).
// 🔴 `AllowApproverSkipToFinal` GIỮ ép-false CHO RIÊNG type-10 (K3-test O-1):
// service KHKK 0 lần đọc cờ này ("giữ nguyên không port" — 3-ĐIỂM-ADAPT (b))
// ⇒ echo nó cho type-10 là ĐẺ config-lie #78 mới (DB true, máy im).
AllowApproverSkipToFinal = typeEnum != ApprovalWorkflowApplicableType.ContractSigningPlan && l.AllowApproverSkipToFinal,
AllowApproverFinalize = typeEnum != ApprovalWorkflowApplicableType.ContractSigningPlan && l.AllowApproverFinalize,
AllowApproverFinalize = l.AllowApproverFinalize,
AllowApproverDelete = l.AllowApproverDelete, // [Mig 68 S155] F6
}).ToList(),
})

View File

@ -406,6 +406,19 @@ public class CreateContractSigningPlanCommandHandler(
// ---- Rào (iv) WORKFLOW TYPE — copy trọn 2 nhánh Proposal `:258-267`.
await ContractSigningPlanScope.EnsureWorkflowTypeAsync(db, request.ApprovalWorkflowId, ct);
// ---- Rào (v) [K3 S165 — QĐ-3 "K3 chỉ ĐỌC ApprovalGroup"] Workflow họ nhóm
// `KHKK-N{n}` ⇒ n PHẢI khớp nhóm duyệt của phiếu (phiếu nhóm 3 pin quy trình nhóm 5
// = 409). Code NGOÀI họ (type-10 legacy/chung) → cho qua — ràng buộc chỉ áp cho
// 8 workflow nhóm seed @K3.
var wfCode = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == request.ApprovalWorkflowId)
.Select(w => w.Code).FirstOrDefaultAsync(ct);
var groupMatch = System.Text.RegularExpressions.Regex.Match(wfCode ?? string.Empty, "^KHKK-N([1-8])$");
if (groupMatch.Success && int.Parse(groupMatch.Groups[1].Value) != approvalGroup)
throw new ConflictException(
$"Quy trình '{wfCode}' thuộc nhóm duyệt N{groupMatch.Groups[1].Value}, " +
$"không khớp nhóm duyệt N{approvalGroup} của phiếu — chọn đúng quy trình của nhóm.");
// 🔴 GEN MÃ TRƯỚC KHI `Add` — `ContractSigningPlanCodeGenerator` TỰ COMMIT
// (`ContractSigningPlanCodeGenerator.cs:23-43`: transaction SERIALIZABLE +
// `SaveChangesAsync` nội bộ). Nếu phiếu đã nằm trong change-tracker lúc gọi, lượt

View File

@ -26,6 +26,7 @@ public interface IContractSigningPlanWorkflowService
Guid planId,
string action,
string? comment,
bool applyLevelFinalize = true,
CancellationToken ct = default);
}

View File

@ -79,6 +79,23 @@ public static class MenuKeys
public const string ApprovalWorkflowDuyetNccV2 = "AwV2_DuyetNcc"; // leaf cho Duyệt NCC mới
public const string ApprovalWorkflowDuyetNccPhuongAnV2 = "AwV2_DuyetNccPhuongAn"; // leaf cho Duyệt NCC và Giải pháp mới
// [K3 nhịp-1 — S164/S165] 8 leaf Designer cho 8 quy trình duyệt KHKK theo NHÓM
// DUYỆT N1..N8 (danh mục SOL-CCM-SP-002 Mig 70 chia 86 dòng thành 8 nhóm; phiếu
// KHKK chốt nhóm lúc tạo — Mig 71). Mỗi nhóm 1 workflow `KHKK-N{n}` type-10 do
// `DbInitializer.SeedKhkkGroupWorkflowsAsync` seed.
// 🔴 8 key này VÀO `All` (lệnh lead K3-N1) ⇒ sinh 32 policy AwV2_KhkkN{1..8}.{R,C,U,D}
// ⇒ 2 số canonical `docs/STATUS.md` đổi: Menu keys +8 · Policies +32 (DERIVED |All|×|Actions|).
// 🔴 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 test T6 K1).
public const string ApprovalWorkflowKhkkN1 = "AwV2_KhkkN1";
public const string ApprovalWorkflowKhkkN2 = "AwV2_KhkkN2";
public const string ApprovalWorkflowKhkkN3 = "AwV2_KhkkN3";
public const string ApprovalWorkflowKhkkN4 = "AwV2_KhkkN4";
public const string ApprovalWorkflowKhkkN5 = "AwV2_KhkkN5";
public const string ApprovalWorkflowKhkkN6 = "AwV2_KhkkN6";
public const string ApprovalWorkflowKhkkN7 = "AwV2_KhkkN7";
public const string ApprovalWorkflowKhkkN8 = "AwV2_KhkkN8";
// [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ũ
@ -185,6 +202,9 @@ public static class MenuKeys
KeHoachKyKet, // [W1 Mig 69 — S161] GĐ2 KHKK: const :41 vào All ⇒ sinh policy KeHoachKyKet.{Read,Create,Update,Delete}
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).
ApprovalWorkflowKhkkN1, ApprovalWorkflowKhkkN2, ApprovalWorkflowKhkkN3, ApprovalWorkflowKhkkN4,
ApprovalWorkflowKhkkN5, ApprovalWorkflowKhkkN6, ApprovalWorkflowKhkkN7, ApprovalWorkflowKhkkN8,
];
public static readonly string[] Actions = ["Read", "Create", "Update", "Delete"];

View File

@ -164,6 +164,12 @@ public static class DbInitializer
await SeedSampleTravelRequestWorkflowV2Async(db, userManager, logger);
await SeedSampleVehicleBookingWorkflowV2Async(db, userManager, logger);
// [K3 nhịp-1 — S164/S165] 8 quy trình duyệt KHKK theo nhóm duyệt N1..N8
// (ApplicableType=ContractSigningPlan=10). INFRASTRUCTURE seed NOT gated
// DemoSeed (gotcha #51) — prod cần đủ 8 quy trình ngay deploy đầu, thiếu thì
// Drafter không pick được quy trình lúc tạo phiếu KHKK. Idempotent per-Code.
await SeedKhkkGroupWorkflowsAsync(db, userManager, logger);
await WarnDefaultAdminPasswordAsync(userManager, logger);
}
@ -517,6 +523,169 @@ public static class DbInitializer
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for VehicleBooking: QT-XE-V2-001 v01");
}
// ============================================================================
// [K3 nhịp-1 — S164/S165] 8 QUY TRÌNH DUYỆT KHKK theo NHÓM DUYỆT N1..N8
// ============================================================================
// Danh mục Hợp đồng SOL-CCM-SP-002 (Mig 70) chia 86 dòng vào 8 nhóm duyệt
// (`ContractCatalogEntry.ApprovalGroup` 1..8); phiếu KHKK chốt nhóm lúc TẠO
// (`ContractSigningPlan.ApprovalGroup`, Mig 71) ⇒ mỗi nhóm cần 1 quy trình V2
// riêng để pin. Code `KHKK-N{n}`, ApplicableType=ContractSigningPlan (=10).
//
// ĐỘI HÌNH: 3 trạm PRO → CCM → BGĐ, sao đúng workflow Duyệt NCC prod bản LIVE
// (`QT-DN-V2-001`, ApplicableType=1) theo owner OG-2 — "lấy đúng bộ người duyệt
// đang cấu hình ở workflow Duyệt NCC prod". Cấp 1 mỗi trạm = OR-of-N (N row cùng
// Order ⇒ bất kỳ ai trong cấp duyệt là xong cấp đó), Cấp 2 = trưởng phòng.
//
// INFRASTRUCTURE seed — NOT gated DemoSeed (gotcha #51).
// Idempotent PER-CODE (`KHKK-N{n}`), CỐ Ý KHÔNG dùng khuôn `AnyAsync(w.Applicable
// Type == ...)` của 6 seeder mẫu phía trên: ở đây 8 workflow DÙNG CHUNG 1 type,
// check theo type sẽ chặn 7 nhóm còn lại ngay khi nhóm đầu vừa được tạo.
//
// Resolve người duyệt THEO EMAIL. User vắng mặt (DB dev thiếu người thật) ⇒ BỎ QUA
// đúng slot đó + log warning, KHÔNG throw (seeder chạy lúc boot — throw = API chết).
// 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<User> 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)[]
{
("Phòng Cung Ứng", "PRO",
new[]
{
"phuong.nguyen@solutions.com.vn", // Nguyễn Thị Bích Phượng
"long.chau@solutions.com.vn", // Châu Tạ Kim Long
"duy.nguyen@solutions.com.vn", // Nguyễn Văn Duy
"binh.lethanh@solutions.com.vn", // Bình Lê Thanh (⟂ binh.le bên CCM)
},
new[] { "tra.bui@solutions.com.vn" }, // Bùi Lê Thủy Trà (TP.PRO)
false),
("P. Kiểm soát Chi phí", "CCM",
new[]
{
"luu.tran@solutions.com.vn", // Trần Xuân Lưu
"truong.letrandang@solutions.com.vn", // Lê Trần Đăng Trường (⟂ truong.nguyen bên BGĐ)
"binh.le@solutions.com.vn", // Lê Văn Bình
"nguyen.ho@solutions.com.vn", // Hồ Thị Nữ Nguyên
},
new[] { "chuong.phan@solutions.com.vn" }, // Phan Văn Chương (TP.CCM)
// 🔴 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.
true),
("Ban Giám đốc", "BOD",
new[] { "truong.nguyen@solutions.com.vn" }, // Nguyễn Văn Trường (CEO)
Array.Empty<string>(),
false),
};
// Resolve email → UserId MỘT lượt (11 email, dùng lại cho cả 8 workflow).
var usersByEmail = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
var missingEmails = new List<string>();
foreach (var email in roster.SelectMany(r => r.Level1.Concat(r.Level2))
.Distinct(StringComparer.OrdinalIgnoreCase))
{
var user = await userManager.FindByEmailAsync(email);
if (user is null) { missingEmails.Add(email); continue; }
usersByEmail[email] = user.Id;
}
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));
var deptIdByCode = (await db.Departments.ToListAsync())
.GroupBy(d => d.Code)
.ToDictionary(g => g.Key, g => (Guid?)g.First().Id);
var seeded = 0;
for (var n = 1; n <= 8; n++)
{
var code = $"KHKK-N{n}";
if (await db.ApprovalWorkflows.AnyAsync(w => w.Code == code)) continue;
var wf = new ApprovalWorkflow
{
Code = code,
Version = 1,
ApplicableType = ApprovalWorkflowApplicableType.ContractSigningPlan,
Name = $"Duyệt KHKK — Nhóm {n}",
Description =
$"Quy trình duyệt Kế hoạch ký kết HĐ cho nhóm duyệt N{n} (danh mục SOL-CCM-SP-002). " +
"3 trạm PRO → CCM → BGĐ, đội hình sao từ workflow Duyệt NCC prod. " +
"Admin có thể 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,
};
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++;
}
if (wf.Steps.Count == 0)
logger.LogWarning(
"SeedKhkkGroupWorkflowsAsync: {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} KHKK group workflows (K3 SP-002)", 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)
@ -1808,6 +1977,18 @@ public static class DbInitializer
(MenuKeys.ApprovalWorkflowsV2, "Quy trình duyệt (Mới)", MenuKeys.System, 96, "Workflow"),
(MenuKeys.ApprovalWorkflowDuyetNccV2, "Duyệt NCC (Mới)", MenuKeys.ApprovalWorkflowsV2, 1, "FileCheck"),
(MenuKeys.ApprovalWorkflowDuyetNccPhuongAnV2, "Duyệt NCC và Giải pháp (Mới)", MenuKeys.ApprovalWorkflowsV2, 2, "FileCheck"),
// [K3 nhịp-1 — S164/S165] 8 leaf Designer cho 8 nhóm duyệt KHKK (workflow
// `KHKK-N{n}` type-10 seed ở SeedKhkkGroupWorkflowsAsync). Key CÓ trong
// MenuKeys.All ⇒ Admin được grant tự động qua loop SeedAdminPermissionsAsync;
// upsert dưới là idempotent per-Key (key đã có → chỉ re-set Order rồi continue).
(MenuKeys.ApprovalWorkflowKhkkN1, "Duyệt KHKK — Nhóm 1", MenuKeys.ApprovalWorkflowsV2, 3, "FileSignature"),
(MenuKeys.ApprovalWorkflowKhkkN2, "Duyệt KHKK — Nhóm 2", MenuKeys.ApprovalWorkflowsV2, 4, "FileSignature"),
(MenuKeys.ApprovalWorkflowKhkkN3, "Duyệt KHKK — Nhóm 3", MenuKeys.ApprovalWorkflowsV2, 5, "FileSignature"),
(MenuKeys.ApprovalWorkflowKhkkN4, "Duyệt KHKK — Nhóm 4", MenuKeys.ApprovalWorkflowsV2, 6, "FileSignature"),
(MenuKeys.ApprovalWorkflowKhkkN5, "Duyệt KHKK — Nhóm 5", MenuKeys.ApprovalWorkflowsV2, 7, "FileSignature"),
(MenuKeys.ApprovalWorkflowKhkkN6, "Duyệt KHKK — Nhóm 6", MenuKeys.ApprovalWorkflowsV2, 8, "FileSignature"),
(MenuKeys.ApprovalWorkflowKhkkN7, "Duyệt KHKK — Nhóm 7", MenuKeys.ApprovalWorkflowsV2, 9, "FileSignature"),
(MenuKeys.ApprovalWorkflowKhkkN8, "Duyệt KHKK — Nhóm 8", MenuKeys.ApprovalWorkflowsV2, 10, "FileSignature"),
// [S61 Mig 50] Menu module Ngân sách cũ (Budgets + 3 leaf Bg_*) đã XÓA —
// rows cũ trên DB cleanup qua SQL trong migration (idempotent DELETE).
// Module Nhân sự (Phase 10.1 G-H1 — Mig 34 S33). Root operational HR.

View File

@ -34,16 +34,17 @@ namespace SolutionErp.Infrastructure.Services;
//
// 3 ĐIỂM ADAPT:
// (a) Finalize choke-point: helper `ApplyApprovedValuesOnFinalize` gọi ở MỌI nhánh set
// `DaDuyet` — xem khối LUẬT ngay trên helper. 🔴 KHAI RÕ: service này có **ĐÚNG MỘT**
// nhánh set DaDuyet, KHÔNG có site thứ hai kiểu admin-override của nguồn copy
// (`ContractWorkflowService.cs:190-193` gán `contract.Phase = targetPhase` qua BIẾN).
// Lý do: hợp-đồng transition KHKK chỉ nhận 4 action cố định, KHÔNG có tham số
// `targetPhase` ⇒ Admin muốn kết thúc phiếu vẫn phải đi đúng đường `approve`
// (Admin chỉ được miễn vế "có tên trong Cấp", không được miễn choke-point).
// Đó chính là write-path 2 mà test T3 đo.
// (b) 🔴 KHÔNG PORT 2 nhánh kết thúc sớm (`AllowApproverFinalize`, `CeoApprovalThreshold`)
// và cũng KHÔNG port `AllowApproverSkipToFinal` (`:318-352` nguồn copy) — chủ dự án
// phán "PHÁ VỠ" @S155. W3 chỉ đường THƯỜNG: PMH → CCM → CEO ký thật.
// `DaDuyet` — xem khối LUẬT ngay trên helper. 🔄 [K3 S165] service NAY có **HAI** nhánh
// set DaDuyet (đường-thường Cấp-cuối + level-finalize dưới đây), CẢ HAI qua choke-point;
// vẫn KHÔNG có site gán-qua-BIẾN kiểu nguồn copy (`ContractWorkflowService.cs:190-193`):
// hợp-đồng transition KHKK chỉ nhận 4 action cố định, KHÔNG có tham số `targetPhase`
// ⇒ Admin kết thúc phiếu vẫn đi đúng đường `approve` (miễn vế "có tên trong Cấp",
// KHÔNG min choke-point). Tự-kiểm 2 lệnh grep ở khối LUẬT phải cùng khớp.
// (b) 🔄 [K3 S165 — OG-9 SUPERSEDE "PHÁ VỠ" @S155] Owner @S164 đảo phán quyết (verbatim:
// "chỗ budget chốt chặn là a Chương… bài toán trần budget xem như đóng") ⇒ ĐÃ PORT
// nhánh `AllowApproverFinalize` (level-finalize, OPT-OUT khuôn PE S96 — cờ seed cho
// TP.CCM Phan Văn Chương @K3). VẪN KHÔNG port `CeoApprovalThreshold` (ngưỡng máy —
// NĐUQ = human-gate vĩnh viễn, OG-3) và `AllowApproverSkipToFinal` giữ nguyên không port.
// (c) Trả lại = `TraLai` TOÀN PHẦN (về người soạn, xoá con-trỏ; gửi lại chạy LẠI từ Bước 1
// Cấp 1). 4 return-mode per-level (`ApprovalWorkflow.cs:116-129`) CỐ Ý chưa đọc —
// mở thêm mode là đổi hành vi duyệt, thuộc quyền lead.
@ -87,6 +88,7 @@ public class ContractSigningPlanWorkflowService(
Guid planId,
string action,
string? comment,
bool applyLevelFinalize = true,
CancellationToken ct = default)
{
// Rào action đứng TRƯỚC mọi truy vấn: gõ sai tên hành động là lỗi dữ liệu gửi lên,
@ -121,7 +123,7 @@ public class ContractSigningPlanWorkflowService(
break;
case "approve":
await ApproveV2Async(plan, actorId, isAdmin, normalizedComment, ct);
await ApproveV2Async(plan, actorId, isAdmin, normalizedComment, applyLevelFinalize, ct);
break;
case "return":
@ -242,7 +244,8 @@ public class ContractSigningPlanWorkflowService(
// approve — approver Cấp đang chờ duyệt (OR-of-N)
// =====================================================================
private async Task ApproveV2Async(
ContractSigningPlan plan, Guid actorId, bool isAdmin, string? comment, CancellationToken ct)
ContractSigningPlan plan, Guid actorId, bool isAdmin, string? comment,
bool applyLevelFinalize, CancellationToken ct)
{
if (plan.Phase != ContractSigningPlanPhase.ChoDuyet)
throw new ConflictException("Chỉ duyệt được phiếu đang ở trạng thái Đã gửi duyệt.");
@ -264,6 +267,27 @@ public class ContractSigningPlanWorkflowService(
var fromPhase = plan.Phase;
// ---- [K3 S165 — OG-3/OG-9 port level-finalize, mirror PE `:866-876`] Cấp có cờ
// `AllowApproverFinalize` (seed K3: TP.CCM Phan Văn Chương, Bước 2 Cấp 2) được KẾT THÚC
// quy trình TẠI ĐÂY thay vì trình tiếp CEO. OPT-OUT khuôn PE S96: client gửi
// `applyLevelFinalize=false` (người duyệt BỎ tick "Cấp này KẾT THÚC") ⇒ rơi xuống
// advance thường. Supersede "PHÁ VỠ" @S155 theo lời owner @S164 (xem 3-ĐIỂM-ADAPT (b)).
// Cờ RUNTIME `EndedByLevelFinalize` = Mig 71 K2 đẻ — K3 tiêu thụ, HẾT ghost-window.
if (actingLevel.AllowApproverFinalize && applyLevelFinalize)
{
plan.EndedByLevelFinalize = true;
ApplyApprovedValuesOnFinalize(plan); // LUẬT choke-point: TRƯỚC khi gán Phase
plan.Phase = ContractSigningPlanPhase.DaDuyet;
plan.CurrentWorkflowStepIndex = null;
plan.CurrentApprovalLevelOrder = null;
await LogSigningPlanTransitionAsync(
plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Approve, comment,
actingLevel.Id,
$"Duyệt Bước {currentIdx + 1} Cấp {currentLevelOrder} — KẾT THÚC TẠI CẤP (level-finalize), kế hoạch ĐÃ DUYỆT, chốt giá",
ct);
return;
}
// ---- Advance con-trỏ ĐÔI: còn Cấp trong Bước → Cấp++; hết Cấp → Bước kế (Cấp 1);
// hết Bước → FINALIZE.
if (currentLevelOrder < maxLevelOrder)

View File

@ -1,6 +1,7 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.ApprovalWorkflowsV2; // [K3 S164] CreateAwDefinitionCommand — test giữ-cờ
using SolutionErp.Application.Common.Interfaces; // ICurrentUser — bơm cho AuditingInterceptor
using SolutionErp.Application.ContractSigningPlans; // GetMyContractSigningPlanInboxQuery (W2, đã sống)
using SolutionErp.Application.ContractSigningPlans.Services;
@ -307,7 +308,7 @@ public class ContractSigningPlanApprovalTests
{
await using var db = f.NewDb(actor);
var svc = new ContractSigningPlanWorkflowService(db, actor, f.Clock, new NoOpNotificationServiceApp());
return await svc.TransitionAsync(planId, action, comment, CancellationToken.None);
return await svc.TransitionAsync(planId, action, comment, ct: CancellationToken.None);
}
private static async Task<ContractSigningPlan> ReadPlanAsync(KhkkWorkflowFixture f, Guid planId)
@ -794,4 +795,347 @@ public class ContractSigningPlanApprovalTests
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 2,
"A ký được lần nữa ⇒ vòng duyệt chạy lại thật sự, không phải con-trỏ bị đặt lại rồi khoá");
}
// ###################################################################
// [K3 S164 — 2026-07-31] KẾT-THÚC-TẠI-CẤP (level-finalize).
//
// Owner @S164 ĐẢO phán quyết "PHÁ VỠ" của S155 ⇒ service port nhánh mới
// (`ContractSigningPlanWorkflowService.cs:276-289`), đứng NGAY SAU `UpsertLevelOpinionAsync`
// và TRƯỚC mọi nhánh advance. Đây là đường thứ HAI đưa phiếu sang `DaDuyet` — tức đường
// thứ hai phải đi qua choke-point chốt giá; bỏ sót nó thì phiếu "Đã duyệt" mà
// `ApprovedAmount` rỗng và GĐ3 sinh hợp đồng 0đ (đúng lớp hỏng mà T3 đã rào cho đường 1).
//
// Ba lưới dưới đây là MỘT MA TRẬN 2 chiều trên CÙNG MỘT bộ seed (kỹ thuật "cùng-ruột,
// khác đúng 1 biến" — S153): chỉ khi cả ba cùng xanh thì mới kết luận được rằng nhánh
// finalize phụ thuộc CẢ HAI vế, chứ không phải một vế nào đó tình cờ đúng.
//
// │ apply=true (mặc định) │ apply=false (bỏ tick)
// ─────────────────────┼───────────────────────┼──────────────────────
// Cấp CÓ cờ │ (T-a) KẾT THÚC │ (T-b) advance thường
// Cấp KHÔNG cờ │ (T-c) advance thường │ — (không có thông tin thêm)
//
// 🔴 Điều kiện để phép đo có nghĩa: Cấp mang cờ là **Bước 2 Cấp 2 của quy trình 3 Bước**,
// tức KHÔNG phải cấp cuối. Nếu đặt cờ ở cấp cuối thì "kết thúc" xảy ra ở cả hai thế giới
// (có cờ / không cờ) và T-a xanh mà chẳng chứng minh gì. Mỗi test vì thế assert lại số
// Bước = 3 trước khi kết luận.
// ###################################################################
/// Bộ seed dùng chung cho T-a/T-b/T-c. Ba phiếu SONG SINH trên CÙNG một quy trình:
/// • `PlanAtFlagLevel` — đứng ở Bước 2 Cấp 2 (Cấp MANG cờ) → đối tượng đo của T-a/T-b
/// • `WitnessAtFlagLevel` — y hệt, KHÔNG ai đụng → bắt lỗi finalize quên vế "chỉ phiếu này"
/// • `PlanAtPlainLevel` — đứng ở Bước 1 Cấp 1 (Cấp KHÔNG cờ) → đối tượng đo của T-c
private sealed record FinalizeScenario(
Guid WorkflowId,
Guid PlanAtFlagLevel,
Guid WitnessAtFlagLevel,
Guid PlanAtPlainLevel,
User FlagApprover,
User PlainApprover,
TestCurrentUser Drafter);
/// Bẫy nguồn số cố ý: `ProposedAmount` ≠ `PeReferenceAmount` ở MỌI dòng (lấy nhầm cột là lộ
/// ngay), và dòng thứ 3 đã có `ApprovedAmount` sẵn để đo đúng ngữ nghĩa `??=` (GIỮ, không đè).
private static readonly LineSeed[] FinalizeLines =
[
new(PeReference: 999m, Proposed: 111m),
new(PeReference: 888m, Proposed: 222m),
new(PeReference: 777m, Proposed: 333m, Approved: 300m),
];
private static async Task<FinalizeScenario> SeedFinalizeScenarioAsync(KhkkWorkflowFixture f, string tag)
{
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
var s1c1 = await AddUserAsync(f, "PMH cấp 1");
var s1c2 = await AddUserAsync(f, "PMH cấp 2");
var s2c1 = await AddUserAsync(f, "CCM cấp 1");
var s2c2 = await AddUserAsync(f, "TP.CCM — Cấp được KẾT THÚC");
var s3c1 = await AddUserAsync(f, "Thư ký CEO");
var s3c2 = await AddUserAsync(f, "CEO");
// Order 5/15/25 (KHÔNG 1/2/3) giữ nguyên chủ ý của T2: `CurrentWorkflowStepIndex` là
// INDEX vào list đã sort, đọc nhầm thành `Step.Order` là lộ ngay chứ không trùng do may.
var wf = await SeedWorkflowAsync(f, $"QT-KHKK-FIN-{tag}", steps:
[
[[s1c1.Id], [s1c2.Id]], // Bước 1
[[s2c1.Id], [s2c2.Id]], // Bước 2 ← Cấp 2 mang cờ
[[s3c1.Id], [s3c2.Id]], // Bước 3 ← còn NGUYÊN phía sau ⇒ kết thúc ở Bước 2 là SỚM
],
stepOrderStart: 5, stepOrderStride: 10);
await SetLevelFinalizeAsync(f, wf, stepIndex: 1, levelOrder: 2, expectedApprover: s2c2.Id);
var flagPlan = await SeedPlanAsync(f, drafter, wf, $"KHKK/2026/{tag}01",
ContractSigningPlanPhase.ChoDuyet, 1, 2, FinalizeLines);
var witness = await SeedPlanAsync(f, drafter, wf, $"KHKK/2026/{tag}02",
ContractSigningPlanPhase.ChoDuyet, 1, 2, FinalizeLines);
var plainPlan = await SeedPlanAsync(f, drafter, wf, $"KHKK/2026/{tag}03",
ContractSigningPlanPhase.ChoDuyet, 0, 1, FinalizeLines);
return new FinalizeScenario(wf, flagPlan, witness, plainPlan, s2c2, s1c1, drafter);
}
/// Bật cờ `AllowApproverFinalize` cho ĐÚNG MỘT Cấp, rồi tự kiểm 2 CHIỀU từ ĐĨA:
/// (i) Cấp được bật đúng là Cấp của người ta nghĩ; (ii) TOÀN quy trình chỉ có đúng nó
/// mang cờ. Thiếu vế (ii) thì một seed lỡ bật thêm Cấp khác vẫn cho T-c xanh ở thế giới sai.
private static async Task SetLevelFinalizeAsync(
KhkkWorkflowFixture f, Guid workflowId, int stepIndex, int levelOrder, Guid expectedApprover)
{
await using (var db = f.NewDb(TestCurrentUser.System()))
{
var stepIds = await db.ApprovalWorkflowSteps
.Where(s => s.ApprovalWorkflowId == workflowId)
.OrderBy(s => s.Order)
.Select(s => s.Id)
.ToListAsync(CancellationToken.None);
var levels = await db.ApprovalWorkflowLevels
.Where(l => l.ApprovalWorkflowStepId == stepIds[stepIndex] && l.Order == levelOrder)
.ToListAsync(CancellationToken.None);
levels.Should().ContainSingle("seed đặt đúng 1 NV ở Cấp mang cờ — nhiều hơn là OR-of-N, phép đo đổi nghĩa");
levels[0].ApproverUserId.Should().Be(expectedApprover,
"bật cờ nhầm Cấp thì mọi assert phía sau nói về một người khác");
levels[0].AllowApproverFinalize = true;
await db.SaveChangesAsync(CancellationToken.None);
}
await using var verify = f.NewDb(TestCurrentUser.System());
var flagged = await verify.ApprovalWorkflowLevels.AsNoTracking()
.Where(l => l.Step!.ApprovalWorkflowId == workflowId && l.AllowApproverFinalize)
.Select(l => l.ApproverUserId)
.ToListAsync(CancellationToken.None);
flagged.Should().BeEquivalentTo(new[] { expectedApprover },
"ĐÚNG MỘT Cấp trong cả quy trình mang cờ — cờ rò sang Cấp khác thì T-c mất răng");
}
/// Số Bước của quy trình — dùng để chốt "còn Bước phía sau" (điều kiện KẾT THÚC SỚM có nghĩa).
private static async Task<int> StepCountAsync(KhkkWorkflowFixture f, Guid workflowId)
{
await using var db = f.NewDb(TestCurrentUser.System());
return await db.ApprovalWorkflowSteps.AsNoTracking()
.CountAsync(s => s.ApprovalWorkflowId == workflowId, CancellationToken.None);
}
/// `ActAsync` luôn dùng mặc định (`applyLevelFinalize = true`) — helper NÀY là đường DUY NHẤT
/// gửi `false`, tách riêng để không đụng vào `ActAsync` mà 6 test cũ đang dựa vào.
private static async Task<ContractSigningPlanTransitionResult> ActWithFinalizeFlagAsync(
KhkkWorkflowFixture f, TestCurrentUser actor, Guid planId, string action,
string? comment, bool applyLevelFinalize)
{
await using var db = f.NewDb(actor);
var svc = new ContractSigningPlanWorkflowService(db, actor, f.Clock, new NoOpNotificationServiceApp());
return await svc.TransitionAsync(planId, action, comment, applyLevelFinalize, CancellationToken.None);
}
/// Hai dòng khởi điểm `ApprovedAmount = null` phải CÒN null; dòng đã có 300 phải GIỮ 300.
/// Viết thành hàm để ba test nói cùng một câu, không mỗi chỗ một kiểu.
private static void LinesShouldBeUnpriced(ContractSigningPlan plan, string because)
{
plan.Lines.Where(l => l.ProposedAmount != 333m).Should()
.OnlyContain(l => l.ApprovedAmount == null, because);
plan.Lines.Single(l => l.ProposedAmount == 333m).ApprovedAmount.Should().Be(300m,
"dòng vốn đã có giá thì giữ nguyên số cũ — " + because);
}
// ===================================================================
// (T-a) Cấp MANG cờ + không bỏ tick ⇒ KẾT THÚC NGAY tại Cấp đó, kèm chốt giá.
// ===================================================================
[Fact]
public async Task ApproveV2_CapMangCoKetThuc_ChotPhieuNgay_KhongTrinhTiepBuocSau()
{
using var f = new KhkkWorkflowFixture();
var sc = await SeedFinalizeScenarioAsync(f, "A");
// Sanity: đo đúng thế giới mình nghĩ — phiếu đứng ở Cấp mang cờ, chưa từng finalize,
// và quy trình CÒN Bước 3 phía sau (nếu không thì "kết thúc sớm" là vô nghĩa).
var before = await ReadPlanAsync(f, sc.PlanAtFlagLevel);
ShouldBeAt(before, ContractSigningPlanPhase.ChoDuyet, 1, 2, "seed đặt phiếu đúng Bước 2 Cấp 2");
before.EndedByLevelFinalize.Should().BeFalse("cờ runtime phải sạch trước khi đo");
(await StepCountAsync(f, sc.WorkflowId)).Should().Be(3,
"Cấp mang cờ KHÔNG được là cấp cuối — nếu là cấp cuối thì phiếu kết thúc ở CẢ HAI thế giới "
+ "(có cờ / không cờ) và test này xanh mà không chứng minh gì");
var r = await ActAsync(f, As(sc.FlagApprover), sc.PlanAtFlagLevel,
"approve", "TP.CCM chốt — không cần trình CEO");
var after = await ReadPlanAsync(f, sc.PlanAtFlagLevel);
ShouldBeAt(after, ContractSigningPlanPhase.DaDuyet, null, null,
"Cấp mang cờ duyệt = kết thúc quy trình: phase DaDuyet và XOÁ cả 2 con-trỏ; "
+ "để sót con-trỏ thì phiếu đã duyệt vẫn nằm trong hộp thư chờ của Bước 3");
ResultShouldMirror(r, after);
after.EndedByLevelFinalize.Should().BeTrue(
"cờ RUNTIME phân biệt 'kết thúc sớm THẬT' với 'chạy hết quy trình' — FE/GĐ3 đọc cột này; "
+ "suy ngược từ cấu hình quy trình là bài học gotcha #78 (config-lie) đã trả giá ở PE S97");
// Chốt giá: đường finalize là WRITE-PATH THỨ HAI tới DaDuyet ⇒ cũng phải qua choke-point.
after.Lines.Should().NotContain(l => l.ApprovedAmount == null,
"phiếu 'Đã duyệt' mà ApprovedAmount rỗng thì GĐ3 sinh hợp đồng 0đ — nhánh finalize "
+ "PHẢI gọi `ApplyApprovedValuesOnFinalize`, đúng như nhánh duyệt-cấp-cuối");
var byProposed = after.Lines.ToDictionary(l => l.ProposedAmount);
byProposed[111m].ApprovedAmount.Should().Be(111m,
"giá chốt lấy `ProposedAmount` (BCH đề xuất), KHÔNG phải `PeReferenceAmount` 999");
byProposed[222m].ApprovedAmount.Should().Be(222m);
byProposed[333m].ApprovedAmount.Should().Be(300m,
"`??=` GIỮ giá đã chốt sẵn (300), không đè bằng 333");
// Nhánh finalize nằm SAU `UpsertLevelOpinionAsync` ⇒ chữ ký của người kết thúc vẫn phải có.
var op = await ReadOpinionsAsync(f, sc.PlanAtFlagLevel);
op.Should().ContainSingle("kết thúc tại Cấp vẫn là một lượt DUYỆT ⇒ vẫn ghi ý kiến của Cấp đó");
op[0].SignedByUserId.Should().Be(sc.FlagApprover.Id,
"người kết thúc phiếu phải để lại tên — bản in FO-002.01 lấy chữ ký từ đây");
// Chứng nhân: phiếu song sinh (cùng quy trình, cùng con-trỏ) tuyệt đối bất động.
var w = await ReadPlanAsync(f, sc.WitnessAtFlagLevel);
ShouldBeAt(w, ContractSigningPlanPhase.ChoDuyet, 1, 2,
"finalize chỉ được chạm phiếu ĐANG duyệt — lây sang phiếu khác là duyệt hộ cả hệ thống");
w.EndedByLevelFinalize.Should().BeFalse("cờ runtime cũng không được lây");
LinesShouldBeUnpriced(w, "phiếu hàng xóm không được dính giá chốt");
}
// ===================================================================
// (T-b) OPT-OUT — CÙNG bộ seed, đổi ĐÚNG MỘT biến `applyLevelFinalize: false`.
// Người duyệt bỏ tick "Cấp này kết thúc" ⇒ phiếu đi tiếp Bước 3 như thường.
// ===================================================================
[Fact]
public async Task ApproveV2_CapMangCoKetThuc_NhungBoTick_ThiVanTrinhTiepBuocSau()
{
using var f = new KhkkWorkflowFixture();
var sc = await SeedFinalizeScenarioAsync(f, "B");
(await StepCountAsync(f, sc.WorkflowId)).Should().Be(3, "cùng bộ seed với T-a");
var r = await ActWithFinalizeFlagAsync(f, As(sc.FlagApprover), sc.PlanAtFlagLevel,
"approve", "Gói lớn — tôi vẫn trình CEO", applyLevelFinalize: false);
var after = await ReadPlanAsync(f, sc.PlanAtFlagLevel);
ShouldBeAt(after, ContractSigningPlanPhase.ChoDuyet, 2, 1,
"bỏ tick ⇒ rơi xuống advance THƯỜNG: hết Cấp của Bước 2 ⇒ sang Bước 3 (index 2) và "
+ "Cấp RESET về 1. Ra DaDuyet ở đây tức là cờ quy trình một mình quyết định, "
+ "quyền chọn của người duyệt bị bỏ qua");
ResultShouldMirror(r, after);
after.EndedByLevelFinalize.Should().BeFalse(
"phiếu KHÔNG kết thúc sớm ⇒ cờ runtime phải sạch; bật ở đây là badge 'kết thúc sớm' "
+ "hiện sai trên phiếu vẫn còn đang chờ CEO (đúng bệnh #78 mà PE S97 đã sửa)");
LinesShouldBeUnpriced(after,
"chưa tới DaDuyet thì TUYỆT ĐỐI chưa được chốt giá — chốt sớm là ký thay CEO");
// Vòng chạy tiếp là THẬT: đúng người của Bước 3 Cấp 1 duyệt được (không phải con-trỏ
// bị đặt tới một chỗ chết).
var op = await ReadOpinionsAsync(f, sc.PlanAtFlagLevel);
op.Should().ContainSingle("lượt duyệt opt-out vẫn ghi ý kiến của Cấp vừa ký");
}
// ===================================================================
// (T-c) Cờ KHÔNG RÒ — Cấp không mang cờ, duyệt với mặc định (`applyLevelFinalize = true`)
// vẫn phải advance thường. Đây là vế chứng minh `applyLevelFinalize` MỘT MÌNH không đủ
// để kết thúc phiếu — nếu điều kiện viết nhầm thành `applyLevelFinalize` đơn lẻ (hoặc `||`
// thay vì `&&`) thì MỌI lượt duyệt đầu tiên sẽ chốt luôn cả phiếu.
// ===================================================================
[Fact]
public async Task ApproveV2_CapKhongMangCo_ThiKhongBaoGioKetThucSom()
{
using var f = new KhkkWorkflowFixture();
var sc = await SeedFinalizeScenarioAsync(f, "C");
var r = await ActAsync(f, As(sc.PlainApprover), sc.PlanAtPlainLevel,
"approve", "Bước 1 Cấp 1 duyệt");
var after = await ReadPlanAsync(f, sc.PlanAtPlainLevel);
ShouldBeAt(after, ContractSigningPlanPhase.ChoDuyet, 0, 2,
"Cấp KHÔNG có cờ ⇒ chỉ tăng Cấp trong cùng Bước, dù client gửi mặc định "
+ "`applyLevelFinalize = true`; ra DaDuyet ở đây là quy trình 3 Bước bị nuốt còn 1 lượt ký");
ResultShouldMirror(r, after);
after.EndedByLevelFinalize.Should().BeFalse("không có cờ thì không có kết-thúc-sớm để ghi");
LinesShouldBeUnpriced(after, "mới Bước 1 Cấp 1 — chưa có gì để chốt");
// Và phiếu ĐANG đứng ở Cấp mang cờ vẫn nguyên (cờ là thuộc tính của CẤP, không phải
// của quy trình/phiếu — một lượt duyệt ở Cấp khác không được kích hoạt nó).
var atFlag = await ReadPlanAsync(f, sc.PlanAtFlagLevel);
ShouldBeAt(atFlag, ContractSigningPlanPhase.ChoDuyet, 1, 2, "phiếu kia bất động");
atFlag.EndedByLevelFinalize.Should().BeFalse("cờ chỉ bật khi CHÍNH Cấp mang cờ bấm duyệt");
}
// ===================================================================
// (T-e) GIỮ CỜ QUA LỆNH TẠO QUY TRÌNH — `ApprovalWorkflowV2AdminFeatures.cs:376-385`.
//
// Trước @K3, handler tạo version ÉP `AllowApproverFinalize = false` riêng cho type-10
// (containment thời "PHÁ VỠ" S155). Owner đảo phán quyết ⇒ ép-false đã gỡ. Nếu ai đó
// khôi phục nó thì admin vẫn tick được cờ trên Designer, DB vẫn im lặng lưu `false`, và
// T-a phía trên KHÔNG bắt được (T-a set cờ thẳng vào DB, không đi qua lệnh tạo) —
// đúng hình "config-lie" gotcha #78: giao diện nói một đằng, máy chạy một nẻo.
//
// Ba vế để phép đo không rỗng:
// (i) Cấp tick cờ ⇒ DB `true`;
// (ii) Cấp KHÔNG tick ⇒ DB `false` (chứng handler không bật bừa mọi dòng);
// (iii) ĐỐI CHỨNG type khác (DuyetNcc = 1) cũng `true` ⇒ cơ chế là ECHO tham số, không
// phải một nhánh đặc-cách theo loại — vế này là thứ phân biệt "đã gỡ ép-false"
// với "đổi ép-false thành ép-true".
// ===================================================================
[Fact]
public async Task CreateAwDefinition_LoaiKhkk_GiuNguyenCoKetThucTaiCap_KhongConEpFalse()
{
using var f = new KhkkWorkflowFixture();
var plain = await AddUserAsync(f, "NV duyệt Cấp 1");
var finalizer = await AddUserAsync(f, "TP.CCM — Cấp kết thúc");
var khkkId = await CreateAwDefinitionAsync(
f, ApprovalWorkflowApplicableType.ContractSigningPlan, "KHKK-N1", plain.Id, finalizer.Id);
var peId = await CreateAwDefinitionAsync(
f, ApprovalWorkflowApplicableType.DuyetNcc, "QT-PE-DOICHUNG", plain.Id, finalizer.Id);
var khkk = await ReadLevelFlagsAsync(f, khkkId);
khkk.Should().HaveCount(2, "lệnh tạo dựng đúng 2 Cấp");
khkk.Single(l => l.Approver == finalizer.Id).Finalize.Should().BeTrue(
"admin tick 'Cấp này KẾT THÚC' thì DB phải lưu true — ép false ở đây là cờ chết: "
+ "Designer hiện đã tick, service `…WorkflowService.cs:276` đọc ra false, "
+ "và không ai biết cho tới khi phiếu thật treo chờ CEO");
khkk.Single(l => l.Approver == plain.Id).Finalize.Should().BeFalse(
"Cấp KHÔNG tick phải là false — nếu true thì handler đang bật bừa, và vế trên xanh vô nghĩa");
var pe = await ReadLevelFlagsAsync(f, peId);
pe.Single(l => l.Approver == finalizer.Id).Finalize.Should().BeTrue(
"ĐỐI CHỨNG: loại quy trình khác cũng giữ cờ ⇒ cơ chế là echo tham số của lệnh, "
+ "không phải một nhánh đặc-cách theo `ApplicableType`");
}
private sealed record LevelFlag(int Order, Guid Approver, bool Finalize);
/// Tạo 1 quy trình 1 Bước × 2 Cấp qua lệnh THẬT: Cấp 1 không tick, Cấp 2 tick kết-thúc.
private static async Task<Guid> CreateAwDefinitionAsync(
KhkkWorkflowFixture f, ApprovalWorkflowApplicableType type, string code,
Guid plainApprover, Guid finalizeApprover)
{
await using var db = f.NewDb(TestCurrentUser.System());
var handler = new CreateAwDefinitionCommandHandler(db);
return await handler.Handle(
new CreateAwDefinitionCommand(
ApplicableType: (int)type,
Code: code,
Name: $"Quy trình {code}",
Description: null,
Steps:
[
new CreateAwStepInput(1, "Bước 1", null,
[
new CreateAwLevelInput(1, "Cấp 1", plainApprover),
new CreateAwLevelInput(2, "Cấp 2", finalizeApprover, AllowApproverFinalize: true),
]),
]),
CancellationToken.None);
}
private static async Task<List<LevelFlag>> ReadLevelFlagsAsync(KhkkWorkflowFixture f, Guid workflowId)
{
await using var db = f.NewDb(TestCurrentUser.System());
return await db.ApprovalWorkflowLevels.AsNoTracking()
.Where(l => l.Step!.ApprovalWorkflowId == workflowId)
.OrderBy(l => l.Order)
.Select(l => new LevelFlag(l.Order, l.ApproverUserId, l.AllowApproverFinalize))
.ToListAsync(CancellationToken.None);
}
}

View File

@ -244,7 +244,7 @@ public class ContractSigningPlanGroupAndCatalogTests
{
await using var db = f.NewDb(actor);
var svc = new ContractSigningPlanWorkflowService(db, actor, f.Clock, new NoOpNotificationServiceApp());
return await svc.TransitionAsync(planId, action, comment, CancellationToken.None);
return await svc.TransitionAsync(planId, action, comment, ct: CancellationToken.None);
}
private static async Task<ContractSigningPlan> ReadPlanAsync(KhkkGroupFixture f, Guid planId)
@ -821,4 +821,83 @@ public class ContractSigningPlanGroupAndCatalogTests
new CreateContractSigningPlanCommand(peId, workflowId, GhiChu: null, ApprovalGroup: approvalGroup),
CancellationToken.None);
}
// ===================================================================
// [K3 S164 — 2026-07-31] Rào (v) PIN-ĐÚNG-NHÓM — `ContractSigningPlanFeatures.cs:409-420`.
//
// 8 quy trình seed @K3 mang mã họ `KHKK-N{n}`; phiếu nhóm 3 mà pin quy trình nhóm 5 thì
// toàn bộ dây chuyền ký (người ký, thứ tự trạm) là của LOẠI HỢP ĐỒNG KHÁC — sai im lặng,
// chỉ lộ khi hồ sơ tới tay người không có thẩm quyền ký. Mã NGOÀI họ (legacy/chung) cố ý
// không bị ràng buộc.
//
// Ba điều được đo, mỗi cái bịt một cách "xanh giả":
// (1) CÙNG MỘT quy trình `KHKK-N5` cho ra HAI kết quả trái ngược khi chỉ đổi nhóm của
// phiếu (3 → chặn, 5 → cho qua). Chỉ đo một chiều thì một rào chặn-tuốt hoặc một
// rào chưa-bao-giờ-chạy đều xanh.
// (2) Lần bị chặn KHÔNG ĐỐT MÃ: rào (v) đứng TRƯỚC `codeGen.GenerateAsync` (`:428`), mà
// codegen TỰ COMMIT trong transaction riêng ⇒ nếu ai đó đảo thứ tự, số thứ tự bốc hơi
// vĩnh viễn và mã phiếu nhảy cóc. Đo bằng chính mã của phiếu hợp lệ ĐẦU TIÊN (001).
// (3) Mã ngoài họ đi lọt — vế này giữ cho rào không nuốt các quy trình chung.
// ===================================================================
[Fact]
public async Task Create_PinQuyTrinhKhacNhomDuyet_ThiBiChan_VaKhongDotMa()
{
using var f = new KhkkGroupFixture();
var bch = As(await AddUserAsync(f, "BCH lập kế hoạch"));
var approver = await AddUserAsync(f, "NV duyệt");
var pe = await SeedPeForCreateAsync(f);
var wfN5 = await SeedWorkflowAsync(f, "KHKK-N5", steps: [[[approver.Id]]]);
var wfN3 = await SeedWorkflowAsync(f, "KHKK-N3", steps: [[[approver.Id]]]);
var wfLegacy = await SeedWorkflowAsync(f, "QT-KHKK-LEGACY", steps: [[[approver.Id]]]);
// ---- (1a) Phiếu nhóm 3 pin quy trình nhóm 5 ⇒ chặn, và nói ĐÍCH DANH quy trình sai.
var wrongGroup = async () => await CreateAsync(f, bch, pe.PeId, wfN5, approvalGroup: 3);
var ex = await wrongGroup.Should().ThrowAsync<ConflictException>(
"quy trình họ KHKK-N{n} chỉ dùng được cho phiếu đúng nhóm n — sai nhóm là ký sai dây chuyền");
ex.Which.Message.Should().Contain("KHKK-N5").And.Contain("N3",
"thông báo phải chỉ ra CẢ quy trình đang pin lẫn nhóm của phiếu, nếu không người dùng "
+ "chỉ thấy '409' và không biết đổi cái gì");
// Bị chặn thì KHÔNG được để lại dấu vết nào.
(await ReadPlanScopesAsync(f)).Should().BeEmpty("rào ném trước khi `Add` ⇒ không có phiếu nửa vời");
// ---- (2) + (1b) Pin ĐÚNG nhóm ⇒ qua. Mã 001 chứng minh lần 409 không tiêu số thứ tự.
var okSameGroup = await CreateAsync(f, bch, pe.PeId, wfN3, approvalGroup: 3);
okSameGroup.MaKeHoach.Should().Be("KHKK/2026/001",
"rào (v) đứng TRƯỚC codegen ⇒ lần bị chặn KHÔNG đốt mã; ra 002 tức là số thứ tự đã bị "
+ "tiêu cho một phiếu chưa từng tồn tại (codegen tự commit, không rollback theo request)");
// ---- (1c) CHÍNH quy trình vừa bị chặn, nay hợp lệ vì phiếu đúng nhóm 5.
var okOtherGroup = await CreateAsync(f, bch, pe.PeId, wfN5, approvalGroup: 5);
okOtherGroup.MaKeHoach.Should().Be("KHKK/2026/002",
"cùng một quy trình KHKK-N5: nhóm 3 bị chặn, nhóm 5 cho qua — hai kết quả trái ngược "
+ "chỉ khác đúng một biến, đó là bằng chứng rào SO KHỚP chứ không chặn-tuốt");
// ---- (3) Mã ngoài họ `KHKK-N{n}` KHÔNG bị ràng buộc (quy trình chung/legacy).
var okLegacy = await CreateAsync(f, bch, pe.PeId, wfLegacy, approvalGroup: 4);
okLegacy.MaKeHoach.Should().Be("KHKK/2026/003",
"quy trình ngoài họ đi thẳng — rào chỉ áp cho 8 mã nhóm seed @K3");
// ---- Chốt bằng ĐĨA: 3 phiếu, mỗi phiếu pin đúng quy trình của nó.
var scopes = await ReadPlanScopesAsync(f);
scopes.Should().BeEquivalentTo(new[]
{
new PlanScope("KHKK/2026/001", 3, wfN3),
new PlanScope("KHKK/2026/002", 5, wfN5),
new PlanScope("KHKK/2026/003", 4, wfLegacy),
}, "nhóm duyệt + quy trình phải được ghi đúng như lúc tạo — rào cho qua mà lưu sai cặp "
+ "thì phiếu vẫn chạy nhầm dây chuyền, chỉ là muộn hơn một bước");
}
private sealed record PlanScope(string MaKeHoach, int ApprovalGroup, Guid ApprovalWorkflowId);
private static async Task<List<PlanScope>> ReadPlanScopesAsync(KhkkGroupFixture f)
{
await using var db = f.NewDb(TestCurrentUser.System());
return await db.ContractSigningPlans.AsNoTracking()
.OrderBy(p => p.MaKeHoach)
.Select(p => new PlanScope(p.MaKeHoach!, p.ApprovalGroup, p.ApprovalWorkflowId!.Value))
.ToListAsync(CancellationToken.None);
}
}