Compare commits

...

7 Commits

Author SHA1 Message Date
aaed699228 [CLAUDE] Contract: K7 SP-002 bridge KHKK->HD hoi-2-khoa + Line.ContractId + grant seeder (gate PASS-WITH-FLAGS 10, F-8 fixed)
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled
BE: POST {id}/create-contract (Contracts.Create AND KeHoachKyKet.Read) + guard 4-ve IsActive/IsUserSelectable
+ Line.ContractId write-site DAU TIEN + list-DTO ContractIds + SeedContractCreateAccessAsync (Drafter/
Procurement/Admin upgrade-only, cau III default cho ratify). FE x2 app SHA-pair 3/3: nut bridge gate 2-khoa
+ dialog khuon-san + cay GD3/GD4 doc TAP contractIds (union pe.contractId legacy). Test +17 (12 bridge T1-T12
+ 5 authz) suite 639/0; ngoai-le CreateContract trong AuthorizePolicyRegressionTests (#85).
Gate F-8 fixed truoc commit: neo so-dong :2516 chet-luc-land -> neo ten ham (3 cho) + 13/13->12/13 theo do
prod + khai chu-dich CanRead + sua tro-nham-ham. No mang theo (0-blocker): MAJOR-race-2-request-dong-thoi
(chong-trung in-memory, phuong an unique/RowVersion = nhip sau) + DraftData-khong-carry + a11y/O(n.m) FE.
UAT-NOI-TRUOC: nut sang sau restart + user RE-LOGIN (menu cache); acceptance seeder log = 0 added + 2 upgraded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 13:04:49 +07:00
c97f948a7b wal: flush 20260801T1244 2026-08-01 12:44:16 +07:00
f5ee146d9a wal: flush 20260801T1242 2026-08-01 12:42:24 +07:00
6b2ec7b591 wal: flush 20260801T1240 2026-08-01 12:40:53 +07:00
b827fd393b wal: flush 20260801T1228 2026-08-01 12:28:55 +07:00
e7a4a4a58d wal: flush 20260801T1211 2026-08-01 12:11:32 +07:00
5f2d5098d8 wal: flush 20260801T1158 2026-08-01 11:58:34 +07:00
27 changed files with 3055 additions and 201 deletions

View File

@ -19,18 +19,49 @@ chain:
gate-K4c 3-MINOR (helper-dispose · link-mất-query · highlight) · K5 MINOR-3-"SẮP" {UX chờ anh} ·
flaky-1/12 DeleteCSP_SoftDeletes (mốc: #439 + S167-local đều xanh) · UAT-NÓI-TRƯỚC: prod 0 phiếu ⇒
8 ngăn "SẮP" rỗng mọi gói = ĐÚNG SPEC
[!] K6 admin-thu-gọn: lane FE DONE trên đĩa @S166, CHƯA gate CHƯA commit — sub-implfe-k6.md (ADMIN_HIDDEN_KEYS
12-key nguyên-văn spec + comment lineage-2-ĐẢO S29→S57→QĐ7 + display-only #82; C1 15-lá LIỆT TÊN 16/16
assert + CONTROL-ÂM bỏ-KeHoachKyKet 15→63 · C2 fe-user 0-diff · C4 0-route/0-guard · build PASS 1961
modules; Layout.tsx +52/19 UNCOMMITTED; 3 FLAG MINOR: N=15 chốt-số-spec [spec quên +8 designer] ·
prod-menu-row cần SELECT AwV2% đóng-tuyệt-đối · resolvePath nhánh-chết CỐ-Ý-không-dọn [đảo 2 lần rồi])
→ gate-K6 ĐANG CHẠY @S167 (sub-reviewer-gate-k6.md ghi liên-tục — cửa chết thì ĐỌC FILE ĐÓ, đừng
spawn lại từ đầu) → commit → push → cicd
[ ] K7 hội-2-khóa + grant Contracts.Create [câu III default Drafter+Procurement+Admin] +
Line.ContractId-ghi-luôn [nợ K5 FLAG-1 chỗ-cắm-1-dòng] (spec chi tiết _context PAUSE-3 mục (2))
[ ] K8-prep form-14-người fail-closed + gói SOLUTION-PE-{n} + QĐ9 đo-2-nhánh + ZZTEST + rollback-theo-ID
[ ] closeout-nợ: diary vai (C2) · tally #53 CHỐT derive-from-body (S166 nháp 8 lượt + cicd-K5K4C chết-return
artifact-trọn = 9-cận-dưới / 0 mất công) · nhãn D MIND · STATUS row S163-S167 · spec-cụm1 vá-3 đính-chính
[x] K6 SHIPPED @S167 — commit 1c6d77a PUSHED (1 file Layout.tsx +54/19) · gate PASS-WITH-FLAGS 5
0-blocker (resume-điền sau #53-garble — sub-reviewer-gate-k6.md R0→R10: 12/12-key từng-ký-tự +
control-âm 3-key-bịa MISS · C1 N=15 tái-lập trên PROD SỐNG · C2 0-diff control-dương · C4 3-tầng
+ curl prod 200 ×2 · tsc/build tái-lập trùng số lane) · MAJOR-1 3-số-đếm-comment FIXED trước commit
("28 leaf" đã sai lúc land — luật #84) + MINOR-3 câu-xếp-nhánh · FLAG-2 lane ĐÓNG bằng /api/menus/me
prod (10 key AwV2*, 0 AwV2_Contract) · FLAG-3 GIỮ nhánh-chết (đảo 2 lần) · cicd-K6 PASS 5/5
return-CLEAN (run #440 6m29s · gate 622 delta-0 · mig 71 · admin ROTATE Ig1R5tAh +168B · eoffice
FROZEN-lành-tính [CI-log cùng-hash + cmp byte-identical] · marker 12-key +1 differential + marker-ÂM
route /purchase-evaluations 68→68 · /api/menus vẫn trả 12-key-ẩn = display-only đo bằng số · bài mới:
Vite-8-minify-backtick làm regex-marker FAIL-OAN ×2 → luật grep -F, vai đã ghi MEMORY —
sub-cicd-verify-k6.md). ✅ CLARIFY-2 CHỐT @S167 (anh: "Admin (Site) để nguyên"): giữ nguyên cả 2 —
Hrm/Off/Personal tạm 0 lối menu 2 app (route/quyền còn nguyên; bật lại = màn Menu eOffice, 0 code)
[!] K7 — BE DONE / FE+test ĐANG nền @S167. 🔴 BE-lane BÁC tiền-đề lead "bridge đã tồn tại" (4 phép đo —
W6 chỉ có ĐƯỜNG-ỐNG; lead nhập nhằng 2 khái niệm) → DỰNG bridge theo invest §K7.1 pin-sẵn + 5 vá:
seeder SeedContractCreateAccessAsync :2757 (Drafter/Procurement/Admin upgrade-only — lỗ thật: trước
S167 0-role có Contracts.Create) · POST {id}/create-contract hội-2-khóa AND :230-238 · guard 4-vế
throw-tách :132-148 · Line.ContractId write-site ĐẦU-TIÊN :237-238 + list-DTO +ContractIds · vá-6
codegen/SLA/pin (spec THẮNG invest chỗ WorkflowDefinitionId). Build 0/0 · runtime 34/35 (1 fail
= AuthorizePolicyRegressionTests:398 đòi-prefix, bridge CỐ Ý Contracts.Create #85 → test-lane thêm
ngoại-lệ CreateContract). 4 file uncommitted (3 M + 1 mới CreateContractFromSigningPlanFeatures.cs)
— sub-implbe-k7.md §0-§6 trọn. FE DONE (garble×2 → resume + LEAD-CLOSE-ON-BEHALF từ đĩa: SHA-pair 3/3
identical · tsc 2-app EXIT-0 · build 2-app ✓ · KHỚP-DTO đóng `ContractIds` — sub-implfe-k7.md §2-§4
lead-ghi, gate-K7 = lưới review THẬT đầu tiên của diff FE). TEST DONE (engine-exit-0-delta → resume
Write-trước: suite 639/0 = 45D+594I Δ+17 [12 bridge T1-T12 + 5 authz] · 1-FAIL-bàn-giao đóng bằng
ngoại-lệ CreateContract #85 · 0-FLAG-production · 2-khai: vá-6-thắng-invest-T1 + seeder-grant-chỉ-đo-
được-@restart-prod→giao-cicd — sub-test-k7.md §0-§5). Gate-K7 ĐANG nền (sub-reviewer-gate-k7.md — cửa
chết ĐỌC FILE trước, đừng spawn lại). CÒN: gate → commit ([CLAUDE] Contract: K7) → push → cicd
(kèm đo seeder-grant restart-proof) → GĐ3-đồng-nhất (chốt 55)
[ ] GĐ3 cây-4-folder ĐỒNG-NHẤT — anh chốt (55) @S167 "Nên đồng nhất": áp cây 4-folder lên cụm trang
Duyệt-hợp-đồng (7 loại HĐ) ×2 app — làm SAU K7 ship (tái dùng tập ContractIds/usePipelineStages mới)
[ ] K8-prep form-14-người fail-closed + gói SOLUTION-PE-{n} + QĐ9 đo-2-nhánh + ZZTEST + rollback-theo-ID.
🔴 (54) FINAL @S167 (anh, 2 lượt): "quy trình duyệt tử tế rồi ko sao" + "hiện cứ như vậy — Nếu điều
chỉnh, tao sẽ tự tay điều chỉnh" ⇒ GIỮ HIỆN TRẠNG workflow/người-duyệt KHKK (8 WF N1-N8 đã seed @K3,
roster verified-live); em KHÔNG dựng/chỉnh gì thêm — chỉnh người duyệt = VIỆC TAY CỦA ANH qua Designer.
K8-prep pre-flight chỉ CHECK-đọc (workflow LIVE 2-cờ + roster đủ người) rồi báo, KHÔNG sửa
[ ] closeout-nợ: ✅ slot(57) ĐÓNG @S167 (anh "auto"): SPLIT _INDEX 21.468→8.756B (42,7% cap) +
_INDEX-2026-H1.md 13.906B tầng-lạnh · vá kèm 3-bệnh-sẵn (mega-line #423 · 29-entry lạc-section ·
needle-quote test-specialist) · 3 script cùng-class → `_INDEX*.md` (gate-2-chỗ chống-haystack-dương-giả
+ measure-trục-l2_index + shard-probe-loại-trừ) · luật AUTO-rotate @header-_INDEX + split_rule_S167
@budget.json · CHỨNG: A7 427/427-PASS-lần-đầu-sạch + probe l2idx-0-over + fault-inject-2-chiều ·
🆕 G-1 RATIFIED @S167 (anh: "Đồng ý. Cứ theo khuôn PE") ⇒ grep đóng mọi comment/ghi-chú "chờ ratify"
SAU khi lane FE-K7 trả (tránh đua fe-*) · diary vai (C2, gồm ctx-verifier S167 lead-seed) · tally #53
CHỐT derive-from-body (S166 nháp 8 lượt + cicd-K5K4C + gate-K6-garble = 10-cận-dưới / 0 mất công) · nhãn D MIND · STATUS row S163-S167 · spec-cụm1 vá-3 đính-chính
PUT-Conflict · lỗi-tự-bắt 607/609 · H24-DEEP 11-FLAG-SÀN resolve (sub-lead-stale-deep-S166.md: F-1
STATUS-canonical-5-row + canonical-poison :465-472 · F-2 bundle :479 · F-7 slot(57) _INDEX 101,23% ·
F-8 slot(59) ring2-BÁC-một-phần số-mới-120.633B-vẫn-dưới-sàn · F-4 CLAUDE-mig-70/71 · F-5/6/9/10/11;
@ -49,11 +80,12 @@ verify:
dotnet test SolutionErp.slnx --nologo # 622/0 = 45D + 577I
notes: MANDATE PAUSE-3 sống (sổ nhớ kỹ từng đầu việc đã review — rà OG từng dòng trước khi tuyên xong).
S166 chết-vì-limit GIỮA /pause-4: draft MIND-4 7Đ/0T CHƯA chèn (ctx-curator chết) ⇒ /pause kế (ctx-verifier
@S167 11 cờ): PAUSE-4 khai 2-WINDOW (④ _context mù trọn window-4) · NÉN MIND-2 SÂU ≤1,5KB (⑤ số-học thiếu
≥700B dù nén khuôn cũ) + di-trú nhãn TỪNG Ý 22-ý-D + 4 con-trỏ vào TRƯỚC (PARK-2/PARK-5 cấp nhà ⑨ ·
ESCALATE(i) disposition ⑩ · slot(57) _INDEX đo tươi 104,8% TRÀN 988B ⑥ · slot(58) ruột "65,7%" đã lật ⑦)
· chuẩn-hoá nhãn `{UX chờ anh}``{treo-chờ-anh}` khi di-trú (⑧ ngoài bộ-4 = trượt-cấu-trúc) · MIND-4 ≤6.331B
· diary ctx-verifier S167 = lead seed on-behalf (vai nhn Write giữ rào R0 gate-K6). run.md ledger tụt hậu ③
4-chốt anh @S167 lượt-2: (54) ratified-hướng · (55) GĐ3 đồng-nhất → chain · (56) ĐÓNG (anh view "hiện
ổn", có yêu-cầu User thì cập nhật sau) · (58) TRẦN `_mind` 32→48KB ĐÃ THI HÀNH (mind-check live 62,2%
ĐẠT — "57" anh gõ = slot 58, lead khai cách hiểu) ⇒ áp-lực "PHẢI nén MIND-2" HẾT, nén = tùy-chọn-khi-tiện.
S166 chết-vì-limit GIỮA /pause-4: draft MIND-4 7Đ/0T CHƯA chèn ⇒ /pause kế (cờ ctx-verifier còn hiệu-lực):
PAUSE-4 khai 2-WINDOW (④ _context mù trọn window-4) · khi nào NÉN MIND-2 thì di-trú nhãn TỪNG Ý trước
(PARK-2/PARK-5 cấp nhà ⑨ · ESCALATE(i) disposition ⑩) · chuẩn-hoá nhãn `{UX chờ anh}``{treo-chờ-anh}`
(⑧ ngoài bộ-4 = trượt-cấu-trúc) · diary ctx-verifier S167 = lead seed on-behalf. run.md ledger tụt hậu ③
(row 4 "☐ lần /tiep sau" + 0 row F-1) = sửa cùng closeout. Treo-không-chặn: (I)(II)(III) · OG-5 ·
OG-6-soát-4-ô-UI · 2-hở-UX-K1-FE · F-5-PermGuard-khuôn-cũ · O-2-literal-2-nơi · G-1-ratify-OPT-IN.
2-hở-UX-K1-FE · F-5-PermGuard-khuôn-cũ · O-2-literal-2-nơi. G-1 comment-đóng-dấu = commit riêng sau K7.

View File

@ -1,6 +1,10 @@
# CI/CD Monitor Agent — Persistent Memory
- **S166 (08-01) `88e7ecf` K4a+K4b sidebar 8-nhóm × 6-leaf + nối `?group=` (0-mig) — PASS 7/7** (#438 6m23s · gate **621=45+576** · Mig GIỮ 71 · menu 151→**200** · perm 1584→**2221**). 🔑 **PHỦ 2 RUN 1 LƯỢT ⇒ delta KHÉP KÍN**: #437 F-1 = 614 (Infra +0) ⇒ trọn **+7** quy được về K4. 🔑 **MỐC PRE ĐỀ BÀI SAI, bắt bằng ĐO**: lead giao `143⇒192`, đo LIVE lúc còn `running` ra **151** → truy phạm-vi thay vì hô drift (K3 đẻ 8 row `AwV2_KhkkN1..N8`) ⇒ đúng là **200**. **Mốc PRE lão hoá sau MỖI deploy.** 🔑 **phân rã 49+588=637 khớp cơ-chế 2-site skip-existing** (588=49×12 role còn-lại) — KHÔNG suy được từ tổng ⇒ bit thật (tam-giác source ⟂ sqlcmd ⟂ Serilog). ⚠️ **PERM row ≠ policy** (1 row/cặp 4 cờ; "49×4=196" là POLICY). 🔑 **A5 restart ×2 THẬT** (PID 7020→220→6636): 8 chỉ số ĐỨNG IM + **soi ĐIỀU KIỆN log trong source TRƯỚC khi đọc im-lặng** (`if (added>0)`) ⇒ vắng dòng ⟺ 0 added; control #84 `Hrm*/Off*` giữ **29/377**. 🔴 **MARKER LEAD GỢI Ý BỊ BÁC BẰNG ĐO** (`Kết cấu…` có sẵn từ `7a903cf` ⇒ đo **1/1** = 0 bit) → thay bằng POS `/khkk/list?group=1` 4/4 element-wise · NEG `/khkk/list?view=deleted` 0/0 · 🔑 **DIFFERENTIAL admin 0 / eoffice 2** (bất-đối-xứng CỐ Ý ship + tự chứng comment-strip). 🔑 `?group=abc` no-token=**401 (authz chặn TRƯỚC bind)** → bearer re-probe **400 bind-error**. ⚠️ Treo: bảng **0 dòng**chứng ống+bind KHÔNG chứng lọc-đúng-nhóm; `?group=999`→200. Evidence `runs/2026-07-31-S164-4gd-khkk-fanout/sub-cicd-verify-k4ab.md`. Tag `[run-438, phu-2-run, moc-PRE-sai, marker-0-bit, differential-marker]`
- **S167 (08-01) `1c6d77a` K6 admin thu-gọn menu `ADMIN_HIDDEN_KEYS` 12-key (FE-admin 1 file, 0-mig) — PASS 5/5** (#440 6m29s · gate **622=45+577** delta 0 · Mig GIỮ 71 · tables 97 · menu 200/perm 2221 đứng im). 🔴 **BÀI TO NHẤT: 0-hit marker SAI 2 LẦN LIÊN TIẾP do CƠ-CHẾ REGEX, cả 2 lần trông y hệt "code không ship" ⇒ suýt FAIL-OAN.** (a) **rolldown/Vite 8 minify string → BACKTICK** (`new Set([\`PurchaseEvaluations\`,…])`), regex neo `["']` không bao giờ khớp; (b) vá xong truyền pattern-chứa-backtick qua biến trong `"…"` → shell nuốt, `grep -cE` gãy (`Unmatched (`) trả **0 gần như im lặng** trong vòng lặp. ⇒ **LUẬT: marker chuỗi trên bundle dùng `grep -F` fixed-string + LUÔN trích byte thật `.{90}X.{90}` TRƯỚC khi kết luận vắng-mặt.** 🔑 **KỸ THUẬT MỚI — COUNT-DELTA**: literal đã có từ K4a ⇒ *có/không* = 0 bit, nhưng *số lần xuất hiện* PRE→POST đo được: **12/12 key đều +1**; ngoại lệ duy nhất `Workflows` **+2** giải-thích-được (substring ⊂ `PeWorkflows`) ⇒ thành kiểm-tra-nhất-quán MIỄN PHÍ. 🔑 **LOG CI IN THẲNG `dist/assets/index-<hash>.js <size>`** = nhân chứng THỨ 4 độc lập curl, khớp LM prod **tới từng giây** ⇒ rẻ nhất để phân biệt **FROZEN-lành-tính** (eoffice: LM 11:14→**12:00:41** đổi + log in CÙNG hash `DFOCQ2ks` = rebuild deterministic) với bundle-kẹt (LM cũ). 🔑 **2-TẦNG #82 đo bằng SỐ**: `/api/menus` trả **12/12 key bị ẩn** ⇒ ẩn client KHÔNG thu quyền. Marker-ÂM display-only: route `/purchase-evaluations` **68→68** nguyên vẹn. Evidence `runs/2026-07-31-S164-4gd-khkk-fanout/sub-cicd-verify-k6.md`. Tag `[run-440, 0-hit-gia-2-lan, grep-F, count-delta, frozen-lanh-tinh, log-CI-nhan-chung-4]`
- _(**08-01 `e202393` S166 K5+K4c FE-only — PASS 5/5**: #439 6m16s, gate **622=45+577** (+1 = đúng 1 Fact mới). 🔑 marker differential 4/4 element-wise (K5 vào CẢ 2 app · K4c CHỈ eoffice) + Δsize cùng chiều = nhân chứng ⟂ grep. 🔴 **recipe đếm mig trong MEMORY có FALSE-NEGATIVE**: `grep -v Snapshot` ăn nhầm `AddPeApprovedBudgetSnapshot` (Mig 67) → phải neo đuôi `Designer\.cs$|ModelSnapshot\.cs$`; ưu tiên **set-diff repo↔prod 2 chiều** thay khớp-tổng. ⚠️ **bar "FE route 200" KHÔNG CÓ RĂNG** với SPA-fallback (POS ≡ NEG byte-identical 876B). verbatim → `runs/…/sub-cicd-verify-k5-k4c.md`)_
- _(**08-01 `88e7ecf` S166 K4a+K4b — PASS 7/7**: #438, gate 621=45+576, menu 151→**200**, perm 1584→**2221**. 🔑 **PHỦ 2 RUN 1 LƯỢT ⇒ delta khép kín**. 🔑 **MỐC PRE ĐỀ BÀI SAI, bắt bằng ĐO** (lead giao 143⇒192, đo LIVE ra 151 → truy phạm-vi thay vì hô drift) — **mốc PRE lão hoá sau MỖI deploy**. 🔑 phân rã 49+588 khớp cơ-chế 2-site skip-existing (588=49×12 role). ⚠️ **PERM row ≠ policy**. 🔑 A5 restart ×2 thật + **soi ĐIỀU KIỆN log trong source TRƯỚC khi đọc im-lặng** (`if (added>0)`). 🔴 **marker lead gợi ý BỊ BÁC BẰNG ĐO** (`Kết cấu` có sẵn ⇒ 1/1 = 0 bit). verbatim → `runs/…/sub-cicd-verify-k4ab.md`)_
- _(**07-31 `15349e8` S164 K3 8 workflow KHKK-N1-8 (0-mig) — PASS 6/6**: #436, gate 614=45+569. 🔑 **dự-đoán-TRƯỚC-khi-đo** (seeder → 8wf×3step×11level + finalize 2+8=10 → khớp từng đồng + shape element-wise từng email + control ÂM OUTSIDE=2/OUTOFSET=0). 🔑 **FROZEN = hệ-quả BẮT BUỘC** (diff toàn `//` → Vite strip); ❌ marker `earlyFinalizeLocked` LOẠI vì **tên biến local bị minifier đổi = 0 bit**. ⚠️ tao chết #53 ×2. verbatim → `archive/2026-07.md`)_
@ -50,7 +54,7 @@ cicd-lane trả nhầm slice transcript thay verdict — **tao TÁI PHẠM dai n
- **Stage 0 RAG infra:** `Get-Service Qdrant` Running + `http://localhost:6333/healthz`. Collection `proj_solution_erp`.
- **Stage 1 Push+filter:** `git log -1 --format='%H %s'` + `git log origin/main..HEAD` empty + diff vs paths-ignore (docs-only SKIPPED-DOCS return).
- **Stage 2 Gitea poll** (max 10 iter × 60s): API `.../actions/tasks?limit=5` (NOT `/runs` 404). Match `head_sha` (NOT run_number cancelled runs shift numbering). `updated_at` stale ~2min (gotcha #46) cross-check VPS mtime. Foreground-sleep BLOCKED Windows Monitor-style busy-wait-curl-spin ~30s/iter.
- **Stage 3 Test gate:** baseline **621 PASS** (45 Domain + 576 Infra; run #438 `88e7ecf` 08-01; climb 621/614/609/598/590/566/562/551/532/528/520/509/458/440). CI chạy cả 2 proj TRƯỚC build/deploy `status:success` = gate passed (`tasks` KHÔNG populate `conclusion` tin `status`). Local grep undercount (Theory/InlineData). Phase 9 UAT skip OK.
- **Stage 3 Test gate:** baseline **622 PASS** (45 Domain + 577 Infra; run #440 `1c6d77a` 08-01; climb 622/621/614/609/598/590/566/562/551/532/528/520/509/458/440). CI chạy cả 2 proj TRƯỚC build/deploy `status:success` = gate passed (`tasks` KHÔNG populate `conclusion` tin `status`). Local grep undercount (Theory/InlineData). Phase 9 UAT skip OK.
- **Stage 4 Post-deploy** (if SUCCESS): auth login bearer (admin + nv.test gotcha #44; token=`accessToken` route `/api/auth/login`) 3-5 endpoint smoke 2XX (incl new NEW-endpoint bodyless-POST returns **411 IIS Length-Required pre-auth NOT 404**; re-probe WITH body 401 confirms wired) FE bundle hash 2 app (per #69 = real-size-vs-fake not hash-delta) SignalR negotiate (gotcha #25) EF mig prod==repo.
- **Stage 4.6 (S29):** sqlcmd seed verify post-deploy (NOT chỉ schema) seed = runtime-row-insert (history/tables KHÔNG nhích) verify bằng DB-query, không bằng bundle-frozen.
- enum cần input SỐ (no `JsonStringEnumConverter`). INFRASTRUCTURE seed ungated / DEMO seed gated #51.
@ -68,10 +72,10 @@ cicd-lane trả nhầm slice transcript thay verdict — **tao TÁI PHẠM dai n
- **Gitea:** `git.baocaogiaoduc.vn/vietreport-admin/solution-erp` · workflow `.gitea/workflows/deploy.yml` · paths-ignore 7-entry (D10 S111 #495): `['docs/**','**/*.md','.claude/skills/**','.claude/agent-memory/**','.claude/workflows/runs/**','.gitignore','scripts/**.md']`. Anon API works (public repo) when GITEA_TOKEN absent.
- **Prod:** api/admin/eoffice `.solutions.com.vn` · SSH `ssh vietreport-vps` (Administrator, id_ed25519) · IIS phys paths: API `C:\inetpub\solution-erp\api` · admin `\fe-admin` · user `\fe-user`. DB `.\SQLEXPRESS`/`SolutionErp`/`vrapp` SQL-auth. **Conn key = `ConnectionStrings.Default` (NOT `DefaultConnection`)**. DB pw = `buKL3TGBkD0wDDbYVw65QeX9` (User Id=`vrapp`; Password field has NO `vrapp/` prefix S89 login-failed when prefix included; read verbatim from prod `appsettings.Production.json` ConnectionStrings.Default when `$env:PROD_DB_PASSWORD` empty).
- **SSHPS quoting (S42):** bashsshpowershell nuốt `$var`/`\"` viết `.sql`/`.ps1` rồi `scp` + `sqlcmd -i` / `powershell -File` (đừng nhét inline). sqlcmd `-W -h -1` pw-inline. sys-catalog concat `COLLATE DATABASE_DEFAULT`.
- **Tests baseline:** **621 PASS** (45 Domain + 576 Infra · 0 fail/skip; run #438 `88e7ecf` 08-01 đo LIVE web-UI log). 🔑 **Số cụ thể lấy ở web-UI log `https://git.baocaogiaoduc.vn/vietreport-admin/solution-erp/actions/runs/{run_number}/jobs/0/logs` (~18KB, KHÔNG có `/api/v1/` — api/v1 runs+tasks logs đều 404)** mỗi project 1 dòng `Passed! - Failed: 0, Passed: N, Skipped: 0`; đối chiếu TÁCH-PHẦN (45/576) chứ đừng chỉ khớp tổng. Delta test mới = so run trước.
- **Mig latest repo: Mig 71 `AddKhkkApprovalGroupCatalogAndFinalizeRuntime`** (`20260731085624`, S164 K2 AddColumn+index-swap, 0 CreateTable; applied-prod #435, GIỮ qua #436-#438). Path `src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/` (đếm `.cs` excl Designer/Snapshot). Prod check `sqlcmd __EFMigrationsHistory ORDER BY MigrationId DESC`. **`sys.tables`(is_ms_shipped=0)=97** (sau Mig 70; 96 trước đó) AddColumn/data-only KHÔNG đổi số no-new-table commit giữ nguyên, đừng FAIL. **NO-Mig proof = history NOT-advance + empty `git diff <base>..<head> -- '*Migrations*'`.** Bảng mig đầy đủ skill `ef-core-migration`.
- **Bundle hash live:** baseline #438 (08-01) admin `qOcoitOr`/`Cf3iCOOv` · eoffice `D9J3BbcA`/`DX1FRk5n`. **SNAPSHOT-per-run, KHÔNG phải mốc ổn định** (#69) LUÔN re-snapshot LIVE sau status=success; ship-proof = LM-in-window + real-size + marker, KHÔNG phải hash-delta. id run_number (key theo head_sha).
- **Prod DB anchor (POST #438, đo 08-01):** `MenuItems`=**200** · `Permissions`=**2221** · `Roles`=**13** · họ `Khkk%` = 56 menu / 728 perm (=56×13, CanRead=1 hết) · control revoker `Hrm*/Off*` CanRead=1 = **29**/377. PK `MenuItems` = **`[Key]`** (KHÔNG `MenuKey`); `Permissions(Id,RoleId,**MenuKey**,CanRead,CanCreate,CanUpdate,CanDelete)` = 1 row/cặp.
- **Tests baseline:** **622 PASS** (45 Domain + 577 Infra · 0 fail/skip; run #440 `1c6d77a` 08-01 đo LIVE web-UI log). **Flaky đã khai** `DeleteContractSigningPlan_SoftDeletes` (1/12 theo commit-msg K4c) #439+#440 đều XANH; nếu đỏ run sau, đối chiếu 2 mốc này trước khi hồi-quy. 🔑 **Số cụ thể lấy ở web-UI log `https://git.baocaogiaoduc.vn/vietreport-admin/solution-erp/actions/runs/{run_number}/jobs/0/logs` (~18KB, KHÔNG có `/api/v1/` — api/v1 runs+tasks logs đều 404)** mỗi project 1 dòng `Passed! - Failed: 0, Passed: N, Skipped: 0`; đối chiếu TÁCH-PHẦN (45/576) chứ đừng chỉ khớp tổng. Delta test mới = so run trước.
- **Mig latest repo: Mig 71 `AddKhkkApprovalGroupCatalogAndFinalizeRuntime`** (`20260731085624`, S164 K2 AddColumn+index-swap, 0 CreateTable; applied-prod #435, GIỮ qua #436-#440). Path `src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/`. 🔴 **Đếm phải NEO ĐUÔI**: `grep -vE 'Designer\.cs$|ModelSnapshot\.cs$'` recipe `grep -v Snapshot` **ăn nhầm** `AddPeApprovedBudgetSnapshot` (Mig 67 chữ "Snapshot" trong TÊN) đếm hụt 1 im lặng. Mạnh hơn khớp-tổng = **set-diff repo↔prod 2 chiều**. Prod check `sqlcmd __EFMigrationsHistory ORDER BY MigrationId DESC`. **`sys.tables`(is_ms_shipped=0)=97** (sau Mig 70; 96 trước đó) AddColumn/data-only KHÔNG đổi số no-new-table commit giữ nguyên, đừng FAIL. **NO-Mig proof = history NOT-advance + empty `git diff <base>..<head> -- '*Migrations*'`.** Bảng mig đầy đủ skill `ef-core-migration`.
- **Bundle hash live:** baseline #440 (08-01) admin `Ig1R5tAh`/`DqXm5Hpn` · eoffice `DFOCQ2ks`/`DlN3yZ5L`. 🔑 **Ship-proof rẻ nhất = so LM prod với dòng `dist/assets/index-<hash>.js <size>` IN TRONG LOG CI** (khớp tới từng giây tách FROZEN-lành-tính khỏi bundle-kẹt). marker chuỗi: **rolldown/Vite 8 minify ra BACKTICK** không phải `"`/`'` dùng `grep -F`. **SNAPSHOT-per-run, KHÔNG phải mốc ổn định** (#69) LUÔN re-snapshot LIVE sau status=success; ship-proof = LM-in-window + real-size + marker, KHÔNG phải hash-delta. id run_number (key theo head_sha).
- **Prod DB anchor (POST #438, re-verify đứng im #440 08-01):** `MenuItems`=**200** · `Permissions`=**2221** · `Roles`=**13** · họ `Khkk%` = 56 menu / 728 perm (=56×13, CanRead=1 hết) · control revoker `Hrm*/Off*` CanRead=1 = **29**/377. PK `MenuItems` = **`[Key]`** (KHÔNG `MenuKey`); `Permissions(Id,RoleId,**MenuKey**,CanRead,CanCreate,CanUpdate,CanDelete)` = 1 row/cặp.
- **Bearer:** admin `admin@solutions.com.vn/Admin@123456` (full) · UAT `nv.test@solutions.com.vn/TestUser@123456` (Drafter CCM, gotcha #44 check).
- **API Serilog:** sink THẬT `C:\inetpub\solution-erp\api\logs\solution-erp-YYYYMMDD.log` (skill-runbook `…\Logs\log-*.txt` STALE). Level=Information (seed/revoke lines ghi). deploy xoá luôn `logs/` chỉ còn từ deploy cuối. Grep revoke-line + seeder-line cùng-giây để audit seed-race. *(S162: bản cũ dòng này chứa byte BEL 0x07 do `\a` bị nuốt — đã sửa.)*

View File

@ -0,0 +1,104 @@
# CI/CD Monitor — Archive INDEX — 2026-H1 (records ≤ 2026-06) — SPLIT @S167
> File TÁCH từ `_INDEX.md` @S167 (owner "auto") — VERBATIM từng dòng, anchor substring giữ nguyên 100%.
> Tầng LẠNH: chỉ mở khi cần record ≤ 2026-06. Gate A7 vẫn verify pointer trong file này (đọc `_INDEX*.md`).
> Legend/pointer-style: xem header `_INDEX.md` (không chép lại — 1 nguồn).
## 2026-06 (file: `archive/2026-06.md`)
<!-- ===== S80 (2026-06-20) curate batch — moved 3 huge run-entries L1->L2 ===== -->
- 2026-06-19 · S78 #330 cross-stack NO-MIG enum ApprovalAttachment=5 (attach-on-… · PASS bundle-BOTH-rotate js+css… · `2026-06.md` · substring:"Run #330 (run_number 330, id=444)"
- 2026-06-19 · S76 #318 full-stack Mig56 ProInitial/ProAdjust VERIFIED-APPLIED-PROD · PASS backfill-4rows-0violation… · `2026-06.md` · substring:"Run #318 (run_number 318, id=432)"
- 2026-06-19 · S83 #325 FE-only tiny budget-subitem indent/dash · PASS bundle-BOTH-js-rotate css… · `2026-06.md` · substring:"Run #325 (id=439)"
- 2026-06-17 · FE-only re-skin Văn phòng số 10-page PURO · PASS bundle-BOTH-rotate Wt54PH… · `2026-06.md` · substring:"Run #306 (run_number 306, id420) sha=`c556f6c`"
- 2026-06-17 · FE-both-app Văn phòng số foundation + index.css + BE menu-seed · PASS bundle-BOTH-rotate Bl2o_k… · `2026-06.md` · substring:"Run #305 (run_number 305, id419) sha=`a8bbdae`"
- 2026-06-16 · FE-both-app 1-line CSS-precedence fix (name text-white!) · PASS bundle-BOTH-rotate CNUv1j… · `2026-06.md` · substring:"Run #304 (run_number 304, id418) sha=`37752eb`"
- 2026-06-16 · FE-both-app Hồ sơ NS banner text-polish · PASS bundle-BOTH-rotate D532XZ… · `2026-06.md` · substring:"Run #303 sha=`6983609` PASS"
- 2026-06-16 · FE-both-app PE Link-hồ-sơ file:// render · PASS bundle-BOTH-rotate CcrZqf… · `2026-06.md` · substring:"Run #302 (run_number 302, id416) sha=`536dd6b`"
- 2026-06-16 · TESTS-ONLY BE +23 test (286) · PASS bundle-BOTH-frozen xkSz9B… · `2026-06.md` · substring:"Run #299 (id413) sha=`bcd619d`"
- 2026-06-16 · FE-Admin MIRROR Hồ sơ NS from fe-user · PASS bundle-asymmetric-NGƯỢC a… · `2026-06.md` · substring:"Run #298 (run_number 298, id412) sha=`292d64d`"
- 2026-06-16 · FE-User Hồ sơ NS cosmetic brand800 · PASS bundle-asymmetric user-ro… · `2026-06.md` · substring:"Run #297 (run_number 297, id411) sha=`ab4e681`"
- 2026-06-16 · FE-User Hồ sơ NS 2-col layout · PASS bundle-asymmetric user-ro… · `2026-06.md` · substring:"Run #295 (run_number 295, id409) sha=`456c7a7`"
- 2026-06-16 · CROSS-STACK PE HoSoLink Mig 52 + FE-User 3-panel + rename Dự trù→… · PASS Mig52-applied + bundle-bo… · `2026-06.md` · substring:"Run #293 (run_number 293, id407) sha=`5a0aaa4`"
- 2026-06-13 · CROSS-STACK PE PeWorkItemBudgets vượt-ngân-sách soft-warn (Mig 50… · PASS · `2026-06.md` · substring:"Run #286 (run_number 286, id400)"
- 2026-06-12 · CROSS-STACK PE guard 4-thông-tin mục 3 · PASS · `2026-06.md` · substring:"Run #283 (run_number 283) sha=`37122f0`"
- 2026-06-11 · S59-CLOSE final đóng sổ · PASS · `2026-06.md` · substring:"Run #280 (run_number 280) sha=`69997da`"
- 2026-06-11 · S59-đợt6 CROSS-STACK BE · PASS · `2026-06.md` · substring:"Run #278 (run_number 278) sha=`9c330d2`"
- 2026-06-11 · S59-đợt5 FE-only ×2 · PASS · `2026-06.md` · substring:"Run #277 (run_number 277) sha=`faed59f`"
- 2026-06-11 · S59-đợt4 rename WorkItems 71 idempotent-seed · PASS rename-via-prod-SQL targe… · `2026-06.md` · substring:"Run #276 (run_number 276) sha=`c869d26`"
- 2026-06-11 · S59-đợt3 BE-only DbInitializer · PASS · `2026-06.md` · substring:"Run #275 (run_number 275) sha=`bbd1554`"
- 2026-06-11 · S59 FE×2 PE-list tree regroup · PASS · `2026-06.md` · substring:"Run #273 (run_number 273) sha=`56882ac`"
- 2026-06-11 · S59-đợt2 FE×2 PE-list tree · PASS · `2026-06.md` · substring:"Run #274 (run_number 274) sha=`0eafcd3`"
- 2026-06-11 · SUPERSEDE-CHAIN (benign cancel→shipped) · CANCELLED-benign→PASS-via-3eba… · `2026-06.md` · substring:"Run #385#386 SUPERSEDE-CHAIN sha=`ea793a4`"
- 2026-06-11 · S58 FE-USER visual redesign · PASS asymmetric fe-user-only · `2026-06.md` · substring:"Run #384 (run_number 270) sha=`e959f72`"
- 2026-06-16 · TEST-GATE COMPILE BREAK (CS7036 ParentId 5th positional, call-sit… · **[FAIL]** ~64s deploy-gated p… · `2026-06.md` · substring:"Run #291 (run_number 291, id405) sha=`8c8179c`"
- 2026-06-11 · S58 FIX #381 lock NO-OP (lock-noop→pw-11to12) · PASS dump-Users-set-before-sco… · `2026-06.md` · substring:"Run #382 (run_number 268) sha=`5998163`"
- 2026-06-11 · S57bis PE gắn WorkItemId loose-Guid (Mig 49) · PASS+1PARTIAL · `2026-06.md` · substring:"Run #381 (run_number 267) sha=`dd117b7`"
- 2026-06-09 · S56 GOLIVE-HARDEN BE fixes · PASS · `2026-06.md` · substring:"Run #379 (run_number 265) sha=`a20cde8`"
- 2026-06-09 · S56 pre-golive re-verify prod truth · **[audit]** no-deploy read-only · `2026-06.md` · substring:"S56 pre-golive verify — NO deploy"
- 2026-06-09 · S55 Phase-1 FE-Admin visual redesign · PASS asymmetric fe-admin-only… · `2026-06.md` · substring:"Run #378 (run_number 264) sha=`7feb53e`"
- 2026-06-09 · S55 HMW-P4 real master-data seed (Mig 48, 62 dự án+71 hạng mục+3… · PASS ungated-seed sqlcmd-COUNT… · `2026-06.md` · substring:"Run #377 (run_number 263) sha=`69cb393`"
- 2026-06-08 · S54 IT-staff self-reassign ticket · PASS · `2026-06.md` · substring:"Run #376 (run_number 262) sha=`ca4b602`"
- 2026-06-08 · S50 HMW-Wave2 P11-C Vehicle+Driver · PASS Discovery#3 mixed-docs+ts… · `2026-06.md` · substring:"Run #371 (run_number 257) sha=`30a99aa`"
- 2026-06-03 · S48 FE-only login subtitle a11y · PASS · `2026-06.md` · substring:"Run #369 (run_number 255) sha=`350b2bf`"
- 2026-06-01 · S45 Mig 43 filter Holiday UNIQUE · PASS · `2026-06.md` · substring:"Run #368 (run_number 254) sha=`0c5a014`"
- 2026-05-30 · S42 P11-B LeaveBalance business · PASS · `2026-06.md` · substring:"Run #367 (run_number 253) sha=`82d7fcf`"
- 2026-05-30 · S42 P11-A workflow-picker 2-bug + SetWorkflow · PASS · `2026-06.md` · substring:"Run #365 sha=`75df04e`"
- 2026-05-30 · S42 P11-A wire ApproveV2+LevelOpinions · PASS (mem #250 ≠ real Gitea id… · `2026-06.md` · substring:"Run #364 (mem #250) sha=`e7b66cd`"
- 2026-05-28 · S38 SKELETON 5-plan combo Mig 39+40 dual · PASS · `2026-06.md` · substring:"Run #247 sha=`e54a22d`"
- 2026-05-?? · S37 Proposal Mig 37+38 (/api/proposals 200 + QT-DX-V2-001 AppType=4) · PASS [archived] · `2026-06.md` · substring:"Archived Run #246 (S37 Proposal Mig 37+38"
- 2026-06-11 · ⚠️ VỊ-TRÍ-LẠC entry (mem run id ≠ real Gitea — FIFO slot between… · note · `2026-06.md` · substring:"VỊ TRÍ LẠC — entry MỚI 2026-06-11"
- 2026-05-22 · S29 gotcha #51 catch (SeedSampleContractWorkflowV2 nested in demo… · PASS [archived] · `2026-06.md` · substring:"Archived Run #232 (S29 gotcha #51 catch"
## 2026-05 late (file: `archive/2026-05-q4.md`)
- 2026-05-27 · BE+FE×2 Danh bạ nội bộ /api/directory (G-O1) · PASS bundle-both-rotate, menu-… · `2026-05-q4.md` · substring:"Run #238 (S34 Plan 2 G-O1 Danh bạ nội bộ"
- 2026-05-26 · BE Hrm CQRS /api/employees + FE×2 + menu (G-H1 Phase2) · PASS bundle-both-rotate, Emplo… · `2026-05-q4.md` · substring:"Run #237 (S33 Plan B G-H1 Phase 2 Task 4+5+6"
- 2026-05-26 · BE Mig 34 EmployeeProfile + Plan C BW1-BW7 +9 test (62 Infra) · PASS gotcha#51 INFRASTRUCTURE-… · `2026-05-q4.md` · substring:"Run #350 (S33 Plan B G-H1 Mig 34 EmployeeProfile"
- 2026-05-26 · S33 startup health-check · **[audit]** HEALTHY, last-5-ru… · `2026-05-q4.md` · substring:"S33 startup health-check — em main spawn read-only verify, VERDICT=HEALTHY"
- 2026-05-26 · S32 wrap curate + Phase9→10 · **[meta]** no-run docs-only skip · `2026-05-q4.md` · substring:"S32 wrap — em main proxy curate + Phase 9 stabilize done"
- 2026-05-26 · S32 startup verify (foundation freshness + 3-endpoint smoke) · **[audit]** no-CI-poll, RAG-he… · `2026-05-q4.md` · substring:"S32 startup verify — no CI poll"
- 2026-05-21 · S26 Plan AG PE-List tree-view UI iteration (#222-#227 cumulative) · PASS hybrid-verify spawn-1×-pe… · `2026-05-q4.md` · substring:"Run #222-#227 cumulative — Plan AG series PE List tree view"
## 2026-05 mid (file: `archive/2026-05-runs.md`)
### Run-records (20)
- 2026-05-19 · S25 t7 Plan AF FE userMap fallback resolve historical · PASS bundle-rotate C8TvDy7r/Bv… · `2026-05-runs.md` · substring:"Run #221 id=335 sha=`506cada`"
- 2026-05-19 · S25 t6 Plan AE Changelog UserName 9-sites batch · PASS BE-only bundle-frozen, pr… · `2026-05-runs.md` · substring:"Run #220 id=334 sha=`9ea62be`"
- 2026-05-19 · S25 t5 Plan AD Lịch-sử-duyệt redesign next-target-hint · PASS FE-only bundle-rotate, pa… · `2026-05-runs.md` · substring:"Run #219 id=333 sha=`0aaf2df`"
- 2026-05-19 · S25 t4 Plan AC2 FE merge recover historical Reject · PASS bundle-rotate, synthetic-… · `2026-05-runs.md` · substring:"Run #218 id=332 sha=`25837b6`"
- 2026-05-15 · S24 t1 Plan AA wrap fix sidebar · PASS last-deploy-before-AB, ba… · `2026-05-runs.md` · substring:"Run #214 id=328 sha=`ee0902a`"
- 2026-05-15 · S24 t1 Plan AA IsUserSelectable filter + WfView menu + sidebar (4… · PASS bundle-rotate, Order-shif… · `2026-05-runs.md` · substring:"Run #210 id=324 sha=`ac2c859`"
- 2026-05-15 · S23 t11 Plan U FE sidebar truncate · PASS long-label ellipsis+title… · `2026-05-runs.md` · substring:"Run #209 sha=`86d8806`"
- 2026-05-15 · S23 t10 Plan T5+T6 docs final (DemoSeed wipe ~720 rows + force re… · PASS flag-proven-end-to-end · `2026-05-runs.md` · substring:"Run #208 sha=`7b7b28f`"
- 2026-05-15 · S23 t10 Plan T DemoSeed disable (flag in appsettings, 5 demo-seed… · PASS · `2026-05-runs.md` · substring:"Run #207 sha=`0b97840`"
- 2026-05-15 · S23 t7 Plan Q FE banner mx-5 fix · PASS bundle-rotate QZIPWD-g/Da… · `2026-05-runs.md` · substring:"Run #204 sha=`108268a`"
- 2026-05-15 · S23 t6 Plan P HOTFIX Controller TransitionPeBody +3 fields · PASS Discovery#4 ASP.NET10-rec… · `2026-05-runs.md` · substring:"Run #203 id=317 sha=`1727bd5`"
- 2026-05-15 · S23 t5 Plan O HOTFIX cascade 4 lookup-sites (point 9) · PASS 409-mode-not-403-actor pr… · `2026-05-runs.md` · substring:"Run #202 id=316 sha=`a1c8386`"
- 2026-05-15 · S23 t4 Plan N HOTFIX per-NV lookup discrimination :765 · PASS 7/7-vs-1/7-per-actor, bug… · `2026-05-runs.md` · substring:"Run #201 id=315 sha=`fb3c22c`"
- 2026-05-15 · S23 t3 Plan M Service F1 OneLevel/OneStep edge-case Bước1 reset · PASS Discovery#3-anomaly docs-… · `2026-05-runs.md` · substring:"Run #200 id=314 sha=`f4055a1`"
- 2026-05-14/15 · S23 t1 K10 hotfix AwLevelDto +AllowApproverSkipToFinal · PASS 8-surface-point-checklist… · `2026-05-runs.md` · substring:"Run #195 id=309 sha=`0062fcb`"
- 2026-05-14 · S23 t1 Plan K Mig 31 per-Approver-slot refactor · **[PARTIAL]** Mig+schema OK bu… · `2026-05-runs.md` · substring:"Run #194 id=308 sha=`098baa6`"
- 2026-05-13 · S22-chốt cumulative verify · PASS Discovery#3 push-range-ev… · `2026-05-runs.md` · substring:"Verify S22 chốt cuối cumulative (push range `3d725c4..cc8a7d3` 12 commits) VERDICT=PASS"
- 2026-05-13 · S22 Plan D Users-toggle + Plan C 14-test + Plan E strict-V2-scope · PASS 103 test, Discovery#1 rat… · `2026-05-runs.md` · substring:"Run #188 id=302 sha=a74e671"
- 2026-05-13 · S21 t5 Mig 29 refactor Allow*-per-NV + Designer 5-checkbox · PASS Mig29 applied, Discovery… · `2026-05-runs.md` · substring:"Run #187 id=301 sha=c0af9e0"
- 2026-05-13 · S21 t3+t4 gotcha#45 Trả-lại + F1/F2/F3 advanced-options + Mig 28 · PASS baseline, Discovery API-t… · `2026-05-runs.md` · substring:"Run #186 id=300 sha=eea86fd"
### Curate-note meta-headers (6) — [meta]
- 2026-05-19 · curate-note S25 t5 Plan AD #219 · **[meta]** · `2026-05-runs.md` · substring:"added S25 t5 Plan AD Run #219 entry"
- 2026-05-19 · curate-note S25 t3 Plan AC #217 (Discovery#5 sqlcmd 4-backslash \… · **[meta]** · `2026-05-runs.md` · substring:"added S25 t3 Plan AC Run #217 entry"
- 2026-05-19 · curate-note S25 t1 Plan AB #215 (gotcha#48 SQLite tie-break FAIL) · **[meta]** · `2026-05-runs.md` · substring:"added S25 t1 Plan AB Run #215 entry"
- 2026-05-15 · curate-note S24 t1 Plan AA #210 · **[meta]** · `2026-05-runs.md` · substring:"added S24 t1 Plan AA Run #210 entry"
- 2026-05-15 · curate-note S23 t6 Plan P #203 (Discovery#4 numeric-enum) · **[meta]** · `2026-05-runs.md` · substring:"added S23 t6 Plan P HOTFIX Run #203 entry"
- 2026-05-15 · curate-note S23 t5 Plan O #202 (4-site discrimination, Transition… · **[meta]** · `2026-05-runs.md` · substring:"added S23 t5 Plan O HOTFIX Run #202 entry"
## 2026-05 early (file: `archive/2026-05-q3.md`)
- 2026-05-19 · ★ Run #215 FAIL → #216 PASS pair — **gotcha #48 SQLite tie-break*… · FAIL→PASS · `2026-05-q3.md` · substring:"Run #215 FAIL → Run #216 PASS (gotcha #48 SQLite tie-break catch+fix pair)"
- 2026-05-13 · Verify S22 chốt cuối cumulative (Discovery#3 first-surfaced, 33 u… · PASS · `2026-05-q3.md` · substring:"Verify S22 chốt cuối cumulative (push range `3d725c4..cc8a7d3` 12 commits VERDICT=PASS"
- 2026-05-12 · Setup baseline (agent init, 44 gotchas + 5-stage + 3 skills) · init · `2026-05-q3.md` · substring:"2026-05-12 — Setup baseline"
## 2026-05 early (file: `archive/2026-05-q2.md`)
- 2026-05-22 · Run #231 Plan B Contract V2 wire kick-off · **[PARTIAL]** PASS-deploy + se… · `2026-05-q2.md` · substring:"Run #231 (id=345) Plan B Contract V2 wire kick-off VERDICT=PARTIAL"

View File

@ -1,6 +1,8 @@
# CI/CD Monitor — Archive INDEX (table of contents, NO content)
> 🗜️ **gist-of-index @S152 (D1 queue):** desc-verdict nén ~45c (desc đầy-đủ cũ = git-history file này); **toàn bộ anchor substring giữ NGUYÊN-VĂN 100%** — KHÔNG split (gate chỉ đọc `_INDEX.md`).
> 🗜️ **gist-of-index @S152 (D1 queue):** desc-verdict nén ~45c (desc đầy-đủ cũ = git-history file này); **toàn bộ anchor substring giữ NGUYÊN-VĂN 100%**.
> 🔄 **SPLIT @S167 (owner "auto" 2026-08-01):** records ≤ 2026-06 dời VERBATIM sang `_INDEX-2026-H1.md` (cùng thư mục — tầng lạnh, mở khi cần record cổ). Gate A7 (`memory-archive-gate.ps1`) đọc **MỌI `_INDEX*.md`** nên pointer phần cũ VẪN được canh; probe cap (`nhip-no-probe.ps1`) chỉ đo file CHÍNH này. **LUẬT ROTATE (auto, không hỏi lại):** khi probe in `l2idx N over` → dời TRỌN section tháng CŨ NHẤT của file này sang `_INDEX-<period>.md` tương ứng (tạo mới nếu chưa có), VERBATIM từng dòng, KHÔNG bao giờ drop marker.
> **Purpose:** L2 archive is "dark-matter" (not in RAG). This index is the map: 1 line per archived record so a future spawn can locate + open the right verbatim block on-demand without reading 220KB cold.
> **Pointer style = SUBSTRING (Ctrl-F) primary.** Each line ends `→ <file> · substring:"<unique-string>"`. The string is grep-verified `count==1` inside its target file (76/76 unique at build time). Open the file, Ctrl-F the string → lands on the record start. Fallback if a future edit shifts text: search the bare `Run #NNN` + nearest date.
@ -8,7 +10,7 @@
> **Archives are FROZEN / additive-only.** Records are never edited in place; this index + the `.gist.md` files are the only additive layers.
> **SORTED by DATE (newest → oldest).** Labels: `[meta]` = curate-note (not a rich run-record) · `[stub→git]` = FIFO-trim pointer-to-git, thin · `[audit]` = read-only verify, no deploy · `[FAIL]`/`[PARTIAL]` = non-PASS verdict.
>
> **Files indexed:** `2026-07.md` (**4** records — re-dem DIA @S159) · `2026-06.md` (39 records, no headings) · `2026-05-runs.md` (20 run-records + 6 meta-headers) · `2026-05-q4.md` (7) · `2026-05-q3.md` (3) · `2026-05-q2.md` (1). Total **80 records**.
> **Files indexed HERE (2026-07+):** `2026-07.md`. Records ≤ 2026-06 (`2026-06.md` · `2026-05-runs.md` · `2026-05-q4.md` · `2026-05-q3.md` · `2026-05-q2.md`) → xem `_INDEX-2026-H1.md`. Đếm record = đếm dòng `^- ` từng file index (đừng tin số chép tay — bài Errata @S159).
> 🔴 **Errata @S159:** ban cu khai `2026-07.md`=2 / Total 78; dia co **4** (#407 @S155, #416 @S157). Nang hon: **#407 mo-coi** — co trong kho, 0 con-tro => vo hinh voi nguoi tra `_INDEX`; da them. Bai: **dem-lech la trieu-chung, ban-ghi-mo-coi la thiet hai.** Sweep lai: 0 mo-coi.
> Even-older verbatim (S29 #232 ← pre-S38) also lives in git `d2f52ba`. Gist companions: `2026-06.gist.md` + `2026-05.gist.md`.
@ -20,103 +22,7 @@
- 2026-07-01 · S96+S97 FINAL-SESSION-REVIEW 11-commit day #478-#484 ALL-HEALTHY… · CLEAN, bundle rotated-post-#48… · `2026-07.md` · substring:"S96+S97 FINAL-SESSION-REVIEW (2026-07-01 end"
- 2026-07-01 · S97 #484 DATA-FIX Mig 61 backfill EndedByLevelFinalize từ Changel… · PASS backfill-flip A/005+A/008… · `2026-07.md` · substring:"S97 #484 (id=484, run_number 370)"
## 2026-06 (file: `archive/2026-06.md`)
<!-- ===== S80 (2026-06-20) curate batch — moved 3 huge run-entries L1->L2 ===== -->
- 2026-06-19 · S78 #330 cross-stack NO-MIG enum ApprovalAttachment=5 (attach-on-… · PASS bundle-BOTH-rotate js+css… · `2026-06.md` · substring:"Run #330 (run_number 330, id=444)"
- 2026-06-19 · S76 #318 full-stack Mig56 ProInitial/ProAdjust VERIFIED-APPLIED-PROD · PASS backfill-4rows-0violation… · `2026-06.md` · substring:"Run #318 (run_number 318, id=432)"
- 2026-06-19 · S83 #325 FE-only tiny budget-subitem indent/dash · PASS bundle-BOTH-js-rotate css… · `2026-06.md` · substring:"Run #325 (id=439)"
- 2026-06-17 · FE-only re-skin Văn phòng số 10-page PURO · PASS bundle-BOTH-rotate Wt54PH… · `2026-06.md` · substring:"Run #306 (run_number 306, id420) sha=`c556f6c`"
- 2026-06-17 · FE-both-app Văn phòng số foundation + index.css + BE menu-seed · PASS bundle-BOTH-rotate Bl2o_k… · `2026-06.md` · substring:"Run #305 (run_number 305, id419) sha=`a8bbdae`"
- 2026-06-16 · FE-both-app 1-line CSS-precedence fix (name text-white!) · PASS bundle-BOTH-rotate CNUv1j… · `2026-06.md` · substring:"Run #304 (run_number 304, id418) sha=`37752eb`"
- 2026-06-16 · FE-both-app Hồ sơ NS banner text-polish · PASS bundle-BOTH-rotate D532XZ… · `2026-06.md` · substring:"Run #303 sha=`6983609` PASS"
- 2026-06-16 · FE-both-app PE Link-hồ-sơ file:// render · PASS bundle-BOTH-rotate CcrZqf… · `2026-06.md` · substring:"Run #302 (run_number 302, id416) sha=`536dd6b`"
- 2026-06-16 · TESTS-ONLY BE +23 test (286) · PASS bundle-BOTH-frozen xkSz9B… · `2026-06.md` · substring:"Run #299 (id413) sha=`bcd619d`"
- 2026-06-16 · FE-Admin MIRROR Hồ sơ NS from fe-user · PASS bundle-asymmetric-NGƯỢC a… · `2026-06.md` · substring:"Run #298 (run_number 298, id412) sha=`292d64d`"
- 2026-06-16 · FE-User Hồ sơ NS cosmetic brand800 · PASS bundle-asymmetric user-ro… · `2026-06.md` · substring:"Run #297 (run_number 297, id411) sha=`ab4e681`"
- 2026-06-16 · FE-User Hồ sơ NS 2-col layout · PASS bundle-asymmetric user-ro… · `2026-06.md` · substring:"Run #295 (run_number 295, id409) sha=`456c7a7`"
- 2026-06-16 · CROSS-STACK PE HoSoLink Mig 52 + FE-User 3-panel + rename Dự trù→… · PASS Mig52-applied + bundle-bo… · `2026-06.md` · substring:"Run #293 (run_number 293, id407) sha=`5a0aaa4`"
- 2026-06-13 · CROSS-STACK PE PeWorkItemBudgets vượt-ngân-sách soft-warn (Mig 50… · PASS · `2026-06.md` · substring:"Run #286 (run_number 286, id400)"
- 2026-06-12 · CROSS-STACK PE guard 4-thông-tin mục 3 · PASS · `2026-06.md` · substring:"Run #283 (run_number 283) sha=`37122f0`"
- 2026-06-11 · S59-CLOSE final đóng sổ · PASS · `2026-06.md` · substring:"Run #280 (run_number 280) sha=`69997da`"
- 2026-06-11 · S59-đợt6 CROSS-STACK BE · PASS · `2026-06.md` · substring:"Run #278 (run_number 278) sha=`9c330d2`"
- 2026-06-11 · S59-đợt5 FE-only ×2 · PASS · `2026-06.md` · substring:"Run #277 (run_number 277) sha=`faed59f`"
- 2026-06-11 · S59-đợt4 rename WorkItems 71 idempotent-seed · PASS rename-via-prod-SQL targe… · `2026-06.md` · substring:"Run #276 (run_number 276) sha=`c869d26`"
- 2026-06-11 · S59-đợt3 BE-only DbInitializer · PASS · `2026-06.md` · substring:"Run #275 (run_number 275) sha=`bbd1554`"
- 2026-06-11 · S59 FE×2 PE-list tree regroup · PASS · `2026-06.md` · substring:"Run #273 (run_number 273) sha=`56882ac`"
- 2026-06-11 · S59-đợt2 FE×2 PE-list tree · PASS · `2026-06.md` · substring:"Run #274 (run_number 274) sha=`0eafcd3`"
- 2026-06-11 · SUPERSEDE-CHAIN (benign cancel→shipped) · CANCELLED-benign→PASS-via-3eba… · `2026-06.md` · substring:"Run #385#386 SUPERSEDE-CHAIN sha=`ea793a4`"
- 2026-06-11 · S58 FE-USER visual redesign · PASS asymmetric fe-user-only · `2026-06.md` · substring:"Run #384 (run_number 270) sha=`e959f72`"
- 2026-06-16 · TEST-GATE COMPILE BREAK (CS7036 ParentId 5th positional, call-sit… · **[FAIL]** ~64s deploy-gated p… · `2026-06.md` · substring:"Run #291 (run_number 291, id405) sha=`8c8179c`"
- 2026-06-11 · S58 FIX #381 lock NO-OP (lock-noop→pw-11to12) · PASS dump-Users-set-before-sco… · `2026-06.md` · substring:"Run #382 (run_number 268) sha=`5998163`"
- 2026-06-11 · S57bis PE gắn WorkItemId loose-Guid (Mig 49) · PASS+1PARTIAL · `2026-06.md` · substring:"Run #381 (run_number 267) sha=`dd117b7`"
- 2026-06-09 · S56 GOLIVE-HARDEN BE fixes · PASS · `2026-06.md` · substring:"Run #379 (run_number 265) sha=`a20cde8`"
- 2026-06-09 · S56 pre-golive re-verify prod truth · **[audit]** no-deploy read-only · `2026-06.md` · substring:"S56 pre-golive verify — NO deploy"
- 2026-06-09 · S55 Phase-1 FE-Admin visual redesign · PASS asymmetric fe-admin-only… · `2026-06.md` · substring:"Run #378 (run_number 264) sha=`7feb53e`"
- 2026-06-09 · S55 HMW-P4 real master-data seed (Mig 48, 62 dự án+71 hạng mục+3… · PASS ungated-seed sqlcmd-COUNT… · `2026-06.md` · substring:"Run #377 (run_number 263) sha=`69cb393`"
- 2026-06-08 · S54 IT-staff self-reassign ticket · PASS · `2026-06.md` · substring:"Run #376 (run_number 262) sha=`ca4b602`"
- 2026-06-08 · S50 HMW-Wave2 P11-C Vehicle+Driver · PASS Discovery#3 mixed-docs+ts… · `2026-06.md` · substring:"Run #371 (run_number 257) sha=`30a99aa`"
- 2026-06-03 · S48 FE-only login subtitle a11y · PASS · `2026-06.md` · substring:"Run #369 (run_number 255) sha=`350b2bf`"
- 2026-06-01 · S45 Mig 43 filter Holiday UNIQUE · PASS · `2026-06.md` · substring:"Run #368 (run_number 254) sha=`0c5a014`"
- 2026-05-30 · S42 P11-B LeaveBalance business · PASS · `2026-06.md` · substring:"Run #367 (run_number 253) sha=`82d7fcf`"
- 2026-05-30 · S42 P11-A workflow-picker 2-bug + SetWorkflow · PASS · `2026-06.md` · substring:"Run #365 sha=`75df04e`"
- 2026-05-30 · S42 P11-A wire ApproveV2+LevelOpinions · PASS (mem #250 ≠ real Gitea id… · `2026-06.md` · substring:"Run #364 (mem #250) sha=`e7b66cd`"
- 2026-05-28 · S38 SKELETON 5-plan combo Mig 39+40 dual · PASS · `2026-06.md` · substring:"Run #247 sha=`e54a22d`"
- 2026-05-?? · S37 Proposal Mig 37+38 (/api/proposals 200 + QT-DX-V2-001 AppType=4) · PASS [archived] · `2026-06.md` · substring:"Archived Run #246 (S37 Proposal Mig 37+38"
- 2026-06-11 · ⚠️ VỊ-TRÍ-LẠC entry (mem run id ≠ real Gitea — FIFO slot between… · note · `2026-06.md` · substring:"VỊ TRÍ LẠC — entry MỚI 2026-06-11"
- 2026-05-22 · S29 gotcha #51 catch (SeedSampleContractWorkflowV2 nested in demo… · PASS [archived] · `2026-06.md` · substring:"Archived Run #232 (S29 gotcha #51 catch"
## 2026-05 late (file: `archive/2026-05-q4.md`)
- 2026-05-27 · BE+FE×2 Danh bạ nội bộ /api/directory (G-O1) · PASS bundle-both-rotate, menu-… · `2026-05-q4.md` · substring:"Run #238 (S34 Plan 2 G-O1 Danh bạ nội bộ"
- 2026-05-26 · BE Hrm CQRS /api/employees + FE×2 + menu (G-H1 Phase2) · PASS bundle-both-rotate, Emplo… · `2026-05-q4.md` · substring:"Run #237 (S33 Plan B G-H1 Phase 2 Task 4+5+6"
- 2026-05-26 · BE Mig 34 EmployeeProfile + Plan C BW1-BW7 +9 test (62 Infra) · PASS gotcha#51 INFRASTRUCTURE-… · `2026-05-q4.md` · substring:"Run #350 (S33 Plan B G-H1 Mig 34 EmployeeProfile"
- 2026-05-26 · S33 startup health-check · **[audit]** HEALTHY, last-5-ru… · `2026-05-q4.md` · substring:"S33 startup health-check — em main spawn read-only verify, VERDICT=HEALTHY"
- 2026-05-26 · S32 wrap curate + Phase9→10 · **[meta]** no-run docs-only skip · `2026-05-q4.md` · substring:"S32 wrap — em main proxy curate + Phase 9 stabilize done"
- 2026-05-26 · S32 startup verify (foundation freshness + 3-endpoint smoke) · **[audit]** no-CI-poll, RAG-he… · `2026-05-q4.md` · substring:"S32 startup verify — no CI poll"
- 2026-05-21 · S26 Plan AG PE-List tree-view UI iteration (#222-#227 cumulative) · PASS hybrid-verify spawn-1×-pe… · `2026-05-q4.md` · substring:"Run #222-#227 cumulative — Plan AG series PE List tree view"
## 2026-05 mid (file: `archive/2026-05-runs.md`)
### Run-records (20)
- 2026-05-19 · S25 t7 Plan AF FE userMap fallback resolve historical · PASS bundle-rotate C8TvDy7r/Bv… · `2026-05-runs.md` · substring:"Run #221 id=335 sha=`506cada`"
- 2026-05-19 · S25 t6 Plan AE Changelog UserName 9-sites batch · PASS BE-only bundle-frozen, pr… · `2026-05-runs.md` · substring:"Run #220 id=334 sha=`9ea62be`"
- 2026-05-19 · S25 t5 Plan AD Lịch-sử-duyệt redesign next-target-hint · PASS FE-only bundle-rotate, pa… · `2026-05-runs.md` · substring:"Run #219 id=333 sha=`0aaf2df`"
- 2026-05-19 · S25 t4 Plan AC2 FE merge recover historical Reject · PASS bundle-rotate, synthetic-… · `2026-05-runs.md` · substring:"Run #218 id=332 sha=`25837b6`"
- 2026-05-15 · S24 t1 Plan AA wrap fix sidebar · PASS last-deploy-before-AB, ba… · `2026-05-runs.md` · substring:"Run #214 id=328 sha=`ee0902a`"
- 2026-05-15 · S24 t1 Plan AA IsUserSelectable filter + WfView menu + sidebar (4… · PASS bundle-rotate, Order-shif… · `2026-05-runs.md` · substring:"Run #210 id=324 sha=`ac2c859`"
- 2026-05-15 · S23 t11 Plan U FE sidebar truncate · PASS long-label ellipsis+title… · `2026-05-runs.md` · substring:"Run #209 sha=`86d8806`"
- 2026-05-15 · S23 t10 Plan T5+T6 docs final (DemoSeed wipe ~720 rows + force re… · PASS flag-proven-end-to-end · `2026-05-runs.md` · substring:"Run #208 sha=`7b7b28f`"
- 2026-05-15 · S23 t10 Plan T DemoSeed disable (flag in appsettings, 5 demo-seed… · PASS · `2026-05-runs.md` · substring:"Run #207 sha=`0b97840`"
- 2026-05-15 · S23 t7 Plan Q FE banner mx-5 fix · PASS bundle-rotate QZIPWD-g/Da… · `2026-05-runs.md` · substring:"Run #204 sha=`108268a`"
- 2026-05-15 · S23 t6 Plan P HOTFIX Controller TransitionPeBody +3 fields · PASS Discovery#4 ASP.NET10-rec… · `2026-05-runs.md` · substring:"Run #203 id=317 sha=`1727bd5`"
- 2026-05-15 · S23 t5 Plan O HOTFIX cascade 4 lookup-sites (point 9) · PASS 409-mode-not-403-actor pr… · `2026-05-runs.md` · substring:"Run #202 id=316 sha=`a1c8386`"
- 2026-05-15 · S23 t4 Plan N HOTFIX per-NV lookup discrimination :765 · PASS 7/7-vs-1/7-per-actor, bug… · `2026-05-runs.md` · substring:"Run #201 id=315 sha=`fb3c22c`"
- 2026-05-15 · S23 t3 Plan M Service F1 OneLevel/OneStep edge-case Bước1 reset · PASS Discovery#3-anomaly docs-… · `2026-05-runs.md` · substring:"Run #200 id=314 sha=`f4055a1`"
- 2026-05-14/15 · S23 t1 K10 hotfix AwLevelDto +AllowApproverSkipToFinal · PASS 8-surface-point-checklist… · `2026-05-runs.md` · substring:"Run #195 id=309 sha=`0062fcb`"
- 2026-05-14 · S23 t1 Plan K Mig 31 per-Approver-slot refactor · **[PARTIAL]** Mig+schema OK bu… · `2026-05-runs.md` · substring:"Run #194 id=308 sha=`098baa6`"
- 2026-05-13 · S22-chốt cumulative verify · PASS Discovery#3 push-range-ev… · `2026-05-runs.md` · substring:"Verify S22 chốt cuối cumulative (push range `3d725c4..cc8a7d3` 12 commits) VERDICT=PASS"
- 2026-05-13 · S22 Plan D Users-toggle + Plan C 14-test + Plan E strict-V2-scope · PASS 103 test, Discovery#1 rat… · `2026-05-runs.md` · substring:"Run #188 id=302 sha=a74e671"
- 2026-05-13 · S21 t5 Mig 29 refactor Allow*-per-NV + Designer 5-checkbox · PASS Mig29 applied, Discovery… · `2026-05-runs.md` · substring:"Run #187 id=301 sha=c0af9e0"
- 2026-05-13 · S21 t3+t4 gotcha#45 Trả-lại + F1/F2/F3 advanced-options + Mig 28 · PASS baseline, Discovery API-t… · `2026-05-runs.md` · substring:"Run #186 id=300 sha=eea86fd"
### Curate-note meta-headers (6) — [meta]
- 2026-05-19 · curate-note S25 t5 Plan AD #219 · **[meta]** · `2026-05-runs.md` · substring:"added S25 t5 Plan AD Run #219 entry"
- 2026-05-19 · curate-note S25 t3 Plan AC #217 (Discovery#5 sqlcmd 4-backslash \… · **[meta]** · `2026-05-runs.md` · substring:"added S25 t3 Plan AC Run #217 entry"
- 2026-05-19 · curate-note S25 t1 Plan AB #215 (gotcha#48 SQLite tie-break FAIL) · **[meta]** · `2026-05-runs.md` · substring:"added S25 t1 Plan AB Run #215 entry"
- 2026-05-15 · curate-note S24 t1 Plan AA #210 · **[meta]** · `2026-05-runs.md` · substring:"added S24 t1 Plan AA Run #210 entry"
- 2026-05-15 · curate-note S23 t6 Plan P #203 (Discovery#4 numeric-enum) · **[meta]** · `2026-05-runs.md` · substring:"added S23 t6 Plan P HOTFIX Run #203 entry"
- 2026-05-15 · curate-note S23 t5 Plan O #202 (4-site discrimination, Transition… · **[meta]** · `2026-05-runs.md` · substring:"added S23 t5 Plan O HOTFIX Run #202 entry"
## 2026-05 early (file: `archive/2026-05-q3.md`)
- 2026-05-19 · ★ Run #215 FAIL → #216 PASS pair — **gotcha #48 SQLite tie-break*… · FAIL→PASS · `2026-05-q3.md` · substring:"Run #215 FAIL → Run #216 PASS (gotcha #48 SQLite tie-break catch+fix pair)"
- 2026-05-13 · Verify S22 chốt cuối cumulative (Discovery#3 first-surfaced, 33 u… · PASS · `2026-05-q3.md` · substring:"Verify S22 chốt cuối cumulative (push range `3d725c4..cc8a7d3` 12 commits VERDICT=PASS"
- 2026-05-12 · Setup baseline (agent init, 44 gotchas + 5-stage + 3 skills) · init · `2026-05-q3.md` · substring:"2026-05-12 — Setup baseline"
## 2026-05 early (file: `archive/2026-05-q2.md`)
- 2026-05-22 · Run #231 Plan B Contract V2 wire kick-off · **[PARTIAL]** PASS-deploy + se… · `2026-05-q2.md` · substring:"Run #231 (id=345) Plan B Contract V2 wire kick-off VERDICT=PARTIAL"
<!-- ===== các entry 2026-07 dưới đây RELOCATE @S167 từ cuối file (từng bị append lạc vào section 2026-05-q2) — thứ tự ngày TĂNG dần như cũ, VERBATIM ===== -->
- 2026-07-06 · S103 #487 `f0874dd` GOVERNANCE/SCRIPTS-ONLY GO — GOVERNANCE-PASS… · PASS · `2026-07.md` · substring:"S103 #487 (rn 373) `f0874dd` GO ~4m"
- 2026-07-02 · S100 #486 `5ee32c0` GOVERNANCE-ONLY GO — bundle FROZEN #69-REFINE… · PASS · `2026-07.md` · substring:"S100 #486 (rn 372) `5ee32c0` GO 5m17s"
- 2026-07-10 · S107 #489 `21387cd` GOVERNANCE-ONLY (+office-document 12th sub) G… · PASS · `2026-07.md` · substring:"S107 #489 (rn 375, id 489)"
@ -133,7 +39,6 @@
- 2026-07-13 · S115 #385 `81b59f0` GOVERNANCE/TOOLING-ONLY (hmw.js+memory-archiv… · PASS · `2026-07.md` · substring:"S115 #385 (rn 385, id 499)"
- 2026-07-13 · S116 #386 `88368fd` PE nhap-gia-AM + submit-guard all-zero GO — j… · PASS · `2026-07.md` · substring:"S116 #386 (rn 386, id 500)"
- 2026-07-14 · S117 #387 `316a82f` PE endsBeforeCeo-pointer-aware + duyet-theo-k… · PASS · `2026-07.md` · substring:"S117 #387 (rn 387, id 501)"
- 2026-07-14 · S118 #389 `c808b9a` SECURITY-AUTHZ ReportsController→Reports.Read… · PASS · `2026-07.md` · substring:"S118 #389 (rn 389, id 503)"
- 2026-07-16 · S126 #394 `e9124fc` GOVERNANCE-TOOLING GO — gate 509 exact, bundl… · PASS · `2026-07.md` · substring:"S126 (2026-07-16) run 508/#394"
- 2026-07-17 · S133 #398 `d4fbced` PE budget-freeze + Mig67 AddPeApprovedBudgetS… · PASS · `2026-07.md` · substring:"S133 (07-17) verify d4fbced"
@ -141,7 +46,8 @@
- 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-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-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"
- 2026-07-31 · S164 #435 `0779f2d` K2 KHKK mang hang-muc + nhom duyet N1-8 + Mig 71 — PARTIAL 5/6, bai CHAN-LY-RONG (acceptance 0-row hai phia) + liet-ke-tron-ho index + wire-proof bind-400 · `2026-07.md` · substring:"07-31 verify `0779f2d` S164 K2"
- 2026-07-31 · S164 #436 `15349e8` K3 8 workflow nhom KHKK-N1-8 (0-mig) — PASS 6/6, du-doan-truoc-khi-do + FROZEN-la-he-qua + marker bien-local 0-bit · `2026-07.md` · substring:"S166 (08-01) re-verify `15349e8`"

View File

@ -1,5 +1,7 @@
# Implementer-Backend Agent — Persistent Memory
- **S167 (08-01) lane K7 — cầu KHKK→HĐ BE + 5 vá `[artifact sub-implbe-k7.md; return-1 garble #53 → lead SendMessage-resume, xong solo]`:** Bài lớn nhất = **BÁC TIỀN ĐỀ ĐỀ BÀI bằng 4 phép đo trước khi gõ**: lead nói "bridge ĐÃ TỒN TẠI (W6-S161), mày chỉ vá quanh nó" — đĩa nói KHÔNG (`grep create-contract` 1 hit duy nhất = bridge PE cũ · 0 file · 0 endpoint · 0 write-site `Line.ContractId`). Cái tồn tại @W6**đường-ống HĐ V2**, không phải cầu ⇒ lead nhập nhằng "đường ống đã vá"≠"bridge có". **Phép thử rẻ phân biệt:** vá-6 ("nạp Project.Code/Supplier.Code theo khuôn bridge-cũ") là **no-op nếu áp lên bridge cũ** (nó ĐÃ có 2 dòng đó) ⇒ vá chỉ có nghĩa với vật CHƯA có ⇒ suy ngược ra vật thiếu. Xử: DỰNG bridge theo spec invest §K7.1 đã pin sẵn (0 quyết-định-mới của tôi) + nhúng 5 vá, KHAI RÕ chệch-khuôn + trỏ dòng spec — lead nhận. Khi **spec vá ⊥ nền invest** (`WorkflowDefinitionId` null vs pin) → **vá THẮNG** (spec `:4` viết luật đó). Cột đã có từ mig cũ ⇒ **0-mig**. `??=` giữ CHIỀU: cầu chạy sau không cướp ô của cầu trước. Thêm field vào projection dùng-chung-3-endpoint → **chạy test filter rẻ ngay** (collection-subquery SQLite dịch được: 34/35). 1 fail = reflection-authz test bắt mọi action phải cùng key module, mà vá-1 cố ý gate key MODULE KHÁC (`Contracts.Create`, #85) ⇒ **verified-fail bàn giao test-lane, KHÔNG tự sửa** (S112 khuôn). Tag `[s167, k7-bridge-khkk-hd, bac-tien-de-4-phep-do, va-noop-suy-nguoc-vat-thieu, spec-thang-nen, 0-mig, verified-fail-handoff]`
- **S166 (08-01) lane K4a — 49 row menu KHKK 8-nhóm, DONE B0-B5 `[LEAD SEED ON-BEHALF TẠI-CỬA — mày #53 sau B5 ("Now the site-4 revoker comment" trong khi comment ĐÃ land — return tụt sau đĩa 1 nhịp); B6 + END lead đóng; nguồn: sub-implbe-k4a.md + git diff]`:** Nghề mới ăn tiền: **BỘ-SINH thay 49 const tay**`KhkkGroupNumbers[7] × (KhkkGroup + 6 KhkkLeafActions)``KhkkGroupMenuKeys` làm NGUỒN CHUNG 3 phía (spread `.. ` vào `All` · vòng seed tree.Add · site-2 `.Concat`) ⇒ khớp **by-construction, 0 mirror tay 147 chuỗi** (đúng loại drift nổ FK Permission T6-K1); chệch-khuôn-so-brief KHAI RÕ + trỏ đề xuất gốc invest `:9` ⇒ lead chấp nhận không mất lượt. Anchor re-ground TRƯỚC khi sửa (số spec trượt vì DbInitializer phình sau K2/K3): dict labelBackfill `:2141-2183` · upsert THẬT `:2162-2179` (vá 2 comment stale "1893" bằng mỏ-neo GREP `existingItems.TryGetValue` thay số-dòng-trần — số dòng sẽ trượt tiếp, mỏ neo thì không). Vá-10 sửa LÝ-DO comment (group-key KHÔNG cần CanRead row — HasAccess đệ-quy theo con `:96`; invest doc viết sai lý-do, kết luận giữ-3-site vẫn đúng). labelBackfill `Khkk_G1` đổi-nhãn-không-nhân-row + fail-fast KeyNotFound khi thiếu nhãn (thà nổ lúc seed còn hơn FK nổ về sau). Suite **614/0 SAU diff** (idempotent/revoke/permission-seed giữ xanh). Tag `[s166, k4a-lane, bo-sinh-by-construction-3-phia, mo-neo-grep-thay-so-dong, fail-fast-luc-seed, return-tut-sau-dia-1-nhip, seed-on-behalf-tai-cua]`
- **S161 (07-29) ARC W1+W2+W3đ1 KHKK — 3 lượt/3 wave, chi tiết = sub-md run-folder `runs/2026-07-29-S161-khkk-{w1-schema,w2-crud,w3-duyet}/` (tổng ~47KB ghi-từng-mục; cả 3 lượt return #53 nhưng 0 mất):** **W1** Mig 69 7-bảng (UNIQUE filtered #57 · cascade-1-hop · apply 2 DB 0-drift · sys.tables 96) + seeder `SeedKeHoachKyKetAccessAsync` UPGRADE-if-exists+CanDelete + CodeGen `KHKK/{YYYY}/{Seq:D3}` WorkflowAppCodeSequences + 3 site AWV2Admin — ship `a8027c0` cicd 6/6. **W2** mega-file CRUD 15-endpoint policy-15/15 + 4 quyết D1-D4 khai-lead-duyệt; 5 bẫy né ghi sổ: codegen-TRƯỚC-Add (S88) · SUM so `w.Id` KHÔNG `w.SupplierId` · `Remove` trước `Add(changelog)` (T26) · subquery-Any-2-tầng vỡ-SQL-lúc-chạy · giao-1-grep-cùng-lớp-ra-3 (S122) — ship `6cbc6ad` cicd 6/6 (reviewer 12F: D1 "FE sẽ khớp" = giả-định-không-phải-phép-đo → 6 điểm đứt, lead vá). **W3đ1** skeleton test-before: switch-throw từng nhánh (đỏ-THẬT không đỏ-compile) + **rào-FK-547-bằng-VẮNG-MẶT-dep** (không inject IChangelogService) + **§6 tự đối-chứng chéo hợp-đồng FE 5/5 TRƯỚC bàn giao** (bài W2 → nghi thức) + §7 bàn-giao ctor-CUỐI. Tag `[s161, khkk-w1-w2-w3d1, mig69, upgrade-if-exists, rao-vang-mat-dep, doi-chung-cheo, gia-dinh-vs-phep-do]`
@ -82,19 +84,13 @@ UI `disabled={!canX}` + BE helper `EnsureCanXAsync(id, userId)` throw 403 (NOT i
## 📅 Recent activity (FIFO — older → archive/git)
- **2026-07-13 (S116 PE cho nhập giá ÂM — submit-guard 1-line BE, owner anh Kiệt PROD-UAT-live, NO mig/FE/test, spec deterministic exact-line → ACCEPT Case 1):** Giá âm ô "Số tiền báo giá hạng mục" (form.thanhTien→Quote.ThanhTien) = phát-sinh-giảm/hoàn-tiền NCC = giá-trị THẬT (KHÔNG lỗi). Rule "có giá là được": ≥1 quote-selected khác 0 (âm/dương) → gửi được; CHỈ all-zero (chưa báo giá) mới chặn. **THAY DUY NHẤT** `PurchaseEvaluationWorkflowService.cs:206` submit-guard `selectedThanhTien.Sum() <= 0``.All(x => x == 0)`. Guard `Count==0` (chưa chọn NCC) ở `if` phía trên GIỮ RIÊNG → `else if` chỉ chạy khi list non-empty → `.All(==0)` = "mọi quote chọn đúng bằng 0". Message giữ "Đơn vị được chọn chưa có giá chào thầu". **KHÔNG đụng** (owner R1/R3/R-CEO): validator ≥0 khác (budget/price PeFeatures/PeWorkItemBudget/PeSuggestedPrice) GIỮ · clamp `CreateContractFromEvaluationFeatures.cs:88-100` GIỮ (Contract.GiaTri cho âm R1) · CEO-threshold winnerQuoteTotal NET GIỮ · KHÔNG thêm validator quote. DB nhận âm sẵn (Mig 65, decimal(18,2) 0 CHECK, 0 validator). Build `SolutionErp.Api` **0 warn/0 err** (Api ref Infrastructure → file compiled). ⚠️ Comment `:193-195` ("tổng ≤0"/"D5 giữ >0"/"winner phải có giá") giờ STALE vs all-zero-logic — KHÔNG sửa (owner "THAY DUY NHẤT :206"), flag owner quyết. LESSON: domain "âm là giá-trị-thật" → presence-check dùng `.All(==0)` KHÔNG `Sum()<=0`/`>=0` (sum-guard nhầm âm-lớn với chưa-nhập). Tag `[s116, pe-negative-quote, submit-guard-1line, all-zero-not-sum, co-gia-la-duoc, no-mig, stale-comment-flagged]`.
- **2026-07-12 (S113 Supplier import v2 BE — spec `wf_e7896ca6` fable-clone-ensemble, owner anh Kiệt, Mig 64, D1/D2/D3 pre-chốt → ACCEPT Case 1+2; ⚠️ return-1 garbled #53 → coordinator SendMessage-resume, xong solo):** IsPublic publish-gate + dedup MST-primary. **Mig 64 `AddSupplierPublishState`** (Supplier +`bool IsPublic` NOT NULL default false + `Sql("UPDATE Suppliers SET IsPublic=1")` backfill 22 prod + DropIndex→CreateIndex đổi Code-unique filter `[IsDeleted]=0 AND [Code]<>''` cho draft-row Code="" coexist; no new table 89). **Service** `SupplierExcelImportService`: (a) re-bake ExpectedHeaderTokens BYTE-EXACT raw-`\n` form = single-source cho validator(NormalizeHeader collapse \n→space)+BuildTemplate (spec §② 30 token, c7 trailing-space); (b) dedup **MST-primary + Code-backstop**`BuildMstIndex`(NormalizeMst=Trim+strip-inner-ws `\s+`→"" OrdinalIgnoreCase, blank→skip) precedence existing-MST→existing-Code→batch-MST→batch-Code, `countedUpdates HashSet<Supplier>` tránh double-count, blank-MST KHÔNG vào batchByMst (2 blank=2 insert); (c) BỎ hard-error blank-Code (=draft), GIỮ Name+all-or-nothing; (d) New `IsPublic=!blank(Code)`, update-path KHÔNG đụng IsPublic; +MstMissing(soft-warn) +DraftCount; (e) `BuildTemplate()` ClosedXML sheet "Sheet1" 30 token ROW4 WrapText+FreezeRows. **PublishSupplierCommand**(Id,MaNcc?,Publish): set Code+unique-CI-check + gate Publish→Code-non-empty ConflictException, KHÔNG UpdateSupplierCommand (#73 clobber). **API THẬT:** GET `/api/suppliers/import/template`(xlsx "Mau-Database-NCC.xlsx") · POST `/api/suppliers/{id}/publish` body`{maNcc?,publish}` · GET `/api/suppliers?published={t/f}` filter · SupplierDto +`bool IsPublic` append-CUỐI (sửa 2 projection List+Get). **D3 authz:** import preview/confirm/template/publish `[Roles=Admin,CatalogManager]``[Policy="Suppliers.Update"]` (khớp FE PermissionGuard). DbInit +IsPublic=true 4 real+18 sample. Build slnx **0/0**, apply BOTH DB (Dev catch-up+Design). KHÔNG commit/FE/test. LESSON: S112 CI-dedup-at-service mở rộng → dual-key MST-primary; blank-key KHÔNG index. Tag `[s113, supplier-import-v2, mig64, ispublic-publish-gate, dedup-mst-primary-code-backstop, blank-code-draft, buildtemplate-single-source, publish-cmd-no-#73]`.
- **2026-07-12 (S112 PE sign-off close-review implement — `wf_7df56b89-7bb` GO-WITH-ADJ, owner anh Kiệt PROD-UAT-live, 4-region/1 file `PurchaseEvaluationWorkflowService.cs`, NO mig/FE/test → ACCEPT Case 1):** **C1 A1 (`ApplyDrafterBypassOnSubmitAsync`):** người soạn KÝ TAY ô cấp mình — `k=Max``minOwn=Min(drafterSlots.Order)`, bypass `<=k``<minOwn` (chỉ NV cấp dưới THUẦN), XÓA canned-opinion UPSERT (không tự ký hộ ai) + dead `maxLevelOrder`/`ownSlot`; advance 3-nhánh→1 `if(minOwn>1){pointer=minOwn}` STOP tại minOwn (người soạn tự ApproveV2Async); minOwn=1→bypass rỗng pointer giữ (0,1); non-contiguous Cấp1+Cấp3 ký tay đúng thứ tự. **C2 EDGE-5 hard-lock:** top `TransitionAsync` sau isAdmin/isSystem TRƯỚC mọi branch → `if(fromPhase is DaDuyet or TuChoi && !isAdmin && !isSystem) throw Conflict` (chặn non-admin lật phiếu terminal; Admin+System MIỄN); KHÔNG đụng EnsureCanRejectV2Async. **C3** no self-finalize (owner oQ1 permission-driven). **C4 opinion-history (`ApproveV2Async` UPSERT else):** old.Comment→Changelog (Workflow/Update/EntityId=level.Id) TRƯỚC overwrite chỉ khi non-empty — NO mig/bảng/endpoint. Build slnx **0/0**. Infra **4 FAIL/401 pass**: fail=C1 (PeSubmitGuardAndBypassTests 9/10/12/14 assert OLD auto-sign/terminal-on-submit); C2/C4 break 0 test → test-specialist ADD. LESSON: behavior-change write-site → run consumer tests, hand VERIFIED fail-list (KHÔNG sửa test). Tag `[s112, pe-signoff, a1-sign-own-level, edge5-hardlock, opinion-history-changelog, consumer-test-verified-handoff]`.
- **2026-07-12 (S112 Supplier Excel-import Phase B BE — `wf_e2f23a84` invest-ensemble → `wf_75d6149b` close-review GO-WITH-ADJ, owner anh Kiệt, commit `e100ef0`, Mig 63 → ACCEPT Case 1/2):** **Service** `SupplierExcelImportService` (preview + confirm 2-stage) + 2 endpoint (`/suppliers/import/preview` [415 non-xlsx] · `/suppliers/import/confirm` [401 auth]) + **Mig 63 `AddSupplierImportSourceFields`** (Supplier +`SourceUpdatedAt datetime2?`+`SourceUpdatedBy nvarchar(200)?` provenance cột 29/30; 2 AddColumn nullable no-new-table 89). FE 2-app byte-identical (`SupplierImportDialog`). **LESSONS:** (1) dedup Code = **OrdinalIgnoreCase ở SERVICE** — DB unique-index CI-collation lật (SQLite fixture BINARY KHÔNG bắt, prod SQL Server CI bắt) → dedup KHÔNG dựa DB; (2) parser **absolute cell-index** (row-4 header-fingerprint), KHÔNG `CellsUsed()` (column-drift); (3) **all-or-nothing** gate TRƯỚC mutate (1 hard-error→Committed=false DB unchanged); (4) fill-nulls **SKIP Code/Name/Type** (chỉ điền field trống); (5) `.Normalize(FormC)` cho real-file NFD (close-review adjust bake 30 `RealFileHeaderTokens` byte-verify). **SURPRISE:** Approach-B (extend seed) ĐÃ ship 4 NCC prod (Mig 62) → spec "partial" SAI; import ghi entity **TRỰC TIẾP** (fill-where-non-empty) KHÔNG qua `UpdateSupplierCommand` (tránh NULL-clobber #73). Shipped Run #496 PASS, reviewer GO-WITH-ADJ (BE 6/6 axes). Tag `[s112, supplier-excel-import, mig63, ordinalignorecase-dedup-at-service, abs-cell-index-parser, all-or-nothing, fill-nulls-safe, normalize-formc-nfd, direct-entity-write]`.
- _(2026-06-25→19 [S88 frozen-relocation · PE Section-B 3-cột Mig 59 · PE chuông approver] FIFO'd → `archive/2026-06.md` at S116; + S77→S56 [2026-06-10..19, 7 entries] at S112. Recall verbatim there / `_INDEX.md` / RAG `search_memory`.)_
- _(Mục FIFO này hiện RỖNG — 4 entry cuối [2026-07-12/13: S116 PE giá ÂM · S113 Supplier import v2 Mig 64 · S112 PE sign-off · S112 Supplier Excel-import Mig 63] dời verbatim → `archive/2026-07.md` @S167. Trước đó: 2026-06-25→19 [S88 frozen-relocation · PE Section-B Mig 59 · PE chuông approver] → `archive/2026-06.md` @S116; S77→S56 [7 entries] @S112. Việc gần đây SỐNG nằm ở khối L1 HOT đầu file (S167/S166/S161/S139). Recall verbatim: archive tương ứng / `archive/_INDEX.md` / RAG `search_memory`.)_
---
## 🔄 Curate trigger
- >~30KB → archive recent → L2 `archive/<period>.md`. Stale >3mo → remove.
- **Last curate: 2026-07-13 S116 (implementer-backend self-FIFO, hook @21.5KB read-limit-guard)** (21.5→~15KB): moved 3 oldest Recent-activity (2026-06-19/24/25: PE chuông · PE Section-B Mig 59 · S88 frozen-relocation) verbatim → `archive/2026-06.md`, kept 4 newest (S116/S113/S112×2). Prev **S112** (25.6→~16KB): S77→S56 7-entry → 2026-06.md. Prev **S70 Harness-9** (33.2→17.4KB): L2 dark-matter recovery + `_INDEX` + gists. Prev: S40 dedup-split · S34 · S22.
- **Last curate: 2026-08-01 S167 (self-FIFO, hook read-limit-guard bắn @22.4KB)** (22.4→15.4KB, dưới ngưỡng 17.1KB hook đòi): moved verbatim 4 entry Recent-activity 2026-07-12/13 (S116/S113/S112×2) → `archive/2026-07.md` (2.8→10.3KB). Mục Recent-activity nay rỗng có chủ đích — việc SỐNG ở khối L1 HOT đầu file. Prev **S116** (21.5→~15KB): moved 3 oldest Recent-activity (2026-06-19/24/25: PE chuông · PE Section-B Mig 59 · S88 frozen-relocation) verbatim → `archive/2026-06.md`, kept 4 newest (S116/S113/S112×2). Prev **S112** (25.6→~16KB): S77→S56 7-entry → 2026-06.md. Prev **S70 Harness-9** (33.2→17.4KB): L2 dark-matter recovery + `_INDEX` + gists. Prev: S40 dedup-split · S34 · S22.
## S152 (2026-07-25) — R1 retention-note ranh auto-refuse test [M9 on-behalf lead]
- **Integration testing multiple components = việc `test-specialist`, KHÔNG phải mình** — mình scaffold + compile-check; assertion/integration test cross-component (SQLite fixture, authz regression) do test-specialist own (tiền lệ S67 HRM test-after +23, S69 +20 đều test-specialist). Nhận viết integration test = double-touch tests/ sai split. Tag `[s152, r1-retention, refuse-integration-testing-components]`

View File

@ -5,3 +5,12 @@
- **S134 (07-17) PE lũy kế TẠM TÍNH BE [wf_58302bd5-b9f INV-2.A · return sạch 0-garble] `[engine: opus-worker]`:** +`ComputePendingAsync` + `PePendingAccumulation` (PeBudgetAccumulator.cs pure-add +68/0) + `PePendingPriorPeDto` + `PeBudgetSummaryDto` append-cuối 5 field (2 construction site: `:946` frozen-defaults 0/0/0/0/null · `:985` live-fill) + D4 audit-note TRONG `ApplyBudgetSnapshotOnFinalizeAsync` (Action=**Update** KHÔNG Transition — #49 tránh UI đọc 2 lần chuyển-phase; insertion-only +30/0; signature GIỮ → 5 call-site 0-diff, site cuối line-shift :1112→:1142 content bất động). Build slnx 0/0 · 0 mig · ComputeAsync body 0-diff (A1). Learned: (1) **insertion-only diff (grep `'^-'` = 0 real-removal) = bằng-chứng MẠNH NHẤT "thân method/call-site bất động"** — mạnh hơn đọc-mắt, chống báo-số-sai kiểu #53; (2) derived-audit thêm vào helper KHÔNG cần đổi signature nếu tính nội bộ từ field entity sẵn có (giữ call-site 0-diff); (3) accumulator ns ≠ DTO ns (`...Dtos`) → cần using tường minh dù cùng project. Tag `[s134, pe-luyke-tam-tinh, computepending, insertion-only-proof, d4-changelog-update-not-transition]`
- **S133 (07-17) PE budget-freeze BE [wf_4bef08b8-730 INV-B · #53 return-fail → disk-truth + em-main-solo hoàn tất; entry on-behalf]:** lane sửa XONG 6 file + `PeBudgetAccumulator.cs` mới (Domain 11 props ApprovedBudget* + Config precision/maxlength + DTO append `BudgetFrozen=false` cuối positional-record + display-gate frozen/live 2-nhánh trong GetPurchaseEvaluationQuery + helper `ApplyBudgetSnapshotOnFinalizeAsync` gọi 4-site `:862/:901/:934/:1109` + 2 terminal-guard block-CẢ-Admin) rồi CHẾT TRƯỚC bước sinh migration → em-main solo: scaffold Mig 67 sạch 11 AddColumn (0 RenameColumn #63) + backfill `Sql()` OUTER APPLY ×3 khớp accumulator TỪNG điều kiện (Suppliers/Quotes = BaseEntity KHÔNG soft-delete filter → SQL không thêm IsDeleted trên s/q; WorkItemId-null → 4 lũy kế NULL mirror helper, SnapshotAt vẫn set) + apply 2 DB — **Dev lòi drift Mig 66 committed-chưa-apply-local, catch-up luôn** — + filter test 44/44 (T0 RED→GREEN không sửa test). Phát hiện đáng giữ của lane: `TransitionAsync` KHÔNG SaveChanges nội bộ `:537` → snapshot flush qua SaveChanges của caller (mirror ApprovedPrice pattern); khuôn backfill THẬT Mig 61/65 = plain `Sql()` KHÔNG C# provider-guard (SQLite EnsureCreated không replay = guard kiến trúc). Tag `[s133, pe-budget-freeze, mig67, 53-writelane-disk-recover]`
- _(FIFO @S167 — 4 entry 2026-07-12/13 dời verbatim từ L1 khi hook read-limit-guard bắn @22.4KB; moved-not-cut: S116 PE giá ÂM · S113 Supplier import v2 Mig 64 · S112 PE sign-off · S112 Supplier Excel-import Mig 63.)_
- **2026-07-13 (S116 PE cho nhập giá ÂM — submit-guard 1-line BE, owner anh Kiệt PROD-UAT-live, NO mig/FE/test, spec deterministic exact-line → ACCEPT Case 1):** Giá âm ô "Số tiền báo giá hạng mục" (form.thanhTien→Quote.ThanhTien) = phát-sinh-giảm/hoàn-tiền NCC = giá-trị THẬT (KHÔNG lỗi). Rule "có giá là được": ≥1 quote-selected khác 0 (âm/dương) → gửi được; CHỈ all-zero (chưa báo giá) mới chặn. **THAY DUY NHẤT** `PurchaseEvaluationWorkflowService.cs:206` submit-guard `selectedThanhTien.Sum() <= 0``.All(x => x == 0)`. Guard `Count==0` (chưa chọn NCC) ở `if` phía trên GIỮ RIÊNG → `else if` chỉ chạy khi list non-empty → `.All(==0)` = "mọi quote chọn đúng bằng 0". Message giữ "Đơn vị được chọn chưa có giá chào thầu". **KHÔNG đụng** (owner R1/R3/R-CEO): validator ≥0 khác (budget/price PeFeatures/PeWorkItemBudget/PeSuggestedPrice) GIỮ · clamp `CreateContractFromEvaluationFeatures.cs:88-100` GIỮ (Contract.GiaTri cho âm R1) · CEO-threshold winnerQuoteTotal NET GIỮ · KHÔNG thêm validator quote. DB nhận âm sẵn (Mig 65, decimal(18,2) 0 CHECK, 0 validator). Build `SolutionErp.Api` **0 warn/0 err** (Api ref Infrastructure → file compiled). ⚠️ Comment `:193-195` ("tổng ≤0"/"D5 giữ >0"/"winner phải có giá") giờ STALE vs all-zero-logic — KHÔNG sửa (owner "THAY DUY NHẤT :206"), flag owner quyết. LESSON: domain "âm là giá-trị-thật" → presence-check dùng `.All(==0)` KHÔNG `Sum()<=0`/`>=0` (sum-guard nhầm âm-lớn với chưa-nhập). Tag `[s116, pe-negative-quote, submit-guard-1line, all-zero-not-sum, co-gia-la-duoc, no-mig, stale-comment-flagged]`.
- **2026-07-12 (S113 Supplier import v2 BE — spec `wf_e7896ca6` fable-clone-ensemble, owner anh Kiệt, Mig 64, D1/D2/D3 pre-chốt → ACCEPT Case 1+2; ⚠️ return-1 garbled #53 → coordinator SendMessage-resume, xong solo):** IsPublic publish-gate + dedup MST-primary. **Mig 64 `AddSupplierPublishState`** (Supplier +`bool IsPublic` NOT NULL default false + `Sql("UPDATE Suppliers SET IsPublic=1")` backfill 22 prod + DropIndex→CreateIndex đổi Code-unique filter `[IsDeleted]=0 AND [Code]<>''` cho draft-row Code="" coexist; no new table 89). **Service** `SupplierExcelImportService`: (a) re-bake ExpectedHeaderTokens BYTE-EXACT raw-`\n` form = single-source cho validator(NormalizeHeader collapse \n→space)+BuildTemplate (spec §② 30 token, c7 trailing-space); (b) dedup **MST-primary + Code-backstop**`BuildMstIndex`(NormalizeMst=Trim+strip-inner-ws `\s+`→"" OrdinalIgnoreCase, blank→skip) precedence existing-MST→existing-Code→batch-MST→batch-Code, `countedUpdates HashSet<Supplier>` tránh double-count, blank-MST KHÔNG vào batchByMst (2 blank=2 insert); (c) BỎ hard-error blank-Code (=draft), GIỮ Name+all-or-nothing; (d) New `IsPublic=!blank(Code)`, update-path KHÔNG đụng IsPublic; +MstMissing(soft-warn) +DraftCount; (e) `BuildTemplate()` ClosedXML sheet "Sheet1" 30 token ROW4 WrapText+FreezeRows. **PublishSupplierCommand**(Id,MaNcc?,Publish): set Code+unique-CI-check + gate Publish→Code-non-empty ConflictException, KHÔNG UpdateSupplierCommand (#73 clobber). **API THẬT:** GET `/api/suppliers/import/template`(xlsx "Mau-Database-NCC.xlsx") · POST `/api/suppliers/{id}/publish` body`{maNcc?,publish}` · GET `/api/suppliers?published={t/f}` filter · SupplierDto +`bool IsPublic` append-CUỐI (sửa 2 projection List+Get). **D3 authz:** import preview/confirm/template/publish `[Roles=Admin,CatalogManager]``[Policy="Suppliers.Update"]` (khớp FE PermissionGuard). DbInit +IsPublic=true 4 real+18 sample. Build slnx **0/0**, apply BOTH DB (Dev catch-up+Design). KHÔNG commit/FE/test. LESSON: S112 CI-dedup-at-service mở rộng → dual-key MST-primary; blank-key KHÔNG index. Tag `[s113, supplier-import-v2, mig64, ispublic-publish-gate, dedup-mst-primary-code-backstop, blank-code-draft, buildtemplate-single-source, publish-cmd-no-#73]`.
- **2026-07-12 (S112 PE sign-off close-review implement — `wf_7df56b89-7bb` GO-WITH-ADJ, owner anh Kiệt PROD-UAT-live, 4-region/1 file `PurchaseEvaluationWorkflowService.cs`, NO mig/FE/test → ACCEPT Case 1):** **C1 A1 (`ApplyDrafterBypassOnSubmitAsync`):** người soạn KÝ TAY ô cấp mình — `k=Max``minOwn=Min(drafterSlots.Order)`, bypass `<=k``<minOwn` (chỉ NV cấp dưới THUẦN), XÓA canned-opinion UPSERT (không tự ký hộ ai) + dead `maxLevelOrder`/`ownSlot`; advance 3-nhánh→1 `if(minOwn>1){pointer=minOwn}` STOP tại minOwn (người soạn tự ApproveV2Async); minOwn=1→bypass rỗng pointer giữ (0,1); non-contiguous Cấp1+Cấp3 ký tay đúng thứ tự. **C2 EDGE-5 hard-lock:** top `TransitionAsync` sau isAdmin/isSystem TRƯỚC mọi branch → `if(fromPhase is DaDuyet or TuChoi && !isAdmin && !isSystem) throw Conflict` (chặn non-admin lật phiếu terminal; Admin+System MIỄN); KHÔNG đụng EnsureCanRejectV2Async. **C3** no self-finalize (owner oQ1 permission-driven). **C4 opinion-history (`ApproveV2Async` UPSERT else):** old.Comment→Changelog (Workflow/Update/EntityId=level.Id) TRƯỚC overwrite chỉ khi non-empty — NO mig/bảng/endpoint. Build slnx **0/0**. Infra **4 FAIL/401 pass**: fail=C1 (PeSubmitGuardAndBypassTests 9/10/12/14 assert OLD auto-sign/terminal-on-submit); C2/C4 break 0 test → test-specialist ADD. LESSON: behavior-change write-site → run consumer tests, hand VERIFIED fail-list (KHÔNG sửa test). Tag `[s112, pe-signoff, a1-sign-own-level, edge5-hardlock, opinion-history-changelog, consumer-test-verified-handoff]`.
- **2026-07-12 (S112 Supplier Excel-import Phase B BE — `wf_e2f23a84` invest-ensemble → `wf_75d6149b` close-review GO-WITH-ADJ, owner anh Kiệt, commit `e100ef0`, Mig 63 → ACCEPT Case 1/2):** **Service** `SupplierExcelImportService` (preview + confirm 2-stage) + 2 endpoint (`/suppliers/import/preview` [415 non-xlsx] · `/suppliers/import/confirm` [401 auth]) + **Mig 63 `AddSupplierImportSourceFields`** (Supplier +`SourceUpdatedAt datetime2?`+`SourceUpdatedBy nvarchar(200)?` provenance cột 29/30; 2 AddColumn nullable no-new-table 89). FE 2-app byte-identical (`SupplierImportDialog`). **LESSONS:** (1) dedup Code = **OrdinalIgnoreCase ở SERVICE** — DB unique-index CI-collation lật (SQLite fixture BINARY KHÔNG bắt, prod SQL Server CI bắt) → dedup KHÔNG dựa DB; (2) parser **absolute cell-index** (row-4 header-fingerprint), KHÔNG `CellsUsed()` (column-drift); (3) **all-or-nothing** gate TRƯỚC mutate (1 hard-error→Committed=false DB unchanged); (4) fill-nulls **SKIP Code/Name/Type** (chỉ điền field trống); (5) `.Normalize(FormC)` cho real-file NFD (close-review adjust bake 30 `RealFileHeaderTokens` byte-verify). **SURPRISE:** Approach-B (extend seed) ĐÃ ship 4 NCC prod (Mig 62) → spec "partial" SAI; import ghi entity **TRỰC TIẾP** (fill-where-non-empty) KHÔNG qua `UpdateSupplierCommand` (tránh NULL-clobber #73). Shipped Run #496 PASS, reviewer GO-WITH-ADJ (BE 6/6 axes). Tag `[s112, supplier-excel-import, mig63, ordinalignorecase-dedup-at-service, abs-cell-index-parser, all-or-nothing, fill-nulls-safe, normalize-formc-nfd, direct-entity-write]`.

View File

@ -6,8 +6,9 @@
"session_ctx_kb": 64,
"_session_ctx_kb_owner_set": "🔴 OWNER-SET TUONG-MINH @S146 2026-07-22 — anh chot '1 -> tuong minh' sau khi lead surface DAY DU 2 ly-do phan-bien (K6.2 muon-so-chua-do + H18 ghost-wire). Quyet-dinh cua owner co concern trong tam nhin => THUC THI, khong hoi lai. Don vi = KB; code doc PHAI nhan 1024 (khop manifest hub 101e69d6 'session_ctx_kb: 64'). Nguon so 64 = manifest hub, KHONG phai SE tu do.",
"_session_ctx_kb_reader": "🔄 GHOST-WIRE DONG @S153 2026-07-26 (adopt 0df10df4 T7): reader THAT dau tien = scripts/session_ctx.py mind-check phep-phu-8 — doc khoa nay LIVE, nhan 1024, do _context-s-<N>.md, CO khi vuot (verify acceptance-d @S153: output in 'aux tran _context ... session_ctx_kb'). Lam dung 3 dieu caveat cu tu dan {(a) doc khoa khong hardcode (b) x1024 (c) go dong caveat khi co reader}. 🧊 Lineage: caveat H18 class-2 defined-but-unread song S146→S153 (do @S146 0-reader, hit duy nhat = comment nhip-no-probe.ps1:42; doi chung pull_warn_days co reader that :190) — go dung luc co reader, khong som hon.",
"mind_ctx_kb": 32,
"_mind_ctx_kb_set": "🔴 OWNER-DELEGATED → ENGINE-PROPOSED → LEAD-DECIDED @S153 2026-07-26 (adopt thu 0df10df4 khoan (35); lenh pipeline anh 26-07 'fable-real invest → spec → hmw xu ly' = UY QUYEN — KHONG PHAI owner chot so; anh veto 1 edit thi doi — khuon hmw_width._ratified_by). Don vi KB, reader x1024 = scripts/session_ctx.py mind-check phep-7 (fail-loud exit-3 khi khoa vang — CAM default, khuon H24-2). Nguon so 32 = hub O-4 (hub tu khai 'so chu dat, khong rut tu du lieu'); SE cung nac khai: file _mind dien hinh <10KB (p=1-3 pause/phien-logic, quan sat S147-S152) => 32 = >=3x headroom; re-tune sau dogfood T9 khi co so do that.",
"mind_ctx_kb": 48,
"_mind_ctx_kb_raise_S167": "🔴 OWNER-DECIDED 2026-08-01 (anh: 'Đồng ý phương án nâng trần' — slot (58), anh gõ nhầm số 57, lead khai cách hiểu trong turn): 32→48 vì phiên-LOGIC L9 dài bất thường (5 window, chuỗi 8 wave — file phình theo độ dài MẠCH; đo 30.573B = 93,3% trần cũ, số học MIND-4 2-window thiếu ≥700B dù nén). L10 mở thì file mới về 0 nên không phình vĩnh viễn. Áp lực 'PHẢI nén MIND-2 mỗi cửa' HẾT — nén thành tùy-chọn-khi-tiện (luật di-trú nhãn TỪNG Ý trước nén GIỮ NGUYÊN khi nào nén thì làm).",
"_mind_ctx_kb_set": "🧊 lineage (số 32 cũ): OWNER-DELEGATED → ENGINE-PROPOSED → LEAD-DECIDED @S153 2026-07-26 (adopt thu 0df10df4 khoan (35); lenh pipeline anh 26-07 'fable-real invest → spec → hmw xu ly' = UY QUYEN — KHONG PHAI owner chot so; anh veto 1 edit thi doi — khuon hmw_width._ratified_by). Don vi KB, reader x1024 = scripts/session_ctx.py mind-check phep-7 (fail-loud exit-3 khi khoa vang — CAM default, khuon H24-2). Nguon so 32 = hub O-4 (hub tu khai 'so chu dat, khong rut tu du lieu'); SE cung nac khai: file _mind dien hinh <10KB (p=1-3 pause/phien-logic, quan sat S147-S152) => 32 = >=3x headroom; re-tune sau dogfood T9 khi co so do that.",
"tiep_reload": {
"_note": "🔄 @S153 adopt 0df10df4 khoan (37) RESOLVED (b) — nap-lai NEN RUT-GON mac dinh @/tiep (OWNER-DELEGATED cung vet _mind_ctx_kb_set). Nen-full SE do tuoi @S153 = 596.316B ~ [149.079..198.772] tok (instrument-backfill S153) — nang hon nen hub (439.328B ~ 110K tok) 1.36x, nuot tron rang mem ~150K/cua cua hub-owner neu full => rut gon ~ 40-60K tok (input one-shot cache-duoc). Reader = nghi-thuc /tiep (AI doc danh sach nay qua tiep.md §3-bis TRO KHOA — B1 lenh KHONG chep danh sach); KHAI THANG: chua co script-consumer — KHAC class ghost-wire vi hanh-vi tieu thu moi luot /tiep + dogfood T9 do that. Pham-vi tung muc khoanh ngay trong sources (khuon hub 'do theo pham-vi tung muc da khoanh trong cau-hinh').",
"mode": "rut-gon",
@ -56,6 +57,7 @@
"file": "archive/_INDEX.md",
"injected": "read-on-demand map (inject the map, not the territory)",
"cap_bytes": 20480,
"split_rule_S167": "owner 'auto' 2026-08-01: cap applies to the MAIN archive/_INDEX.md only (the cold-load file). Rotated _INDEX-<period>.md = cold tier, no cap, still A7-verified (gate reads _INDEX*.md; measure counts both into l2_index axis; shard-probe excludes both). AUTO-rotate rule lives in the _INDEX.md header of the affected sub (first: cicd-monitor). Probe (nhip-no-probe.ps1) deliberately measures MAIN file only - by design, do not 'fix' it to sum both.",
"seeded_from": "max measured _INDEX = cicd-monitor 16779B (+ ~22% headroom). cicd-monitor at ~82% = WATCH-agent (grows with each run); when it nears cap -> gist-of-index or split, do NOT drop markers",
"pointer_style": "substring (git-sha / Run#NNN / unique-phrase keyed), fallback Ctrl-F. NO line-hints (additive appends shift lines)"
},

View File

@ -6,6 +6,8 @@
- **S166 (08-01) gate K4c chốt cụm-2 (FE lọc nhóm WfView + 1 Fact test) — PASS 0 blocker (1 CLARIFY + 3 MINOR):** ① 🔴 **flake chỉ lộ ở LƯỢT ĐẦU, và chính tôi giết mất chứng cớ**: chạy `dotnet test -v q` SONG SONG với `tsc` ⇒ 1 test CŨ đỏ (`Failed 1/8`) rồi 10/10 xanh sau đó + suite 622/0 ⇒ `-v q` **nuốt thông điệp assert** ⇒ có tên test mà không có lý do. Bài: lượt chạy ĐẦU dùng verbosity thường, `-v q` để dành cho lượt lặp. Truy nhiễm phải đo 3 vế mới dám nói "không do diff": fixture `:memory:` per-test · codegen **0 static** · xUnit 2.9.3 same-class = tuần tự. Báo "8/8 ở 11/12 lượt", KHÔNG báo 8/8 tuyệt đối. ② **probe RED bằng đổi TEST-INPUT (không chạm prod) là hợp lệ CHỈ KHI tham số xuất hiện ĐÚNG 1 CHỖ trong handler** — grep xong mới được chấp nhận; ≥2 chỗ là lập luận sập. ③ `Total` là dấu-vân-tay lọc-SERVER **chỉ khi** `CountAsync` chạy trên `q` SAU `Where` và TRƯỚC `Skip/Take` — đọc `ProjectPagedAsync:790-792` mới dám ký, đừng tin comment. ④ **queryKey KHÔNG thêm trục là ĐÚNG** khi BE endpoint không nhận trục đó (đọc controller: chỉ `applicableType`+`isUserSelectable`) + giá trị lọc là **dẫn xuất trong render** (không `useState`) ⇒ miễn nhiễm bẫy "đổi leaf không remount". ⑤ Guard `/^[1-8]$/` chặn `Number("03")=3`, và **đồng nhất 3 trang** KHKK — kiểm cả 3, lệch 1 chỗ là 2 luật cho 1 tham số. ⑥ Lane khai bundle `1,643.96 kB` — tôi chạy lại `npm run build`, **trùng từng chữ số** ⇒ claim đo-lường có thật. Tag `[s166, chung-co-bi-nuot-boi-verbosity, probe-1-cho-moi-hop-le, total-la-van-tay]` **Evidence:** `runs/2026-07-31-S164-4gd-khkk-fanout/sub-reviewer-gate-k4c.md`
- **S167 (08-01) gate K6 admin thu-gọn (1 file FE, tập 12 key ẩn) — PASS-WITH-FLAGS 5 (1 MAJOR + 1 CLARIFY + 3 MINOR):** ① 🔴 **cây menu PROD SỐNG là thước, seed chỉ là bản vẽ**: `curl /api/menus/me` (token admin, 44KB) → áp CHÍNH tập key **cắt bằng regex từ file đã ship** ⇒ 200 node · 166 lá → **15 lá**, trùng tên 15/15 + control-âm bỏ `KeHoachKyKet` = 63 (+48) khớp lane. Cùng payload đóng luôn FLAG "row chèn tay": **10 `AwV2*`, 0 `AwV2_Contract`**. Khai giới hạn: endpoint đã lọc-quyền ⇒ row không có Permission thì vô hình (nhưng cũng vô hình trên sidebar ⇒ không lệch C1). ② **`filterForAdmin` mới là bước MỘT**: lá còn "THẤY" phải qua tiếp `resolvePath→null ⇒ MenuLeaf drop im lặng` (#50) và **group rỗng-hoá tụt xuống render như LEAF** (`children.length>0`) — lane bỏ cả 2, tôi đo: 0 drop · 0 rỗng-hoá. ③ **typo-noop**: key ẩn sai chính tả = `Set.has` false, module vẫn hiện, tsc vẫn xanh ⇒ phải assert từng key TỒN TẠI trong cây prod (12/12 OK; control-âm 3 key bịa đều MISS). ④ **MAJOR: comment mới nhồi 3 số-đếm, 1 số SAI ngay lúc land** — "28 leaf `Ct_`" (thật 42 lá/49 key, S155 thêm WfView/Approved/Deleted), số chép từ skill `permission-matrix` stale ⇒ đúng bệnh #84(4) "CẤM số-đếm trong comment". ⑤ **CLARIFY hệ-quả không ai khai**: prod có `Hrm/Off/Personal` `isVisible=false`, fe-user lọc `isVisible!==false` còn fe-admin KHÔNG đọc cờ đó ⇒ admin là lối vào CUỐI, ẩn nốt = 3 module mất menu ở CẢ 2 app. ⑥ display-only chứng 3 tầng: diff (grep `+` chỉ hit COMMENT) < kiến-trúc (`usePermission:15` đọc cây THÔ, chỉ 2 consumer) < curl prod 200×2 endpoint đã ẩn. Tag `[s167, thuoc-la-cay-prod, filter-moi-la-buoc-mot, typo-noop, so-dem-trong-comment]` **Evidence:** `runs/2026-07-31-S164-4gd-khkk-fanout/sub-reviewer-gate-k6.md`
- **[→ archive/2026-07.md verbatim ĐÃ VERIFY moved-not-cut 4/4 @S166-curate] S164/S165 gate K3 · S164 gate K1 · S162 cây-4-folder · S161-W2 KHKK-CRUD (digest):** tham-số-chết-vì-thiếu-tầng-UI (đo TẦNG-CUỐI ai GỬI) · sweep 2-cờ bất-đối-xứng · flake "N/0 tươi" phải khai SỐ LƯỢT · giá-trị lớn nhất GATE QUY-TRÌNH không · **ghost-policy** (grep policy, đừng suy từ "key đã vào All") · default-lấn-ngữ-nghĩa · im-lặng-về-độ-chính-xác-của-con-số ×3/diff · **#82 ngược chiều** (FE OR nhiều key LỎNG HƠN policy khớp-CHÍNH-XÁC-1-key tự chuốc 403) · hợp-đồng FEBE VỠ 6 điểm tsc+build+574-test XANH (2 lỗi CHE NHAU) so ROUTE+FIELD trên ĐĨA.
- **[→ archive/2026-07.md verbatim, verify moved 5/5 @S166-curate] S159-đợt5 · S161-W1 · S155-đợt2 · S145-4lane · S143 · S139 · S134b · S133 · S146/S147/S152-D2 (digest gộp):** chú-thích-mang-SỐ phải chạy phép đếm · cookie-cutter soi CẢ phần bản gốc đã CHẶN · `tsc`+`noUnusedLocals` > grep cho claim 'đã cắt' · comment 'bên kia lo' = ĐI ĐỌC BÊN KIA · grep bằng TOKEN-SONG-SINH · `hợp-đồng-đứt-2-bờ` · đổi-chiều-assert ≠ nới-assert · bất-biến chia-đôi 2-axis chỉ cross-cut sweep bắt · `bằng-chứng-tự-huỷ-sau-squash` · re-measure acceptance từ git HEAD + no-self-exempt · 'mọi nhánh set X' phải grep assignment-qua-BIẾN (#81) · `ExecuteDelete` ngoài transaction = mất dữ liệu · xanh-giả bắt bằng phép 2-thế-giới · 3 THƯỚC-HỎNG (md5 CRLF · `grep -c` citation-trap · `grep -iF` MSYS 0-hit im lặng).

View File

@ -6,7 +6,7 @@
- activity-s51-s52.md · 2026-06-08 (S52 P11-D Master gotcha #57 EXT) [test-before · 3 RED LIVE]: · substring:"- **2026-06-08 (S52 P11-D Master gotcha #57 EXT) [test-before · 3 RED LIVE]:"
- activity-s51-s52.md · 2026-06-08 (S52 P11-E + P11-F WorkflowApps/Attendance test-after · substring:"- **2026-06-08 (S52 P11-E + P11-F WorkflowApps/Attendance test-after"
- activity-s51-s52.md · 2026-06-08 (S51 P11-C HMW Wave2 filtered-unique gotcha #57 · substring:"- **2026-06-08 (S51 P11-C HMW Wave2 filtered-unique gotcha #57"
- baseline-history.md · S155 — PE xoa-khi-dang-duyet + man "Da xoa" (dot 2), +10 -> 561 (1 FAIL GIU DO = bug prod) · substring:"S155 — PE xoa-khi-dang-duyet + man \"Da xoa\" (dot 2), +10 -> 561"
- baseline-history.md · S155 — PE xoa-khi-dang-duyet + man "Da xoa" (dot 2), +10 -> 561 (1 FAIL GIU DO = bug prod) · substring:"S155 (2026-07-27) ĐỢT 2"
- baseline-history.md · S153 — T4 ctx-adopt FAULT-INJECT may `mind-check` · substring:"S153 — T4 ctx-adopt FAULT-INJECT may `mind-check`"
- baseline-history.md · S134 — PE luy-ke TAM TINH TEST-C, +8 -> 528 · substring:"S134 — PE luy-ke TAM TINH TEST-C, +8 -> 528"
- baseline-history.md · S133 INV-C — PE budget-freeze T1-T9, +10 -> 519 · substring:"S133 INV-C — PE budget-freeze T1-T9, +10 -> 519"

View File

@ -0,0 +1,145 @@
CICD-K6: PASS — 5/5
# cicd-monitor — verify deploy K6 (S167, 2026-08-01)
Push range `e202393..1c6d77a` (2 commit: `d82c5a8` wal-flush + `1c6d77a` K6). ĐÚNG 1 file code `fe-admin/src/components/Layout.tsx`. 0-mig · fe-user 0-diff · BE 0-diff (đo: `git diff --name-only e202393..1c6d77a -- fe-user/ src/Backend/ tests/` = **0 file**).
Run **#440** (task id 553) — **success**, 11:54:38 → 12:01:07 (**6m29s**, trong dải quen 5m3x6m3x).
Artifact ghi TỪNG MỤC ngay khi có bằng chứng (luật #53: ghi-đĩa-TRONG-lúc-làm; return chỉ là tóm tắt).
---
## M0 — Mốc PRE (tự đo LIVE lúc run CÒN `running`, KHÔNG tin số chép)
Cửa sổ vàng: bắt được #440`running` lúc **11:55:43** ⇒ bundle LIVE lúc đó = PRE thật.
| App | index-*.js | index-*.css | size js (B) | Last-Modified js | Content-Type |
|---|---|---|---|---|---|
| admin | `index-C2FhHCNT.js` | `index-DqXm5Hpn.css` (86.495) | **1.758.685** | 01 Aug 04:13:05 GMT = **11:13:05 +07** | application/javascript |
| eoffice | `index-DFOCQ2ks.js` | `index-DlN3yZ5L.css` (92.233) | **1.676.752** | 01 Aug 04:14:05 GMT = **11:14:05 +07** | application/javascript |
🔑 **PRE tự chứng là bundle #439 bằng LM, không chỉ bằng hash**: LM 11:13:05 / 11:14:05 nằm TRỌN trong cửa-sổ deploy #439 (11:08:17 → 11:14:33). Trùng baseline đề bài — nhưng trùng đó là KẾT QUẢ ĐO, không phải giả-định đầu vào (bài "mốc PRE lão hoá sau MỖI deploy").
Content-Type `application/javascript` + ~1,7MB ⇒ không dính bẫy SPA-fallback (`text/html` ~900B). File tải về khớp byte-for-byte `Content-Length`.
## M0-bis — Chọn byte-marker CÓ RĂNG (verify 0-hit PRE TRƯỚC khi phong)
Đề bài cảnh báo đúng: K6 = 12 key literal **đã có từ K4a** + comment sẽ bị Vite strip ⇒ marker ngây thơ = 0 bit. Đo PRE xác nhận từng cái:
**LOẠI "key đơn lẻ"** — PRE admin đã có sẵn: `PurchaseEvaluations` **4** · `KeHoachKyKet` **1** · `HopDongCung` **1** · `PeWorkflows` **3** ⇒ POST có hit cũng **0 bit**.
**LOẠI tên biến `ADMIN_HIDDEN_KEYS`** — minifier đổi tên; đo POST: source-name **0 hit**, thực tế thành `var IW=new Set([...])`. (Bài K3 `earlyFinalizeLocked` tái xác nhận.)
**LOẠI comment lineage**`NƠI-CONFIG` / `thắng mốc trước`: PRE **0** POST **0** ⇒ Vite strip đúng như dự đoán.
**CHỌN marker HỢP-CHẤT: TRẬT TỰ KỀ NHAU của 12 literal trong `new Set([...])`.** Từng literal thì cũ, nhưng *thứ tự kề nhau* chỉ sinh ra khi Set mới được minify. Lineage source: `git grep ADMIN_HIDDEN_KEYS` @`e202393` = **0 hit** (exit 1) · @`1c6d77a` = **2 hit** ⇒ marker chưa tồn tại ở mốc PRE.
**Marker ÂM (K6 là DISPLAY-ONLY, không gỡ route):** `/purchase-evaluations`, `/khkk/list`, `/suppliers` PHẢI CÒN trong bundle admin POST.
**Differential:** eoffice kỳ vọng FROZEN (fe-user 0-diff) ⇒ marker phải **0** ở eoffice cả PRE lẫn POST.
---
## Mục 1 — Run Gitea cho `1c6d77a` ✅ PASS
| | |
|---|---|
| head_sha | `1c6d77a6ccd95b2e287a3602f23780558c9d1ecc` (khớp HEAD; `git log origin/main..HEAD` **rỗng**) |
| run | **#440** / task id **553** / `status=success` |
| thời gian | created 11:54:38 → updated 12:01:07 = **6m29s** |
| tail log | `Deploy done. App pool started.``API /health/live -> 200``🏁 Job succeeded` |
`paths-ignore` (7-glob): 8/10 file trong range là `.claude/**` (bị bỏ qua), **nhưng** `fe-admin/**` KHÔNG ⇒ CI đúng ra phải chạy, và nó đã chạy.
## Mục 2 — Gate CI **622 = 45 D + 577 I** ✅ PASS (tách-phần, khớp từng vế)
Đo ở web-UI log `…/actions/runs/440/jobs/0/logs` (18.697 B):
| Project | dòng log | Failed | Passed | Skipped |
|---|---|---|---|---|
| `SolutionErp.Domain.Tests.dll` | L29 | **0** | **45** | 0 |
| `SolutionErp.Infrastructure.Tests.dll` | L47 | **0** | **577** | 0 |
| **Tổng** | | **0** | **622** | 0 |
- Khớp **dự đoán TRƯỚC khi đo**: giữ nguyên 622, delta **0** — đúng: K6 chỉ thêm `Set` + comment, 0 file test trong range.
- **control ÂM:** `grep -c 'Failed!'` = **0**; pattern lỗi = **1 hit duy nhất** là dòng runner vô hại `Failed to setup Pty Unsupported` (L12, có ở mọi run).
- **Flaky đã khai** `DeleteContractSigningPlan_SoftDeletes`: run này **KHÔNG đỏ** (Failed 0). Mốc #439 xanh, #440 xanh ⇒ 2 run liên tiếp sạch, chưa cần re-run.
## Mục 3 — Mig GIỮ **71** + `sys.tables` **97** ✅ PASS (2 nguồn)
- **Nguồn 1 — git:** `git diff e202393..1c6d77a -- '*Migrations*'` = **rỗng** ⇒ 0-mig phía code. Đếm file repo (filter neo-đuôi `Designer\.cs$|ModelSnapshot\.cs$` — bản vá từ K5) = **71**.
- **Nguồn 2 — sqlcmd prod (đo SAU `status=success`, tức sau recycle):**
`MIGTOP=20260731085624_AddKhkkApprovalGroupCatalogAndFinalizeRuntime` (= Mig 71, GIỮ) · `MIGCOUNT=71` · `SYSTABLES=97` (is_ms_shipped=0).
- Anchor DB không nhích (đúng cho FE-only): `MENUITEMS=200` · `PERMS=2221` — y hệt mốc #438.
## Mục 4 — Bundle: admin ROTATE / eoffice FROZEN-hợp-lệ ✅ PASS
| App | asset | PRE | POST | size | LM POST (+07) | trong cửa-sổ deploy? |
|---|---|---|---|---|---|---|
| admin | js | `C2FhHCNT` | **`Ig1R5tAh`** ROTATE | 1.758.685 → **1.758.853** (**+168**) | 11:59:40 | ✅ (11:54:3812:01:07) |
| admin | css | `DqXm5Hpn` | `DqXm5Hpn` FROZEN | 86.495 (giữ) | 11:59:40 | ✅ |
| eoffice | js | `DFOCQ2ks` | `DFOCQ2ks` FROZEN | 1.676.752 (giữ) | **12:00:41** (PRE là 11:14:05) | ✅ |
| eoffice | css | `DlN3yZ5L` | `DlN3yZ5L` FROZEN | 92.233 (giữ) | 12:00:41 | ✅ |
**Admin rotate = ĐẠT điều kiện FAIL-nếu-không** của đề bài. Δ **+168 B** — dương, nhỏ, đúng cỡ 1 `new Set` 12 key backtick (~170 ký tự); comment bị strip nên không cộng thêm.
🔑 **eoffice FROZEN là LÀNH TÍNH — chứng bằng 3 nhân chứng độc lập, KHÔNG phải "đoán vì fe-user 0-diff":**
1. **LM ĐÃ ĐỔI** 11:14:05 → **12:00:41**, nằm trong cửa-sổ #440 ⇒ file **có bị ghi lại** (deploy.yml copy `dist\*` vô điều kiện = #69 mode-A), không phải bundle cũ kẹt lại. *Đây là discriminator quyết định: bundle-cũ-kẹt sẽ giữ LM 11:14:05.*
2. **Log CI in thẳng asset phát ra** — L128 `dist/assets/index-DFOCQ2ks.js 1,676.75 kB` lúc `05:00:41.56` GMT ⇒ runner **ĐÃ BUILD LẠI** fe-user trong CHÍNH run này và Vite phát ra **cùng hash**. LM trên prod = mốc build này **khớp tới từng giây**. Frozen = build-determinism, không phải skip.
3. `cmp` PRE vs POST eoffice = **byte-identical** ⇒ nhất quán với source 0-diff.
(Đối chiếu admin: L93 `dist/assets/index-Ig1R5tAh.js 1,758.85 kB` @ `04:59:40.39` — khớp cả hash lẫn size lẫn LM prod. Log-CI là nhân chứng **thứ 4**, độc lập với curl prod.)
🔑 **Control-ÂM tầng-2 (chứng ghi-đè thật):** GET hash CŨ admin `index-C2FhHCNT.js`**200 `text/html` 900B** ⇒ rơi về SPA-fallback ⇒ bundle cũ đã biến mất khỏi đĩa. (200 ở đây KHÔNG phải "còn sống" — discriminator là Content-Type.)
### 4-bis — Byte-marker: 🔴 SUÝT FAIL-OAN 2 LẦN vì CƠ-CHẾ REGEX, không phải vì code
Marker hợp-chất đo lần đầu ra **PRE 0 / POST 0** — đọc vội thì thành "K6 không ship" ⇒ FAIL. Không phong vội, đi **soi byte thật** (`grep -oE '.{90}HopDongCung.{90}'`) thì thấy dạng minify THẬT:
```
var IW=new Set([`PurchaseEvaluations`,`KeHoachKyKet`,`Contracts`,`HopDongCung`,`Forms`,
`Reports`,`Master`,`Hrm`,`Off`,`Personal`,`Workflows`,`PeWorkflows`]);
```
- **Sai lần 1:** regex của tao chỉ chấp `["']` — rolldown/Vite 8 phát **backtick** (template literal). 0-hit là do regex không thể khớp, KHÔNG phải do vắng mặt.
- **Sai lần 2:** vá xong, truyền pattern qua biến trong `"..."` → backtick bị shell nuốt, `grep -cE` gãy (`grep: Unmatched (`) và trả **0** gần như im lặng trong vòng lặp. Lại một 0-hit giả.
- **Chốt bằng `grep -F` fixed-string** (không còn regex để sai):
| Marker | PRE admin | POST admin | POST eoffice | kỳ vọng | |
|---|---|---|---|---|---|
| Trọn chuỗi 12-key `new Set([...])` | **0** | **1** | **0** | 0→1, eoffice 0 | ✅ |
| Adjacency 3-key `` `PurchaseEvaluations`,`KeHoachKyKet`,`Contracts` `` | **0** | **1** | 0 | 0→1 | ✅ |
| control-ÂM đảo trật tự `` `KeHoachKyKet`,`PurchaseEvaluations` `` | 0 | **0** | 0 | 0 | ✅ |
| control-ÂM chuỗi bịa `ZZZ_NOT_EXIST_MARKER` | 0 | **0** | 0 | 0 | ✅ |
🔑 **COUNT-DELTA — nhân chứng thứ 2, độc lập với adjacency.** Vì literal đã tồn tại từ K4a nên *sự có mặt* = 0 bit, nhưng *số lần xuất hiện tăng* thì đo được. PRE→POST admin, cả **12/12** key đều **+1**:
`PurchaseEvaluations` 4→5 · `KeHoachKyKet` 1→2 · `Contracts` 7→8 · `HopDongCung` 1→2 · `Forms` 3→4 · `Reports` 3→4 · `Master` 4→5 · `Hrm` 28→29 · `Off` 400→401 · `Personal` 2→3 · `PeWorkflows` 3→4 · **`Workflows` 9→11 (+2)**.
Con **+2** duy nhất KHÔNG phải bất thường mà là **kiểm-tra-nhất-quán tự thân**: `Workflows` là substring của `PeWorkflows`, nên Set thêm 1 `Workflows` + 1 `PeWorkflows` ⇒ đếm substring ra +2. Nếu K6 chỉ ship một phần thì hình dạng 12×(+1) với đúng 1 ngoại lệ giải-thích-được này sẽ không dựng lại được.
🔑 **Marker ÂM (display-only, KHÔNG phải gỡ route) — ĐẠT:** `/purchase-evaluations` **68→68** · `/khkk/list` **13→13** · `/suppliers` **16→16**. Route còn nguyên trong bundle admin ⇒ K6 cắt đúng lớp *hiển thị*, không lỡ tay cắt lớp routing.
## Mục 5 — Smoke + phép K6-đặc-thù ✅ PASS
Bearer admin: `POST /api/auth/login` → `accessToken` len **468** ✅
| Endpoint | code |
|---|---|
| `GET /api/contracts` | **200** |
| `GET /api/purchase-evaluations` | **200** |
| `GET /api/menus` | **200** |
| `GET /api/contract-signing-plans` | **200** |
| control-ÂM `GET /api/khong-ton-tai-zzz` (có bearer) | **404** ✅ |
| control-ÂM `GET /api/contracts` KHÔNG token | **401** ✅ |
🔑 **Phép K6-đặc-thù — 2 tầng độc lập (gotcha #82) đo được bằng số:** `GET /api/menus` bearer admin trả **200 item** (khớp `MENUITEMS=200` sqlcmd ⇒ không mất row nào). Trong đó **10/10 root bị K6 ẩn VẪN được BE trả về**: `Contracts`, `Forms`, `HopDongCung`, `Hrm`, `KeHoachKyKet`, `Master`, `Off`, `Personal`, `PurchaseEvaluations`, `Reports` — cộng cả 2 con của `System` là `Workflows` + `PeWorkflows` ⇒ **12/12**.
⇒ Đúng như comment K6 tự khai: **ẩn ở client, KHÔNG thu quyền**. Deep-link + API vẫn 200. Muốn CHẶN thật phải làm ở BE authz.
---
## Verdict
**PASS 5/5.** Run #440 `1c6d77a` success 6m29s · gate **622 = 45 D + 577 I** (Failed 0, delta 0 đúng dự đoán) · Mig GIỮ **71** + `sys.tables` **97**, MenuItems/Perms đứng im 200/2221 · admin ROTATE `Ig1R5tAh` (+168 B) & eoffice FROZEN-**lành-tính** (LM đổi + log CI in cùng hash + byte-identical) · marker 12-key `Set` PRE 0→POST 1 + count-delta 12/12 + marker-ÂM route 68/13/16 nguyên vẹn · smoke 4/4 + 2 control-âm + menu-tree 12/12 key vẫn được BE phục vụ.
**Treo / khuyến nghị (không chặn):**
1. 🔴 **Bài học to nhất run này: 0-hit marker bị SAI 2 lần liên tiếp do cơ-chế regex, cả 2 lần đều trông y hệt "code không ship".** (a) rolldown/Vite 8 minify string → **backtick**, regex neo `["']` không bao giờ khớp; (b) truyền pattern chứa backtick qua biến trong `"..."` làm grep gãy và trả 0 gần như im lặng. ⇒ Từ nay: marker chuỗi trên bundle **dùng `grep -F` fixed-string**, và **luôn trích byte thật quanh literal (`.{90}X.{90}`) trước khi kết luận vắng mặt**.
2. **Kỹ thuật mới dùng được lại — COUNT-DELTA:** khi literal đã tồn tại từ đợt trước (marker "có/không" = 0 bit), đo **số lần xuất hiện PRE→POST**. Ngoại lệ substring (`Workflows` ⊂ `PeWorkflows` ⇒ +2) lại thành kiểm-tra-nhất-quán miễn phí.
3. **Log CI in thẳng `dist/assets/index-<hash>.js <size>`** — nhân chứng thứ 4 cho ship-proof, độc lập hoàn toàn với curl prod, và là cách rẻ nhất phân biệt FROZEN-lành-tính với bundle-kẹt (so LM prod với mốc build in trong log).
**Bundle baseline MỚI (post-#440):** admin `Ig1R5tAh`/`DqXm5Hpn` · eoffice `DFOCQ2ks`/`DlN3yZ5L`. *(Snapshot-per-run — lão hoá ngay ở deploy kế.)*
END cicd-k6 — VERDICT=PASS 5/5

View File

@ -0,0 +1,86 @@
IMPLBE-K7: DONE
# sub-implbe-k7 — Lane BE K7 (chuỗi SP-002 B4, S167 · 2026-08-01)
> Rào: chỉ `src/Backend/**` · KHÔNG FE · KHÔNG tests/ · KHÔNG commit. Trọng tài = `dotnet build` SẠCH.
> Artifact ghi-từng-mục chống-#53: mỗi vá xong ghi NGAY {file:line, 1 câu}.
## §0 — Tiền đề đo TRƯỚC khi gõ (KHÔNG đoán)
**git status @spawn** (sạch phía `src/`): dirty chỉ `.claude/WAL.md · .claude/agent-memory/reviewer/MEMORY.md · .claude/auto-memory/* · .claude/governance/.session-counter.json · .claude/workflows/runs/.../sub-reviewer-gate-k6.md · docs/STATUS.md` = **của lead, bỏ qua**. `src/Backend/**` = 0 file dirty. Branch `main`, HEAD `1c6d77a` (K6).
### 🔴 BÁC TIỀN ĐỀ ĐỀ BÀI — "Bridge KHKK→HĐ ĐÃ TỒN TẠI (W6-S161)" là **SAI** (4 phép đo)
| # | Phép đo | Kết quả |
|---|---|---|
| 1 | `grep -rn "CreateContractFromSigningPlan\|create-contract" src/` | **1 hit duy nhất** = `PurchaseEvaluationsController.cs:339` (bridge **PE→HĐ** cũ). 0 hit KHKK. |
| 2 | `ls src/Backend/SolutionErp.Application/ContractSigningPlans/` | `ContractSigningPlanFeatures.cs` + `Services/`**0 file bridge** |
| 3 | `grep "HttpPost" ContractSigningPlansController.cs` | 4 POST: `""` · `{id}/dossier-items` · `{id}/attachments` · `{id}/transitions`**0 endpoint tạo HĐ** |
| 4 | write-site `ContractSigningPlanLine.ContractId` | **0 assignment toàn codebase** (khớp đo độc lập của invest K7.0(b) @S164 **và** K5 FLAG-1 "`lines[].contractId` không tồn tại ở bất cứ đâu") |
**Cái ĐÃ tồn tại @W6-S161 = "đường ống HĐ V2"** (List/Detail V2-aware `ContractFeatures.cs:306-318` · Inbox V2 `:532-543` · guard trình `ContractWorkflowService.cs:73-92`) — invest K7.0 đã BÁC đúng chỗ này. Lead **nhập nhằng "đường ống đã vá" ⇒ "bridge đã tồn tại"** *(lead đã nhận @lượt-2)*.
### Hệ quả + quyết định lane (khai rõ, không im lặng)
4/5 vá được giao (**vá-1-endpoint · vá-3 · vá-4a · vá-6**) **VẬT LÝ KHÔNG THỰC HIỆN ĐƯỢC** nếu không có bridge — chúng đều là mệnh đề *về thân bridge*. Đặc biệt **vá-6** ("nạp Project.Code + Supplier.Code theo khuôn bridge-cũ `:63-66/:85-86`") là **no-op** nếu áp lên bridge PE cũ: bridge cũ **ĐÃ CÓ** 2 dòng đó ⇒ vá-6 chỉ có nghĩa với **bridge MỚI**.
**Quyết: DỰNG bridge KHKK→HĐ (BE-only) theo spec sẵn có `sub-invest-fable-b2-cum3.md §K7.1` (= K7.6 V1+V2) + nhúng thẳng 5 vá vào lúc dựng.** Chệch-khuôn-so-brief KHAI RÕ, trỏ đề xuất gốc (invest `:23-56` pin sẵn command signature · 6 guard · field-map · codegen · write-site · changelog · controller ⇒ **0 quyết-định-thiết-kế mới của tôi**). **KHÔNG làm:** FE (V4/V5/V6 → lane FE), test (V3/V7 → test-lane), migration (cột `ContractId` có sẵn từ Mig 69 + index `ContractSigningPlanLineConfiguration.cs:26`**0-mig**).
### Tiền đề phụ thuộc cụm-1 — ĐO, ĐỦ
- K2 Mig 71: `ContractSigningPlanLine.TenHangMuc` (`:27`) + `CatalogEntryId` (`:23`) ✅ trên đĩa.
- K3 finalize: write-site `ApprovedAmount``ContractSigningPlanWorkflowService.cs:348` (`line.ApprovedAmount ??= line.ProposedAmount`) ⇒ happy-path bridge có số để đọc.
---
## §1 — [vá-1a] Grant `Contracts.Create` (seeder, code-first)
- **`DbInitializer.cs:2757-2822`** — hàm MỚI `SeedContractCreateAccessAsync`: cấp `CanRead+CanCreate` trên key root **`Contracts`** cho **Drafter + Procurement + Admin**, UPGRADE-ONLY (row có sẵn → nâng đúng 2 cờ, KHÔNG hạ cờ nào, KHÔNG đụng Update/Delete; row chưa có → tạo). Khuôn sao y `SeedKeHoachKyKetAccessAsync:2672` (W1 S161).
- **`DbInitializer.cs:2361`** — call site, đặt **SAU** `RevokeTemporarilyHiddenModulesAsync:2341` (luật "chạy sau revoke để THẮNG", cùng chỗ 2 seeder anh em `:2347`/`:2354`).
- **Lỗ đã đóng (đo được, không suy đoán):** `SeedAllRolesReviewReadPermissionsAsync:2516` tạo row key `Contracts` với `CanCreate = isPe``isPe = IsPeKey("Contracts") = false`**trước S167 KHÔNG role nào mang `Contracts.Create`**; chỉ Admin lọt nhờ bypass. Không có §1 thì nút bridge 403 với 13/13 role.
- **Câu III (ai bấm) = default, chờ ratify:** comment `[K7 — câu III default, chờ anh ratify]` ngay trên mảng `roleNames` — anh chốt khác thì sửa ĐÚNG 1 mảng đó, **máy BE không đổi** (controller giữ nguyên).
- ⚠️ **Grant này ăn @RESTART API prod (seeder), KHÔNG phải deploy FE.** CẤM SQL tay (#84 — grant tay bị revoker lật, bài S159 Run #423 mất 447/494 row). Nghiệm thu = restart THẬT rồi đếm row `Permissions WHERE MenuKey='Contracts' AND CanCreate=1` (#75/#76).
## §2 — [vá-1b] Endpoint bridge = HỘI 2 khóa
- **`ContractSigningPlansController.cs:230-238`** — `POST /api/contract-signing-plans/{id}/create-contract`, `[Authorize(Policy = "Contracts.Create")]` (action) **∧** class-level `[Authorize(Policy = "KeHoachKyKet.Read")]` `:28` (đã có sẵn từ W2 — đo, không thêm). ASP.NET Core cộng dồn class+action theo **AND** ⇒ thiếu 1 trong 2 vế = **403**. Trả `201 Created` + `{ contractId, maHopDong }`.
- **`:262-266`** — body record `CreateContractFromSigningPlanBody(LineIds?, ContractType, ApprovalWorkflowId, TenHopDong?)`; `PlanId` lấy từ **route** (1 nguồn sự thật). `LineIds` nullable ở body để thiếu-field ra **400 validator** ("chọn ít nhất 1 dòng") thay vì 500 NRE.
- **`:8`** — `using SolutionErp.Domain.Contracts;` cho `ContractType`.
- **Chọn key theo gotcha #85** (đúng-key-của-endpoint): tài nguyên SINH RA là `Contract` ⇒ vế ghi phải là `Contracts.Create`, KHÔNG phải `KeHoachKyKet.Create`. Đối chiếu PE controller per-action: `PurchaseEvaluationsController` **class `[Authorize]` TRẦN + 0 policy per-action** (`:15`, `:339-343`) ⇒ bridge PE cũ chỉ cần đăng-nhập; cửa MỚI này chặt hơn hẳn — **KHÔNG đụng cửa cũ** (`POST /api/contracts` `[Authorize]` trần = #82 treo chủ đích).
## §3 — [vá-3] Guard workflow pin `IsActive ∧ IsUserSelectable`
- **`CreateContractFromSigningPlanFeatures.cs:132-148`** — "Rào (6)" **4 vế**: (a) tồn tại → `NotFoundException`; (b) `ApplicableType == Contract(3)` → Conflict; (c) `IsActive`; (d) `IsUserSelectable`.
- (a)+(b) = khuôn `ContractFeatures.cs:79-87`; **(c)+(d) là phần VÁ** — khuôn gốc thiếu, nên forge POST pin được quy trình đã retired/bỏ ghim (dropdown FE lọc 2 cờ `ContractCreatePage.tsx:334-341` nhưng FE không phải rào).
- 2 cờ **throw TÁCH RIÊNG** (không gộp `||`): "đã ngừng hoạt động" và "chưa được ghim" xử ở 2 nút khác nhau trong Designer ⇒ admin phải đọc được nguyên nhân; test-lane cũng phân biệt được 2 nhánh.
## §4 — [vá-4a] `Line.ContractId` write-site + DTO expose
- **WRITE-SITE:** `CreateContractFromSigningPlanFeatures.cs:237-238``foreach (var l in lines) l.ContractId = contract.Id;` = **assignment ĐẦU TIÊN toàn codebase** (§0 phép đo 4 xác nhận trước đó = 0). Đóng nợ K5 FLAG-1 "chỗ-cắm-1-dòng".
- **FALLBACK giữ nguyên chiều:** `:246` `if (pe is not null) pe.ContractId ??= contract.Id;``??=` CÓ CHIỀU đúng như đề bài yêu cầu: HĐ đầu của gói thắng ô đơn, HĐ 2..N **không ghi đè** (chúng vẫn thấy được qua `Line.ContractId` — đúng lý do vá-4a tồn tại). Bridge PE cũ gán thẳng `= contracts[0].Id` (`CreateContractFromEvaluationFeatures.cs:145`) vì đã chặn ở `:59-60`; ở cầu MỚI phải `??=` để 2 cầu cùng chạy trên 1 PE không cướp ô của nhau.
- **DTO — trả lời chính xác câu "đã có hay chưa" (2 DTO KHÁC NHAU, đừng gộp):**
1. 🟢 **`ContractSigningPlanLineDto.ContractId`** (chi tiết phiếu) = **ĐÃ CÓ SẴN** từ W2-S161: khai báo `ContractSigningPlanFeatures.cs:74`, projection `:657`. ⇒ **lane FE K7 KHỎI CHỜ** — dialog "Đưa vào Hợp đồng" đọc `detail.lines[].contractId` để render badge "Đã có HĐ" + disable là **dùng được NGAY**, tôi không phải thêm gì.
2. 🆕 **`ContractSigningPlanListItemDto.ContractIds` (`List<Guid>`)** = **THÊM MỚI** — khai báo `:65-75` (append-CUỐI đúng convention file), projection `:836-842` (đặt trong `ProjectPagedAsync` = **1 site dùng chung 3 endpoint** list/inbox/deleted). Lý do phải có: cây 4-folder `usePipelineStages.ts:287-291` dựng GĐ3 từ payload **LIST** và hôm nay chỉ có 1 nguồn `pe.contractId` đơn ⇒ gói N-HĐ thì HĐ thứ 2+ vô hình. Trả mảng **PHẲNG** (consumer là `Set<string>`), KHÔNG `Distinct()` trong projection (N dòng gộp 1 HĐ ra ID trùng — vô hại, đỡ 1 tầng dịch SQL).
## §5 — [vá-6] Project.Code/Supplier.Code + SlaDeadline + WorkflowDefinitionId
- **`:150-157`** — nạp `Project` + `Supplier` ĐỦ (không `Select` mỏng) + **2 guard `NotFoundException`** theo khuôn bridge cũ `:63-66/:85-86`; 2 con trỏ này là **loose-Guid không FK vật lý** ⇒ thiếu guard = NRE ngay tại `GenerateAsync`.
- **`:212`** — `contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct)` gọi **TRƯỚC `db.Contracts.Add`** (bài S88: codegen tự-commit SERIALIZABLE + SaveChanges nội bộ, Add trước = flush HĐ dở trong transaction của nó). Cùng thứ tự bridge cũ `:112-113` + `CreateContractSigningPlanCommandHandler:426-429`.
- **`:203-206`** — field-map `SlaDeadline = UtcNow + GetPhaseSla(DangSoanThao) ?? 7d` (thiếu thì đây là HĐ DUY NHẤT trong hệ không có hạn phase; cả 2 tiền lệ `ContractFeatures.cs:110` + bridge cũ `:109-110` đều set).
- **`:159-166` + `:201`** — `WorkflowDefinitionId = activeWfId` (V1 active theo `ContractType`) pin **NGANG** 2 tiền lệ `ContractFeatures.cs:105-109` ("Cả 2 set ok") + bridge cũ `:108`. 🔴 **Chỗ này spec-cụm-3 vá-6 THẮNG invest K7.1** (invest `:46` viết `WorkflowDefinitionId = null`) — đúng luật "16 vá thắng khi xung đột với nền" (spec `:4`); để null là rơi về policy hardcode `ContractFeatures.cs:647-662`.
- Field-map còn lại theo invest `:46`: `GiaTri = Σ ApprovedAmount(ĐÚNG tập LineIds)` `:180` · `SupplierId` từ dòng · `ProjectId/DepartmentId` từ plan · `ApprovalWorkflowId` pin V2 `:202` · `BudgetManual*` = Σ `PeReferenceAmount` `:198-199` · 2 changelog (`ContractChangelogs` Insert + `ContractSigningPlanChangelogs` Update/EntityType=Line) `:216-235` · **1 `SaveChangesAsync` cuối** `:248` (atomic, 0 HĐ dở-dang).
- 6 guard tuần tự (invest `:38-44`): phase DaDuyet `:106-108` · dòng ∈ phiếu `:111-118` · 1-HĐ-1-NCC `:121-124` · `ApprovedAmount != null` **message NÊU TÊN hạng mục** `:127-141` (KHÔNG fallback `ProposedAmount`) · `ContractId == null` per-dòng (chống double-bridge) · workflow §3.
## §6 — Build + runtime check
- **`dotnet build SolutionErp.slnx`** → **Build succeeded · 0 Warning · 0 Error** (67s). Diffstat `src/`: `ContractSigningPlansController.cs +33` · `ContractSigningPlanFeatures.cs +23/-2` · `DbInitializer.cs +86` · **file mới** `CreateContractFromSigningPlanFeatures.cs` (~250 dòng). **0 file ngoài `src/Backend/**`.**
- **Runtime check (câu hỏi thật: collection-subquery mới có dịch được trên SQLite fixture không?):**
`dotnet test tests/SolutionErp.Infrastructure.Tests --filter "FullyQualifiedName~ContractSigningPlan"`**34 passed / 1 failed / 35 total (12s)**.
**Cả 3 handler đọc `ProjectPagedAsync` đều XANH** (`GroupAndCatalogTests.ListAsync` · `CrudTests.ListDeletedAsync` · `ApprovalTests.InboxAsync`) ⇒ `ContractIds` subquery **dịch được**, không phải sửa gì. (Khuôn giống `WinnerSupplierNames` list PE — nay có bằng chứng CHẠY THẬT, không chỉ tiền lệ.)
- 🔴 **1 FAIL — ĐÚNG DỰ KIẾN, thuộc lane test-specialist, TÔI KHÔNG SỬA (rào split):**
`tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs` :: `ContractSigningPlansController_EveryWriteEndpoint_HasAuthorizePolicy`.
Nguyên nhân chính xác: `:398` `parts[0].Should().Be(MenuKeys.KeHoachKyKet)` — test bắt **MỌI** action của controller phải gate bằng key `KeHoachKyKet`; endpoint bridge cố ý gate `Contracts.Create` (đó CHÍNH LÀ vá-1, và là gotcha #85 đúng-key-của-endpoint).
**Bàn giao test-lane:** thêm ngoại lệ cho action `CreateContract` (kỳ vọng `Contracts.Create`), giữ nguyên luật cho 15 action còn lại. Neo chống-vacuous `:375` (`>= 15 action`) **KHÔNG vỡ** — nay 16 action. Đề nghị bổ sung 2 test authz 2 chiều (đủ 2 vế → 201 · thiếu 1 vế → 403) theo spec `:9`.
- **Chưa làm / không thuộc lane (khai để không ai tưởng đã xong):** T1-T8 (test-lane) · nút + dialog ×2 app (lane FE) · A1-A7 acceptance sau deploy · FE type mirror `khkk.ts` cho `contractIds` (lane FE — JSON thừa field không gãy TS hiện tại).
END implbe-k7 — VERDICT=DONE (bridge KHKK→HĐ BE dựng xong + 5/5 vá land · build 0/0 · 34/35 test chạm, 1 fail đã-verify thuộc test-lane · 0-mig · 0 file FE/tests · chưa commit)

View File

@ -0,0 +1,82 @@
IMPLFE-K7: DONE — lane code-trọn (2 lượt #53-garble, resume-1 + lead-close-on-behalf từ đĩa)
# Lane FE K7 — bridge KHKK→HĐ (vá-1 FE · vá-16 · vá-4b) — S167 2026-08-01
## §0 ĐO TRƯỚC KHI GÕ (đĩa, không đoán)
**git status TRƯỚC-Write** (`git status --short`): 8 file dirty + 1 untracked, **TẤT CẢ thuộc `.claude/*` + `docs/STATUS.md`** (của lead) — `src/Backend`, `fe-admin`, `fe-user` **SẠCH 0 dòng** (`git status --short -- src/Backend fe-admin fe-user` → rỗng). HEAD = `1c6d77a [CLAUDE] FE-Admin: K6 …`.
**Đo hiện trạng nút bridge trên màn KHKK (2 app):**
```
grep -rn -i -E "vào HĐ|create-contract|bridge|contractId" fe-user/src/pages/khkk fe-admin/src/pages/khkk
→ 0 HIT ⇒ CHƯA CÓ nút bridge nào (W6-S161 KHÔNG dựng phần này)
```
Tiền lệ DUY NHẤT đang sống = bridge PE: `POST /purchase-evaluations/{id}/create-contract`
(`PurchaseEvaluationsController.cs:339-343`, body `CreateContractFromEvaluationBody:379`), FE gọi ở
`components/pe/PeDetailTabs.tsx:2186`.
**Baseline SHA256 (trước sửa) — 3 cặp mirror ĐỀU identical:**
| file | SHA256 (cả 2 app) |
|---|---|
| `src/hooks/usePipelineStages.ts` | `edff9931…94a19ac` |
| `src/types/khkk.ts` | `7a99b865…6f22b6` |
| `src/pages/khkk/KhkkDetailPage.tsx` | `1863153b…95f16be` |
**Chữ ký hook quyền THẬT** (`src/hooks/usePermission.ts:14-26`, 2 app identical):
`usePermission()` trả `{ can(menuKey, action: CrudAction = 'Read') }`**KHÔNG** phải
`usePermission('X','Y')` như đề bài viết. Dùng `const { can } = usePermission()` rồi
`can('KeHoachKyKet','Read') && can('Contracts','Create')`.
---
## §1 [vá-4b] Cây GĐ3/GĐ4 nối qua TẬP — `usePipelineStages.ts` (fe-user XONG, `npx tsc -b` EXIT 0)
**Trạng thái đo được TRƯỚC:** K5 (S166) đã đổi HÌNH DẠNG sẵn (`Set contractIdsOfPackage` + ghi chú
"chỗ cắm nguồn thứ hai") nhưng **chỉ có 1 nguồn** `pe.contractId`. Lượt này CẮM nguồn 2.
- `types/khkk.ts`: +`KhkkListItemDto.lineContractIds?: string[] | null` (OPTIONAL — xem KHỚP-DTO)
+`khkkPlanContractIds(plan)` gom 2 nguồn (`lineContractIds` `lines[].contractId`) — **1 hàm duy
nhất** để BE đổi tên thì sửa 1 chỗ, không vá rải.
- `usePipelineStages.ts`: `contractIdsOfPackage` = `pe.contractId` (legacy) **** `khkkPlanContractIds(k)`
cho mọi phiếu KHKK của gói, dedupe bằng `Set` (2 nguồn CỐ Ý chồng nhau ở HĐ đầu).
`fallbackName` tra CẢ 2 nguồn (HĐ đến từ KHKK thì `peItems` không trỏ tới nó → trước đây rơi về `null`).
- **Sai-thầm-lặng → thấy được:** BE chưa trả field ⇒ nguồn 2 im, mà "im vì chưa nối" trông y hệt "im vì
gói chưa có HĐ". `console.warn` DEV-only **1 lần/phiên** (cờ module `warnedMissingLineContractIds`)
khi có phiếu `DaDuyet`**mọi** phiếu `lineContractIds == null`. BE trả `[]` (gói chưa có HĐ) thì IM
— phân biệt được vắng-field ⟂ rỗng-thật.
### 🔴 KHỚP-DTO (lead đối chiếu khi lane BE trả)
| Vế | Đo được trên đĩa lúc 2026-08-01 | FE làm gì |
|---|---|---|
| `KhkkLineDto.contractId` | ✅ **ĐÃ CÓ 2 phía**: FE `types/khkk.ts:201` · BE `ContractSigningPlanLineDto:74 Guid? ContractId` | không phải thêm gì |
| Line-contract-id trên **LIST** payload | ❌ **KHÔNG CÓ**: `ContractSigningPlanListItemDto:34-64` (17 field) không mang contract-id nào; `KhkkListItemDto` cũng không có `lines[]` | FE thêm **`lineContractIds?: string[]`** (optional) |
| Endpoint bridge | ❌ chưa tồn tại lúc đo (`0 hit` grep `create-contract` phía KHKK) | FE gọi `POST /contract-signing-plans/{id}/create-contract` theo `sub-invest-fable-b2-cum3.md §K7.1:23,54` |
⚠️ **Tên `lineContractIds` là FE chọn** (BE-lane K7 sở hữu tên thật). Nếu BE đặt khác ⇒ sửa **đúng 1 hàm**
`khkkPlanContractIds()` trong `types/khkk.ts` ×2 app. Nếu BE **không** expose trên list ⇒ cây vẫn chạy
bằng nguồn legacy + có chuông DEV (không vỡ, không im).
## §2 KHỚP-DTO ĐÓNG (lead đối chiếu 2 artifact @S167)
BE-lane expose **`ContractIds`** trên list DTO (`ContractSigningPlanFeatures.cs:65-75/:836-842` — sub-implbe-k7.md §4).
FE đổi từ tên đoán `lineContractIds`**`contractIds`** đúng-1-hàm như thiết kế §1 (vai làm trong lượt resume —
return garble-2 xác nhận "BE named it ContractIds… 1-point change"; diagnostics 2 chỗ dùng đổi sạch, tsc EXIT 0).
Chuông DEV vắng-field ⟂ rỗng-thật (§1) giữ nguyên.
## §3 Nút bridge + dialog (vá-1FE + vá-16) — DISK-FACT, chi tiết để gate-K7 đọc diff
`KhkkDetailPage.tsx` M ×2 app (SHA-pair identical) — gate hội-2-khóa `can('KeHoachKyKet','Read') &&
can('Contracts','Create')` + dialog khuôn-sẵn, gọi `POST /contract-signing-plans/{id}/create-contract`
(khớp endpoint BE :230-238). ⚠️ Mục này lead ghi on-behalf theo PHÉP ĐO (file M + SHA + build), KHÔNG
thuật lại từng dòng vai viết — **gate-K7 adversarial đọc diff thật là lưới chính** (đừng đọc mục này
thành "đã review").
## §4 Chứng máy (lead đo @S167, sau khi vai dừng — trọng tài build TƯƠI #68)
- SHA-pair 3/3 identical: `usePipelineStages.ts` `76e1823345d4` · `KhkkDetailPage.tsx` `cf0a2272061f` · `types/khkk.ts` `60ec8fa3a5ae`
- `npx tsc -b` fe-user EXIT 0 · fe-admin EXIT 0 (chạy SAU khi vai dừng tay)
- `npm run build` fe-user ✓ 921ms · fe-admin ✓ 765ms (warnings chunk-size + INEFFECTIVE_DYNAMIC_IMPORT = PRE-EXISTING)
- `git status` fe-*: đúng 6 file M (3 cặp mirror), 0 file lạ
END implfe-k7 — VERDICT=DONE (lead-closed-on-behalf; gate-K7 là lưới review)

View File

@ -1,29 +1,213 @@
GATE-K6: IN-PROGRESS — đang đo, chưa chốt verdict (dòng-1 sẽ được ghi đè ở lượt cuối).
GATE-K6: PASS-WITH-FLAGS 5 — luật ẩn ĐÚNG và N=15 tái-lập được trên DỮ LIỆU PROD SỐNG (không chỉ trên seed), nhưng comment mới nhồi 3 con-số và 1 trong 3 ("28 leaf") đã SAI ngay lúc land.
# Gate K6 — reviewer adversarial (S167, 2026-08-01)
Đối tượng: diff UNCOMMITTED `fe-admin/src/components/Layout.tsx` (+52/19).
Spec pin: `sub-invest-fable-b2-cum2.md:67-100` (K6 mục 1-4 + acceptance C1-C4).
Lane artifact: `sub-implfe-k6.md`.
Phép đo mạnh nhất của lượt này: **cây menu LIVE PROD** (`GET https://api.solutions.com.vn/api/menus/me`, token admin, HTTP 200, 44.656 byte) đem áp CHÍNH tập key cắt ra từ file đã ship. Lane chỉ đo được từ SEED; tôi đo được thực tế đang chạy.
## R0 — Rào residual-write (đo TRƯỚC)
---
## R0 — Rào residual-write (đo TRƯỚC khi đọc diff)
`git status --porcelain` cho 8 file `M` + 3 file `??`. Trừ nhóm `.claude/*` (governance — ngoài phạm vi gate; trong đó `.claude/WAL.md``.claude/sessions/session-9/_tiep-4.md` là nghi-thức `/tiep` của lead, KHÔNG phải dư-lượng của lane), phần cây mã chỉ có **đúng 1 file**: `fe-admin/src/components/Layout.tsx`.
- `git diff --stat -- fe-admin/src``1 file changed, 52 insertions(+), 19 deletions(-)` — khớp con số lane khai.
- **0 residual-write.** Không có file BE, không có file `fe-user`, không có file test nào bị chạm.
- Đo lại `--stat` một lần nữa ngay trước khi chốt verdict (chống "cây phình giữa review"): vẫn đúng 1 file mã, 52/19.
---
## R1 — 12-key: so TỪNG CHỮ vs spec + chống gõ-nhầm
Tôi **không gõ tay lại** tập key. Tôi cắt nó ra khỏi file đã ship bằng script riêng (`k6-verify.cjs`, regex bắt khối `const ADMIN_HIDDEN_KEYS = new Set<string>([...])`, xoá comment trước rồi mới lấy chuỗi trong nháy đơn — vì comment trong khối có chứa chữ `Ct_*` dễ lọt vào phép trích).
Kết quả trích (n = 12, đúng thứ tự trong file):
`git status --porcelain`:
```
M .claude/WAL.md
M .claude/agent-memory/implementer-frontend/MEMORY.md
M .claude/agent-memory/implementer-frontend/archive/2026-H2.md
M .claude/auto-memory/MEMORY.md
M .claude/auto-memory/feedback_claim_stronger_than_work.md
M .claude/auto-memory/feedback_return_terminates_agent.md
M .claude/governance/.session-counter.json
M fe-admin/src/components/Layout.tsx
?? .claude/sessions/session-9/_tiep-4.md
?? .claude/workflows/runs/.../sub-cicd-verify-k5-k4c.md
?? .claude/workflows/runs/.../sub-implfe-k6.md
["PurchaseEvaluations","KeHoachKyKet","Contracts","HopDongCung","Forms","Reports",
"Master","Hrm","Off","Personal","Workflows","PeWorkflows"]
```
⇒ Ngoài `.claude/*` (governance, ngoài phạm vi gate) chỉ có **đúng 1 file code**: `fe-admin/src/components/Layout.tsx`.
**0 residual-write.** `git diff --stat` khớp lane khai: `71 +++++-----` = 52 thêm / 19 bớt.
(Ghi chú: lane khai "đúng 1 file" trong `git diff --stat` — thực tế `git diff --stat` trần cho 8 file vì `.claude/*` cũng dirty; câu của lane chỉ đúng khi hiểu ngầm là "1 file CODE". Không phải lỗi thực chất, ghi để không ai đọc thành 0-dirty.)
## R1 — 12-key: so TỪNG CHỮ vs spec (đang đo)
So với 12 key spec liệt ở `sub-invest-fable-b2-cum2.md:73-86`: **khớp 12/12, đúng từng ký tự, đúng thứ tự, 0 thừa, 0 thiếu, 0 sai chính tả.**
Luật ghép cũng khớp nguyên văn spec — `Layout.tsx:246`:
```ts
return ADMIN_HIDDEN_KEYS.has(key) || key.startsWith('Ct_')
```
### R1b — Chống "key gõ đúng chính tả nhưng không tồn tại" (typo-noop)
Một key viết sai sẽ **không báo lỗi gì cả**: `Set.has()` trả `false`, module vẫn hiện, `tsc` vẫn xanh. Nên tôi kiểm sự TỒN TẠI của từng key trong cây prod sống:
| Key | Có trong cây prod? | Parent thật |
|---|---|---|
| PurchaseEvaluations · KeHoachKyKet · Contracts · HopDongCung · Forms · Reports · Master · Hrm · Off · Personal | OK (10/10) | `null` (root) |
| Workflows · PeWorkflows | OK (2/2) | `System` |
**Control-âm** (3 key bịa gần giống, để chứng phép kiểm này phân biệt được chứ không phải luôn trả OK): `PurchaseEvaluation` → MISS · `Offi` → MISS · `Personnel` → MISS.
⇒ Cả 12 key đều trỏ vào node có thật; 2 key `Workflows`/`PeWorkflows` đúng là CON của `System` nên bắt buộc phải liệt riêng như spec nói.
### R1c — Tập ẩn có bỏ sót root nghiệp vụ nào không?
Cây prod có **đúng 12 root**: `Dashboard · PurchaseEvaluations · KeHoachKyKet · Hrm · Off · Personal · Contracts · HopDongCung · Forms · Reports · Master · System`. Tập ẩn phủ 10/12; 2 root còn lại là `Dashboard``System` — đúng bằng phần spec CHỪA (`:91`). Không có root nghiệp vụ nào lọt lưới.
Đối chiếu nguồn mã: `DbInitializer.cs:1926-2049` (danh sách `tree`) cũng cho đúng 12 tuple có `Parent = null`, và `DbInitializer.cs:2177`**điểm INSERT menu-row DUY NHẤT** trong toàn bộ backend (grep `new MenuItem|MenuItems.Add` chỉ 1 hit ghi). Nghĩa là mọi menu-row hợp lệ đều sinh từ đúng danh sách này.
---
## R2 — C1: N = 15, đo trên PROD chứ không chỉ trên seed
Cây prod: **200 node · 166 lá trước lọc** (trùng khít con số lane dựng từ seed — hai đường đo độc lập ra cùng một cây).
Áp bộ lọc đệ quy (tôi dựng lại `filterForAdmin` độc lập, dùng tập key cắt từ file):
**15 lá sau lọc**, đúng tên từng mục:
```
Dashboard · Users · Roles · Permissions · MenuVisibility
AwV2_DuyetNcc · AwV2_DuyetNccPhuongAn
AwV2_KhkkN1 · AwV2_KhkkN2 · AwV2_KhkkN3 · AwV2_KhkkN4
AwV2_KhkkN5 · AwV2_KhkkN6 · AwV2_KhkkN7 · AwV2_KhkkN8
```
Trùng 15/15 với danh sách lane khai ở `sub-implfe-k6.md:41-47`.
### R2b — Hai phép kiểm lane KHÔNG làm, mà C1 thật sự cần
C1 hỏi "**còn THẤY** bao nhiêu lá", mà `filterForAdmin` mới là bước một. Còn hai bước nữa mới ra được cái mắt nhìn thấy:
1. **`resolvePath` trả `null``MenuLeaf` return null ⇒ lá biến mất im lặng** (`Layout.tsx:331-333`, gotcha #50). Tôi kiểm từng lá trong 15 lá: 5 lá đầu nằm trong `staticMap` (`Dashboard:56`, `Users:63`, `Roles:64`, `Permissions:65`, `MenuVisibility:66`); `AwV2_DuyetNcc`/`AwV2_DuyetNccPhuongAn` đi nhánh `:178`; 8 lá `AwV2_KhkkN{1..8}` đi nhánh `:188`. **0 lá bị drop.** Vậy 15 lá lọc được cũng đúng là 15 lá hiện ra.
2. **Nhóm bị rỗng-hoá**: `MenuNodeRenderer:256` phân loại theo `children.length > 0`, nên một GROUP sống sót mà mất sạch con sẽ tụt xuống render như LEAF. Tôi so cây trước/sau lọc theo từng node: **0 group bị rỗng-hoá**. (`System` giữ 5 con: 4 leaf quản trị + `ApprovalWorkflowsV2`; `ApprovalWorkflowsV2` giữ đủ 10 con.)
### R2c — Control-âm (chứng phép đếm phân biệt được, không phải luôn ra 15)
| Bỏ key khỏi tập | Lá sau lọc |
|---|---|
| `KeHoachKyKet` | 63 (+48) |
| `Master` | 29 (+14) |
| `Off` | 29 (+14) |
| `Personal` | 16 (+1) |
| tập RỖNG (giữ mỗi luật `Ct_`) | 125 |
Con số `+48` trùng khít control-âm lane khai (15 → 63). Phép đo có răng.
---
## R3 — C2 (fe-user 0-diff) và C4 (không chạm route/quyền)
**C2 —** `git diff --stat -- fe-user/src`**rỗng, 0 file**. Control-dương chạy cùng lệnh: `git diff --stat -- fe-admin/src` → 1 file. Vậy "rỗng" là rỗng thật, không phải lệnh hỏng.
**C4 —** ba tầng chứng, tầng sau mạnh hơn tầng trước:
1. *Trong diff:* grep các dòng `+` tìm `usePermission|PermissionGuard|Route|navigate|path:|element=` → đúng **1 hit, và là dòng COMMENT** ("Route fe-admin còn nguyên…"), 0 dòng mã. Control-dương: `ADMIN_HIDDEN_KEYS` cho 2 hit.
2. *Trong kiến trúc:* toàn fe-admin chỉ có **2 nơi** đọc `menu` từ `AuthContext``Layout.tsx:362` (lọc, chỉ để render) và `usePermission.ts:15` (**đọc cây THÔ, không lọc**). Nghĩa là `can()``PermissionGuard` hoàn toàn không biết K6 tồn tại. Đây mới là chứng cấu trúc cho "display-only", mạnh hơn việc diff không chạm route.
3. *Trên prod, bằng curl thật* (token admin):
| Verb | Endpoint | Kỳ vọng | Thực tế |
|---|---|---|---|
| GET | `/api/menus/me` | 200 | **200** |
| GET | `/api/purchase-evaluations?page=1&pageSize=1` | 200 | **200** |
| GET | `/api/contract-signing-plans?page=1&pageSize=1` | 200 | **200** |
Hai module vừa bị ẩn khỏi menu vẫn trả 200 cho admin ⇒ đúng là bớt lối đi, không thu hồi quyền. Route đích cũng có thật trong `fe-admin/src/App.tsx`: `/purchase-evaluations` (`:88`) và `/khkk/list` (`:108`) — hai ví dụ mà comment mới nêu đích danh đều là route sống, không rơi vào catch-all `:124`.
---
## R4 — Chất lượng comment (mục 4 của gate)
Ba thứ gate yêu cầu thì comment ĐỦ CẢ BA:
- **Lineage 2 đảo, đủ 3 mốc** — `:203` S29 ẩn → `:204-205` S57 bỏ ẩn (đảo lần 1) → `:206-207` S164 QĐ7 ẩn lại (đảo lần 2).
- **Câu chốt** — `:209-210`: *"cả hai đều từng đúng, mốc sau thắng mốc trước. Đừng 'khôi phục' S57 vì tưởng gặp bug."*
- **Display-only #82 khai rõ** — `:212-216`, có đủ vế "muốn CHẶN thật thì phải làm ở BE authz `[Authorize(Policy=…)]`". Ràng buộc ngược với `resolvePath` cũng được giữ lại (`:224-226`), nên khớp nối K4a không mất.
Nhưng vế thứ tư thì hỏng:
**MAJOR-1 — comment nhồi 3 con-số, 1 số đã sai ngay lúc land.** Gotcha #84 mục (4) viết thẳng: *"Comment quanh seeder nêu LUẬT + lệnh grep tự-kiểm, CẤM ghi số-đếm (số thành nợ tự-lan — 2 lần lệch trong 1 ngày @S159)"*. Diff đưa vào 3 số:
| Vị trí | Câu | Đo trên prod | Phán |
|---|---|---|---|
| `:229` | "Khkk_G1..G8 + **48 leaf** chết theo" | 48 lá dưới `KeHoachKyKet` | ĐÚNG |
| `:221` | "**42 leaf** `Khkk_G{2..8}_*`" | 42 | ĐÚNG |
| **`:245`** | "mở lại root thì **28 leaf** không ùa về theo" | **42 lá** (49 key `Ct_*` = 7 group + 42 leaf) | **SAI** |
Số 28 là con số của thời `Ct_<Type>_<Group|List|Create|Pending>`; S155 đợt-2 đã thêm `WfView`/`Approved`/`Deleted` nên mỗi loại HĐ nay 6 lá, 7 loại = 42. Số này còn nằm nguyên trong skill `permission-matrix` (dòng `× 7 type = 28 leaf`) nên nhiều khả năng được chép từ doc stale chứ không phải tự đếm — đúng bệnh "số thành nợ tự-lan" mà #84 cảnh báo. Tự nó không đổi hành vi chạy, nhưng nó là một câu SAI mới toanh được thêm vào file, ngay dưới đoạn đang dặn người sau đừng đọc nhầm.
**Acceptance để đóng MAJOR-1:** hoặc bỏ cả ba số và thay bằng luật + lệnh tự-kiểm (ví dụ nêu "mọi key `Ct_*`" kèm lệnh grep đếm tại chỗ), hoặc giữ hai số đúng và sửa 28 → 42 kèm ghi rõ mốc S155. Không nhận phương án "để nguyên vì chỉ là comment".
**MINOR-2 — câu mở đầu xếp sai tầng.** `:197-198` liệt "Hệ thống (Người dùng / Vai trò / Phân quyền / Menu eOffice) · Quy trình duyệt (Mới)" bằng cùng một dấu chấm giữa dòng, đọc thành hai mục ngang hàng; thực tế `ApprovalWorkflowsV2` là CON của `System` (`DbInitializer.cs`, Order 96). Lồng nó vào trong ngoặc của "Hệ thống" là hết mơ hồ.
**MINOR-3 — `|| key.startsWith('Ct_')` nay là nhánh chết.** `Contracts` đã bị ẩn ở tầng root nên không node `Ct_*` nào còn đi tới được luật prefix. Comment `:243-245` đã tự khai điều này và nêu lý do giữ (phòng khi mở lại root). Tôi ĐỒNG Ý giữ — chỉ ghi lại để lượt sau không tưởng là vừa phát hiện ra thứ mới.
---
## R5 — Hệ quả không ai khai: 3 module mất lối vào ở CẢ HAI app
**CLARIFY-1 (cần owner biết, không phải lỗi mã).** Cây prod cho thấy 4 node đang mang `isVisible = false`: `Hrm`, `Off`, `Personal`, `Pe_DuyetNccPhuongAn` (và `Pe_DuyetNcc``displayLabel` admin đặt lại).
- `fe-user/src/components/Layout.tsx:243` lọc `n.isVisible !== false`**`Hrm`/`Off`/`Personal` hiện đã tắt bên eOffice**.
- `fe-admin` KHÔNG đọc `isVisible` (grep 0 hit) ⇒ trước K6, fe-admin là **lối vào menu cuối cùng** của ba module đó.
- Sau K6, ba key này nằm trong tập ẩn ⇒ **không còn mục menu nào cho `Hrm`/`Off`/`Personal` ở bất kỳ app nào.**
Vẫn còn đường thoát: quyền không đổi (deep-link chạy), và trang `Menu eOffice` (`MenuVisibility`) vẫn nằm trong 15 lá sống nên admin bật lại được. Nhưng spec K6 chỉ nói "module nghiệp vụ làm việc ở fe-user" — với ba module này thì fe-user đang tắt sẵn, nên hệ quả thật là "biến mất khỏi menu", khác với điều câu spec gợi ra. Cần owner xác nhận đây là ý muốn, hoặc bật `isVisible` lại bên eOffice trước khi K6 lên prod.
---
## R6 — Build tươi + kiểm chứng con số lane khai
- `npx tsc -b` trong `fe-admin`: **exit 0**, không phát ra chẩn đoán nào. Chạy SAU diff nên không dính bẫy snapshot cũ (gotcha #68).
- `npm run build`: PASS, và tái lập **trùng từng chữ số** với artifact lane — `dist/assets/index-B0OrpamE.js` 1,726.07 kB (gzip 429.28) · `index-AgG72YRZ.css` 86.93 kB (gzip 14.51). Tên file băm trùng nghĩa là nội dung bundle giống hệt ⇒ claim đo-lường của lane là thật, không phải chép lại.
- Hai cảnh báo (`chunk > 500 kB`, `INEFFECTIVE_DYNAMIC_IMPORT`) đúng là có sẵn từ trước, không do diff.
**MINOR-4 — câu khai của lane hơi rộng.** `sub-implfe-k6.md:56` viết *"`git diff --stat` = đúng 1 file"*; chạy trần thì `git diff --stat` cho 8 file (7 file `.claude/*` đang dirty). Câu đúng phải là "đúng 1 file MÃ". Không sai bản chất, nhưng người đọc sau có thể tưởng cây sạch tuyệt đối.
---
## R7 — Disposition 3 FLAG của lane
**FLAG-1 (N = 15) — CHẤP NHẬN, và tôi xác nhận bằng đường đo độc lập.** Lane nói spec quên cộng 8 leaf Designer K3 vào công thức của chính nó: đúng. Spec `:97` viết `N = Dashboard + Users + Roles + Permissions + MenuVisibility + |leaf AwV2 trong DB|` rồi chốt "7 (nếu 2 AwV2) / 8 (nếu 3)", trong khi cùng dòng lại nói "SAU K3 = N+8". K3 đã land và đã lên prod (10 row `AwV2*` đang sống), nên đáp số hiện tại là 15, không phải 7/8. Số 15 của tôi đo từ payload prod, không đo từ seed.
**FLAG-2 (dư-lượng prod: có row `AwV2_Contract` chèn tay không?) — ĐÓNG.**
- Đường sqlcmd: **không chạm được, tôi đã thử chứ không đoán.** Binary có trên máy (`/c/Program Files/Microsoft SQL Server/Client SDK/ODBC/170/Tools/Binn/sqlcmd`), nhưng chuỗi kết nối prod là `Server=.\SQLEXPRESS` + `Password=__SET_VIA_SECRETS__` (`appsettings.Production.json.example:3`) — tức DB nằm local trên VPS, không có endpoint lẫn mật khẩu nào từ máy này ra tới. Khai đúng: không đo được bằng SQL.
- Đường thay thế **mạnh hơn**: `/api/menus/me` trên prod với token admin. Kết quả: **đúng 10 key `AwV2*`**`AwV2_DuyetNcc`, `AwV2_DuyetNccPhuongAn`, `AwV2_KhkkN1..N8`. **Không có `AwV2_Contract`.** Đồng thời 8 group `Khkk_G1..G8` đều đã có mặt trên prod.
- Phụ chứng phía mã: grep `AwV2_Contract` toàn repo = **0 hit**, với control-dương `AwV2_KhkkN1` = 2 hit (nên biết chắc lệnh grep còn sống). Nhánh `resolvePath:178` có nhận code `Contract` nhưng đó là bộ giải route chờ sẵn, không phải menu-row — lane phân biệt đúng chỗ này.
- **Giới hạn của phép đo, khai thẳng:** `/api/menus/me` đã lọc theo quyền (`GetMyMenuTreeQuery` chỉ trả node `CanRead` hoặc có con `CanRead`). Một row chèn tay mà không kèm row `Permissions` sẽ vô hình trong phép đo này — nhưng nó cũng vô hình trên sidebar admin, tức không ảnh hưởng con số C1. Vậy: C1 đóng trọn vẹn; còn mệnh đề "bảng `MenuItems` không tồn tại row nào khác" thì đây chỉ là cận dưới. Bối cảnh liên quan: gotcha #84 nói mọi sửa tay trên prod đều tạm thời nếu có code-path ngược chạy lúc khởi động, nên kể cả có row chèn tay thì nó cũng không bền.
**FLAG-3 (nhánh chết trong `resolvePath`) — GIỮ, đừng dọn lượt này.** Ba lý do: (1) file này đã đảo hai lần, dọn đi là xoá đúng thứ phải dựng lại nếu có lần đảo thứ ba; (2) nhánh chết ở đây là dữ liệu tĩnh trong `staticMap`, không tốn gì lúc chạy và `tsc` không coi là lỗi; (3) dọn sẽ phình diff ra ngoài phạm vi spec K6, tức tự chuốc lấy lỗi trôi-phạm-vi. Điều kiện kèm theo: giữ nguyên cảnh báo ngược ở `:224-226` (gỡ `KeHoachKyKet` khỏi tập thì phải nới regex `Khkk_G*` trước) — tôi đã kiểm cảnh báo này còn ĐÚNG: `staticMap` hiện chỉ có 6 key `Khkk_*` không-infix của nhóm 1, chưa hề có nhánh `Khkk_G{n}_*`. Nếu sau này muốn dọn thì tách một lượt riêng.
---
## R8 — Bảng 6 hạng mục
| Hạng mục | Kết luận | Ghi chú |
|---|---|---|
| 1. Wire BE / feature claim | PASS | Diff thuần FE hiển thị; 3 endpoint prod trả 200 chứng quyền không đổi |
| 2. Schema integrity | N-A | 0 migration, 0 entity, 0 menu-key mới |
| 3. Security | PASS | `usePermission` đọc cây thô ⇒ guard không bị nới/siết; không đụng `[Authorize]` |
| 4. Code quality | PASS-WITH-FLAGS | `tsc -b` exit 0, build tái lập trùng băm; trừ MAJOR-1 comment sai số |
| 5. Test coverage | N-A có điều kiện | fe-admin không có hạ tầng test (`package.json` chỉ `dev/build/lint/preview`); 15 lá này chỉ được canh bởi phép đo thủ công của gate, không có lưới tự động |
| 6. Writing quality | PASS | Comment tiếng Việt câu trọn, đủ dấu; nội bộ nên không soi theo chuẩn outward |
## R9 — Tổng hợp finding
| # | Mức | Vị trí | Nội dung |
|---|---|---|---|
| 1 | MAJOR | `fe-admin/src/components/Layout.tsx:245` | "28 leaf" sai (thật 42 lá / 49 key `Ct_*`); 3 số-đếm trong comment vi phạm gotcha #84 mục (4) |
| 2 | CLARIFY | `fe-user/src/components/Layout.tsx:243` + dữ liệu prod | `Hrm`/`Off`/`Personal` đang `isVisible=false` bên eOffice ⇒ sau K6 mất lối vào menu ở cả hai app; cần owner xác nhận |
| 3 | MINOR | `Layout.tsx:197-198` | Câu mở đầu xếp "Quy trình duyệt (Mới)" ngang hàng "Hệ thống", thực tế là con của `System` |
| 4 | MINOR | `Layout.tsx:243-246` | Luật prefix `Ct_` nay là nhánh chết — cố ý, đã khai, đồng ý giữ |
| 5 | MINOR | `sub-implfe-k6.md:56` | "`git diff --stat` = đúng 1 file" nên viết là "1 file MÃ" (thực tế 8 file dirty) |
**Không có blocker.** Cả 5 mục đều không chặn commit về mặt hành vi; MAJOR-1 nên sửa trước khi commit vì nó là một câu sai mới được thêm vào, mà sửa chỉ tốn một dòng.
## R10 — Những chỗ diff này chịu được soi (ghi để không chỉ kể lỗi)
- Chọn liệt ROOT thay vì liệt từng leaf là đúng cấu trúc: `filterForAdmin:249-253` cắt đệ quy cả cây con, nên tập 12 key tự động theo kịp mọi leaf BE thêm sau này mà không phải bảo trì.
- Ràng buộc ngược `resolvePath ↔ tập ẩn` (di sản K4a) không bị mất khi viết lại trọn khối comment — đây là chỗ dễ rơi nhất khi thay comment cũ bằng comment mới.
- Con số lane khai đều tái lập được: 166 lá trước lọc, 15 lá sau lọc, control-âm +48, tên bundle sau build. Không có con số nào thuộc loại "tự khai không kiểm được".
END gate-k6 — VERDICT=PASS-WITH-FLAGS 5

View File

@ -0,0 +1,107 @@
TEST-K7: DONE — suite 639/0 (45D+594I, Δ+17) · 0-FLAG-production · 2-khai (dòng-1 lead ghi-đè on-behalf — vai chết đúng lúc closing, END-line + §0-§5 vai tự ghi trọn)
# sub-test-k7 — Lane TEST K7 (bridge KHKK→HĐ, chuỗi SP-002 B4, S167 · 2026-08-01)
> Rào: chỉ `tests/**` · KHÔNG production code · KHÔNG commit. Ghi-TỪNG-MỤC chống-#53: xong mục nào ghi ngay mục đó; mục CHƯA CHẠY chỉ được tiêu-đề + "CHƯA ĐO".
## §0 — Tiền đề ĐO TƯƠI (không tin số đề bài)
- `git status` @spawn: `src/Backend/**` dirty 3 file + 1 file MỚI (`CreateContractFromSigningPlanFeatures.cs`) = **lane BE K7 đã LAND, uncommitted** — khớp `sub-implbe-k7.md`. `tests/**` = **0 file dirty** (baseline sạch).
- **Baseline suite ĐO TƯƠI (trước khi tôi gõ 1 chữ):** `SolutionErp.Infrastructure.Tests` = **577 total / 576 passed / 1 FAILED**. FAIL đúng test đã bàn giao:
`AuthorizePolicyRegressionTests.ContractSigningPlansController_EveryWriteEndpoint_HasAuthorizePolicy` @`:398``Expected parts[0] ... they differ: "Contracts" vs "KeHoachKyKet"`**RED tái hiện đúng nguyên nhân implbe-k7 khai**, không phải flaky.
- Đếm action controller từ ĐĨA: `grep -c "^ \[Http"` = **17** (implbe-k7 ước "16"; số đo là 17). Neo chống-vacuous `>= 15` không vỡ, giữ nguyên nghĩa.
### 🔎 Chồng-lấn TÌM TRƯỚC KHI VIẾT (bài S166) — T8 ĐÃ CÓ NGƯỜI ĐO MỘT NỬA
`Application/ContractV2PipelineTests.cs` (W6-S161) đã phủ đúng mệnh đề T8 của bảng invest (`sub-invest-fable-b2-cum3.md:82`): approver-V2-không-role-legacy thấy List/Detail + Inbox, cấp-2 KHÔNG thấy khi đang cấp-1 — `Inbox_PendingLevelApprover_SeesChoDuyetContract` · `Inbox_OtherLevel_DoesNotSeeContract` · `View_V2Approver_NotDrafter_CanSeeChoDuyetContract`.
**KHÔNG nhân bản.** Cái các test đó KHÔNG đo: chúng **seed thẳng** `Contract{Phase=ChoDuyet, ApprovalWorkflowId, stepIndex}` — tức giả định sẵn thứ mà cầu K7 phải TỰ sinh. Vế còn hở = **mối nối cầu→đường-ống**. T8 của tôi đo đúng chỗ hở đó (bridge → trình THẬT qua `ContractWorkflowService` → inbox), 0 chồng lấn.
## §1 — [BƯỚC 1 ✅ XONG] Fix bàn-giao BE: ngoại lệ `CreateContract`
File: `tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs`
- Thêm **bản đồ kỳ-vọng-CHÍNH-XÁC** `KhkkCrossModuleGates = { CreateContract → "Contracts.Create" }` (KHÔNG phải danh sách "bỏ qua"): action trong bản đồ **vẫn bị đo**, chỉ đổi key kỳ vọng ⇒ gate sai thành `KeHoachKyKet.Create` **vẫn ĐỎ**. Comment nêu LUẬT (tài nguyên SINH RA quyết định khoá ghi) + trỏ gotcha **#85** + `MenuPermissionHandler.cs:40` (so khớp CHÍNH XÁC 1 key, không kế-thừa). **0 số-đếm trong comment.**
- Vế (b) tách 2 nhánh: có trong bản đồ → `authz.Policy.Should().Be("Contracts.Create")`; không → giữ nguyên luật cũ `parts[0] == MenuKeys.KeHoachKyKet` cho 16 action còn lại.
- Thêm (e) chốt-bằng-TÊN: `byName[CreateContract] == "Contracts.Create"` + `KhkkCrossModuleGates.Keys.Should().BeSubsetOf(byName.Keys)` — đổi tên/xoá action mà quên dọn bản đồ = **cấu-hình-chết im lặng** (vắng-mặt trông giống ổn) ⇒ bắt luôn.
- Neo chống-vacuous `:375` **GIỮ NGUYÊN `>= 15`** (không hạ, không đổi nghĩa; thực đo 17).
- Vế (a)/(c)/(d) không đụng: `CreateContract` là POST nên vẫn phải qua luật "verb ghi không được gate Read" + "phải Create/Update/Delete".
**ĐO:** `--filter AuthorizePolicyRegressionTests`**Passed! Failed: 0, Passed: 22** (trước: 1 FAIL). File này +0 test mới (sửa test sẵn có, +2 vế assert).
## §2 — [BƯỚC 2 ✅ XONG] File MỚI 11 test bridge — `--filter CreateContractFromSigningPlanTests` → **Passed! Failed: 0, Passed: 11**
File: `tests/SolutionErp.Infrastructure.Tests/Application/CreateContractFromSigningPlanTests.cs` (mirror khuôn bridge PE cũ `CreateContractFromEvaluationMultiWinnerTests``IdentityFixture` + deps THẬT `ContractCodeGenerator`/`ContractWorkflowService`, KHÔNG stub).
| # | Test | Đo cái gì (vế mạnh nhất) |
|---|---|---|
| T1 | `T1_MotDong_TaoDungMotHopDong_VaGhiNguocContractId` | trọn field-map + **mã HĐ so RUỘT** `{Project.Code}/HĐTP/SOL&{Supplier.Code}/01` (bằng chứng cầu ĐÃ nạp 2 bảng — vá-6) + SLA≈+7d + `Line.ContractId` + `pe.ContractId` + **2 changelog ĐÚNG 2 BẢNG**. 3 số 900/999/750 cố ý khác nhau ⇒ chỉ `Σ ApprovedAmount` đúng |
| T2 | `T2_HaiDongCungNcc_GopVaoMotHopDong_GiaTriLaTong` | gộp N (vá-5, chỉ tồn tại nhờ Mig 71 bộ-3 unique) — 1 HĐ, CẢ 2 dòng cùng `ContractId` |
| T3 | `T3_DongChuaCoGiaChot_ThiChan_KhongTaoHd_VaKhongDotMa` | message NÊU TÊN hạng mục + atomic (dòng LÀNH cũng không bị ghi) + **`ContractCodeSequences` rỗng** = rào đứng TRƯỚC codegen |
| T4 | `T4_PhieuChuaDuyetXong_ThiChan` | dòng CÓ đủ giá ⇒ chỉ đổi ĐÚNG 1 biến (phase) |
| T5 | `T5_DongDaCoHopDong_ThiChan` | double-bridge PER-DÒNG + HĐ cũ không bị ghi đè |
| T6 | `T6_HaiDongKhacNcc_ThiChan` | + **ĐỐI CHỨNG DƯƠNG cùng lượt**: tách ra bấm từng dòng thì QUA (2 HĐ) |
| T7 | `T7_QuyTrinhPinSai_Chan4Nhanh_VaQuyTrinhDungThiQua` | **ma-trận cùng-ruột đổi ĐÚNG 1 biến**: NotFound · sai-loại · [vá-3] `!IsActive` · [vá-3] `!IsUserSelectable`**4 message PHÂN BIỆT** + đối-chứng-dương quy-trình-đúng QUA |
| T8 | `T8_HdSinhTuCau_TrinhDuyet_ThiVaoDungHopThuCapDangCho` | **mối nối cầu→đường-ống** (chỗ `ContractV2PipelineTests` KHÔNG đo): trình qua máy THẬT → `levelOrder==1` (quên pin V2 = null) → Cấp-1 thấy hộp thư, Cấp-2 chưa |
| T9 | `T9_DongCuaPhieuKhac_ThiChan` | trộn dòng phiếu hàng xóm + id rác → chặn, dòng hàng xóm không bị đụng |
| T10 | `T10_BacCauLanHai_TaoHdThuHai_NhungPeGiuHdDau` | **CHIỀU của `??=` (vá-4a)**: viết `=` thay `??=` thì mọi assert khác vẫn xanh, chỉ vế này ĐỎ. + cardinality DoD vá-15 + seq mã 01→02 |
| T11 | `T11_BamHaiLanCungTapDong_ThiLanHaiBiChan_KhongTaoHdThua` | **double-click** — khoá HÀNH VI ĐANG CÓ (409 "đã có Hợp đồng", KHÔNG chế "no-op") + `LastSeq==1` (không cháy mã oan) |
**Ánh xạ bảng invest T1-T8 → 11 test:** T1→T1 · T2→T2 · T3→T3 · T4→T4 · T5→T5 · T6→T6 · T7→T7(nhánh b, +3 nhánh vá-3/NotFound) · T8→T8. **+3 test NGOÀI bảng** (T9 rào tập LineIds · T10 chiều `??=` · T11 double-click) — 2 cái sau là yêu cầu §4/§5 của đề bài.
🔴 **LỆCH SPEC ĐÃ ĐO, test theo CODE + khai trong header file:** bảng invest T1 (`:75`) ghi `WorkflowDefinitionId null`; **vá-6** (`spec-cum3:16`) ĐẢO lại "pin như 2 tiền lệ" và spec `:4` chốt "16 vá THẮNG". Mã theo vá-6 ⇒ T1 assert **pin ĐÚNG bản active của loại HĐ đó** (seed sẵn 1 bản NHIỄU loại khác ⇒ `!= null` suông sẽ không đủ).
**2 lần ĐỎ trên đường đi (đều là seed, KHÔNG phải prod):** T9 gọi `SeedAsync` 2 lần ⇒ SQLite 19 UNIQUE `ApprovalWorkflows(Code,Version)` rồi `PurchaseEvaluations.MaPhieu` → vá bằng hậu-tố ngẫu nhiên cho MỌI mã (`Projects.Code`/`Suppliers.Code`/2 bảng workflow/`MaPhieu`); T1+T10 chuyển sang so mã HĐ với `s.Project.Code`/`s.Suppliers[0].Code` đọc từ seed. **0 dòng production bị sửa.**
## §3a — [✅ XONG] Authz 2 CHIỀU — `--filter KhkkBridgeAuthzTests` → **Passed! Failed: 0, Passed: 5**
File MỚI: `tests/SolutionErp.Infrastructure.Tests/Api/KhkkBridgeAuthzTests.cs`. Chọn khuôn **RẺ NHẤT MÀ VẪN THẬT**: chạy thẳng `MenuPermissionHandler` + `UserManager`/`RoleManager` thật trên `IdentityFixture`**KHÔNG WebApplicationFactory**. Khác `AuthorizePolicyRegressionTests` (đọc ATTRIBUTE = "cửa có khoá tên gì"), file này đo **QUYẾT ĐỊNH** = "khoá đó mở cho ai".
🔴 **Điều kiện làm phép đo có răng: 2 tên policy KHÔNG hardcode** — đọc TỪ CHÍNH controller (class-level + action-level của `CreateContract`) rồi mới đem đi hỏi handler ⇒ ai sửa attribute thì test đo theo cái MỚI mà vẫn hỏi đúng câu "hai vế có phải HỘI không". Kèm vế `actionPolicy != classPolicy` ("hai vế TRÙNG NHAU thì hội-2-khoá chỉ còn 1 khoá — cửa tự nới mà nhìn vẫn như cũ").
| # | Test | Vế |
|---|---|---|
| 1 | `CauKhkkSangHd_VaiDuCaHaiVe_ThiQua` | **ĐỐI CHỨNG DƯƠNG** — thiếu nó thì 2 test 403 vẫn xanh trong thế giới "chặn tất cả" |
| 2 | `CauKhkkSangHd_VaiChiCoQuyenKeHoach_ThiBiChan` | cấp RỘNG TAY cả 4 cờ trên `KeHoachKyKet` vẫn 403 — chốt trước vế-1-PHẢI-qua để không xanh vì lý do sai |
| 3 | `CauKhkkSangHd_VaiChiCoQuyenTaoHopDong_ThiBiChan` | chiều ngược lại, cũng chốt trước vế-2-PHẢI-qua |
| 4 | `CauKhkkSangHd_Admin_KhongCanDongQuyenNao_VanQua` | khoá nhánh bypass (0 dòng `Permissions`) — chống "siết cho nhất quán" rồi khoá luôn Admin |
| 5 | `CauKhkkSangHd_UserBiVoHieuHoa_ThiChan_DuCoDuQuyen` | **cùng bộ quyền, đổi ĐÚNG 1 biến `IsActive`** ⇒ kết quả ngược (nghỉ việc mà token còn hạn) |
## §3b — [✅ XONG] Vá-7 B6 neo mới = **T12 end-to-end** — `--filter CreateContractFromSigningPlanTests` → **12/12** (11 + T12)
`T12_DiTronDuongFinalizeRoiBacCau_GiaTriLaSoDaCHOT_VaKhongDoiVeSau` (cùng file §2).
**KHÔNG chỉ "thêm 1 assert vào test K3"** — lý do khai rõ: test K3 dừng ở "finalize GHI `ApprovedAmount`"; cầu K7 thì ĐỌC cột đó; **mối nối giữa 2 vế chưa ai đo**, mà chính nó là điều kiện sống của happy-path (invest `:154`: "không có K3 thì `ApprovedAmount` NULL 100%" ⇒ cầu chặn mọi phiếu). T12 cho phiếu đi TRỌN đường qua `ContractSigningPlanWorkflowService` THẬT (trình → duyệt → terminal chốt giá) rồi mới bắc cầu — **KHÔNG seed thẳng Phase/giá** (seed thẳng = tự đưa đáp án rồi chấm).
- neo (a) `Phase == DaDuyet` do MÁY tự đi tới.
- neo (b) **tương phản chống-rỗng:** dòng A `Approved=111` chốt sẵn (`Proposed=999`) · dòng B `Approved=null` (`Proposed=250`) ⇒ sau finalize phải là {111, 250} — chứng `??=` GIỮ số người có thẩm quyền chốt tay, không bị 999 đè.
- neo (c) `GiaTri == 361` = 111+250. Σ `Proposed` = 1249; lấy nhầm nguồn ra 1249/1109/500 — **mọi cách sai đều KHÁC 361**.
- vế cuối theo đúng chữ vá-7: sau bắc cầu, bấm `approve` lần nữa → `ConflictException` (phiếu terminal) và đọc lại **`Line.ContractId` KHÔNG đổi** + `ApprovedAmount` KHÔNG đổi.
## §4 — [✅ XONG] Suite FULL `dotnet test SolutionErp.slnx --nologo`
```
Passed! - Failed: 0, Passed: 45, Total: 45 — SolutionErp.Domain.Tests
Passed! - Failed: 0, Passed: 594, Total: 594 — SolutionErp.Infrastructure.Tests
```
| | Domain | Infra | Tổng | FAIL |
|---|---|---|---|---|
| Baseline (đo tươi @§0) | 45 | 577 | **622** | **1** (bàn giao BE) |
| Sau K7-test | 45 | **594** | **639** | **0** |
| Δ | 0 | **+17** | **+17** | 1 |
**+17 = 12** (`CreateContractFromSigningPlanTests`, T1-T12) **+ 5** (`KhkkBridgeAuthzTests`). File `AuthorizePolicyRegressionTests` +0 test (sửa test sẵn có: 1 FAIL → PASS, +2 vế assert).
- Flaky đã khai `DeleteContractSigningPlan_SoftDeletes`: **KHÔNG nổ** lượt này (0 Failed, chạy 1 lần duy nhất — không phải chạy lại).
- **Dấu chân trên đĩa (`git status`):** đúng **3 file `tests/**`** (1 M + 2 ??). `src/Backend/**` + `fe-*/**` dirty là của lane BE/FE, **tôi không chạm 1 dòng nào**. KHÔNG commit.
## §5 — FLAG cho lead
**0 FLAG bug production.** 12 test bridge + 5 authz đều xanh trên mã BE hiện tại; không có test nào phải giữ ĐỎ.
2 việc KHAI để lead phân xử (không phải bug, là chỗ lệch giấy tờ / vùng chưa phủ):
1. 🟡 **Lệch spec đã đo (giấy tờ, không phải mã):** bảng invest T1 `:75` ghi `WorkflowDefinitionId null`**vá-6 thắng** và mã đang pin V1. Tôi test theo CODE + vá-6, ghi mismatch trong header file test. Nếu lead muốn giữ chữ của bảng invest thì phải sửa MÃ (không phải sửa test).
2. 🟡 **Vùng KHÔNG phủ được bằng unit test, thuộc acceptance sau restart:** `SeedContractCreateAccessAsync` (`DbInitializer.cs:2757-2822`) — grant `Contracts.Create` chỉ ăn **@restart API prod**. `KhkkBridgeAuthzTests` chứng "vai có 2 vế thì qua / thiếu 1 vế thì 403" nhưng KHÔNG chứng seeder THẬT cấp đúng vai (Drafter/Procurement/Admin) và cấp SAU revoker (#84). Nghiệm thu vẫn phải là đếm row `Permissions WHERE MenuKey='Contracts' AND CanCreate=1` sau restart thật — đúng như implbe-k7 §1 đã khai.
END test-k7 — VERDICT=DONE (suite FULL 639/0 = 45 Domain + 594 Infra · Δ+17 · 1 FAIL bàn-giao đã đóng · 0 file production chạm · chưa commit)

View File

@ -28,7 +28,9 @@ import {
KHKK_APPROVAL_GROUPS,
KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS,
KhkkPhase,
khkkGroupMenuLabel,
khkkPlanContractIds,
type KhkkListItemDto,
type PagedResult,
} from '@/types/khkk'
@ -56,6 +58,10 @@ const KHKK_MENU_KEYS = ['KeHoachKyKet']
const CONTRACT_MENU_KEYS = ['Contracts', ...CONTRACT_TYPE_CODES.map(c => `Ct_${c}_List`)]
const HARD_COPY_MENU_KEYS = ['HopDongCung', ...CONTRACT_TYPE_CODES.map(c => `Hdc_${c}`)]
// [K7 S167 — vá-4b] Chuông DEV bắn TỐI ĐA 1 LẦN/phiên (buildStages chạy lại theo từng gói
// thầu ⇒ không có cờ này thì console ngập). Xem chỗ dùng ở GĐ3.
let warnedMissingLineContractIds = false
export type PipelineLinkage = {
canPe: boolean
canKhkk: boolean
@ -164,7 +170,8 @@ export type BuildStagesOptions = {
* GĐ1 = chính các phiếu ấy
* GĐ2 = KHKK có `purchaseEvaluationId` thuộc tập phiếu, chia thành 8 NGĂN theo
* nhóm duyệt (+ ngăn "(chưa phân nhóm)" khi có phiếu lạc) — K5
* GĐ3 = HĐ trong TẬP id của gói (xem forward-provision B3-A5 ở thân hàm)
* GĐ3 = HĐ trong TẬP id của gói — hợp của `pe.contractId` (legacy) và contract-id trên
* các DÒNG phiếu KHKK (K7 vá-4b; xem thân hàm)
* GĐ4 = đúng các HĐ ấy khi `hasSealedCopy === true`
*/
export function usePipelineStages() {
@ -269,26 +276,41 @@ export function usePipelineStages() {
// HĐ nằm ngoài index (list kẹp 200) vẫn được nêu bằng leaf tối giản thay vì
// biến mất im lặng — nhưng KHÔNG suy được `hasSealedCopy` ⇒ chỉ vào GĐ3.
//
// [K5 S166 — forward-provision B3-A5] Lấy HĐ của gói bằng một TẬP id gộp từ
// NHIỀU nguồn, thay vì đọc thẳng một ô `pe.contractId`. Hôm nay tập ấy chỉ có
// một nguồn, nhưng hình dạng đã đúng nên thêm nguồn sau là thêm một dòng.
// [K5 S166 — forward-provision B3-A5 · K7 S167 vá-4b ĐÃ CẮM NGUỒN 2] Lấy HĐ của gói
// bằng một TẬP id gộp từ NHIỀU nguồn, thay vì đọc thẳng một ô `pe.contractId`.
//
// 🔴 VÌ SAO CẦN: 1 phiếu có thể đẻ N hợp đồng (liên danh — endpoint
// `create-contract` trả `contractIds: string[]`, xem `PeDetailTabs.tsx:2186`),
// NHƯNG BE chỉ ghi ngược HĐ ĐẦU vào phiếu:
// 🔴 VÌ SAO CẦN: 1 gói có thể đẻ N hợp đồng (liên danh, và từ K7 là N dòng phiếu
// KHKK × mỗi lần bấm "Đưa vào HĐ"), NHƯNG phía PE chỉ ghi ngược HĐ ĐẦU:
// `CreateContractFromEvaluationFeatures.cs:143-145`
// "Idempotency: pe.ContractId = HĐ ĐẦU … N HĐ liên-danh truy qua changelog"
// ⇒ HĐ thứ 2 trở đi hiện KHÔNG có đường nào để cây nhìn thấy: `PeListItem`
// không có `lines[]`, `ContractListItem` không có `purchaseEvaluationId`,
// `Contract.cs` cũng không có khoá ngược. Đây là giới hạn DỮ LIỆU, không
// phải chỗ này thiếu logic — đừng "sửa" bằng cách bịa field không tồn tại.
// ⇒ KHI BE mở đường (một trong hai: list DTO trả `contractIds[]`, hoặc dòng
// phiếu có `contractId` riêng), nối vào ngay dưới đây và xoá ghi chú này.
// ⇒ đọc mỗi `pe.contractId` thì HĐ thứ 2 trở đi VÔ HÌNH trên cây (spec cụm-3 vá-4).
//
// 2 nguồn (dedupe bằng `Set` — chúng CỐ Ý chồng nhau ở HĐ đầu tiên):
// (1) `pe.contractId` — LEGACY, giữ cho phiếu PE cũ đã bắc cầu bằng đường PE→HĐ.
// (2) `khkkPlanContractIds(k)` — HĐ bắc cầu từ TỪNG DÒNG phiếu KHKK của gói. K7 ghi
// `Line.ContractId` là NGUỒN THẬT, `pe.ContractId ??=` chỉ còn là fallback hiển
// thị ⇒ N HĐ / 1 PE hiện ĐỦ, đúng OG-1 "máy rộng, UI hẹp".
const contractIdsOfPackage = new Set<string>()
for (const p of peItems) {
if (p.contractId) contractIdsOfPackage.add(p.contractId)
// ← chỗ cắm nguồn thứ hai (HĐ 2..N). Ví dụ khi có: `p.contractIds?.forEach(...)`
// hoặc `p.lines?.forEach(l => l.contractId && add(l.contractId))`.
}
for (const k of khkkItems) {
for (const cid of khkkPlanContractIds(k)) contractIdsOfPackage.add(cid)
}
// Nguồn (2) IM khi BE chưa trả field trên LIST payload — mà "im vì chưa nối" trông y
// hệt "im vì gói chưa có HĐ nào". Nói ra ở DEV thay vì để người sau tưởng cây đã đủ.
// Chỉ báo khi có phiếu ĐÃ DUYỆT (chỉ phiếu duyệt rồi mới bắc cầu được) mà KHÔNG phiếu
// nào mang field — `[]` (BE đã trả, gói chưa có HĐ) thì im, đúng như phải thế.
if (import.meta.env.DEV && !warnedMissingLineContractIds) {
const approved = khkkItems.filter(k => k.phase === KhkkPhase.DaDuyet)
if (approved.length > 0 && approved.every(k => k.contractIds == null)) {
warnedMissingLineContractIds = true
console.warn(
'[pipeline GĐ3] Phiếu KHKK đã duyệt nhưng payload list KHÔNG có `contractIds` ⇒ cây ' +
'chỉ thấy HĐ qua `pe.contractId` (HĐ ĐẦU). API cũ chưa deploy hoặc cache cũ — xem ghi ' +
'chú `types/khkk.ts` (K7 vá-4b, BE `…ListItemDto:75`).',
)
}
}
const stage3Leaves: PipelineStage['leaves'] = []
@ -296,8 +318,13 @@ export function usePipelineStages() {
// `Set` đã khử trùng ⇒ không cần biến `seen` riêng như trước.
for (const contractId of contractIdsOfPackage) {
const c = linkage.contractById.get(contractId)
// Tên gói thầu để làm dòng phụ khi HĐ nằm ngoài cửa sổ index 200.
const fallbackName = peItems.find(p => p.contractId === contractId)?.tenGoiThau ?? null
// Tên gói thầu để làm dòng phụ khi HĐ nằm ngoài cửa sổ index 200. [K7 vá-4b] tra CẢ
// 2 nguồn: HĐ đến từ dòng phiếu KHKK thì KHÔNG phiếu PE nào trỏ tới nó ⇒ chỉ tra
// `peItems` sẽ rơi về `null` và dòng phụ trống trơn.
const fallbackName =
peItems.find(p => p.contractId === contractId)?.tenGoiThau
?? khkkItems.find(k => khkkPlanContractIds(k).includes(contractId))?.peTenGoiThau
?? null
if (linkage.canContract) {
stage3Leaves.push({
id: contractId,

View File

@ -12,7 +12,8 @@ import { useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom'
import {
ArrowLeft, ClipboardList, FileCheck, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
ArrowLeft, ClipboardList, FileCheck, FileSignature, Paperclip, Pencil, Plus, Save, Trash2,
Upload, Users,
} from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/ui/PageHeader'
@ -20,17 +21,21 @@ import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { usePermission } from '@/hooks/usePermission'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { ContractTypeLabel } from '@/types/forms'
import {
DOSSIER_KIND_LABELS, DOSSIER_STATUS_BADGE, DOSSIER_STATUS_LABELS,
DossierItemKind, DossierItemStatus, KHKK_APPROVAL_GROUP_LABELS,
KHKK_PHASE_BADGE, KHKK_PHASE_LABELS,
KhkkAttachmentPurpose, KhkkPhase,
type CreateContractFromKhkkInput, type CreateContractFromKhkkResult,
type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto,
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkLineDto, type KhkkPhaseValue,
type UpsertKhkkDossierItemInput,
} from '@/types/khkk'
import { KhkkWorkflowPanel } from './KhkkWorkflowPanel'
@ -123,6 +128,18 @@ export function KhkkDetailPage() {
const [ghiChu, setGhiChu] = useState('')
const [hoSoLink, setHoSoLink] = useState('')
const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null)
const [bridgeOpen, setBridgeOpen] = useState(false)
// [K7 S167 — vá-1 vế FE] HỘI 2 KHÓA, đúng bằng server, KHÔNG lỏng hơn:
// · `KeHoachKyKet.Read` — cửa đọc chính phiếu này (`ContractSigningPlansController.cs:36`,
// `MenuPermissionHandler.cs:40` so khớp CHÍNH XÁC 1 key ⇒ KHÔNG OR `Khkk_*`, gotcha #85);
// · `Contracts.Create` — cửa của endpoint bắc cầu: tài nguyên SINH RA là Contract nên key
// phải là của endpoint đích, không phải của màn đang đứng.
// Chữ ký hook là `can(menuKey, action)` (`hooks/usePermission.ts:17`) — không có dạng
// `usePermission('X','Y')`.
const { can } = usePermission()
const canReadPlan = can('KeHoachKyKet', 'Read')
const canCreateContract = can('Contracts', 'Create')
const plan = useQuery({
queryKey: ['khkk-detail', id],
@ -276,6 +293,23 @@ export function KhkkDetailPage() {
const isDraft = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
const canDeletePlan = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TuChoi
// [K7 S167] Dòng bắc cầu được = CHƯA có HĐ ∧ ĐÃ có giá duyệt chốt — đúng 2 guard BE
// (`§K7.1` guard 4 + 5). Chặn sớm ở FE để người dùng không bấm rồi mới ăn 409.
const bridgeableLines = k.lines.filter(l => l.contractId == null && l.approvedAmount != null)
// Nút chỉ có nghĩa khi phiếu ĐÃ DUYỆT (giá mới chốt) và người dùng đọc được phiếu.
const showBridge = phase === KhkkPhase.DaDuyet && canReadPlan
// Thiếu vế nào thì NÓI RA vế đó thay vì ẩn nút: người dùng đang đứng trên phiếu, ẩn đi họ
// chỉ đoán "hệ thống không có chức năng". Khuôn nút-khoá-kèm-title `PeWorkflowPanel.tsx:547-561`.
const bridgeBlockReason = !canCreateContract
? 'Bạn chưa có quyền "Tạo hợp đồng" (Contracts.Create) — nhờ quản trị cấp quyền rồi thử lại.'
: k.lines.length === 0
? 'Phiếu chưa có dòng nhà cung cấp nào.'
: bridgeableLines.length === 0
? (k.lines.every((l) => l.contractId != null)
? 'Mọi dòng của phiếu đã được đưa vào hợp đồng.'
: 'Chưa dòng nào có giá duyệt chốt — phiếu phải qua bước chốt giá trước khi đưa vào HĐ.')
: null
const startEditHeader = () => {
setGhiChu(k.ghiChu ?? '')
setHoSoLink(k.hoSoLink ?? '')
@ -311,6 +345,21 @@ export function KhkkDetailPage() {
>
{KHKK_PHASE_LABELS[phase]}
</span>
{/* [K7 S167 — vá-1 vế FE] Nút bắc cầu KHKK → HĐ. `<span title>` bọc ngoài vì
`Button` có `disabled:pointer-events-none` (Button.tsx:12) ⇒ title đặt thẳng
trên nút đã khoá sẽ không hiện, người dùng mất luôn câu giải thích. */}
{showBridge && (
<span title={bridgeBlockReason ?? 'Tạo hợp đồng từ một dòng của kế hoạch này'}>
<Button
variant="outline"
disabled={bridgeBlockReason != null}
onClick={() => setBridgeOpen(true)}
>
<FileSignature className="mr-2 h-4 w-4" />
Đưa vào
</Button>
</span>
)}
{canDeletePlan && (
<Button
variant="outline"
@ -467,7 +516,21 @@ export function KhkkDetailPage() {
<tbody>
{k.lines.map((l) => (
<tr key={l.id} className="border-b border-slate-100 last:border-0">
<td className="px-3 py-2 font-medium text-brand-800">{l.supplierName ?? '—'}</td>
<td className="px-3 py-2 font-medium text-brand-800">
{l.supplierName ?? '—'}
{/* [K7 S167] Dòng đã bắc cầu — khuôn dấu "✓ HĐ" của danh sách PE
(`PurchaseEvaluationsListPage.tsx:599`). Thấy ngay ở bảng chứ không
phải mở dialog mới biết dòng nào đã xong. */}
{l.contractId && (
<a
href={`/contracts/${l.contractId}`}
className="ml-2 text-[10px] font-medium text-brand-600 hover:underline"
title="Dòng này đã được đưa vào hợp đồng — bấm để mở HĐ"
>
</a>
)}
</td>
<td className="px-3 py-2 text-xs text-slate-700">
{l.tenHangMuc ? l.tenHangMuc : <span className="text-slate-300"></span>}
</td>
@ -713,6 +776,245 @@ export function KhkkDetailPage() {
</div>
)}
</Dialog>
{/* [K7 S167 — vá-16] Dialog bắc cầu. Render có điều kiện (không dùng prop `open`) để
mỗi lần mở là state mới — khuôn dialog Xóa-phiếu `PeWorkflowPanel.tsx:879`. */}
{bridgeOpen && (
<CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />
)}
</div>
)
}
// ===================== K7 S167 — cầu KHKK → Hợp đồng =================================
// [vá-16] 0 KHUÔN MỚI — ghép 2 khuôn đang sống:
// · vỏ dialog + khối "nói thẳng hệ quả" = nút Xóa-phiếu S155 (`PeWorkflowPanel.tsx:879-918`);
// · ô chọn Quy trình duyệt = picker `ContractCreatePage.tsx:333-343`
// (GET `/approval-workflows-v2?applicableType=3` → lọc theo cờ).
// [OG-1 "máy rộng, UI hẹp"] Máy BE nhận `lineIds[]` (gộp được N dòng vào 1 HĐ); ô chọn ở
// đây CỐ Ý 1-1 — mỗi lần bấm đúng 1 dòng. Mở gộp sau = đổi radio thành checkbox, KHÔNG
// đụng hợp-đồng API.
type BridgeWorkflowOption = {
id: string
code: string
version: number
name: string
isActive: boolean
isUserSelectable: boolean
}
function CreateContractFromKhkkDialog({ plan, onClose }: { plan: KhkkDetailDto; onClose: () => void }) {
const navigate = useNavigate()
const qc = useQueryClient()
const bridgeable = plan.lines.filter((l) => l.contractId == null && l.approvedAmount != null)
const [lineId, setLineId] = useState(() => bridgeable[0]?.id ?? '')
const [contractType, setContractType] = useState(1)
const [workflowId, setWorkflowId] = useState('')
// null = "đang dùng tên mặc định theo dòng đang chọn". Giữ null thay vì đồng bộ bằng
// useEffect: đổi dòng thì tên gợi ý đổi theo NGAY trong render, không có nhịp lệch.
const [tenHopDongOverride, setTenHopDongOverride] = useState<string | null>(null)
const selected: KhkkLineDto | null = plan.lines.find((l) => l.id === lineId) ?? null
const defaultName = selected
? `${plan.peTenGoiThau ?? 'Kế hoạch ký kết'}${selected.tenHangMuc ?? selected.supplierName ?? ''}`.trim()
: ''
const tenHopDong = tenHopDongOverride ?? defaultName
const workflows = useQuery({
// Query key RIÊNG (không dùng lại `approval-workflows-v2-contract` của ContractCreatePage):
// cùng URL nhưng LỌC KHÁC ⇒ dùng chung ô cache là để trang này ăn dữ liệu trang kia một
// cách câm (bài S159).
queryKey: ['khkk-bridge-contract-workflows'],
queryFn: async () => {
const res = await api.get<{ types: { applicableType: number; history: BridgeWorkflowOption[] }[] }>(
'/approval-workflows-v2',
{ params: { applicableType: 3 } },
)
const bucket = res.data.types.find((t) => t.applicableType === 3)
// 🔴 LỌC ĐỦ 2 CỜ (spec cụm-3 vá-2): `isUserSelectable` (admin ghim cho user chọn) ∧
// `isActive` (bản còn hiệu lực). Picker cũ `ContractCreatePage.tsx:341` chỉ lọc 1 cờ ⇒
// mời chọn được bản đã retire rồi ăn 409 từ guard BE. Ở đây lọc đủ ngay từ nguồn.
return (bucket?.history ?? []).filter((w) => w.isUserSelectable && w.isActive)
},
})
const create = useMutation({
mutationFn: async () => {
const body: CreateContractFromKhkkInput = {
lineIds: [lineId],
contractType,
approvalWorkflowId: workflowId,
tenHopDong: tenHopDong.trim() || null,
}
return (
await api.post<CreateContractFromKhkkResult>(
`/contract-signing-plans/${plan.id}/create-contract`,
body,
)
).data
},
onSuccess: (data) => {
toast.success(data.maHopDong ? `Đã tạo hợp đồng ${data.maHopDong}` : 'Đã tạo hợp đồng từ kế hoạch')
qc.invalidateQueries({ queryKey: ['khkk-detail', plan.id] })
qc.invalidateQueries({ queryKey: ['khkk-list'] })
// Cây 4 folder đọc 2 index riêng (`usePipelineStages.ts`) với `staleTime` 60s — không
// gọi 2 dòng này thì HĐ vừa tạo cả phút sau mới hiện trên cây.
qc.invalidateQueries({ queryKey: ['pipeline-khkk-index'] })
qc.invalidateQueries({ queryKey: ['pipeline-contract-index'] })
onClose()
navigate(`/contracts/${data.contractId}`)
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const noWorkflow = !workflows.isLoading && (workflows.data?.length ?? 0) === 0
return (
<Dialog
open
size="lg"
onClose={onClose}
title="Đưa dòng kế hoạch vào Hợp đồng"
footer={
<>
<Button variant="ghost" onClick={onClose}>
Huỷ
</Button>
<Button
onClick={() => create.mutate()}
disabled={create.isPending || !lineId || !workflowId}
>
{create.isPending ? 'Đang tạo...' : 'Tạo hợp đồng'}
</Button>
</>
}
>
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-900">
<div className="font-semibold">Tạo hợp đng từ kế hoạch &ldquo;{plan.maKeHoach ?? 'chưa cấp mã'}&rdquo;</div>
<ul className="mt-1.5 list-disc space-y-1 pl-4 text-[11px]">
<li>Mỗi lần bấm tạo <strong>1 hợp đng cho 1 dòng</strong> (1 nhà cung cấp). Liên danh nhiều NCC thì làm lần lượt.</li>
<li>Giá trị lấy đúng <strong>giá duyệt đã chốt</strong> của dòng không lấy giá đ xuất.</li>
<li> mới trạng thái <strong>Nháp</strong> đi theo quy trình duyệt bạn chọn dưới đây.</li>
<li>Dòng đã đưa vào thì <strong>không đưa lần hai</strong> cần sửa thì xử chính hợp đng đó.</li>
</ul>
</div>
<div className="space-y-3">
<div>
<Label>Chọn dòng đưa vào hợp đng</Label>
{plan.lines.length === 0 ? (
<div className="text-sm text-muted-foreground">Phiếu chưa dòng nhà cung cấp nào.</div>
) : (
<ul className="mt-1 space-y-1.5">
{plan.lines.map((l) => {
const done = l.contractId != null
const noPrice = l.approvedAmount == null
const disabled = done || noPrice
return (
<li key={l.id}>
<label
className={cn(
'flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm',
disabled && 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400',
!disabled && lineId === l.id && 'border-brand-400 bg-brand-50',
!disabled && lineId !== l.id && 'border-slate-200 hover:bg-slate-50',
)}
title={
done
? 'Dòng này đã có hợp đồng.'
: noPrice
? 'Dòng chưa có giá duyệt chốt — phiếu phải qua bước chốt giá.'
: undefined
}
>
<input
type="radio"
name="khkk-bridge-line"
checked={lineId === l.id}
disabled={disabled}
onChange={() => {
setLineId(l.id)
setTenHopDongOverride(null)
}}
/>
<span className="min-w-0 flex-1">
<span className="font-medium">{l.tenHangMuc ?? '(chưa gán hạng mục)'}</span>
<span className="text-slate-500"> · {l.supplierName ?? '—'}</span>
</span>
<span className="shrink-0 tabular-nums">{formatVnd(l.approvedAmount)}</span>
{done && (
<a
href={`/contracts/${l.contractId}`}
className="shrink-0 text-[11px] font-medium text-brand-600 hover:underline"
>
Đã
</a>
)}
{!done && noPrice && (
<span className="shrink-0 text-[11px]">Chưa giá chốt</span>
)}
</label>
</li>
)
})}
</ul>
)}
</div>
<div>
<Label htmlFor="bridge-type">Loại hợp đng</Label>
<Select
id="bridge-type"
value={contractType}
onChange={(e) => setContractType(Number(e.target.value))}
>
{Object.entries(ContractTypeLabel).map(([v, label]) => (
<option key={v} value={v}>
{label}
</option>
))}
</Select>
</div>
<div>
<Label htmlFor="bridge-workflow">Quy trình duyệt hợp đng</Label>
<Select
id="bridge-workflow"
value={workflowId}
onChange={(e) => setWorkflowId(e.target.value)}
disabled={workflows.isLoading || noWorkflow}
>
<option value=""> Chọn quy trình </option>
{(workflows.data ?? []).map((w) => (
<option key={w.id} value={w.id}>
{w.code} v{w.version} {w.name}
</option>
))}
</Select>
{noWorkflow && (
// Rỗng-vì-chưa-cấu-hình phải khác câu với rỗng-vì-đang-tải: người dùng cần biết
// đây là việc của quản trị, không phải chờ thêm.
<p className="mt-1 text-[11px] text-red-700">
Chưa quy trình duyệt hợp đng nào đưc ghim cho người dùng chọn (cần đang hiệu lực
đưc ghim). Nhờ quản trị mở trong màn Quy trình duyệt rồi quay lại.
</p>
)}
</div>
<div>
<Label htmlFor="bridge-name">Tên hợp đng</Label>
<Input
id="bridge-name"
value={tenHopDong}
onChange={(e) => setTenHopDongOverride(e.target.value)}
maxLength={500}
/>
<p className="mt-1 text-[11px] text-slate-500">
Bỏ trống thì hệ thống tự đt theo tên gói thầu hạng mục của dòng.
</p>
</div>
</div>
</Dialog>
)
}

View File

@ -189,6 +189,17 @@ export interface KhkkListItemDto {
totalApprovedAmount: number | null
createdAt: string
updatedAt: string | null
// [K7 S167 — vá-4b] TẬP id HĐ đã sinh ra từ CÁC DÒNG của phiếu này.
// ✅ KHỚP BE: `ContractSigningPlanListItemDto:75 List<Guid> ContractIds` (lane BE K7 vá-4a,
// append-CUỐI) ⇒ JSON `contractIds`. Đối chiếu đã ghi ở `sub-implfe-k7.md` mục KHỚP-DTO.
// VÌ SAO CẦN: cây 4 folder phải hiện ĐỦ N hợp đồng của một gói, mà `pe.contractId` chỉ
// giữ HĐ ĐẦU (`CreateContractFromEvaluationFeatures.cs:143-145`) — HĐ thứ 2 trở đi
// CHỈ có ở dòng phiếu KHKK (`ContractSigningPlanLineDto:74 Guid? ContractId`).
// 🔴 KHAI OPTIONAL DÙ BE KHÔNG NULL: payload đang nằm trong cache React Query từ trước
// lượt này (và API cũ chưa deploy) KHÔNG có field ⇒ optional để cây bớt nguồn chứ
// KHÔNG vỡ; chỗ đọc phân biệt được vắng-field ⟂ mảng-rỗng (xem `usePipelineStages`).
// ⇒ Mọi nơi đọc qua `khkkPlanContractIds()` bên dưới — đổi tên chỉ sửa ĐÚNG 1 hàm đó.
contractIds?: string[] | null
}
export interface KhkkLineDto {
@ -290,6 +301,22 @@ export interface KhkkDetailDto extends KhkkListItemDto {
budgetFrozen: boolean
}
// [K7 S167 — vá-4b] Tập id HỢP ĐỒNG mà một phiếu KHKK đã đẻ ra. Gom từ MỌI nguồn CÓ THẬT
// trên payload thay vì đọc một ô cố định — hình dạng "một TẬP" là thứ K5 đã chừa sẵn:
// (1) `contractIds` — LIST payload (BE `…ListItemDto:75`; vắng khi payload đến từ cache
// cũ / API chưa deploy ⇒ nhánh này trả rỗng, KHÔNG ném lỗi);
// (2) `lines[].contractId` — DETAIL payload, TỒN TẠI HÔM NAY trên đĩa
// (`KhkkDetailDto.lines` + `KhkkLineDto.contractId`, mirror BE `…LineDto:74`).
// Trả mảng CÓ THỂ TRÙNG — nơi gọi đang đổ vào `Set` nên không khử trùng hai lần.
export function khkkPlanContractIds(
plan: KhkkListItemDto & Partial<Pick<KhkkDetailDto, 'lines'>>,
): string[] {
const out: string[] = []
for (const id of plan.contractIds ?? []) if (id) out.push(id)
for (const line of plan.lines ?? []) if (line.contractId) out.push(line.contractId)
return out
}
// Phiếu PE nguồn đủ điều kiện lập kế hoạch (DaDuyet + có NCC trúng thầu).
// CỐ Ý là TẬP CON của `ApprovedPeAwaitingPlanDto` BE trả — field thừa bỏ qua runtime.
// 🔴 [K2 S164 — OG-7] Vị ngữ "chưa có plan sống" ĐÃ BỎ khỏi picker (`Features.cs:1291-1295`):
@ -372,6 +399,28 @@ export interface KhkkTransitionResult {
currentApprovalLevelOrder: number | null
}
// ===================== K7 S167 — cầu KHKK → Hợp đồng ================================
// 🔴 HỢP-ĐỒNG LITERAL với lane BE K7 (nguồn `sub-invest-fable-b2-cum3.md §K7.1:29-54`,
// lane BE viết SONG SONG lượt này — lệch 1 chữ = đứt CÂM, bài W2 6-điểm-đứt):
// POST /contract-signing-plans/{id}/create-contract policy **`Contracts.Create`**
// (gotcha #85 — key theo TÀI NGUYÊN SINH RA là Contract, KHÔNG phải `KeHoachKyKet.*`)
// 201 → { contractId, maHopDong }
// `lineIds` là MẢNG vì máy BE gộp-capable (OG-1 "máy rộng"), còn ô chọn hiện gửi ĐÚNG 1
// phần tử (OG-1 "giao diện 1-1") ⇒ mở gộp sau = đổi ô chọn, KHÔNG đụng hợp-đồng này.
export interface CreateContractFromKhkkInput {
lineIds: string[]
contractType: number
approvalWorkflowId: string
tenHopDong?: string | null
}
export interface CreateContractFromKhkkResult {
contractId: string
// BE gen mã NGAY lúc bắc cầu (khuôn bridge PE `:113`), nhưng khai nullable để lỡ nhánh
// nào chưa gen thì FE hiện câu khác chứ không in "undefined" lên toast.
maHopDong: string | null
}
export interface PagedResult<T> {
items: T[]
total: number

View File

@ -28,7 +28,9 @@ import {
KHKK_APPROVAL_GROUPS,
KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS,
KhkkPhase,
khkkGroupMenuLabel,
khkkPlanContractIds,
type KhkkListItemDto,
type PagedResult,
} from '@/types/khkk'
@ -56,6 +58,10 @@ const KHKK_MENU_KEYS = ['KeHoachKyKet']
const CONTRACT_MENU_KEYS = ['Contracts', ...CONTRACT_TYPE_CODES.map(c => `Ct_${c}_List`)]
const HARD_COPY_MENU_KEYS = ['HopDongCung', ...CONTRACT_TYPE_CODES.map(c => `Hdc_${c}`)]
// [K7 S167 — vá-4b] Chuông DEV bắn TỐI ĐA 1 LẦN/phiên (buildStages chạy lại theo từng gói
// thầu ⇒ không có cờ này thì console ngập). Xem chỗ dùng ở GĐ3.
let warnedMissingLineContractIds = false
export type PipelineLinkage = {
canPe: boolean
canKhkk: boolean
@ -164,7 +170,8 @@ export type BuildStagesOptions = {
* GĐ1 = chính các phiếu ấy
* GĐ2 = KHKK có `purchaseEvaluationId` thuộc tập phiếu, chia thành 8 NGĂN theo
* nhóm duyệt (+ ngăn "(chưa phân nhóm)" khi có phiếu lạc) — K5
* GĐ3 = HĐ trong TẬP id của gói (xem forward-provision B3-A5 ở thân hàm)
* GĐ3 = HĐ trong TẬP id của gói — hợp của `pe.contractId` (legacy) và contract-id trên
* các DÒNG phiếu KHKK (K7 vá-4b; xem thân hàm)
* GĐ4 = đúng các HĐ ấy khi `hasSealedCopy === true`
*/
export function usePipelineStages() {
@ -269,26 +276,41 @@ export function usePipelineStages() {
// HĐ nằm ngoài index (list kẹp 200) vẫn được nêu bằng leaf tối giản thay vì
// biến mất im lặng — nhưng KHÔNG suy được `hasSealedCopy` ⇒ chỉ vào GĐ3.
//
// [K5 S166 — forward-provision B3-A5] Lấy HĐ của gói bằng một TẬP id gộp từ
// NHIỀU nguồn, thay vì đọc thẳng một ô `pe.contractId`. Hôm nay tập ấy chỉ có
// một nguồn, nhưng hình dạng đã đúng nên thêm nguồn sau là thêm một dòng.
// [K5 S166 — forward-provision B3-A5 · K7 S167 vá-4b ĐÃ CẮM NGUỒN 2] Lấy HĐ của gói
// bằng một TẬP id gộp từ NHIỀU nguồn, thay vì đọc thẳng một ô `pe.contractId`.
//
// 🔴 VÌ SAO CẦN: 1 phiếu có thể đẻ N hợp đồng (liên danh — endpoint
// `create-contract` trả `contractIds: string[]`, xem `PeDetailTabs.tsx:2186`),
// NHƯNG BE chỉ ghi ngược HĐ ĐẦU vào phiếu:
// 🔴 VÌ SAO CẦN: 1 gói có thể đẻ N hợp đồng (liên danh, và từ K7 là N dòng phiếu
// KHKK × mỗi lần bấm "Đưa vào HĐ"), NHƯNG phía PE chỉ ghi ngược HĐ ĐẦU:
// `CreateContractFromEvaluationFeatures.cs:143-145`
// "Idempotency: pe.ContractId = HĐ ĐẦU … N HĐ liên-danh truy qua changelog"
// ⇒ HĐ thứ 2 trở đi hiện KHÔNG có đường nào để cây nhìn thấy: `PeListItem`
// không có `lines[]`, `ContractListItem` không có `purchaseEvaluationId`,
// `Contract.cs` cũng không có khoá ngược. Đây là giới hạn DỮ LIỆU, không
// phải chỗ này thiếu logic — đừng "sửa" bằng cách bịa field không tồn tại.
// ⇒ KHI BE mở đường (một trong hai: list DTO trả `contractIds[]`, hoặc dòng
// phiếu có `contractId` riêng), nối vào ngay dưới đây và xoá ghi chú này.
// ⇒ đọc mỗi `pe.contractId` thì HĐ thứ 2 trở đi VÔ HÌNH trên cây (spec cụm-3 vá-4).
//
// 2 nguồn (dedupe bằng `Set` — chúng CỐ Ý chồng nhau ở HĐ đầu tiên):
// (1) `pe.contractId` — LEGACY, giữ cho phiếu PE cũ đã bắc cầu bằng đường PE→HĐ.
// (2) `khkkPlanContractIds(k)` — HĐ bắc cầu từ TỪNG DÒNG phiếu KHKK của gói. K7 ghi
// `Line.ContractId` là NGUỒN THẬT, `pe.ContractId ??=` chỉ còn là fallback hiển
// thị ⇒ N HĐ / 1 PE hiện ĐỦ, đúng OG-1 "máy rộng, UI hẹp".
const contractIdsOfPackage = new Set<string>()
for (const p of peItems) {
if (p.contractId) contractIdsOfPackage.add(p.contractId)
// ← chỗ cắm nguồn thứ hai (HĐ 2..N). Ví dụ khi có: `p.contractIds?.forEach(...)`
// hoặc `p.lines?.forEach(l => l.contractId && add(l.contractId))`.
}
for (const k of khkkItems) {
for (const cid of khkkPlanContractIds(k)) contractIdsOfPackage.add(cid)
}
// Nguồn (2) IM khi BE chưa trả field trên LIST payload — mà "im vì chưa nối" trông y
// hệt "im vì gói chưa có HĐ nào". Nói ra ở DEV thay vì để người sau tưởng cây đã đủ.
// Chỉ báo khi có phiếu ĐÃ DUYỆT (chỉ phiếu duyệt rồi mới bắc cầu được) mà KHÔNG phiếu
// nào mang field — `[]` (BE đã trả, gói chưa có HĐ) thì im, đúng như phải thế.
if (import.meta.env.DEV && !warnedMissingLineContractIds) {
const approved = khkkItems.filter(k => k.phase === KhkkPhase.DaDuyet)
if (approved.length > 0 && approved.every(k => k.contractIds == null)) {
warnedMissingLineContractIds = true
console.warn(
'[pipeline GĐ3] Phiếu KHKK đã duyệt nhưng payload list KHÔNG có `contractIds` ⇒ cây ' +
'chỉ thấy HĐ qua `pe.contractId` (HĐ ĐẦU). API cũ chưa deploy hoặc cache cũ — xem ghi ' +
'chú `types/khkk.ts` (K7 vá-4b, BE `…ListItemDto:75`).',
)
}
}
const stage3Leaves: PipelineStage['leaves'] = []
@ -296,8 +318,13 @@ export function usePipelineStages() {
// `Set` đã khử trùng ⇒ không cần biến `seen` riêng như trước.
for (const contractId of contractIdsOfPackage) {
const c = linkage.contractById.get(contractId)
// Tên gói thầu để làm dòng phụ khi HĐ nằm ngoài cửa sổ index 200.
const fallbackName = peItems.find(p => p.contractId === contractId)?.tenGoiThau ?? null
// Tên gói thầu để làm dòng phụ khi HĐ nằm ngoài cửa sổ index 200. [K7 vá-4b] tra CẢ
// 2 nguồn: HĐ đến từ dòng phiếu KHKK thì KHÔNG phiếu PE nào trỏ tới nó ⇒ chỉ tra
// `peItems` sẽ rơi về `null` và dòng phụ trống trơn.
const fallbackName =
peItems.find(p => p.contractId === contractId)?.tenGoiThau
?? khkkItems.find(k => khkkPlanContractIds(k).includes(contractId))?.peTenGoiThau
?? null
if (linkage.canContract) {
stage3Leaves.push({
id: contractId,

View File

@ -12,7 +12,8 @@ import { useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom'
import {
ArrowLeft, ClipboardList, FileCheck, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
ArrowLeft, ClipboardList, FileCheck, FileSignature, Paperclip, Pencil, Plus, Save, Trash2,
Upload, Users,
} from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/ui/PageHeader'
@ -20,17 +21,21 @@ import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { usePermission } from '@/hooks/usePermission'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { ContractTypeLabel } from '@/types/forms'
import {
DOSSIER_KIND_LABELS, DOSSIER_STATUS_BADGE, DOSSIER_STATUS_LABELS,
DossierItemKind, DossierItemStatus, KHKK_APPROVAL_GROUP_LABELS,
KHKK_PHASE_BADGE, KHKK_PHASE_LABELS,
KhkkAttachmentPurpose, KhkkPhase,
type CreateContractFromKhkkInput, type CreateContractFromKhkkResult,
type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto,
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkLineDto, type KhkkPhaseValue,
type UpsertKhkkDossierItemInput,
} from '@/types/khkk'
import { KhkkWorkflowPanel } from './KhkkWorkflowPanel'
@ -123,6 +128,18 @@ export function KhkkDetailPage() {
const [ghiChu, setGhiChu] = useState('')
const [hoSoLink, setHoSoLink] = useState('')
const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null)
const [bridgeOpen, setBridgeOpen] = useState(false)
// [K7 S167 — vá-1 vế FE] HỘI 2 KHÓA, đúng bằng server, KHÔNG lỏng hơn:
// · `KeHoachKyKet.Read` — cửa đọc chính phiếu này (`ContractSigningPlansController.cs:36`,
// `MenuPermissionHandler.cs:40` so khớp CHÍNH XÁC 1 key ⇒ KHÔNG OR `Khkk_*`, gotcha #85);
// · `Contracts.Create` — cửa của endpoint bắc cầu: tài nguyên SINH RA là Contract nên key
// phải là của endpoint đích, không phải của màn đang đứng.
// Chữ ký hook là `can(menuKey, action)` (`hooks/usePermission.ts:17`) — không có dạng
// `usePermission('X','Y')`.
const { can } = usePermission()
const canReadPlan = can('KeHoachKyKet', 'Read')
const canCreateContract = can('Contracts', 'Create')
const plan = useQuery({
queryKey: ['khkk-detail', id],
@ -276,6 +293,23 @@ export function KhkkDetailPage() {
const isDraft = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
const canDeletePlan = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TuChoi
// [K7 S167] Dòng bắc cầu được = CHƯA có HĐ ∧ ĐÃ có giá duyệt chốt — đúng 2 guard BE
// (`§K7.1` guard 4 + 5). Chặn sớm ở FE để người dùng không bấm rồi mới ăn 409.
const bridgeableLines = k.lines.filter(l => l.contractId == null && l.approvedAmount != null)
// Nút chỉ có nghĩa khi phiếu ĐÃ DUYỆT (giá mới chốt) và người dùng đọc được phiếu.
const showBridge = phase === KhkkPhase.DaDuyet && canReadPlan
// Thiếu vế nào thì NÓI RA vế đó thay vì ẩn nút: người dùng đang đứng trên phiếu, ẩn đi họ
// chỉ đoán "hệ thống không có chức năng". Khuôn nút-khoá-kèm-title `PeWorkflowPanel.tsx:547-561`.
const bridgeBlockReason = !canCreateContract
? 'Bạn chưa có quyền "Tạo hợp đồng" (Contracts.Create) — nhờ quản trị cấp quyền rồi thử lại.'
: k.lines.length === 0
? 'Phiếu chưa có dòng nhà cung cấp nào.'
: bridgeableLines.length === 0
? (k.lines.every((l) => l.contractId != null)
? 'Mọi dòng của phiếu đã được đưa vào hợp đồng.'
: 'Chưa dòng nào có giá duyệt chốt — phiếu phải qua bước chốt giá trước khi đưa vào HĐ.')
: null
const startEditHeader = () => {
setGhiChu(k.ghiChu ?? '')
setHoSoLink(k.hoSoLink ?? '')
@ -311,6 +345,21 @@ export function KhkkDetailPage() {
>
{KHKK_PHASE_LABELS[phase]}
</span>
{/* [K7 S167 — vá-1 vế FE] Nút bắc cầu KHKK → HĐ. `<span title>` bọc ngoài vì
`Button` có `disabled:pointer-events-none` (Button.tsx:12) ⇒ title đặt thẳng
trên nút đã khoá sẽ không hiện, người dùng mất luôn câu giải thích. */}
{showBridge && (
<span title={bridgeBlockReason ?? 'Tạo hợp đồng từ một dòng của kế hoạch này'}>
<Button
variant="outline"
disabled={bridgeBlockReason != null}
onClick={() => setBridgeOpen(true)}
>
<FileSignature className="mr-2 h-4 w-4" />
Đưa vào
</Button>
</span>
)}
{canDeletePlan && (
<Button
variant="outline"
@ -467,7 +516,21 @@ export function KhkkDetailPage() {
<tbody>
{k.lines.map((l) => (
<tr key={l.id} className="border-b border-slate-100 last:border-0">
<td className="px-3 py-2 font-medium text-brand-800">{l.supplierName ?? '—'}</td>
<td className="px-3 py-2 font-medium text-brand-800">
{l.supplierName ?? '—'}
{/* [K7 S167] Dòng đã bắc cầu — khuôn dấu "✓ HĐ" của danh sách PE
(`PurchaseEvaluationsListPage.tsx:599`). Thấy ngay ở bảng chứ không
phải mở dialog mới biết dòng nào đã xong. */}
{l.contractId && (
<a
href={`/contracts/${l.contractId}`}
className="ml-2 text-[10px] font-medium text-brand-600 hover:underline"
title="Dòng này đã được đưa vào hợp đồng — bấm để mở HĐ"
>
</a>
)}
</td>
<td className="px-3 py-2 text-xs text-slate-700">
{l.tenHangMuc ? l.tenHangMuc : <span className="text-slate-300"></span>}
</td>
@ -713,6 +776,245 @@ export function KhkkDetailPage() {
</div>
)}
</Dialog>
{/* [K7 S167 — vá-16] Dialog bắc cầu. Render có điều kiện (không dùng prop `open`) để
mỗi lần mở là state mới — khuôn dialog Xóa-phiếu `PeWorkflowPanel.tsx:879`. */}
{bridgeOpen && (
<CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />
)}
</div>
)
}
// ===================== K7 S167 — cầu KHKK → Hợp đồng =================================
// [vá-16] 0 KHUÔN MỚI — ghép 2 khuôn đang sống:
// · vỏ dialog + khối "nói thẳng hệ quả" = nút Xóa-phiếu S155 (`PeWorkflowPanel.tsx:879-918`);
// · ô chọn Quy trình duyệt = picker `ContractCreatePage.tsx:333-343`
// (GET `/approval-workflows-v2?applicableType=3` → lọc theo cờ).
// [OG-1 "máy rộng, UI hẹp"] Máy BE nhận `lineIds[]` (gộp được N dòng vào 1 HĐ); ô chọn ở
// đây CỐ Ý 1-1 — mỗi lần bấm đúng 1 dòng. Mở gộp sau = đổi radio thành checkbox, KHÔNG
// đụng hợp-đồng API.
type BridgeWorkflowOption = {
id: string
code: string
version: number
name: string
isActive: boolean
isUserSelectable: boolean
}
function CreateContractFromKhkkDialog({ plan, onClose }: { plan: KhkkDetailDto; onClose: () => void }) {
const navigate = useNavigate()
const qc = useQueryClient()
const bridgeable = plan.lines.filter((l) => l.contractId == null && l.approvedAmount != null)
const [lineId, setLineId] = useState(() => bridgeable[0]?.id ?? '')
const [contractType, setContractType] = useState(1)
const [workflowId, setWorkflowId] = useState('')
// null = "đang dùng tên mặc định theo dòng đang chọn". Giữ null thay vì đồng bộ bằng
// useEffect: đổi dòng thì tên gợi ý đổi theo NGAY trong render, không có nhịp lệch.
const [tenHopDongOverride, setTenHopDongOverride] = useState<string | null>(null)
const selected: KhkkLineDto | null = plan.lines.find((l) => l.id === lineId) ?? null
const defaultName = selected
? `${plan.peTenGoiThau ?? 'Kế hoạch ký kết'}${selected.tenHangMuc ?? selected.supplierName ?? ''}`.trim()
: ''
const tenHopDong = tenHopDongOverride ?? defaultName
const workflows = useQuery({
// Query key RIÊNG (không dùng lại `approval-workflows-v2-contract` của ContractCreatePage):
// cùng URL nhưng LỌC KHÁC ⇒ dùng chung ô cache là để trang này ăn dữ liệu trang kia một
// cách câm (bài S159).
queryKey: ['khkk-bridge-contract-workflows'],
queryFn: async () => {
const res = await api.get<{ types: { applicableType: number; history: BridgeWorkflowOption[] }[] }>(
'/approval-workflows-v2',
{ params: { applicableType: 3 } },
)
const bucket = res.data.types.find((t) => t.applicableType === 3)
// 🔴 LỌC ĐỦ 2 CỜ (spec cụm-3 vá-2): `isUserSelectable` (admin ghim cho user chọn) ∧
// `isActive` (bản còn hiệu lực). Picker cũ `ContractCreatePage.tsx:341` chỉ lọc 1 cờ ⇒
// mời chọn được bản đã retire rồi ăn 409 từ guard BE. Ở đây lọc đủ ngay từ nguồn.
return (bucket?.history ?? []).filter((w) => w.isUserSelectable && w.isActive)
},
})
const create = useMutation({
mutationFn: async () => {
const body: CreateContractFromKhkkInput = {
lineIds: [lineId],
contractType,
approvalWorkflowId: workflowId,
tenHopDong: tenHopDong.trim() || null,
}
return (
await api.post<CreateContractFromKhkkResult>(
`/contract-signing-plans/${plan.id}/create-contract`,
body,
)
).data
},
onSuccess: (data) => {
toast.success(data.maHopDong ? `Đã tạo hợp đồng ${data.maHopDong}` : 'Đã tạo hợp đồng từ kế hoạch')
qc.invalidateQueries({ queryKey: ['khkk-detail', plan.id] })
qc.invalidateQueries({ queryKey: ['khkk-list'] })
// Cây 4 folder đọc 2 index riêng (`usePipelineStages.ts`) với `staleTime` 60s — không
// gọi 2 dòng này thì HĐ vừa tạo cả phút sau mới hiện trên cây.
qc.invalidateQueries({ queryKey: ['pipeline-khkk-index'] })
qc.invalidateQueries({ queryKey: ['pipeline-contract-index'] })
onClose()
navigate(`/contracts/${data.contractId}`)
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const noWorkflow = !workflows.isLoading && (workflows.data?.length ?? 0) === 0
return (
<Dialog
open
size="lg"
onClose={onClose}
title="Đưa dòng kế hoạch vào Hợp đồng"
footer={
<>
<Button variant="ghost" onClick={onClose}>
Huỷ
</Button>
<Button
onClick={() => create.mutate()}
disabled={create.isPending || !lineId || !workflowId}
>
{create.isPending ? 'Đang tạo...' : 'Tạo hợp đồng'}
</Button>
</>
}
>
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-900">
<div className="font-semibold">Tạo hợp đng từ kế hoạch &ldquo;{plan.maKeHoach ?? 'chưa cấp mã'}&rdquo;</div>
<ul className="mt-1.5 list-disc space-y-1 pl-4 text-[11px]">
<li>Mỗi lần bấm tạo <strong>1 hợp đng cho 1 dòng</strong> (1 nhà cung cấp). Liên danh nhiều NCC thì làm lần lượt.</li>
<li>Giá trị lấy đúng <strong>giá duyệt đã chốt</strong> của dòng không lấy giá đ xuất.</li>
<li> mới trạng thái <strong>Nháp</strong> đi theo quy trình duyệt bạn chọn dưới đây.</li>
<li>Dòng đã đưa vào thì <strong>không đưa lần hai</strong> cần sửa thì xử chính hợp đng đó.</li>
</ul>
</div>
<div className="space-y-3">
<div>
<Label>Chọn dòng đưa vào hợp đng</Label>
{plan.lines.length === 0 ? (
<div className="text-sm text-muted-foreground">Phiếu chưa dòng nhà cung cấp nào.</div>
) : (
<ul className="mt-1 space-y-1.5">
{plan.lines.map((l) => {
const done = l.contractId != null
const noPrice = l.approvedAmount == null
const disabled = done || noPrice
return (
<li key={l.id}>
<label
className={cn(
'flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm',
disabled && 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400',
!disabled && lineId === l.id && 'border-brand-400 bg-brand-50',
!disabled && lineId !== l.id && 'border-slate-200 hover:bg-slate-50',
)}
title={
done
? 'Dòng này đã có hợp đồng.'
: noPrice
? 'Dòng chưa có giá duyệt chốt — phiếu phải qua bước chốt giá.'
: undefined
}
>
<input
type="radio"
name="khkk-bridge-line"
checked={lineId === l.id}
disabled={disabled}
onChange={() => {
setLineId(l.id)
setTenHopDongOverride(null)
}}
/>
<span className="min-w-0 flex-1">
<span className="font-medium">{l.tenHangMuc ?? '(chưa gán hạng mục)'}</span>
<span className="text-slate-500"> · {l.supplierName ?? '—'}</span>
</span>
<span className="shrink-0 tabular-nums">{formatVnd(l.approvedAmount)}</span>
{done && (
<a
href={`/contracts/${l.contractId}`}
className="shrink-0 text-[11px] font-medium text-brand-600 hover:underline"
>
Đã
</a>
)}
{!done && noPrice && (
<span className="shrink-0 text-[11px]">Chưa giá chốt</span>
)}
</label>
</li>
)
})}
</ul>
)}
</div>
<div>
<Label htmlFor="bridge-type">Loại hợp đng</Label>
<Select
id="bridge-type"
value={contractType}
onChange={(e) => setContractType(Number(e.target.value))}
>
{Object.entries(ContractTypeLabel).map(([v, label]) => (
<option key={v} value={v}>
{label}
</option>
))}
</Select>
</div>
<div>
<Label htmlFor="bridge-workflow">Quy trình duyệt hợp đng</Label>
<Select
id="bridge-workflow"
value={workflowId}
onChange={(e) => setWorkflowId(e.target.value)}
disabled={workflows.isLoading || noWorkflow}
>
<option value=""> Chọn quy trình </option>
{(workflows.data ?? []).map((w) => (
<option key={w.id} value={w.id}>
{w.code} v{w.version} {w.name}
</option>
))}
</Select>
{noWorkflow && (
// Rỗng-vì-chưa-cấu-hình phải khác câu với rỗng-vì-đang-tải: người dùng cần biết
// đây là việc của quản trị, không phải chờ thêm.
<p className="mt-1 text-[11px] text-red-700">
Chưa quy trình duyệt hợp đng nào đưc ghim cho người dùng chọn (cần đang hiệu lực
đưc ghim). Nhờ quản trị mở trong màn Quy trình duyệt rồi quay lại.
</p>
)}
</div>
<div>
<Label htmlFor="bridge-name">Tên hợp đng</Label>
<Input
id="bridge-name"
value={tenHopDong}
onChange={(e) => setTenHopDongOverride(e.target.value)}
maxLength={500}
/>
<p className="mt-1 text-[11px] text-slate-500">
Bỏ trống thì hệ thống tự đt theo tên gói thầu hạng mục của dòng.
</p>
</div>
</div>
</Dialog>
)
}

View File

@ -189,6 +189,17 @@ export interface KhkkListItemDto {
totalApprovedAmount: number | null
createdAt: string
updatedAt: string | null
// [K7 S167 — vá-4b] TẬP id HĐ đã sinh ra từ CÁC DÒNG của phiếu này.
// ✅ KHỚP BE: `ContractSigningPlanListItemDto:75 List<Guid> ContractIds` (lane BE K7 vá-4a,
// append-CUỐI) ⇒ JSON `contractIds`. Đối chiếu đã ghi ở `sub-implfe-k7.md` mục KHỚP-DTO.
// VÌ SAO CẦN: cây 4 folder phải hiện ĐỦ N hợp đồng của một gói, mà `pe.contractId` chỉ
// giữ HĐ ĐẦU (`CreateContractFromEvaluationFeatures.cs:143-145`) — HĐ thứ 2 trở đi
// CHỈ có ở dòng phiếu KHKK (`ContractSigningPlanLineDto:74 Guid? ContractId`).
// 🔴 KHAI OPTIONAL DÙ BE KHÔNG NULL: payload đang nằm trong cache React Query từ trước
// lượt này (và API cũ chưa deploy) KHÔNG có field ⇒ optional để cây bớt nguồn chứ
// KHÔNG vỡ; chỗ đọc phân biệt được vắng-field ⟂ mảng-rỗng (xem `usePipelineStages`).
// ⇒ Mọi nơi đọc qua `khkkPlanContractIds()` bên dưới — đổi tên chỉ sửa ĐÚNG 1 hàm đó.
contractIds?: string[] | null
}
export interface KhkkLineDto {
@ -290,6 +301,22 @@ export interface KhkkDetailDto extends KhkkListItemDto {
budgetFrozen: boolean
}
// [K7 S167 — vá-4b] Tập id HỢP ĐỒNG mà một phiếu KHKK đã đẻ ra. Gom từ MỌI nguồn CÓ THẬT
// trên payload thay vì đọc một ô cố định — hình dạng "một TẬP" là thứ K5 đã chừa sẵn:
// (1) `contractIds` — LIST payload (BE `…ListItemDto:75`; vắng khi payload đến từ cache
// cũ / API chưa deploy ⇒ nhánh này trả rỗng, KHÔNG ném lỗi);
// (2) `lines[].contractId` — DETAIL payload, TỒN TẠI HÔM NAY trên đĩa
// (`KhkkDetailDto.lines` + `KhkkLineDto.contractId`, mirror BE `…LineDto:74`).
// Trả mảng CÓ THỂ TRÙNG — nơi gọi đang đổ vào `Set` nên không khử trùng hai lần.
export function khkkPlanContractIds(
plan: KhkkListItemDto & Partial<Pick<KhkkDetailDto, 'lines'>>,
): string[] {
const out: string[] = []
for (const id of plan.contractIds ?? []) if (id) out.push(id)
for (const line of plan.lines ?? []) if (line.contractId) out.push(line.contractId)
return out
}
// Phiếu PE nguồn đủ điều kiện lập kế hoạch (DaDuyet + có NCC trúng thầu).
// CỐ Ý là TẬP CON của `ApprovedPeAwaitingPlanDto` BE trả — field thừa bỏ qua runtime.
// 🔴 [K2 S164 — OG-7] Vị ngữ "chưa có plan sống" ĐÃ BỎ khỏi picker (`Features.cs:1291-1295`):
@ -372,6 +399,28 @@ export interface KhkkTransitionResult {
currentApprovalLevelOrder: number | null
}
// ===================== K7 S167 — cầu KHKK → Hợp đồng ================================
// 🔴 HỢP-ĐỒNG LITERAL với lane BE K7 (nguồn `sub-invest-fable-b2-cum3.md §K7.1:29-54`,
// lane BE viết SONG SONG lượt này — lệch 1 chữ = đứt CÂM, bài W2 6-điểm-đứt):
// POST /contract-signing-plans/{id}/create-contract policy **`Contracts.Create`**
// (gotcha #85 — key theo TÀI NGUYÊN SINH RA là Contract, KHÔNG phải `KeHoachKyKet.*`)
// 201 → { contractId, maHopDong }
// `lineIds` là MẢNG vì máy BE gộp-capable (OG-1 "máy rộng"), còn ô chọn hiện gửi ĐÚNG 1
// phần tử (OG-1 "giao diện 1-1") ⇒ mở gộp sau = đổi ô chọn, KHÔNG đụng hợp-đồng này.
export interface CreateContractFromKhkkInput {
lineIds: string[]
contractType: number
approvalWorkflowId: string
tenHopDong?: string | null
}
export interface CreateContractFromKhkkResult {
contractId: string
// BE gen mã NGAY lúc bắc cầu (khuôn bridge PE `:113`), nhưng khai nullable để lỡ nhánh
// nào chưa gen thì FE hiện câu khác chứ không in "undefined" lên toast.
maHopDong: string | null
}
export interface PagedResult<T> {
items: T[]
total: number

View File

@ -5,6 +5,7 @@ using SolutionErp.Application.Common.Models;
using SolutionErp.Application.ContractSigningPlans;
using SolutionErp.Application.ContractSigningPlans.Services;
using SolutionErp.Domain.ContractSigningPlans;
using SolutionErp.Domain.Contracts; // ContractType (cầu GĐ2 → GĐ3, K7)
namespace SolutionErp.Api.Controllers;
@ -213,6 +214,30 @@ public class ContractSigningPlansController(
=> Ok(await workflow.TransitionAsync(id, body.Action, body.Comment,
body.ApplyLevelFinalize ?? true, ct));
// ===================== Cầu GĐ2 → GĐ3 (K7 — S167) =====================
// 🔴 [vá-1 spec-cụm-3 §②-1] AUTHZ = HỘI 2 KHOÁ, cả 2 vế đều phải chặn được:
// vế 1 (class, dòng :28) `KeHoachKyKet.Read` — không được xem kế hoạch thì không được
// lấy dòng của nó đi tạo HĐ;
// vế 2 (action, ngay dưới) `Contracts.Create` — tài nguyên SINH RA là Contract ⇒ khoá phải
// là khoá của thứ được tạo (gotcha #85:
// gate bằng đúng key policy của endpoint).
// ASP.NET Core cộng dồn [Authorize] class + action theo AND ⇒ thiếu 1 trong 2 = 403.
// ⚠️ `Contracts.Create` trước S167 KHÔNG role nào có (seeder `SeedAllRolesReviewReadPermissionsAsync`
// đặt `CanCreate = isPe` ⇒ false cho key `Contracts` — grep tên hàm, ĐỪNG neo số dòng: file này
// dài ra là số thối [gate-K7 F-8 bắt neo cũ sai ngay lúc land]); `SeedContractCreateAccessAsync` cấp cho
// Drafter + Procurement + Admin — grant đó ăn @restart, KHÔNG phải deploy FE.
// ⚠️ KHÔNG động tới `POST /api/contracts` (`ContractsController` `[Authorize]` trần, #82 treo
// chủ đích): siết ở đây là siết CỬA MỚI, không phải sửa cửa cũ.
[HttpPost("{id:guid}/create-contract")]
[Authorize(Policy = "Contracts.Create")]
public async Task<ActionResult<CreateContractFromSigningPlanResult>> CreateContract(
Guid id, [FromBody] CreateContractFromSigningPlanBody body, CancellationToken ct)
{
var result = await mediator.Send(new CreateContractFromSigningPlanCommand(
id, body.LineIds ?? [], body.ContractType, body.ApprovalWorkflowId, body.TenHopDong), ct);
return Created($"/api/contracts/{result.ContractId}", result);
}
// ========================= Body records =========================
// PUT header: KHÔNG mang `Id` trong body (id lấy từ route) — tránh 2 nguồn sự thật.
// Mọi field nullable = null-safe: client không gửi ⇒ giữ giá trị cũ (#73).
@ -231,4 +256,13 @@ public class ContractSigningPlansController(
string Action,
string? Comment = null,
bool? ApplyLevelFinalize = null);
// [K7 S167] Cầu → HĐ. `PlanId` lấy từ route (không nhận trong body — 1 nguồn sự thật).
// `LineIds` nullable ở BODY để client gửi thiếu ra 400 của validator ("chọn ít nhất 1 dòng")
// thay vì 500 NullReference ở tầng dưới.
public record CreateContractFromSigningPlanBody(
List<Guid>? LineIds,
ContractType ContractType,
Guid ApprovalWorkflowId,
string? TenHopDong = null);
}

View File

@ -61,7 +61,18 @@ public record ContractSigningPlanListItemDto(
// đề xuất trong khi màn chi tiết đã khoá theo `ApprovedAmount` = hai màn lệch số trên
// cùng một phiếu. Σ này đóng đúng khe đó. Phiếu chưa duyệt → mọi dòng null → 0.
int ApprovalGroup,
decimal TotalApprovedAmount);
decimal TotalApprovedAmount,
// [K7 S167 — vá-4a] ID các HĐ đã sinh ra TỪ CÁC DÒNG của phiếu này (`Line.ContractId`,
// write-site `CreateContractFromSigningPlanFeatures.cs`). Rỗng = phiếu chưa đưa dòng nào
// vào HĐ.
// 🔴 VÌ SAO Ở LIST (không chỉ ở detail — detail đã có `lines[].contractId` từ W2 `:74`):
// cây 4-folder (`usePipelineStages.ts:287-291`) dựng GĐ3 từ payload LIST và hôm nay chỉ
// có MỘT nguồn là ô đơn `pe.contractId` ⇒ gói có N HĐ thì HĐ thứ 2+ VÔ HÌNH trên cây
// (K5 FLAG-1 "chỗ cắm 1 dòng" — lane K5 đã dựng sẵn union, thiếu đúng nguồn thứ hai này).
// Trả mảng PHẲNG thay vì `lines[]` lồng: consumer là một `Set<string>`, không cần hạt dòng.
// ⚠️ KHÔNG `Distinct()` trong projection: N dòng gộp 1 HĐ ra ID trùng — vô hại (consumer là
// Set) và tránh một tầng dịch SQL nữa trên projection dùng chung 3 endpoint.
List<Guid> ContractIds);
public record ContractSigningPlanLineDto(
Guid Id,
@ -821,7 +832,15 @@ public class ListContractSigningPlansQueryHandler(IApplicationDbContext db, ICur
// nói hai con số trên CÙNG một phiếu. `ApprovedAmount` vốn nullable ⇒ phiếu chưa
// duyệt cho ra `null` → `0`, đúng nghĩa "chưa chốt đồng nào".
db.ContractSigningPlanLines.Where(l => l.ContractSigningPlanId == p.Id)
.Sum(l => l.ApprovedAmount) ?? 0m))
.Sum(l => l.ApprovedAmount) ?? 0m,
// [K7 S167 — vá-4a] Nguồn THẬT của đường GĐ2 → GĐ3 cho cây 4-folder (xem chú
// thích tại DTO). Cùng khuôn collection-subquery với `WinnerSupplierNames`
// (`CreateContractFromEvaluationFeatures.cs:185-189`) — đã chạy thật ở list PE.
db.ContractSigningPlanLines
.Where(l => l.ContractSigningPlanId == p.Id && l.ContractId != null)
.OrderBy(l => l.CreatedAt)
.Select(l => l.ContractId!.Value)
.ToList()))
.ToListAsync(ct);
return new PagedResult<ContractSigningPlanListItemDto>(items, total, page, pageSize);

View File

@ -0,0 +1,251 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Contracts.Services;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Application.ContractSigningPlans;
// =====================================================================================
// [K7 — S167 2026-08-01] CẦU GĐ2 → GĐ3: Kế hoạch ký kết (KHKK) đã duyệt → Hợp đồng draft.
//
// Khuôn = bridge PE→HĐ cũ `CreateContractFromEvaluationFeatures.cs` + guard workflow V2 của
// `ContractFeatures.cs:74-87`. Luật spec-4gd §①: GIỐNG NHAU — CHỈ KHÁC FORM — ĐỪNG CHẾ THÊM.
//
// 🔴 KHÁC bridge PE cũ ở 3 điểm CÓ CHỦ ĐÍCH:
// 1. Hạt là DÒNG (`ContractSigningPlanLine`), không phải phiếu: 1 call = 1 HĐ cho ĐÚNG tập
// `LineIds` gửi lên. Đây là write-site ĐẦU TIÊN toàn codebase của `Line.ContractId`
// (đo @S164: 0 assignment) ⇒ cây 4-folder có nguồn THẬT thay vì suy từ `pe.ContractId` đơn.
// 2. Pin V2 `ApprovalWorkflowId` (bridge cũ chỉ pin V1) — `ApprovalWorkflow.ApplicableType`
// = Contract(3) là "HĐ general (any ContractType)" ⇒ KHÔNG suy được từ ContractType,
// người bấm phải chọn trong dialog.
// 3. Trả 1 HĐ (`Guid` + mã) thay vì `List<Guid>` — liên-danh N NCC = N lần bấm (OG-1 UI-1-1).
//
// 🔴 4 VÁ của spec-cụm-3 §② nằm TRONG file này (đừng gỡ khi refactor):
// vá-1 authz HỘI 2 khoá — ở controller (`KeHoachKyKet.Read` class ∧ `Contracts.Create` action).
// vá-3 guard workflow pin phải `IsActive` ∧ `IsUserSelectable` — KHÔNG chỉ tồn-tại+đúng-type
// (khuôn `ContractFeatures.cs:79-87` thiếu 2 vế này ⇒ forge POST pin được quy trình đã
// retired; dropdown FE lọc 2 cờ `ContractCreatePage.tsx:334-341` nhưng FE không phải rào).
// vá-4a `line.ContractId` ghi LUÔN (nguồn thật) + `pe.ContractId ??=` giữ vai FALLBACK hiển thị.
// vá-6 nạp `Project.Code` + `Supplier.Code` (2 guard NotFound) cho codegen RG-001 · field-map
// += `SlaDeadline` · pin `WorkflowDefinitionId` như 2 tiền lệ (`ContractFeatures.cs:105-109`
// + bridge cũ `:108`) — để null là rơi về policy hardcode `ContractFeatures.cs:647-662`.
// =====================================================================================
/// Kết quả bridge — trả CẢ mã HĐ vì mã gen NGAY lúc tạo (FE toast + link không phải gọi lại).
public record CreateContractFromSigningPlanResult(Guid ContractId, string? MaHopDong);
/// <param name="LineIds">
/// Tập dòng đưa vào HĐ này. Máy nhận N (schema GỘP: N dòng cùng NCC → 1 HĐ); UI hướng-1
/// truyền đúng 1 phần tử. `GiaTri` = Σ `ApprovedAmount` của ĐÚNG tập này — không hơn không kém.
/// </param>
/// <param name="ApprovalWorkflowId">
/// Quy trình duyệt HĐ (V2, `ApplicableType = Contract`). BẮT BUỘC — xem chú thích đầu file
/// điểm 2: không suy được từ `ContractType`.
/// </param>
public record CreateContractFromSigningPlanCommand(
Guid PlanId,
List<Guid> LineIds,
ContractType ContractType,
Guid ApprovalWorkflowId,
string? TenHopDong = null) : IRequest<CreateContractFromSigningPlanResult>;
public class CreateContractFromSigningPlanCommandValidator
: AbstractValidator<CreateContractFromSigningPlanCommand>
{
public CreateContractFromSigningPlanCommandValidator()
{
RuleFor(x => x.PlanId).NotEmpty();
RuleFor(x => x.ApprovalWorkflowId).NotEmpty();
RuleFor(x => x.ContractType).IsInEnum();
RuleFor(x => x.LineIds)
.NotEmpty().WithMessage("Chọn ít nhất 1 dòng của kế hoạch để đưa vào Hợp đồng.");
RuleFor(x => x.LineIds)
.Must(ids => ids.Distinct().Count() == ids.Count)
.When(x => x.LineIds is { Count: > 0 })
.WithMessage("Danh sách dòng bị trùng — mỗi dòng chỉ được chọn 1 lần.");
// MaxLength MATCH EF config (`ContractConfiguration` TenHopDong = 500) — EF là source
// of truth, KHÔNG lấy số từ spec (bài S35).
RuleFor(x => x.TenHopDong).MaximumLength(500);
}
}
public class CreateContractFromSigningPlanCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser,
IContractWorkflowService workflow,
IContractCodeGenerator codeGenerator)
: IRequestHandler<CreateContractFromSigningPlanCommand, CreateContractFromSigningPlanResult>
{
public async Task<CreateContractFromSigningPlanResult> Handle(
CreateContractFromSigningPlanCommand request, CancellationToken ct)
{
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
throw new UnauthorizedException();
var actorId = currentUser.UserId.Value;
// ---- Rào (1) PHIẾU + PHASE — mirror rào (i) `ContractSigningPlanFeatures.cs:381-382`.
var plan = await db.ContractSigningPlans
.Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.Id == request.PlanId, ct)
?? throw new NotFoundException("ContractSigningPlan", request.PlanId);
if (plan.Phase != ContractSigningPlanPhase.DaDuyet)
throw new ConflictException(
"Chỉ tạo Hợp đồng từ Kế hoạch ký kết đã duyệt xong (Đã duyệt).");
// ---- Rào (2) DÒNG ∈ PHIẾU. Đếm sau `Distinct` — id lạ/của phiếu khác đều rơi vào đây.
var lineIds = request.LineIds.Distinct().ToList();
var lines = plan.Lines
.Where(l => lineIds.Contains(l.Id))
.OrderBy(l => l.CreatedAt).ThenBy(l => l.Id)
.ToList();
if (lines.Count != lineIds.Count)
throw new ConflictException(
"Có dòng không thuộc kế hoạch này (đã bị xoá hoặc chọn nhầm phiếu) — tải lại trang rồi chọn lại.");
// ---- Rào (3) 1 HĐ = 1 NCC (`Contract.SupplierId` đơn). Liên-danh N NCC = N lần bấm.
var supplierIds = lines.Select(l => l.SupplierId).Distinct().ToList();
if (supplierIds.Count > 1)
throw new ConflictException(
"Các dòng chọn thuộc nhiều NCC khác nhau — mỗi Hợp đồng chỉ 1 NCC, tạo lần lượt.");
// ---- Rào (4)+(5) per-dòng: có giá CHỐT + chưa bridge. Message NÊU TÊN hạng mục để người
// bấm biết dòng nào — danh sách dòng trong dialog không có mã, chỉ có tên.
foreach (var l in lines)
{
var ten = l.TenHangMuc ?? "(chưa gán hạng mục)";
// KHÔNG fallback `ProposedAmount`: đó là số ĐỀ XUẤT chưa qua choke-point finalize
// (`ContractSigningPlanWorkflowService.cs:348` `ApprovedAmount ??=`). Lấy nhầm =
// HĐ mang số chưa ai chốt.
if (l.ApprovedAmount is null)
throw new ConflictException(
$"Dòng '{ten}' chưa có giá duyệt chốt — phiếu phải đi qua bước duyệt kết thúc trước khi đưa vào Hợp đồng.");
if (l.ContractId is not null)
throw new ConflictException($"Dòng '{ten}' đã có Hợp đồng.");
}
// ---- Rào (6) WORKFLOW PIN — 4 vế. 2 vế đầu = khuôn `ContractFeatures.cs:79-87`;
// 2 vế sau = [vá-3 spec-cụm-3 §②-3] đóng cửa forge-POST pin quy trình đã nghỉ.
var aw = await db.ApprovalWorkflows.AsNoTracking()
.FirstOrDefaultAsync(w => w.Id == request.ApprovalWorkflowId, ct)
?? throw new NotFoundException("ApprovalWorkflow", request.ApprovalWorkflowId);
if (aw.ApplicableType != ApprovalWorkflowApplicableType.Contract)
throw new ConflictException(
$"Quy trình '{aw.Code}' áp dụng cho {aw.ApplicableType}, không khớp với Hợp đồng " +
"(cần quy trình loại Hợp đồng).");
// 2 cờ TÁCH RIÊNG (không gộp `!IsActive || !IsUserSelectable`): admin cần đọc được
// NGUYÊN NHÂN — "đã nghỉ" và "chưa ghim" xử lý ở 2 nút khác nhau trong Designer.
if (!aw.IsActive)
throw new ConflictException(
$"Quy trình '{aw.Code}' đã ngừng hoạt động — chọn quy trình đang hoạt động.");
if (!aw.IsUserSelectable)
throw new ConflictException(
$"Quy trình '{aw.Code}' chưa được ghim cho người dùng chọn — nhờ Admin ghim, hoặc chọn quy trình khác.");
// ---- [vá-6] Nạp Project + Supplier ĐỦ (cần `Code` cho codegen RG-001) — 2 guard NotFound
// theo khuôn bridge cũ `CreateContractFromEvaluationFeatures.cs:63-66/:85-86`.
// loose-Guid ⇒ không có FK vật lý đỡ lưng, thiếu guard = NRE lúc gen mã.
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == plan.ProjectId, ct)
?? throw new NotFoundException("Project", plan.ProjectId);
var supplierId = supplierIds[0];
var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == supplierId, ct)
?? throw new NotFoundException("Supplier", supplierId);
// ---- [vá-6] Pin V1 `WorkflowDefinitionId` NGANG bridge cũ `:108` + `ContractFeatures.cs:105`.
// Cả 2 cùng set là ĐÚNG ("Cả 2 set ok" `:107-108`): `ContractWorkflowService` dispatch
// V2 trước. Để null thì HĐ rơi về policy hardcode `ContractFeatures.cs:647-662` khi
// ai đó gỡ pin V2 — mất luôn đường về quy trình đã cấu hình cho loại HĐ đó.
var activeWfId = await db.WorkflowDefinitions.AsNoTracking()
.Where(w => w.ContractType == request.ContractType && w.IsActive)
.Select(w => (Guid?)w.Id)
.FirstOrDefaultAsync(ct);
// PE nguồn: TRACKED (cần ghi `ContractId` fallback bên dưới) + lấy tên gói cho tên HĐ mặc
// định. loose-Guid ⇒ có thể null (PE bị xoá cứng) — mọi chỗ dùng đều null-safe.
var pe = await db.PurchaseEvaluations
.FirstOrDefaultAsync(e => e.Id == plan.PurchaseEvaluationId, ct);
var giaTri = lines.Sum(l => l.ApprovedAmount!.Value);
var tenHangMuc = string.Join(", ", lines
.Select(l => l.TenHangMuc)
.Where(t => !string.IsNullOrWhiteSpace(t))
.Distinct());
var tenMacDinh = string.IsNullOrWhiteSpace(tenHangMuc)
? (pe?.TenGoiThau ?? plan.MaKeHoach ?? "Hợp đồng")
: $"{pe?.TenGoiThau ?? plan.MaKeHoach} — {tenHangMuc}";
var tenHopDong = request.TenHopDong ?? tenMacDinh;
// Clamp theo EF max length (500) — tên tự ghép từ N hạng mục có thể vượt.
if (tenHopDong.Length > 500) tenHopDong = tenHopDong[..500];
var contract = new Contract
{
Type = request.ContractType,
Phase = ContractPhase.DangSoanThao,
SupplierId = supplierId,
ProjectId = plan.ProjectId,
DepartmentId = plan.DepartmentId,
DrafterUserId = actorId,
GiaTri = giaTri,
TenHopDong = tenHopDong,
NoiDung = pe?.MoTa,
// NS tham chiếu: Σ số PE (snapshot lúc lập kế hoạch), KHÔNG phải số đã chốt — cột
// "ngân sách nhập tay" của HĐ là mốc so sánh, giá trị HĐ nằm ở `GiaTri`.
BudgetManualName = plan.MaKeHoach is null ? null : $"NS tham chiếu KHKK {plan.MaKeHoach}",
BudgetManualAmount = lines.Sum(l => l.PeReferenceAmount),
WorkflowDefinitionId = activeWfId, // [vá-6]
ApprovalWorkflowId = request.ApprovalWorkflowId, // pin V2
// [vá-6] SlaDeadline — thiếu thì HĐ sinh từ cầu này là HĐ DUY NHẤT trong hệ không có
// hạn phase (cả 2 tiền lệ `ContractFeatures.cs:110` + bridge cũ `:109-110` đều set).
SlaDeadline = DateTime.UtcNow.Add(
workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)),
};
// Codegen TỰ-COMMIT (SERIALIZABLE + `SaveChangesAsync` nội bộ — `ContractCodeGenerator`):
// gọi TRƯỚC `Add` để lượt save nội bộ của nó không flush HĐ dở (bài S88, đúng thứ tự
// bridge cũ `:112-113` và `CreateContractSigningPlanCommandHandler:426-429`).
contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct);
db.Contracts.Add(contract);
db.ContractChangelogs.Add(new ContractChangelog
{
ContractId = contract.Id,
EntityType = ChangelogEntityType.Contract,
Action = ChangelogAction.Insert,
PhaseAtChange = contract.Phase,
UserId = actorId,
Summary = $"Tạo HĐ {contract.MaHopDong} từ Kế hoạch ký kết {plan.MaKeHoach ?? plan.Id.ToString()}",
ContextNote = $"ContractSigningPlan {plan.Id} — {lines.Count} dòng",
});
db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
{
ContractSigningPlanId = plan.Id,
EntityType = ContractSigningPlanEntityType.Line,
Action = ChangelogAction.Update,
PhaseAtChange = plan.Phase,
UserId = actorId,
Summary = $"Đưa {lines.Count} dòng vào HĐ {contract.MaHopDong}",
ContextNote = $"Contract {contract.Id}",
});
// ---- [vá-4a] WRITE-SITE `Line.ContractId` — NGUỒN THẬT của đường KHKK → HĐ.
foreach (var l in lines) l.ContractId = contract.Id;
// ---- [vá-4a] `pe.ContractId` giữ vai FALLBACK HIỂN THỊ (cây 4-folder đọc ô đơn này).
// `??=` CÓ CHIỀU: chỉ điền khi còn trống ⇒ HĐ đầu tiên của gói thắng, các HĐ sau
// KHÔNG ghi đè (chúng vẫn nhìn thấy được qua `Line.ContractId` — đó là lý do vá-4a
// tồn tại). Bridge PE cũ gán thẳng `= contracts[0].Id` sau khi đã chặn ở `:59-60`,
// nên ở ĐÂY phải là `??=`: 2 cầu cùng chạy trên 1 PE thì cầu chạy sau không được
// cướp ô của cầu chạy trước.
if (pe is not null) pe.ContractId ??= contract.Id;
await db.SaveChangesAsync(ct);
return new CreateContractFromSigningPlanResult(contract.Id, contract.MaHopDong);
}
}

View File

@ -2353,6 +2353,14 @@ public static class DbInitializer
// @S159 ⇒ nhánh insert skip-existing = NO-OP im lặng ⇒ 403 giết dry-run (gotcha #44).
await SeedKeHoachKyKetAccessAsync(db, roleManager, logger);
// [K7 — S167 2026-08-01] Cầu GĐ2 → GĐ3 golive: cấp `Contracts.Create` cho Drafter +
// Procurement + Admin (câu III default — chờ anh ratify, đổi bộ vai TRONG method).
// CHẠY SAU revoke cùng lý do 2 seeder trên. Không có nó thì nút "Đưa vào Hợp đồng"
// 403 với 12/13 role (`SeedAllRolesReviewReadPermissionsAsync` để CanCreate=false cho key
// `Contracts`; Admin qua nhờ bypass + prod sẵn row CanCreate=True — đo @gate-K7) —
// nghiệm thu = restart THẬT rồi đếm row, không đọc code (#75/#76).
await SeedContractCreateAccessAsync(db, roleManager, logger);
// [S65 2026-06-16] CHẠY SAU revoke để THẮNG: mở lại quyền XEM "Hồ sơ Nhân sự"
// (+ root nhóm "Nhân sự") cho MỌI role — anh chốt public module Nhân sự cho
// user thường tra cứu hồ sơ. Dashboard NS (Hrm_Dashboard) + 6 catalog
@ -2728,6 +2736,88 @@ public static class DbInitializer
}
}
// [K7 — S167 2026-08-01] Cấp `Contracts.Create` cho các vai được bấm "Đưa vào Hợp đồng"
// (cầu GĐ2 → GĐ3, `ContractSigningPlansController` `POST {id}/create-contract`).
//
// 🔴 LÝ DO PHẢI CÓ HÀM NÀY: trước S167 **KHÔNG role nào** mang `Contracts.Create` —
// `SeedAllRolesReviewReadPermissionsAsync` (grep tên hàm, đừng neo số dòng) tạo row key
// `Contracts` với `CanCreate = isPe` mà `isPe` = false cho key này ⇒ nút bridge 403 với
// 12/13 role (kể cả Drafter); Admin qua nhờ bypass và prod ĐÃ sẵn row CanCreate=True cho
// Admin (đo prod @gate-K7). Đây là lỗ ĐO ĐƯỢC, không phải suy đoán.
// CanRead nâng KÈM là CHỦ ĐÍCH, không phải tràn-nhãn: vai được tạo HĐ phải THẤY menu
// "Hợp đồng" để mở HĐ vừa tạo từ cầu KHKK — CanRead là cờ hiển thị menu (2-tầng #82).
//
// ⏳ [K7 — câu III default, chờ anh ratify] Bộ vai = Drafter + Procurement + Admin
// ("người có quyền tạo HĐ"): Drafter = người soạn HĐ, Procurement = Mua hàng chủ trì cầu
// NCC, Admin = quản trị/UAT. Anh chốt khác thì SỬA ĐÚNG mảng `roleNames` dưới đây (1 chỗ),
// KHÔNG đụng controller — máy BE không đổi theo câu trả lời, chỉ đổi ai thấy nút.
//
// Khuôn = `SeedKeHoachKyKetAccessAsync` (W1 S161): UPGRADE-ONLY (row có sẵn → NÂNG cờ
// desired, KHÔNG hạ cờ nào, KHÔNG đụng Update/Delete), row chưa có (fresh DB) → tạo
// Read+Create. CẤM SQL tay (#84): grant tay bị seeder/revoker lật ở restart kế (bài S159
// Run #423 — 447/494 row rơi). Chạy SAU `RevokeTemporarilyHiddenModulesAsync` để thắng
// revoke (revoker hiện KHÔNG còn nhánh `Contracts` từ S159, nhưng giữ thứ tự cho an toàn).
// Thu hồi khi cần: xoá call trong `SeedAdminPermissionsAsync` (gate-K7 bắt bản đầu trỏ nhầm SeedAsync).
private static async Task SeedContractCreateAccessAsync(
ApplicationDbContext db, RoleManager<Role> roleManager, ILogger logger)
{
// CHỈ key root `Contracts` — đó là key mà policy `Contracts.Create` của endpoint đọc
// (`MenuPermissionHandler` so khớp CHÍNH XÁC 1 key, KHÔNG kế thừa xuống `Ct_*`).
// Cấp thêm cho `Ct_*` là cấp thừa: không endpoint nào gate bằng key con đó.
const string key = MenuKeys.Contracts;
var roleNames = new[] { AppRoles.Drafter, AppRoles.Procurement, AppRoles.Admin };
var roles = await roleManager.Roles
.Where(r => r.Name != null && roleNames.Contains(r.Name))
.ToListAsync();
if (roles.Count == 0)
{
logger.LogWarning("SeedContractCreateAccessAsync: skip — chưa seed role nào trong {Roles}.",
string.Join("/", roleNames));
return;
}
var roleIds = roles.Select(r => r.Id).ToList();
var existing = (await db.Permissions
.Where(p => p.MenuKey == key && roleIds.Contains(p.RoleId))
.ToListAsync())
.ToDictionary(p => p.RoleId);
var added = 0;
var upgraded = 0;
foreach (var role in roles)
{
if (existing.TryGetValue(role.Id, out var row))
{
var changed = false;
if (!row.CanRead) { row.CanRead = true; changed = true; }
if (!row.CanCreate) { row.CanCreate = true; changed = true; }
if (changed) upgraded++;
continue;
}
db.Permissions.Add(new Permission
{
RoleId = role.Id,
MenuKey = key,
CanRead = true,
CanCreate = true,
CanUpdate = false,
CanDelete = false,
});
added++;
}
if (added > 0 || upgraded > 0)
{
await db.SaveChangesAsync();
logger.LogInformation(
"Seeded Contracts.Create access: {Added} added + {Upgraded} upgraded " +
"(K7 cầu KHKK→HĐ — {Roles}; câu III default chờ ratify)",
added, upgraded, string.Join("/", roleNames));
}
}
// [S65 2026-06-16] Mở quyền XEM (Read-only) "Hồ sơ Nhân sự" cho MỌI role: anh chốt
// public module Nhân sự (trọng tâm Hồ sơ NS) để user thường tra cứu hồ sơ nhân sự.
// CHẠY SAU RevokeTemporarilyHiddenModulesAsync (SeedAsync) để THẮNG revoke: revoke

View File

@ -355,6 +355,23 @@ public class AuthorizePolicyRegressionTests
.Select(x => (x.Name, x.Http!.HttpMethods.First().ToUpperInvariant(), x.Authz))
.ToArray();
/// 🔴 [K7 S167] NGOẠI LỆ CÓ TÊN — action tạo ra tài nguyên của MODULE KHÁC phải gate bằng
/// key policy của **thứ được tạo**, không phải key của controller đang đứng (gotcha #85:
/// "gate quyền dùng đúng key policy của endpoint"; `MenuPermissionHandler.cs:40` so khớp
/// CHÍNH XÁC 1 key, không kế-thừa ⇒ gate nhầm key = cấp quyền chéo hoặc khoá oan).
///
/// Cầu GĐ2→GĐ3 `POST {id}/create-contract` SINH RA một `Contract` ⇒ vế ghi = `Contracts.Create`.
/// Vế đọc vẫn còn: class-level `KeHoachKyKet.Read` (`:28`) — ASP.NET Core cộng dồn class +
/// action theo **AND**, nên endpoint này là HỘI 2 KHOÁ, KHÔNG phải nới lỏng.
///
/// Viết dạng BẢN ĐỒ kỳ-vọng-CHÍNH-XÁC (không phải danh sách "bỏ qua"): action trong đây vẫn
/// bị đo, chỉ đổi key kỳ vọng. Gate sai thành `KeHoachKyKet.Create` (hoặc bất cứ key nào khác)
/// vẫn ĐỎ.
private static readonly Dictionary<string, string> KhkkCrossModuleGates = new()
{
[nameof(ContractSigningPlansController.CreateContract)] = "Contracts.Create",
};
[Fact]
public void ContractSigningPlansController_EveryWriteEndpoint_HasAuthorizePolicy()
{
@ -395,10 +412,23 @@ public class AuthorizePolicyRegressionTests
"policy \"{0}\" của {1} phải đúng dạng {{menuKey}}.{{action}}", authz.Policy, name);
// ---- (b) Tên policy phải là tên CÓ THẬT trong bộ sinh policy của Program.cs.
parts[0].Should().Be(MenuKeys.KeHoachKyKet,
"action {0} phải gate bằng menu key của CHÍNH module này — Δ W1 chốt prefix " +
"`KeHoachKyKet` (spec cũ ghi `ContractSigningPlans`, đã bỏ); gate nhầm key module " +
"khác = cấp quyền chéo", name);
// Kỳ vọng mặc định = key của CHÍNH module; ngoại lệ CÓ TÊN (K7 cầu → HĐ) đổi kỳ vọng
// sang key của tài nguyên ĐƯỢC TẠO — vẫn là so-khớp TUYỆT ĐỐI, không phải bỏ qua.
var expectedPolicy = KhkkCrossModuleGates.GetValueOrDefault(name);
if (expectedPolicy is not null)
{
authz.Policy.Should().Be(expectedPolicy,
"action {0} tạo ra tài nguyên của module KHÁC ⇒ gate bằng key policy của " +
"endpoint đích (gotcha #85). Gate bằng `KeHoachKyKet.*` sẽ cho người chỉ có " +
"quyền trên kế hoạch ký kết ĐẺ RA hợp đồng — quyền chéo đúng nghĩa", name);
}
else
{
parts[0].Should().Be(MenuKeys.KeHoachKyKet,
"action {0} phải gate bằng menu key của CHÍNH module này — Δ W1 chốt prefix " +
"`KeHoachKyKet` (spec cũ ghi `ContractSigningPlans`, đã bỏ); gate nhầm key module " +
"khác = cấp quyền chéo", name);
}
MenuKeys.All.Should().Contain(parts[0],
"policy \"{0}\" của {1} trỏ menu key KHÔNG có trong MenuKeys.All ⇒ Program.cs không " +
"sinh policy đó ⇒ 500 lúc chạy chứ không phải 403", authz.Policy, name);
@ -438,5 +468,15 @@ public class AuthorizePolicyRegressionTests
"quyền sửa phiếu nhưng không được lập phiếu mới");
byName[nameof(ContractSigningPlansController.UploadAttachment)].Should().Be("KeHoachKyKet.Update",
"upload đính kèm = ghi vào phiếu đã có ⇒ Update");
// ---- (e) [K7 S167] Cầu GĐ2→GĐ3 — chốt bằng TÊN, 2 chiều.
byName[nameof(ContractSigningPlansController.CreateContract)].Should().Be("Contracts.Create",
"cầu KHKK→HĐ sinh ra Contract ⇒ vế GHI phải là khoá của Contract (gotcha #85). " +
"Đây là vế 2 của HỘI-2-KHOÁ; vế 1 = class-level `KeHoachKyKet.Read` đã chốt ở (0)");
// Ngoại lệ phải trỏ action CÓ THẬT: đổi tên/xoá action mà quên dọn bản đồ thì mục đó
// thành cấu-hình-chết (im lặng, không ai đo) — vắng-mặt trông giống ổn.
KhkkCrossModuleGates.Keys.Should().BeSubsetOf(byName.Keys,
"bản đồ ngoại lệ trỏ action không còn tồn tại trên controller");
}
}

View File

@ -0,0 +1,245 @@
using System.Reflection;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Api.Authorization;
using SolutionErp.Api.Controllers;
using SolutionErp.Domain.Identity;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Api;
// [K7 — S167 2026-08-01] AUTHZ 2 CHIỀU cho cầu GĐ2→GĐ3 `POST {id}/create-contract`.
//
// Khác `AuthorizePolicyRegressionTests` (chỉ đọc ATTRIBUTE bằng reflection = "cửa có khoá tên gì"),
// file này chạy **máy quyết định thật** `MenuPermissionHandler` = "khoá đó có mở/không mở cho ai".
// Hai lớp bổ nhau: pin tên khoá mà không đo quyết định thì vẫn có thể sai ở tầng dữ liệu quyền,
// và ngược lại.
//
// 🔴 ĐIỀU KIỆN LÀM PHÉP ĐO CÓ RĂNG: 2 tên policy KHÔNG hardcode — ĐỌC TỪ CHÍNH CONTROLLER
// (class-level + action-level của `CreateContract`). Ai sửa attribute thì test này đo theo cái
// MỚI, và vẫn hỏi đúng câu: "hai vế có phải HỘI không". Hardcode chuỗi ở đây là tự tay tách
// test khỏi thứ nó bảo vệ.
//
// Vì sao "hội 2 khoá" quan trọng (gotcha #85 + #82): ASP.NET Core cộng dồn `[Authorize]` class +
// action theo **AND**. Nếu ai đó gỡ 1 vế:
// - gỡ `Contracts.Create` ⇒ người chỉ được xem/sửa kế hoạch ký kết ĐẺ RA hợp đồng (quyền chéo);
// - gỡ `KeHoachKyKet.Read` ⇒ người ngoài module lấy dòng của phiếu người khác đi tạo hợp đồng.
public class KhkkBridgeAuthzTests
{
/// Nạp 2 vế khoá của endpoint cầu TỪ ĐĨA (không hardcode).
private static (string ClassPolicy, string ActionPolicy) BridgePolicies()
{
var t = typeof(ContractSigningPlansController);
var classPolicy = t.GetCustomAttributes<AuthorizeAttribute>(inherit: false)
.FirstOrDefault()?.Policy;
var actionPolicy = t
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.First(m => m.Name == nameof(ContractSigningPlansController.CreateContract))
.GetCustomAttributes<AuthorizeAttribute>(inherit: false).FirstOrDefault()?.Policy;
classPolicy.Should().NotBeNullOrEmpty("vế 1 (class-level) của hội-2-khoá biến mất");
actionPolicy.Should().NotBeNullOrEmpty("vế 2 (action-level) của hội-2-khoá biến mất");
actionPolicy.Should().NotBe(classPolicy,
"hai vế TRÙNG NHAU thì 'hội 2 khoá' chỉ còn là 1 khoá — cửa tự nới ra mà nhìn vẫn như cũ");
return (classPolicy!, actionPolicy!);
}
/// Chạy ĐÚNG máy production: `MenuPermissionHandler` + `UserManager`/`RoleManager` thật.
/// Trả về quyết định cho MỘT policy "{menuKey}.{action}".
private static async Task<bool> AllowsAsync(IdentityFixture fix, User user, string policy)
{
var parts = policy.Split('.');
parts.Should().HaveCount(2, "policy \"{0}\" không đúng dạng {{menuKey}}.{{action}}", policy);
var handler = new MenuPermissionHandler(
fix.Services.GetRequiredService<TestApplicationDbContext>(),
fix.Services.GetRequiredService<UserManager<User>>(),
fix.Services.GetRequiredService<RoleManager<Role>>());
var principal = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())], "test"));
var req = new MenuPermissionRequirement(parts[0], parts[1]);
var ctx = new AuthorizationHandlerContext([req], principal, resource: null);
await handler.HandleAsync(ctx);
return ctx.HasSucceeded;
}
/// Kết quả THẬT của endpoint = HỘI 2 vế (ASP.NET Core cộng dồn theo AND).
private static async Task<bool> BridgeAllowedAsync(IdentityFixture fix, User user)
{
var (classPolicy, actionPolicy) = BridgePolicies();
return await AllowsAsync(fix, user, classPolicy)
&& await AllowsAsync(fix, user, actionPolicy);
}
/// Menu row PHẢI có trước Permission: FK `Permission.MenuKey → MenuItem.Key` (Cascade).
private static async Task SeedMenuRowsAsync(TestApplicationDbContext db, params string[] keys)
{
var order = 1;
foreach (var k in keys)
{
if (await db.MenuItems.AnyAsync(m => m.Key == k)) continue;
db.MenuItems.Add(new MenuItem { Key = k, Label = k, Order = order++ });
}
await db.SaveChangesAsync(CancellationToken.None);
}
/// Tạo 1 vai + user của vai đó, cấp đúng bộ cờ được liệt kê.
private static async Task<User> UserWithAsync(
IdentityFixture fix, TestApplicationDbContext db, string roleName,
params (string MenuKey, string Action)[] grants)
{
var user = await fix.CreateUserAsync(
$"{roleName.ToLowerInvariant()}-{Guid.NewGuid():N}@test.local", roleName,
departmentId: null, roles: [roleName]);
var role = await fix.Services.GetRequiredService<RoleManager<Role>>().FindByNameAsync(roleName);
role.Should().NotBeNull();
foreach (var g in grants.GroupBy(x => x.MenuKey))
{
var actions = g.Select(x => x.Action).ToHashSet();
db.Permissions.Add(new Permission
{
Id = Guid.NewGuid(),
RoleId = role!.Id,
MenuKey = g.Key,
CanRead = actions.Contains("Read"),
CanCreate = actions.Contains("Create"),
CanUpdate = actions.Contains("Update"),
CanDelete = actions.Contains("Delete"),
});
}
await db.SaveChangesAsync(CancellationToken.None);
return user;
}
private static (string MenuKey, string Action) Split(string policy)
{
var p = policy.Split('.');
return (p[0], p[1]);
}
// ===================================================================
// (1) ĐỦ 2 VẾ → qua. Đây là ĐỐI CHỨNG DƯƠNG: thiếu nó thì 2 test 403 bên dưới
// vẫn xanh trong một thế giới mà KHÔNG AI vào được cửa này (403 cho tất cả).
// ===================================================================
[Fact]
public async Task CauKhkkSangHd_VaiDuCaHaiVe_ThiQua()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (classPolicy, actionPolicy) = BridgePolicies();
var (khkkKey, khkkAction) = Split(classPolicy);
var (contractKey, contractAction) = Split(actionPolicy);
await SeedMenuRowsAsync(db, khkkKey, contractKey);
var user = await UserWithAsync(fix, db, "BridgeFull",
(khkkKey, khkkAction), (contractKey, contractAction));
(await AllowsAsync(fix, user, classPolicy)).Should().BeTrue();
(await AllowsAsync(fix, user, actionPolicy)).Should().BeTrue();
(await BridgeAllowedAsync(fix, user)).Should().BeTrue(
"vai mang ĐỦ `{0}` ∧ `{1}` phải bấm được nút Đưa vào Hợp đồng", classPolicy, actionPolicy);
}
// ===================================================================
// (2) THIẾU ĐÚNG 1 VẾ (không có `Contracts.Create`) → 403.
// Đây chính là hình dạng của LỖ mà lane BE phải vá bằng seeder: trước S167 KHÔNG vai nào
// mang `Contracts.Create` (`SeedAllRolesReviewReadPermissionsAsync` đặt `CanCreate = isPe` ⇒ false cho key
// `Contracts`) ⇒ mọi người-không-Admin đều rơi vào nhánh này.
// ===================================================================
[Fact]
public async Task CauKhkkSangHd_VaiChiCoQuyenKeHoach_ThiBiChan()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (classPolicy, actionPolicy) = BridgePolicies();
var (khkkKey, khkkAction) = Split(classPolicy);
var (contractKey, _) = Split(actionPolicy);
await SeedMenuRowsAsync(db, khkkKey, contractKey);
// Cấp RỘNG TAY trên module kế hoạch (cả 4 cờ) nhưng KHÔNG có gì trên `Contracts`.
var user = await UserWithAsync(fix, db, "KhkkOnly",
(khkkKey, "Read"), (khkkKey, "Create"), (khkkKey, "Update"), (khkkKey, "Delete"));
(await AllowsAsync(fix, user, classPolicy)).Should().BeTrue(
"vế 1 PHẢI qua — nếu vế này cũng chặn thì test không đo được vế 2 nào cả");
(await AllowsAsync(fix, user, actionPolicy)).Should().BeFalse(
"vế 2 `{0}` là khoá của tài nguyên ĐƯỢC TẠO; quyền trên kế hoạch ký kết " +
"KHÔNG được đẻ ra hợp đồng (gotcha #85 — không có kế-thừa key)", actionPolicy);
(await BridgeAllowedAsync(fix, user)).Should().BeFalse("thiếu 1 vế = 403");
}
// ===================================================================
// (3) THIẾU ĐÚNG 1 VẾ CÒN LẠI (không có `KeHoachKyKet.Read`) → 403.
// Chiều ngược: người được tạo HĐ nhưng ngoài module kế hoạch cũng KHÔNG được lấy dòng
// của phiếu người khác đi bắc cầu.
// ===================================================================
[Fact]
public async Task CauKhkkSangHd_VaiChiCoQuyenTaoHopDong_ThiBiChan()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (classPolicy, actionPolicy) = BridgePolicies();
var (khkkKey, _) = Split(classPolicy);
var (contractKey, contractAction) = Split(actionPolicy);
await SeedMenuRowsAsync(db, khkkKey, contractKey);
var user = await UserWithAsync(fix, db, "ContractOnly", (contractKey, contractAction));
(await AllowsAsync(fix, user, actionPolicy)).Should().BeTrue(
"vế 2 PHẢI qua — chứng minh phép đo không xanh nhờ 'chặn tất');");
(await AllowsAsync(fix, user, classPolicy)).Should().BeFalse();
(await BridgeAllowedAsync(fix, user)).Should().BeFalse("thiếu vế còn lại = vẫn 403");
}
// ===================================================================
// (4) Admin đi cửa bypass — KHÔNG có 1 dòng `Permissions` nào vẫn qua cả 2 vế.
// Khoá hành vi ĐANG CÓ (`MenuPermissionHandler.cs` nhánh `AppRoles.Admin`) để không ai
// "siết cho nhất quán" rồi khoá luôn Admin khỏi cầu.
// ===================================================================
[Fact]
public async Task CauKhkkSangHd_Admin_KhongCanDongQuyenNao_VanQua()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var admin = await fix.CreateUserAsync(
$"admin-{Guid.NewGuid():N}@test.local", "Admin", null, [AppRoles.Admin]);
(await db.Permissions.CountAsync()).Should().Be(0, "cố ý 0 dòng quyền — đo đúng nhánh bypass");
(await BridgeAllowedAsync(fix, admin)).Should().BeTrue();
}
// ===================================================================
// (5) User bị vô hiệu hoá: có ĐỦ quyền nhưng `IsActive = false` → chặn cả 2 vế.
// Vế này bắt lỗi "nghỉ việc nhưng token còn hạn vẫn đẻ được hợp đồng".
// ===================================================================
[Fact]
public async Task CauKhkkSangHd_UserBiVoHieuHoa_ThiChan_DuCoDuQuyen()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (classPolicy, actionPolicy) = BridgePolicies();
var (khkkKey, khkkAction) = Split(classPolicy);
var (contractKey, contractAction) = Split(actionPolicy);
await SeedMenuRowsAsync(db, khkkKey, contractKey);
var user = await UserWithAsync(fix, db, "BridgeDisabled",
(khkkKey, khkkAction), (contractKey, contractAction));
(await BridgeAllowedAsync(fix, user)).Should().BeTrue("chốt trước: lúc còn hoạt động thì QUA");
var tracked = await db.Users.SingleAsync(u => u.Id == user.Id);
tracked.IsActive = false;
await db.SaveChangesAsync(CancellationToken.None);
db.ChangeTracker.Clear();
(await BridgeAllowedAsync(fix, user)).Should().BeFalse(
"đổi ĐÚNG 1 biến `IsActive` ⇒ cùng bộ quyền, kết quả phải ngược lại");
}
}

View File

@ -0,0 +1,760 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.ContractSigningPlans;
using SolutionErp.Application.Contracts;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Domain.Master.Catalogs;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// [K7 — S167 2026-08-01] CẦU GĐ2 → GĐ3: `CreateContractFromSigningPlanCommandHandler`
// (`Application/ContractSigningPlans/CreateContractFromSigningPlanFeatures.cs`, file MỚI của
// lane BE cùng đợt). Test-after: BE land trước, đây là lưới khoá hành-vi.
//
// ── Đo cái gì (map bảng T1-T8 `sub-invest-fable-b2-cum3.md:75-82` + 4 vá spec-cụm-3 §②) ──
// T1 happy 1 dòng (trọn field-map + 2 changelog + write-site) T2 gộp 2 dòng CÙNG NCC
// T3 chưa có giá chốt → chặn, KHÔNG đốt mã T4 phiếu chưa duyệt xong
// T5 dòng đã có HĐ (double-bridge) T6 2 dòng KHÁC NCC
// T7 quy trình pin sai — 4 nhánh + ĐỐI CHỨNG DƯƠNG cùng lượt T8 mối nối cầu → đường-ống V2
// T9 dòng-của-phiếu-khác + rào tập LineIds T10 bridge lần 2 dòng khác (`??=` CÓ CHIỀU)
// T11 double-click cùng tập dòng (idempotency)
//
// ── 3 quyết định hạ tầng (điều kiện để phép đo có nghĩa) ──────────────────────────────
//
// (1) `IdentityFixture` chứ không phải fixture KHKK riêng: `ContractWorkflowService` (deps của
// handler cầu, và là máy chạy T8) BẮT BUỘC `UserManager<User>` trong ctor. Khuôn sao y
// bridge PE cũ `CreateContractFromEvaluationMultiWinnerTests.BuildHandler`.
// (2) DÙNG DEPS THẬT (`ContractCodeGenerator` + `ContractWorkflowService` + service KHKK thật ở
// T9-B6), KHÔNG stub: mã HĐ RG-001 và `GetPhaseSla` là hai thứ vá-6 yêu cầu — stub chúng thì
// phép đo tự trả lời chính mình. `BeginTransactionAsync(Serializable)` chạy sạch trên SQLite
// (tiền lệ `SqliteDbFixture.cs:36-42` + codegen tests) — KHÔNG try/skip.
// (3) `ChangeTracker.Clear()` giữa 2 lần bấm (T10/T11): prod là 2 request ⇒ 2 DbContext scoped.
// Giữ nguyên context đã track dòng = một đường đi KHÔNG có thật, và nó CHE mất câu hỏi
// "đọc lại từ DB có thấy `ContractId` đã ghi không".
//
// ── 🔴 LỆCH SPEC ĐÃ ĐO, test theo CODE (single source of truth) ────────────────────────
// Bảng invest T1 (`:75`) ghi kỳ vọng `WorkflowDefinitionId null`. **Vá-6** của spec-cụm-3
// (`spec-cum3-chi-tiet-31-07-2026.md:16`) ĐẢO lại: pin V1 như 2 tiền lệ, và spec `:4` chốt
// "16 vá THẮNG khi xung đột với nền". Mã đang theo vá-6 (`:163-166` + `:201`).
// ⇒ T1 assert **pin ĐÚNG bản active của loại HĐ đó**, và assert luôn vế PHỦ ĐỊNH (không phải
// bản inactive / không phải bản của loại khác) — nếu chỉ assert `!= null` thì `activeWfId` lấy
// nhầm bản nào cũng xanh.
public class CreateContractFromSigningPlanTests
{
private static readonly DateTime Clock = new(2026, 8, 1, 3, 0, 0, DateTimeKind.Utc);
// ===================================================================
// Handler dưới test + deps THẬT (mirror wiring prod ContractSigningPlansController → MediatR)
// ===================================================================
private static CreateContractFromSigningPlanCommandHandler BuildHandler(
TestApplicationDbContext db, IServiceProvider sp, User actor, params string[] roles)
{
var um = sp.GetRequiredService<UserManager<User>>();
var dt = new FixedDateTime(Clock);
var currentUser = new TestCurrentUser(actor.Id, actor.FullName, actor.Email,
roles.Length > 0 ? roles : [AppRoles.Drafter]);
var changelog = new ChangelogService(db, currentUser, um);
var codeGen = new ContractCodeGenerator(db, dt);
var workflowSvc = new ContractWorkflowService(
db, codeGen, dt, new NoOpNotificationServiceApp(), changelog, um);
return new CreateContractFromSigningPlanCommandHandler(db, currentUser, workflowSvc, codeGen);
}
private const string ProjectCode = "FLOCK01";
private const string SupplierCode = "PVL";
private sealed record Seeded(
ContractSigningPlan Plan,
PurchaseEvaluation Pe,
Project Project,
List<Supplier> Suppliers,
Guid ContractWorkflowId, // V2 `ApplicableType = Contract(3)` — hợp lệ
Guid ActiveWorkflowDefId, // V1 active ĐÚNG loại HĐ dùng trong test
User Actor);
/// Mỗi phần tử = 1 dòng của phiếu. `SupplierIndex` trỏ vào `Suppliers` đã seed.
private sealed record LineSeed(int SupplierIndex, decimal PeReference, decimal Proposed, decimal? Approved);
private const ContractType TestType = ContractType.HopDongThauPhu;
/// Seed đủ 1 thế giới nhỏ nhưng THẬT: phiếu KHKK + PE nguồn + Project/Supplier CÓ `Code`
/// (cầu nạp 2 bảng này cho codegen, thiếu = `NotFoundException`) + 1 quy trình V2 loại HĐ
/// hợp lệ + 1 `WorkflowDefinition` V1 active của `TestType`.
///
/// 🔴 MỖI dòng 1 `CatalogEntryId` RIÊNG: UNIQUE `(PlanId, SupplierId, CatalogEntryId)` filtered
/// (`ContractSigningPlanLineConfiguration.cs:39-41`, K2 Mig 71). Chính vì Mig 71 đổi unique từ
/// (Plan, Supplier) sang bộ-3 mà **T2 gộp 2 dòng cùng NCC mới tồn tại được** (vá-5 spec-cụm-3).
private static async Task<Seeded> SeedAsync(
IdentityFixture fix,
TestApplicationDbContext db,
LineSeed[] lines,
int supplierCount = 1,
ContractSigningPlanPhase phase = ContractSigningPlanPhase.DaDuyet,
string maKeHoach = "KHKK/2026/001")
{
// Hậu tố ngẫu nhiên cho MỌI mã: `Projects.Code` / `Suppliers.Code` / `ApprovalWorkflows`
// (Code,Version) / `WorkflowDefinitions` (Code,Version) đều UNIQUE ⇒ T9 gọi `SeedAsync`
// HAI LẦN trên cùng DB sẽ nổ SQLite 19 nếu mã trùng.
var tag = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
var actor = await fix.CreateUserAsync(
$"drafter-k7-{Guid.NewGuid():N}@test.local", "Người soạn K7",
departmentId: null, roles: [AppRoles.Drafter]);
var project = new Project
{
Id = Guid.NewGuid(), Code = $"{ProjectCode}{tag}", Name = "Dự án FLOCK 01",
};
db.Projects.Add(project);
var suppliers = new List<Supplier>();
for (var i = 0; i < supplierCount; i++)
{
var s = new Supplier
{
Id = Guid.NewGuid(),
// NCC[0] mang mã dùng để soi mã HĐ (T1); các NCC sau khác mã để không lẫn.
Code = i == 0 ? $"{SupplierCode}{tag}" : $"NCC{i}{tag}",
Name = $"Nhà cung cấp {i + 1}",
Type = SupplierType.NhaThauPhu,
};
suppliers.Add(s);
db.Suppliers.Add(s);
}
var pe = new PurchaseEvaluation
{
Id = Guid.NewGuid(),
MaPhieu = $"PE-2026-K7-{tag}",
Type = PurchaseEvaluationType.DuyetNccPhuongAn,
Phase = PurchaseEvaluationPhase.DaDuyet,
TenGoiThau = "Gói thầu K7",
ProjectId = project.Id,
DrafterUserId = actor.Id,
MoTa = "Mô tả gói thầu K7",
};
db.PurchaseEvaluations.Add(pe);
var plan = new ContractSigningPlan
{
Id = Guid.NewGuid(),
MaKeHoach = maKeHoach,
PurchaseEvaluationId = pe.Id,
ProjectId = project.Id,
DepartmentId = Guid.NewGuid(),
DrafterUserId = actor.Id,
Phase = phase,
ApprovalGroup = 1,
};
foreach (var l in lines)
{
var catalog = new ContractCatalogEntry
{
Id = Guid.NewGuid(),
Code = "K7-" + Guid.NewGuid().ToString("N")[..8],
TenVi = $"Hạng mục K7 #{plan.Lines.Count + 1}",
GroupCode = "B1",
ApprovalGroup = 1,
SignerRole = "TP.PRO",
IsActive = true,
SortOrder = 1,
};
db.ContractCatalogEntries.Add(catalog);
plan.Lines.Add(new ContractSigningPlanLine
{
Id = Guid.NewGuid(),
ContractSigningPlanId = plan.Id,
SupplierId = suppliers[l.SupplierIndex].Id,
CatalogEntryId = catalog.Id,
TenHangMuc = catalog.TenVi,
PeReferenceAmount = l.PeReference,
ProposedAmount = l.Proposed,
ApprovedAmount = l.Approved,
});
}
db.ContractSigningPlans.Add(plan);
var aw = NewContractWorkflow($"QT-HD-V2-K7-{tag}");
db.ApprovalWorkflows.Add(aw);
// V1 active của ĐÚNG `TestType` + 1 bản NHIỄU (loại khác) để vế pin của T1 không thể
// xanh nhờ "chỉ có mỗi một bản trong bảng".
var activeDef = new WorkflowDefinition
{
Id = Guid.NewGuid(), Code = $"QT-TP-{tag}", Version = 1,
ContractType = TestType, Name = "QT Thầu phụ v01", IsActive = true,
};
db.WorkflowDefinitions.Add(activeDef);
db.WorkflowDefinitions.Add(new WorkflowDefinition
{
Id = Guid.NewGuid(), Code = $"QT-MB-{tag}", Version = 1,
ContractType = ContractType.HopDongMuaBan, Name = "QT Mua bán v01", IsActive = true,
});
await db.SaveChangesAsync(CancellationToken.None);
return new Seeded(plan, pe, project, suppliers, aw.Id, activeDef.Id, actor);
}
private static ApprovalWorkflow NewContractWorkflow(
string code, bool isActive = true, bool isUserSelectable = true,
ApprovalWorkflowApplicableType type = ApprovalWorkflowApplicableType.Contract)
=> new()
{
Id = Guid.NewGuid(),
Code = code,
Version = 1,
Name = $"Quy trình {code}",
ApplicableType = type,
IsActive = isActive,
IsUserSelectable = isUserSelectable,
};
private static CreateContractFromSigningPlanCommand Cmd(
Seeded s, IEnumerable<Guid> lineIds, Guid? workflowId = null, string? ten = null)
=> new(s.Plan.Id, lineIds.ToList(), TestType, workflowId ?? s.ContractWorkflowId, ten);
private static Guid LineId(Seeded s, int index) => s.Plan.Lines[index].Id;
// ===================================================================
// T1 — Happy 1 dòng: trọn field-map + write-site + 2 changelog.
// ===================================================================
[Fact]
public async Task T1_MotDong_TaoDungMotHopDong_VaGhiNguocContractId()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db, [new LineSeed(0, PeReference: 900m, Proposed: 999m, Approved: 750m)]);
var handler = BuildHandler(db, fix.Services, s.Actor);
var result = await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
var c = await db.Contracts.AsNoTracking().SingleAsync(x => x.Id == result.ContractId);
// ---- Tiền + trạng thái.
c.GiaTri.Should().Be(750m,
"`GiaTri` = Σ ApprovedAmount của ĐÚNG tập dòng chọn — KHÔNG phải `ProposedAmount` (999) " +
"cũng không phải `PeReferenceAmount` (900); 3 số cố ý khác nhau để chỉ một cái đúng");
c.Phase.Should().Be(ContractPhase.DangSoanThao);
c.Type.Should().Be(TestType);
c.SupplierId.Should().Be(s.Suppliers[0].Id, "NCC của HĐ lấy từ DÒNG, không phải từ phiếu");
c.ProjectId.Should().Be(s.Project.Id);
c.DepartmentId.Should().Be(s.Plan.DepartmentId);
c.DrafterUserId.Should().Be(s.Actor.Id);
// ---- Pin quy trình: V2 (mới) + V1 (vá-6). Xem khối "LỆCH SPEC" đầu file.
c.ApprovalWorkflowId.Should().Be(s.ContractWorkflowId);
c.WorkflowDefinitionId.Should().Be(s.ActiveWorkflowDefId,
"[vá-6] pin bản V1 ĐANG ACTIVE của ĐÚNG loại HĐ — để null thì HĐ rơi về policy hardcode; " +
"pin nhầm bản của loại khác (bảng có sẵn 1 bản nhiễu `HopDongMuaBan`) cũng sai");
// ---- [vá-6] SLA: `GetPhaseSla(DangSoanThao)` trả null ⇒ nhánh dự phòng 7 ngày.
// Thiếu dòng này thì HĐ sinh từ cầu là HĐ DUY NHẤT trong hệ không có hạn phase.
c.SlaDeadline.Should().NotBeNull("[vá-6] cầu phải set hạn phase như 2 tiền lệ");
c.SlaDeadline!.Value.Should().BeCloseTo(DateTime.UtcNow.AddDays(7), TimeSpan.FromMinutes(5));
// ---- [vá-6] Mã HĐ: soi RUỘT chứ không chỉ non-null — mã có `Project.Code` + `Supplier.Code`
// CHÍNH LÀ bằng chứng cầu đã nạp 2 bảng đó (nếu quên nạp thì `GenerateAsync` NRE/mã rỗng).
c.MaHopDong.Should().Be($"{s.Project.Code}/HĐTP/SOL&{s.Suppliers[0].Code}/01",
"RG-001 `{ProjectCode}/HĐTP/SOL&{SupplierCode}/{Seq:D2}` — gen NGAY lúc bắc cầu; " +
"mã CHỨA mã dự án + mã NCC chính là bằng chứng cầu đã nạp 2 bảng đó [vá-6]");
result.MaHopDong.Should().Be(c.MaHopDong, "kết quả trả về phải khớp DB (FE toast đọc số này)");
// ---- NS tham chiếu = Σ `PeReferenceAmount` (số PE), KHÁC `GiaTri` (số đã chốt).
c.BudgetManualAmount.Should().Be(900m);
c.BudgetManualName.Should().Contain(s.Plan.MaKeHoach!);
// ---- [vá-4a] WRITE-SITE: `Line.ContractId` = nguồn THẬT của đường KHKK → HĐ.
var line = await db.ContractSigningPlanLines.AsNoTracking().SingleAsync(l => l.Id == LineId(s, 0));
line.ContractId.Should().Be(c.Id, "[vá-4a] write-site ĐẦU TIÊN toàn codebase của cột này");
// ---- [vá-4a] `pe.ContractId` = fallback hiển thị cho cây 4-folder (ô còn trống ⇒ điền).
var pe = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(p => p.Id == s.Pe.Id);
pe.ContractId.Should().Be(c.Id);
// ---- 2 changelog, mỗi bên 1 hàng, ĐÚNG bảng (không cùng bảng, không thiếu bên nào).
var ctLog = await db.ContractChangelogs.AsNoTracking().Where(x => x.ContractId == c.Id).ToListAsync();
ctLog.Should().HaveCount(1);
ctLog[0].Action.Should().Be(ChangelogAction.Insert);
ctLog[0].EntityType.Should().Be(ChangelogEntityType.Contract);
ctLog[0].UserId.Should().Be(s.Actor.Id);
ctLog[0].Summary.Should().Contain(c.MaHopDong!).And.Contain(s.Plan.MaKeHoach!);
var planLog = await db.ContractSigningPlanChangelogs.AsNoTracking()
.Where(x => x.ContractSigningPlanId == s.Plan.Id).ToListAsync();
planLog.Should().HaveCount(1);
planLog[0].Action.Should().Be(ChangelogAction.Update);
planLog[0].EntityType.Should().Be(ContractSigningPlanEntityType.Line);
planLog[0].ContextNote.Should().Contain(c.Id.ToString());
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(1, "1 lần bấm = 1 HĐ");
}
// ===================================================================
// T2 — Gộp 2 dòng CÙNG NCC (máy nhận N, UI hướng 1-1).
// 🔴 Case này CHỈ TỒN TẠI ĐƯỢC nhờ K2 Mig 71 đổi UNIQUE (Plan, Supplier) → bộ-3 có
// `CatalogEntryId` (vá-5): trước đó 2 dòng cùng NCC trên 1 phiếu là bất khả về schema.
// ===================================================================
[Fact]
public async Task T2_HaiDongCungNcc_GopVaoMotHopDong_GiaTriLaTong()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db,
[
new LineSeed(0, 100m, 500m, Approved: 300m),
new LineSeed(0, 200m, 500m, Approved: 450m),
]);
var handler = BuildHandler(db, fix.Services, s.Actor);
var result = await handler.Handle(Cmd(s, [LineId(s, 0), LineId(s, 1)]), CancellationToken.None);
var c = await db.Contracts.AsNoTracking().SingleAsync();
c.Id.Should().Be(result.ContractId);
c.GiaTri.Should().Be(750m, "Σ 300 + 450 của ĐÚNG 2 dòng gửi lên");
c.BudgetManualAmount.Should().Be(300m, "NS tham chiếu = Σ PeReferenceAmount (100 + 200)");
var lines = await db.ContractSigningPlanLines.AsNoTracking()
.Where(l => l.ContractSigningPlanId == s.Plan.Id).ToListAsync();
lines.Should().HaveCount(2);
lines.Should().OnlyContain(l => l.ContractId == c.Id, "CẢ HAI dòng cùng trỏ 1 HĐ");
}
// ===================================================================
// T3 — 1 dòng chưa có giá chốt ⇒ chặn, message NÊU TÊN hạng mục, KHÔNG đốt mã.
// "Không đốt mã" = phép đo side-effect của sequence: rào (4) phải đứng TRƯỚC codegen
// (`:119-130` vs `:212`). Nếu ai đảo thứ tự, HĐ không sinh ra nhưng số RG-001 đã cháy
// — hỏng câm lặng, chỉ lộ khi soi bảng sequence.
// ===================================================================
[Fact]
public async Task T3_DongChuaCoGiaChot_ThiChan_KhongTaoHd_VaKhongDotMa()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db,
[
new LineSeed(0, 100m, 500m, Approved: 300m), // dòng lành
new LineSeed(0, 100m, 700m, Approved: null), // dòng thiếu giá chốt
]);
var handler = BuildHandler(db, fix.Services, s.Actor);
var tenHangMucThieu = s.Plan.Lines[1].TenHangMuc!;
var act = async () => await handler.Handle(
Cmd(s, [LineId(s, 0), LineId(s, 1)]), CancellationToken.None);
(await act.Should().ThrowAsync<ConflictException>())
.WithMessage($"*{tenHangMucThieu}*",
"message phải NÊU TÊN hạng mục — dialog chỉ hiện tên, không hiện id");
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0, "atomic: 0 HĐ dở dang");
(await db.ContractSigningPlanLines.AsNoTracking()
.CountAsync(l => l.ContractId != null)).Should().Be(0, "kể cả dòng LÀNH cũng không bị ghi");
(await db.ContractCodeSequences.AsNoTracking().CountAsync()).Should().Be(0,
"rào phải đứng TRƯỚC codegen — chặn xong mà số RG-001 đã cháy là hỏng câm lặng");
}
// ===================================================================
// T4 — Phiếu chưa duyệt xong (`ChoDuyet`) ⇒ chặn.
// ===================================================================
[Fact]
public async Task T4_PhieuChuaDuyetXong_ThiChan()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
// Dòng CÓ đủ giá chốt: nếu bỏ rào phase, call này sẽ THÀNH CÔNG ⇒ test phân biệt được
// đúng một biến (phase), không phải xanh nhờ vướng rào khác.
var s = await SeedAsync(fix, db,
[new LineSeed(0, 100m, 500m, Approved: 300m)],
phase: ContractSigningPlanPhase.ChoDuyet);
var handler = BuildHandler(db, fix.Services, s.Actor);
var act = async () => await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
(await act.Should().ThrowAsync<ConflictException>()).WithMessage("*Đã duyệt*");
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0);
}
// ===================================================================
// T5 — Dòng đã có HĐ ⇒ chặn double-bridge PER-DÒNG (không phải per-phiếu như cầu PE cũ).
// ===================================================================
[Fact]
public async Task T5_DongDaCoHopDong_ThiChan()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 300m)]);
// Đặt sẵn dấu "đã bắc cầu" bằng chính cột write-site.
var seededContractId = Guid.NewGuid();
var line = await db.ContractSigningPlanLines.SingleAsync(l => l.Id == LineId(s, 0));
line.ContractId = seededContractId;
await db.SaveChangesAsync(CancellationToken.None);
db.ChangeTracker.Clear();
var handler = BuildHandler(db, fix.Services, s.Actor);
var act = async () => await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
(await act.Should().ThrowAsync<ConflictException>())
.WithMessage($"*{s.Plan.Lines[0].TenHangMuc}*đã có Hợp đồng*");
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0);
(await db.ContractSigningPlanLines.AsNoTracking().SingleAsync(l => l.Id == LineId(s, 0)))
.ContractId.Should().Be(seededContractId, "HĐ cũ KHÔNG bị ghi đè");
}
// ===================================================================
// T6 — 2 dòng KHÁC NCC trong 1 lần bấm ⇒ chặn (1 HĐ = 1 NCC; liên-danh = N lần bấm).
// ===================================================================
[Fact]
public async Task T6_HaiDongKhacNcc_ThiChan()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db,
[
new LineSeed(0, 100m, 500m, Approved: 300m),
new LineSeed(1, 100m, 500m, Approved: 300m), // NCC KHÁC
], supplierCount: 2);
var handler = BuildHandler(db, fix.Services, s.Actor);
var act = async () => await handler.Handle(
Cmd(s, [LineId(s, 0), LineId(s, 1)]), CancellationToken.None);
(await act.Should().ThrowAsync<ConflictException>()).WithMessage("*nhiều NCC khác nhau*");
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0);
// ĐỐI CHỨNG DƯƠNG cùng lượt: tách ra bấm từng dòng thì QUA — chứng minh 2 dòng này
// hợp lệ về mọi mặt khác, cái chặn ở trên đúng là vế "khác NCC".
var r1 = await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
db.ChangeTracker.Clear();
var r2 = await handler.Handle(Cmd(s, [LineId(s, 1)]), CancellationToken.None);
r1.ContractId.Should().NotBe(r2.ContractId);
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(2, "liên-danh 2 NCC = 2 HĐ");
}
// ===================================================================
// T7 — Rào quy trình pin: 4 nhánh SAI + 1 ĐỐI CHỨNG DƯƠNG, tất cả trên CÙNG một phiếu.
// [vá-3] 2 vế `IsActive`/`IsUserSelectable` là phần VÁ (khuôn gốc `ContractFeatures.cs:79-87`
// chỉ hỏi tồn-tại + đúng-loại ⇒ forge POST pin được quy trình đã nghỉ / chưa ghim).
// Ma-trận cùng-ruột: mọi thứ khác giữ y nguyên, đổi ĐÚNG 1 biến là `ApprovalWorkflowId`.
// Message TÁCH RIÊNG từng nguyên nhân — gộp `||` thì admin không biết bấm nút nào.
// ===================================================================
[Fact]
public async Task T7_QuyTrinhPinSai_Chan4Nhanh_VaQuyTrinhDungThiQua()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 300m)]);
var wrongType = NewContractWorkflow("QT-KHKK-V2",
type: ApprovalWorkflowApplicableType.ContractSigningPlan); // loại 10, không phải 3
var retired = NewContractWorkflow("QT-HD-NGHI", isActive: false);
var unpinned = NewContractWorkflow("QT-HD-CHUA-GHIM", isUserSelectable: false);
db.ApprovalWorkflows.AddRange(wrongType, retired, unpinned);
await db.SaveChangesAsync(CancellationToken.None);
var handler = BuildHandler(db, fix.Services, s.Actor);
var lineIds = new[] { LineId(s, 0) };
// (a) không tồn tại → NotFound (khác Conflict: id rác là lỗi tham chiếu, không phải trạng thái).
await ((Func<Task>)(async () => await handler.Handle(
Cmd(s, lineIds, Guid.NewGuid()), CancellationToken.None)))
.Should().ThrowAsync<NotFoundException>();
// (b) đúng tồn tại nhưng SAI LOẠI (quy trình của chính module KHKK).
(await ((Func<Task>)(async () => await handler.Handle(
Cmd(s, lineIds, wrongType.Id), CancellationToken.None)))
.Should().ThrowAsync<ConflictException>())
.WithMessage("*không khớp với Hợp đồng*");
// (c) [vá-3] đã ngừng hoạt động.
(await ((Func<Task>)(async () => await handler.Handle(
Cmd(s, lineIds, retired.Id), CancellationToken.None)))
.Should().ThrowAsync<ConflictException>())
.WithMessage("*ngừng hoạt động*");
// (d) [vá-3] chưa được ghim cho người dùng chọn — message PHẢI khác (c).
(await ((Func<Task>)(async () => await handler.Handle(
Cmd(s, lineIds, unpinned.Id), CancellationToken.None)))
.Should().ThrowAsync<ConflictException>())
.WithMessage("*chưa được ghim*");
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0, "4 nhánh sai = 0 HĐ");
(await db.ContractCodeSequences.AsNoTracking().CountAsync()).Should().Be(0,
"rào quy trình cũng phải đứng TRƯỚC codegen");
// ---- ĐỐI CHỨNG DƯƠNG: đổi ĐÚNG 1 biến (quy trình hợp lệ) → QUA.
// Thiếu vế này thì 4 khẳng-định trên vẫn xanh trong một thế giới mà cầu chặn TẤT CẢ.
var ok = await handler.Handle(Cmd(s, lineIds, s.ContractWorkflowId), CancellationToken.None);
ok.ContractId.Should().NotBeEmpty();
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(1);
}
// ===================================================================
// T8 (khoá V3) — MỐI NỐI cầu → đường ống V2.
//
// `ContractV2PipelineTests` (W6-S161) đã khoá đường ống, NHƯNG nó **seed thẳng** HĐ ở
// `ChoDuyet` + `ApprovalWorkflowId` + `stepIndex` — tức cho sẵn thứ mà cầu phải tự sinh.
// Test này đo phần KHÔNG ai đo: HĐ do CẦU đẻ có đi vào máy V2 được không.
// Thế giới hỏng phân biệt được: cầu quên pin `ApprovalWorkflowId` ⇒ `TransitionAsync:97`
// để `CurrentApprovalLevelOrder = null` ⇒ `ResolveV2InboxIdsAsync` không khớp ⇒ hộp thư
// của ĐÚNG người phải duyệt rỗng, mà HĐ thì vẫn "đã trình" — treo vô thời hạn, im lặng.
// ===================================================================
[Fact]
public async Task T8_HdSinhTuCau_TrinhDuyet_ThiVaoDungHopThuCapDangCho()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 300m)]);
// 2 Cấp trong 1 Bước: cấp 1 = a, cấp 2 = b (OR-of-N không cần thiết ở đây).
var a = await fix.CreateUserAsync($"a-{Guid.NewGuid():N}@test.local", "Duyệt cấp 1", null, []);
var b = await fix.CreateUserAsync($"b-{Guid.NewGuid():N}@test.local", "Duyệt cấp 2", null, []);
var step = new ApprovalWorkflowStep
{
Id = Guid.NewGuid(), ApprovalWorkflowId = s.ContractWorkflowId,
Order = 1, Name = "Bước 1", DepartmentId = null,
};
db.ApprovalWorkflowSteps.Add(step);
db.ApprovalWorkflowLevels.AddRange(
new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = step.Id, Order = 1, Name = "Cấp 1", ApproverUserId = a.Id },
new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = step.Id, Order = 2, Name = "Cấp 2", ApproverUserId = b.Id });
await db.SaveChangesAsync(CancellationToken.None);
var handler = BuildHandler(db, fix.Services, s.Actor);
var result = await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
// ---- Trình duyệt QUA MÁY THẬT (không set Phase bằng tay — seed thẳng là đo nhầm cơ chế).
var dt = new FixedDateTime(Clock);
var drafterUser = new TestCurrentUser(s.Actor.Id, s.Actor.FullName, s.Actor.Email, AppRoles.Drafter);
var svc = new ContractWorkflowService(
db, new ContractCodeGenerator(db, dt), dt, new NoOpNotificationServiceApp(),
new ChangelogService(db, drafterUser, fix.Services.GetRequiredService<UserManager<User>>()),
fix.Services.GetRequiredService<UserManager<User>>());
var contract = await db.Contracts.SingleAsync(x => x.Id == result.ContractId);
await svc.TransitionAsync(contract, ContractPhase.ChoDuyet, s.Actor.Id,
[AppRoles.Drafter], ApprovalDecision.Approve, "trình", ct: CancellationToken.None);
var after = await db.Contracts.AsNoTracking().SingleAsync(x => x.Id == result.ContractId);
after.Phase.Should().Be(ContractPhase.ChoDuyet);
after.CurrentWorkflowStepIndex.Should().Be(0);
after.CurrentApprovalLevelOrder.Should().Be(1,
"con-trỏ Cấp CHỈ được khởi tạo khi HĐ pin V2 (`ContractWorkflowService.cs:97`) — " +
"null ở đây nghĩa là cầu quên pin `ApprovalWorkflowId`");
// ---- Hộp thư: người của Cấp ĐANG CHỜ thấy; người Cấp SAU chưa thấy.
// Cả 2 user KHÔNG mang role legacy nào ⇒ chỉ có chân V2, đo đúng vế V2.
var inboxA = await new GetMyInboxQueryHandler(db, new TestCurrentUser(a.Id, a.FullName, a.Email))
.Handle(new GetMyInboxQuery(), CancellationToken.None);
inboxA.Select(i => i.Id).Should().BeEquivalentTo(new[] { result.ContractId },
"HĐ sinh từ cầu phải nằm trong hộp thư của Cấp 1 — đây là toàn bộ lý do cầu tồn tại");
var inboxB = await new GetMyInboxQueryHandler(db, new TestCurrentUser(b.Id, b.FullName, b.Email))
.Handle(new GetMyInboxQuery(), CancellationToken.None);
inboxB.Should().BeEmpty("Cấp 2 chưa tới lượt");
}
// ===================================================================
// T9 — Rào tập `LineIds`: id của phiếu KHÁC (hoặc id rác) không được lọt.
// Đây là rào duy nhất chặn "đưa dòng của phiếu người khác vào HĐ của mình".
// ===================================================================
[Fact]
public async Task T9_DongCuaPhieuKhac_ThiChan()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s1 = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 300m)]);
var s2 = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 999m)],
maKeHoach: "KHKK/2026/002");
var handler = BuildHandler(db, fix.Services, s1.Actor);
// Trộn 1 dòng của phiếu 1 + 1 dòng của phiếu 2 → chặn.
var act = async () => await handler.Handle(
Cmd(s1, [LineId(s1, 0), LineId(s2, 0)]), CancellationToken.None);
(await act.Should().ThrowAsync<ConflictException>()).WithMessage("*không thuộc kế hoạch này*");
// Id rác cũng rơi vào cùng rào (đếm sau Distinct).
var act2 = async () => await handler.Handle(
Cmd(s1, [LineId(s1, 0), Guid.NewGuid()]), CancellationToken.None);
(await act2.Should().ThrowAsync<ConflictException>()).WithMessage("*không thuộc kế hoạch này*");
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0);
(await db.ContractSigningPlanLines.AsNoTracking()
.CountAsync(l => l.ContractId != null)).Should().Be(0, "dòng của phiếu hàng xóm không bị đụng");
}
// ===================================================================
// T10 — Bấm lần 2 trên DÒNG KHÁC của cùng phiếu ⇒ HĐ thứ 2 sinh ra,
// nhưng `pe.ContractId` GIỮ HĐ ĐẦU. Đây là phép đo CHIỀU của `??=` (vá-4a):
// viết `=` thay `??=` thì mọi assert khác vẫn xanh, chỉ vế này ĐỎ.
// Đồng thời là phép đo cardinality của DoD vá-15 (N lần bấm = N ContractId).
// ===================================================================
[Fact]
public async Task T10_BacCauLanHai_TaoHdThuHai_NhungPeGiuHdDau()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db,
[
new LineSeed(0, 100m, 500m, Approved: 300m),
new LineSeed(0, 100m, 500m, Approved: 450m),
]);
var handler = BuildHandler(db, fix.Services, s.Actor);
var first = await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
db.ChangeTracker.Clear();
var second = await handler.Handle(Cmd(s, [LineId(s, 1)]), CancellationToken.None);
second.ContractId.Should().NotBe(first.ContractId);
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(2);
var lines = await db.ContractSigningPlanLines.AsNoTracking()
.Where(l => l.ContractSigningPlanId == s.Plan.Id).OrderBy(l => l.ApprovedAmount).ToListAsync();
lines[0].ContractId.Should().Be(first.ContractId);
lines[1].ContractId.Should().Be(second.ContractId);
var pe = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(p => p.Id == s.Pe.Id);
pe.ContractId.Should().Be(first.ContractId,
"[vá-4a] `??=` CÓ CHIỀU: HĐ đầu giữ ô đơn của PE; HĐ sau KHÔNG cướp ô " +
"(chúng vẫn nhìn thấy được qua `Line.ContractId` — chính là lý do vá-4a tồn tại)");
// Mã HĐ thứ 2 tăng seq trên CÙNG prefix (cùng dự án + cùng NCC) — chứng codegen chạy thật.
var codes = await db.Contracts.AsNoTracking().Select(c => c.MaHopDong).ToListAsync();
codes.Should().BeEquivalentTo(new[]
{
$"{s.Project.Code}/HĐTP/SOL&{s.Suppliers[0].Code}/01",
$"{s.Project.Code}/HĐTP/SOL&{s.Suppliers[0].Code}/02",
});
}
// ===================================================================
// T11 — DOUBLE-CLICK: bấm 2 lần liên tiếp CÙNG tập dòng.
// Hành vi ĐANG CÓ của BE (đọc `:128-129`): lần 2 rơi vào rào per-dòng "đã có Hợp đồng"
// ⇒ `ConflictException`. Test khoá đúng hành vi đó, KHÔNG chế kỳ vọng "no-op im lặng".
// `ChangeTracker.Clear()` mô phỏng request thứ 2 (context mới đọc lại từ DB).
// ===================================================================
[Fact]
public async Task T11_BamHaiLanCungTapDong_ThiLanHaiBiChan_KhongTaoHdThua()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 300m)]);
var handler = BuildHandler(db, fix.Services, s.Actor);
var first = await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
db.ChangeTracker.Clear();
var act = async () => await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
(await act.Should().ThrowAsync<ConflictException>()).WithMessage("*đã có Hợp đồng*");
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(1, "double-click không đẻ HĐ thứ 2");
(await db.ContractSigningPlanLines.AsNoTracking().SingleAsync(l => l.Id == LineId(s, 0)))
.ContractId.Should().Be(first.ContractId, "dòng vẫn trỏ HĐ của lần bấm đầu");
var seq = await db.ContractCodeSequences.AsNoTracking().SingleAsync();
seq.LastSeq.Should().Be(1, "lần bấm 2 bị chặn TRƯỚC codegen ⇒ số RG-001 không cháy oan");
}
// ===================================================================
// T12 — [vá-7 spec-cụm-3 §②-7] NEO ACCEPTANCE B6, đo END-TO-END trên máy THẬT.
//
// Vì sao phải có dù K3 đã có test finalize: các test kia dừng ở "finalize ghi
// `ApprovedAmount`". Cầu K7 thì ĐỌC cột đó. Mối nối giữa hai vế chưa ai đo — mà chính nó
// là điều kiện sống của toàn bộ happy-path (invest `:154`: "không có K3 thì `ApprovedAmount`
// NULL 100%" ⇒ cầu chặn mọi phiếu). Ở đây phiếu đi TRỌN đường qua
// `ContractSigningPlanWorkflowService` THẬT (trình → duyệt → terminal chốt giá), KHÔNG seed
// thẳng Phase/giá — seed thẳng là tự đưa cho mình đáp án rồi chấm.
//
// Tương phản để phép đo không rỗng:
// dòng A: `ApprovedAmount` = 111 CÓ SẴN (người có thẩm quyền chốt tay), `Proposed` = 999
// dòng B: `ApprovedAmount` = null → finalize `??=` điền = `Proposed` = 250
// ⇒ `GiaTri` phải = 361. Σ `Proposed` = 1249. Nếu cầu (hoặc finalize) lấy nhầm nguồn thì
// ra 1249 / 1109 / 500 — mọi cách sai đều KHÁC 361.
//
// Vế cuối = yêu cầu chữ của vá-7 ("`Line.ContractId` không đổi sau finalize"): sau khi bắc
// cầu, phiếu ĐÃ terminal nên không còn đường finalize lần nữa — chứng bằng cách BẤM THẬT
// (`approve` → Conflict) rồi đọc lại cả `ContractId` lẫn `ApprovedAmount`.
// ===================================================================
[Fact]
public async Task T12_DiTronDuongFinalizeRoiBacCau_GiaTriLaSoDaCHOT_VaKhongDoiVeSau()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var s = await SeedAsync(fix, db,
[
new LineSeed(0, 100m, Proposed: 999m, Approved: 111m), // đã chốt tay
new LineSeed(0, 100m, Proposed: 250m, Approved: null), // để finalize điền
], phase: ContractSigningPlanPhase.DangSoanThao);
// Quy trình KHKK (loại 10) 1 Bước × 1 Cấp — đường ngắn nhất tới terminal.
var approver = await fix.CreateUserAsync(
$"tp-{Guid.NewGuid():N}@test.local", "TP duyệt", null, []);
var khkkWf = NewContractWorkflow($"QT-KHKK-{Guid.NewGuid().ToString("N")[..6]}",
type: ApprovalWorkflowApplicableType.ContractSigningPlan);
db.ApprovalWorkflows.Add(khkkWf);
var step = new ApprovalWorkflowStep
{
Id = Guid.NewGuid(), ApprovalWorkflowId = khkkWf.Id,
Order = 1, Name = "Bước 1", DepartmentId = null,
};
db.ApprovalWorkflowSteps.Add(step);
db.ApprovalWorkflowLevels.Add(new ApprovalWorkflowLevel
{
Id = Guid.NewGuid(), ApprovalWorkflowStepId = step.Id,
Order = 1, Name = "Cấp 1", ApproverUserId = approver.Id,
});
var plan = await db.ContractSigningPlans.SingleAsync(p => p.Id == s.Plan.Id);
plan.ApprovalWorkflowId = khkkWf.Id;
await db.SaveChangesAsync(CancellationToken.None);
db.ChangeTracker.Clear();
var dt = new FixedDateTime(Clock);
ContractSigningPlanWorkflowService KhkkSvc(User u, params string[] roles)
=> new(db, new TestCurrentUser(u.Id, u.FullName, u.Email, roles), dt,
new NoOpNotificationServiceApp());
// ---- Trình → duyệt (Cấp cuối của Bước cuối ⇒ terminal + chốt giá).
await KhkkSvc(s.Actor, AppRoles.Drafter)
.TransitionAsync(s.Plan.Id, "submit", null, ct: CancellationToken.None);
var afterApprove = await KhkkSvc(approver)
.TransitionAsync(s.Plan.Id, "approve", "ok", ct: CancellationToken.None);
afterApprove.Phase.Should().Be((int)ContractSigningPlanPhase.DaDuyet,
"B6 neo (a): phiếu phải TỰ đi tới Đã duyệt qua máy, không phải do test gán");
db.ChangeTracker.Clear();
var frozen = await db.ContractSigningPlanLines.AsNoTracking()
.Where(l => l.ContractSigningPlanId == s.Plan.Id).OrderBy(l => l.ProposedAmount).ToListAsync();
frozen.Select(l => l.ApprovedAmount).Should().BeEquivalentTo(new decimal?[] { 250m, 111m },
"B6 neo (b): finalize `??=` — dòng chốt tay GIỮ 111 (không bị 999 đè), dòng trống lấy 250");
// ---- Bây giờ mới bắc cầu, trên đúng những con số vừa đông cứng.
var handler = BuildHandler(db, fix.Services, s.Actor);
var result = await handler.Handle(
Cmd(s, frozen.Select(l => l.Id)), CancellationToken.None);
var c = await db.Contracts.AsNoTracking().SingleAsync(x => x.Id == result.ContractId);
c.GiaTri.Should().Be(361m,
"= 111 + 250 (số ĐÃ CHỐT). Σ ProposedAmount = 1249 — mọi cách lấy nhầm nguồn đều ra số khác");
// ---- Vế cuối vá-7: sau khi bắc cầu, phiếu terminal ⇒ không còn đường chốt lại giá.
db.ChangeTracker.Clear();
var act = async () => await KhkkSvc(approver)
.TransitionAsync(s.Plan.Id, "approve", "duyệt lại", ct: CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>();
var after = await db.ContractSigningPlanLines.AsNoTracking()
.Where(l => l.ContractSigningPlanId == s.Plan.Id).OrderBy(l => l.ProposedAmount).ToListAsync();
after.Should().OnlyContain(l => l.ContractId == result.ContractId,
"`Line.ContractId` KHÔNG đổi sau khi phiếu đã terminal");
after.Select(l => l.ApprovedAmount).Should().BeEquivalentTo(new decimal?[] { 250m, 111m },
"giá đã chốt cũng không đổi — HĐ và phiếu kể cùng một câu chuyện tiền");
}
}