Compare commits
5 Commits
a2b37dcccc
...
0bfadc2011
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bfadc2011 | |||
| 7d0867011c | |||
| 5c94612fd5 | |||
| 2305c39019 | |||
| 081557b441 |
@ -17,17 +17,27 @@ chain:
|
|||||||
L9 per-window (chốt @closeout L9) · run.md ledger 8 row mới (K3-fix + F1/K4ab/K5K4c/K6/K7/GĐ3/K8) + B4 7/8
|
L9 per-window (chốt @closeout L9) · run.md ledger 8 row mới (K3-fix + F1/K4ab/K5K4c/K6/K7/GĐ3/K8) + B4 7/8
|
||||||
[x] /tiep S168 nghi-thức — Sàn-3 ①④⑤ khớp-nợ-WAL · verify 5/5 KHỚP · tick CLEAN 41→42 · NHIP-NO light 2/6 deep
|
[x] /tiep S168 nghi-thức — Sàn-3 ①④⑤ khớp-nợ-WAL · verify 5/5 KHỚP · tick CLEAN 41→42 · NHIP-NO light 2/6 deep
|
||||||
2/15 ok · thu-moi se=0 all=0 · mind-check 11Đ/0T · ctx-verifier CO-11-cờ (⑦⑨ vá liền, ⑩⑪ để MIND-5) · _tiep-5
|
2/15 ok · thu-moi se=0 all=0 · mind-check 11Đ/0T · ctx-verifier CO-11-cờ (⑦⑨ vá liền, ⑩⑪ để MIND-5) · _tiep-5
|
||||||
[!] GĐ3 cây-4-folder ĐỒNG-NHẤT — BLOCKED-SẠCH chờ anh 2-quyết HANDOFF slot (63): (a) trang-đích fe-user
|
[!] 🔴 OWNER TRẢ @S168 16:4x (verbatim: "D cây-thay-Panel-3. / Cứ mở rộng đi nhé. / Chạy dry-run hết luôn đi
|
||||||
{MyContractsPage · InboxPage · WorkflowMatrixViewPage} · (b) {A-42px⛔ · B-≥2xl · C-nới-PipelineTreePanel
|
rồi tao giao tiếp plan khác") ⇒ (63)(b)=**D cây-thay-Panel-3** · "mở rộng" lead KHAI CÁCH HIỂU 2-tầng:
|
||||||
[lane+lead NGHIÊNG C; phụ: mở=ĐẨY-hay-PHỦ] · D-cây-thay-Panel-3} — sub-implfe-gd3.md §1. Chốt → scaffold
|
(a)=CẢ-3-trang fe-user + (59)=mở-rộng-nguồn-tiep_reload [anh bác tầng nào thì đảo] · MANDATE MỚI: GĐ3-D
|
||||||
1-lượt → gate → commit → cicd
|
xong → CHẠY LUÔN K8 dry-run toàn trình, không dừng chờ; xong anh giao plan khác
|
||||||
[ ] K8-prep: form-14-người fail-closed + gói SOLUTION-PE-{n} + QĐ9-đo-2-nhánh + ZZTEST + rollback-ID.
|
[x] GĐ3-D SHIP-local `081557b` (3 trang Panel-3 + khuôn +bodyMaxHClass F-1, SHA-pair 7baf6247; WorkflowMatrixView
|
||||||
(54)-FINAL: pre-flight CHỈ check-đọc workflow-live/roster, KHÔNG dựng/sửa
|
KHÔNG áp khai chủ-đích) · gate PASS-WITH-FLAGS-10/0-blocker (garble→resume; disposition từng dòng trong
|
||||||
|
sub-reviewer-gate-gd3d.md) · suite 642/0 (45D+597I) + build ×2 PASS · CHƯA push (tránh #86 — push 1 lượt)
|
||||||
|
[!] K8-BLOCKER Lines-editor vá xong CODE (UpdateContractSigningPlanLineCommand + PUT lines/{lineId} + FE select
|
||||||
|
×2 SHA-pair 21e0ca05 + 3 Fact xanh — 2 Fact đầu fail FK ApproverUserId-cần-row-thật, đã sửa) · gate
|
||||||
|
ĐANG CHẠY NỀN → commit → push 1-lượt cùng GĐ3-D → cicd — sub-k8-dryrun.md + sub-reviewer-gate-lineeditor.md
|
||||||
|
[!] K8 dry-run: B0 PASS trọn (backup 26,6MB C:\Backups · roster LIVE 3 workflow · form-14-dòng đủ chủ · login
|
||||||
|
admin OK · catalog 86/N4 · Contracts prod 0 · dữ-liệu-test 0 → GĐ0 tạo mới) · script k8run.py (scratchpad,
|
||||||
|
state-JSON ghi ID từng bước) sẵn stages gd0→pe→khkk→bridge→hd→qd9 — CHẠY SAU KHI deploy line-editor xanh
|
||||||
|
[ ] K8-prep + dry-run toàn trình: form-14-người fail-closed + gói SOLUTION-PE-{n} + QĐ9-đo-2-nhánh + ZZTEST
|
||||||
|
+ rollback-ID. (54)-FINAL: pre-flight CHỈ check-đọc workflow-live/roster, KHÔNG dựng/sửa
|
||||||
|
[ ] (59) thi-hành: mở rộng tiep_reload.sources (đo trước-sau, khoanh phạm-vi từng nguồn, khai vết owner-answer)
|
||||||
[ ] Monthly drift-audit 2026-08-01 DUE (session-đầu-tháng — CLAUDE.md §Audit; lần trước S96 07-01) + re-sync
|
[ ] Monthly drift-audit 2026-08-01 DUE (session-đầu-tháng — CLAUDE.md §Audit; lần trước S96 07-01) + re-sync
|
||||||
measured{} (6 vai ctx/ring 0-baseline) + re-chạy governance-detectors sau đợt vá S166+S168 (ring2 INFORM #6)
|
measured{} (6 vai ctx/ring 0-baseline) + re-chạy governance-detectors sau đợt vá S166+S168 (ring2 INFORM #6)
|
||||||
|
|
||||||
next: anh trả slot (63) 2-quyết GĐ3 → lane scaffold → gate → commit → cicd. Anh chưa trả ⇒ /tiep sau vào
|
next: GĐ3-D (đọc sub-implfe-gd3 §phương-án-D → scaffold 3 trang → gate → commit → cicd) → (59) → K8 dry-run
|
||||||
drift-audit-tháng-8 HOẶC K8-prep (check-đọc-only) — 3 mạch không chặn nhau.
|
→ báo anh nhận plan mới. Drift-audit xếp sau mandate.
|
||||||
|
|
||||||
verify:
|
verify:
|
||||||
head -1 .claude/workflows/runs/2026-07-31-S164-4gd-khkk-fanout/sub-implfe-gd3.md # IMPLFE-GD3: BLOCKED
|
head -1 .claude/workflows/runs/2026-07-31-S164-4gd-khkk-fanout/sub-implfe-gd3.md # IMPLFE-GD3: BLOCKED
|
||||||
|
|||||||
@ -12,12 +12,16 @@
|
|||||||
"tiep_reload": {
|
"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').",
|
"_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",
|
"mode": "rut-gon",
|
||||||
|
"_expand_S168": "🔄 OWNER-ANSWER @S168 2026-08-01 (slot 59; anh: 'Cứ mở rộng đi nhé' — lead khai cách hiểu 2-tầng trong turn, tầng-(59) = MỞ RỘNG danh-sách nguồn; anh bác thì revert 3 dòng cuối sources). Đo đúng-phạm-vi TRƯỚC = 132.449 B ~ [33K..44K] tok (cận-dưới < sàn 40K; mốc 120.633 B @S166 là trước khi segment HANDOFF mới land) → THÊM 3 nguồn khoanh-phạm-vi (+49.711 B) → SAU = ~182.160 B ~ [45K..61K] tok = NẰM TRỌN dải 40-60K. 🔴 3/5 nguồn gốc phình theo phiên — CẤM neo literal, nghi under/over-fill thì đo lại bằng đúng phạm-vi từng mục (bài H24-DEEP F-8 + ring2-bác @S166).",
|
||||||
"sources": [
|
"sources": [
|
||||||
"docs/STATUS.md :: dong CURRENT + bang CURRENT STATE (KHONG ca file)",
|
"docs/STATUS.md :: dong CURRENT + bang CURRENT STATE (KHONG ca file)",
|
||||||
"docs/HANDOFF.md :: segment Last-updated MOI NHAT (KHONG ca file)",
|
"docs/HANDOFF.md :: segment Last-updated MOI NHAT (KHONG ca file)",
|
||||||
".claude/WAL.md :: tron (<=40 dong by-design)",
|
".claude/WAL.md :: tron (<=40 dong by-design)",
|
||||||
".claude/sessions/session-<N>/_mind-s-<N>.md :: tron (doc TU TREN XUONG)",
|
".claude/sessions/session-<N>/_mind-s-<N>.md :: tron (doc TU TREN XUONG)",
|
||||||
".claude/sessions/session-<N>/_context-s-<N>.md :: tron"
|
".claude/sessions/session-<N>/_context-s-<N>.md :: tron",
|
||||||
|
".claude/governance/ACTIVE-MARKS.md :: tron (bang marks dang hieu luc — +S168 slot 59)",
|
||||||
|
"docs/gotchas.md :: 5 section ### N. MOI NHAT (KHONG ca file — +S168 slot 59)",
|
||||||
|
"docs/changelog/migration-todos.md :: header Active-work + section Phase hien hanh (KHONG ca file — +S168 slot 59)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hmw_width": {
|
"hmw_width": {
|
||||||
|
|||||||
@ -1,17 +1,21 @@
|
|||||||
# Reviewer Agent — Persistent Memory
|
# Reviewer Agent — Persistent Memory
|
||||||
|
|
||||||
- **[→ archive/2026-07.md — verbatim ĐÃ VERIFY moved-not-cut 3/3 @S166-K5-curate] S166 gate K4b · K4a · vá F-1 (digest):** mỏ-neo XUYÊN FILE (quét file BỊ DỜI, không chỉ file đang sửa) · **tham-số-trang-trí** (đo AI ĐỌC từng param, đừng đo "route có tồn tại") · mỏ-neo tự-vô-hiệu ngay lúc land (đo SAU khi chèn) · **ô canonical sai đo được bằng 1 curl** · khuôn-nguồn 2 tầng ⇒ đo **GIÁ-TRỊ KHỞI-TẠO** không đọc chú-thích · **cây phình giữa review** ⇒ neo mốc + re-đo `--stat` TRƯỚC verdict · HIGH-nằm-ở-git (`git status --porcelain -- src tests | grep '^??'`) · khoá-cache định-danh REQUEST không định-danh URL · sợi-dây-1-argument 0 test.
|
- **[→ archive/2026-07.md §@S166-K5-curate — verbatim] S166 gate K4b · K4a · vá F-1 (digest):** mỏ-neo XUYÊN FILE · **tham-số-trang-trí** (đo AI ĐỌC từng param) · mỏ-neo tự-vô-hiệu ngay lúc land · ô canonical sai đo được bằng 1 curl · khuôn-nguồn 2 tầng ⇒ đo GIÁ-TRỊ KHỞI-TẠO · **cây phình giữa review** ⇒ re-đo `--stat` TRƯỚC verdict · HIGH-nằm-ở-git (`git status --porcelain -- src tests | grep '^??'`) · khoá-cache định-danh REQUEST không định-danh URL.
|
||||||
|
|
||||||
- **[→ archive/2026-07.md §@S167-K7-curate — verbatim ĐÃ VERIFY moved-not-cut 2/2] S166 gate K5 cây-GĐ2-8-sub-folder · gate K4c cụm-2 (digest):** bất-biến Σ do CẤU TRÚC (1 nguồn → phân hoạch TOÀN PHẦN → 1 chỗ đếm) · **phép-đo-rẻ-nhất nằm ở file RÁC của lane khác** (`menus.json` untracked = payload prod LIVE, vừa là bằng-chứng vừa là rào-commit) · chân-lý-RỖNG phải khai là rỗng · tương-đương-TỪNG-VẾ > 'cùng tập cùng thứ tự' · tiền-lệ phải đo ở BASE không đo ở cây đã sửa · 'N/N OK' chết theo phiên khi FE 0 hạ-tầng-test · **flake chỉ lộ ở LƯỢT ĐẦU và `-v q` NUỐT thông điệp assert** (lượt đầu dùng verbosity thường) · probe RED bằng đổi TEST-INPUT hợp lệ CHỈ KHI tham số xuất hiện ĐÚNG 1 chỗ (grep trước) · `Total` là vân-tay lọc-SERVER chỉ khi `CountAsync` sau `Where` trước `Skip/Take`.
|
- **[→ archive/2026-07.md §@S167-K7-curate — verbatim ĐÃ PROBE moved-not-cut] S166 gate K5 cây-GĐ2-8-folder · K4c cụm-2 (digest):** bất-biến Σ do CẤU TRÚC · **phép-đo-rẻ-nhất nằm ở file RÁC của lane khác** · chân-lý-RỖNG phải khai là rỗng · tương-đương-TỪNG-VẾ > 'cùng tập' · tiền-lệ đo ở BASE không ở cây đã sửa · 'N/N OK' chết theo phiên khi FE 0 hạ-tầng-test · **flake chỉ lộ ở LƯỢT ĐẦU và `-v q` NUỐT assert** · probe RED bằng đổi TEST-INPUT chỉ khi tham số xuất hiện ĐÚNG 1 chỗ · `Total` là vân-tay lọc-SERVER chỉ khi `CountAsync` sau `Where` trước `Skip/Take`.
|
||||||
|
|
||||||
|
|
||||||
- **[→ archive/2026-07.md §@S167-K7-curate-2 — verbatim ĐÃ VERIFY moved-not-cut] S167 gate K7 cầu KHKK→HĐ — PASS-WITH-FLAGS 10, 0 blocker (digest):** **1 con số đóng 2 câu hỏi** — baseline 622 (đo ở K4c cùng ngày) + 17 `[Fact]` đếm được = 639, đo đúng **639/0** ⇒ chứng CẢ '17 test chạy thật, không skip câm' LẪN '0 test cũ vỡ'. **Mỏ-neo tự-vô-hiệu tái phát, lan 3 nơi**: chú thích trỏ `:2516` — `git show HEAD:<f>|grep -n`=2516 vs `grep -n` cây=**2523**, chính diff chèn 7 dòng đẩy xuống ⇒ phải đo **2 THẾ GIỚI (HEAD ⟂ cây)**, đo 1 thế giới là im. **Prod probe sửa con số của chính comment**: `/permissions/by-role/{id}`×13 vai → `Contracts.CanCreate` **1/13** ⇒ tiền-đề lỗ ĐÚNG, nhưng '13/13 role' sai (thật 12/13, Admin đã có row True) + đẻ acceptance sau-deploy `0 added + 2 upgraded`. **Răng phủ NỬA**: T11 double-click assert 4 vế nhưng `await` tuần tự ⇒ ca ĐỒNG THỜI vẫn hở (0 unique-index/RowVersion/transaction) — đừng đọc 'có T-idem' thành 'đã đóng'. **Ngoại lệ authz đúng cách**: map khớp TUYỆT ĐỐI + action ngoài map vẫn bị đòi prefix ⇒ tương-lai không tự miễn-trừ; kèm `BeSubsetOf` chống cấu-hình-chết. **SHA-identical KHÔNG bảo chứng route-identical** (3 path cứng `/contracts/${id}` trong file mirror 2 app ⇒ grep route CẢ 2). Tag `[s167, mot-so-dong-2-cau-hoi, moneo-tu-vo-hieu, probe-sua-so-cua-comment, rang-phu-nua, sha-khong-bao-chung-route]` **Evidence:** `runs/2026-07-31-S164-4gd-khkk-fanout/sub-reviewer-gate-k7.md`
|
- **[→ archive/2026-07.md §@S167-K7-curate-2 — verbatim ĐÃ PROBE moved-not-cut] S167 gate K7 cầu KHKK→HĐ — PASS-WITH-FLAGS 10 (digest):** **1 con số đóng 2 câu hỏi** (639 = 622 baseline + 17 `[Fact]` ⇒ chứng CẢ 'chạy thật' LẪN '0 test cũ vỡ'). **Mỏ-neo tự-vô-hiệu** — comment trỏ `:2516` nhưng cây đã là `:2523` do chính diff đẩy xuống ⇒ phải đo **2 THẾ GIỚI (HEAD ⟂ cây)**. **Prod probe sửa con số của chính comment** (13/13 role → thật 12/13). **Răng phủ NỬA**: T11 assert 4 vế nhưng `await` tuần tự ⇒ ca ĐỒNG THỜI vẫn hở. **SHA-identical KHÔNG bảo chứng route-identical** ⇒ grep route CẢ 2 app. Tag `[s167, mot-so-dong-2-cau-hoi, moneo-tu-vo-hieu, rang-phu-nua]`
|
||||||
|
|
||||||
- **[→ archive/2026-07.md §@S167-K7-curate-2 — verbatim ĐÃ VERIFY moved-not-cut] S167 gate K6 admin thu-gọn — PASS-WITH-FLAGS 5 (digest):** **cây menu PROD SỐNG là thước, seed chỉ là bản vẽ** (`curl /api/menus/me` + áp CHÍNH tập key cắt regex từ file đã ship; control-âm bỏ 1 key để chứng phép đo có phân biệt). **`filterForAdmin` mới là bước MỘT** — còn `resolvePath→null` drop im lặng (#50) + group rỗng-hoá tụt xuống render như LEAF. **typo-noop**: key ẩn sai chính tả = `Set.has` false, module vẫn hiện, tsc vẫn xanh ⇒ assert TỪNG key TỒN TẠI trong cây prod. MAJOR: comment nhồi 3 số-đếm, 1 số sai ngay lúc land (số chép từ skill stale) ⇒ CẤM số-đếm trong comment. **Evidence:** `runs/2026-07-31-S164-4gd-khkk-fanout/sub-reviewer-gate-k6.md`
|
- **[→ archive/2026-07.md §@S168-curate — verbatim ĐÃ PROBE moved-not-cut] S168 gate GĐ3-D cắm cây vào Panel-3 ×3 trang — PASS-WITH-FLAGS 10, 0 blocker (digest):** **khuôn dùng-lại phải ĐO LẠI theo HOST** — `max-h`/`sticky` hiệu-chỉnh cho host CŨ: cây cần 100vh−158 mà Panel-3 chỉ có 100vh−184 (InboxPage) ⇒ 2 thanh cuộn lồng; 2 trang kia thừa 5px/13px = khít đến mong manh. **Class chết ÂM THẦM khi đổi kiểu cha** (`self-start` cha `block` · `lg:sticky` con DUY NHẤT của chính scroll-container) — tsc+build KHÔNG bắt, chỉ đọc CSS-chain mới thấy. `aside` lồng `aside` CÓ `aria-label` vẫn giữ `role=complementary` ⇒ 2 landmark lồng. Prod-probe biến lo-ngại thành số: `canPe` 13/13 True ⇒ ca `!canPe` chưa xảy ra (control-âm `Permissions`/`Users` False 12/13). `{!selectedId && <X/>}` = unmount ⇒ mất state thuần (`search`) trong khi `localStorage` sống ⇒ trade-off spec khai THIẾU. Thước hỏng: python Windows đọc JSON phải khai `encoding` · `while read` bỏ dòng CUỐI khi thiếu newline · `join` text-mode đẻ CRLF vào tên file. Tag `[s168, khuon-do-lai-theo-host, class-chet-am-tham, unmount-mat-state]`
|
||||||
|
|
||||||
- **[→ 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 ở mã · **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 FE↔BE VỠ 6 điểm mà tsc+build+574-test XANH (2 lỗi CHE NHAU) ⇒ so ROUTE+FIELD trên ĐĨA.
|
- **[→ archive/2026-07.md — verbatim @S168 K8] S168 gate K8-blocker Lines-editor KHKK — PASS-WITH-FLAGS 8, 0 blocker (digest):** 642 = 639 baseline + 3 Fact ⇒ **1 con số đóng 2 câu hỏi** · **quy-ước-của-chính-file là thước rẻ nhất** (mọi changelog sub-entity điền `EntityId`, chỗ mới là chỗ DUY NHẤT trống ⇒ MAJOR bằng 1 grep) · **`<select>` controlled NUỐT giá-trị đã lưu** khi option vắng (đang tải · query lỗi không toast · entry `IsActive=false` sau gán) ⇒ regression hiển thị do ĐỔI KIỂU Ô, tsc+build+test 0 bắt · **bình-luận tự-hẹn mắt-xích tương lai** chưa trả ⇒ nhưng ĐO khả-chạm trước khi hô (UNIQUE tầng trên khoá ⇒ hạ MINOR) · khe RỖNG khai rỗng ×2 · test phủ NỬA vế `if` (`!IsActive` chưa chạy lần nào) · thước hỏng `$?` sau `| tail` ⇒ đo lại `tsc -b --force`.
|
||||||
|
|
||||||
- **[→ 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).
|
- **[→ archive/2026-07.md §@S167-K7-curate-2 — verbatim] S167 gate K6 admin thu-gọn — PASS-WITH-FLAGS 5 (digest):** **cây menu PROD SỐNG là thước, seed chỉ là bản vẽ** (`curl /api/menus/me` + control-âm bỏ 1 key). **`filterForAdmin` mới là bước MỘT** — còn `resolvePath→null` drop im lặng (#50) + group rỗng-hoá render như LEAF. **typo-noop**: key ẩn sai chính tả = `Set.has` false, module vẫn hiện, tsc vẫn xanh ⇒ assert TỪNG key TỒN TẠI trong cây prod. MAJOR: CẤM số-đếm trong comment.
|
||||||
|
|
||||||
|
- **[→ archive/2026-07.md §@S166-curate — verbatim] S164/S165 K3 · S164 K1 · S162 cây-4-folder · S161-W2 (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 · **ghost-policy** (grep policy, đừng suy từ 'key đã vào All') · **#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 FE↔BE VỠ 6 điểm mà tsc+build+574-test XANH ⇒ so ROUTE+FIELD trên ĐĨA.
|
||||||
|
|
||||||
|
- **[→ archive/2026-07.md — ĐÃ RE-PROBE @S168: nội-dung CÓ trong archive; 2 nhãn `S161-W1`/`S152-D2` 0-hit nên GIỮ ở đây] 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ằng-chứng-tự-huỷ-sau-squash` · 'mọi nhánh set X' phải grep assignment-qua-BIẾN (#81) · `ExecuteDelete` ngoài transaction = mất dữ liệu · 3 THƯỚC-HỎNG (md5 CRLF · `grep -c` citation-trap · `grep -iF` MSYS 0-hit im lặng).
|
||||||
|
|
||||||
> **Persistent diary cross-session.** Auto-injected first ~200 lines at spawn (L1 HOT).
|
> **Persistent diary cross-session.** Auto-injected first ~200 lines at spawn (L1 HOT).
|
||||||
> Update BEFORE every stop. Tiered Memory v1: L1 HOT cap ~17KB (hook 24.4KB read-limit) · L2 `archive/` on-demand · L3 RAG `search_memory` just-in-time. Keep entry ≤ 1.5K chars (gotcha #53).
|
> Update BEFORE every stop. Tiered Memory v1: L1 HOT cap ~17KB (hook 24.4KB read-limit) · L2 `archive/` on-demand · L3 RAG `search_memory` just-in-time. Keep entry ≤ 1.5K chars (gotcha #53).
|
||||||
@ -20,18 +24,18 @@
|
|||||||
- **[→ archive/2026-07.md @S161-curate] S160 (07-29) CHỐT-CUỐI 13-fix:** fix áp NỬA spec = dư-lượng (soi CẢ FILE khi nhận 'đã fix') · anchor-list là artifact RIÊNG phải sync · verify NGƯỢC premise · owner để-mở ⇒ finding MOOT-đúng.
|
- **[→ archive/2026-07.md @S161-curate] S160 (07-29) CHỐT-CUỐI 13-fix:** fix áp NỬA spec = dư-lượng (soi CẢ FILE khi nhận 'đã fix') · anchor-list là artifact RIÊNG phải sync · verify NGƯỢC premise · owner để-mở ⇒ finding MOOT-đúng.
|
||||||
|
|
||||||
## 📁 Area memory (L2 on-demand — Read khi review vùng tương ứng)
|
## 📁 Area memory (L2 on-demand — Read khi review vùng tương ứng)
|
||||||
- [S62 PE budget soft-warning](project_s62_pe_budget_soft_warning.md) — PASS: hard-block→soft-warning; submit-guard intact + validator giữ `BudgetPeriodAmount>0`, row8 negative-safe (additive-only). Validator class `PurchaseEvaluationFeatures.cs:317`.
|
- [S62 PE budget soft-warning](project_s62_pe_budget_soft_warning.md) — hard-block→soft-warning, submit-guard intact (additive-only).
|
||||||
- [Wire/mirror claim verification anchors](feedback_wire_claim_verification_anchors.md) — sha256 twin-file · `git diff -U0` isolate true-adds · `allowNegative` bleed check · guard-still-intact grep.
|
- [Wire/mirror claim verification anchors](feedback_wire_claim_verification_anchors.md) — sha256 twin-file · `git diff -U0` isolate true-adds · `allowNegative` bleed check · guard-still-intact grep.
|
||||||
- **[→ archive @S161-curate] S160 review SPEC-BỘ KHKK:** review SPEC ≠ review CODE · seeder-grant kiểu insert = NO-OP 403 im lặng ⇒ acceptance đo CỜ không đếm ROW · acceptance 'PASS SẴN' = 0-bit · choke-point 'DUY NHẤT' sai vì gán QUA BIẾN (#81).
|
- **[→ archive @S161-curate] S160 review SPEC-BỘ KHKK:** review SPEC ≠ review CODE · seeder-grant kiểu insert = NO-OP 403 im lặng ⇒ acceptance đo CỜ không đếm ROW · acceptance 'PASS SẴN' = 0-bit · choke-point 'DUY NHẤT' sai vì gán QUA BIẾN (#81).
|
||||||
- **S89 trio** ([sectionB](project_s89_pe_sectionb_review.md) · [endsBeforeCeo](project_s89_pe_endsbeforeceo_plan_review.md) · [finalize-note](project_s89_finalize_note_plan_review.md)) — adjustMut share race + authz-lens ≠ concurrency-lens · FE 4 render-sites plan-missed-2 · ground-truth-accurate-nhưng-gaps.
|
- **S89 trio** ([sectionB](project_s89_pe_sectionb_review.md) · [endsBeforeCeo](project_s89_pe_endsbeforeceo_plan_review.md) · [finalize-note](project_s89_finalize_note_plan_review.md)) — authz-lens ≠ concurrency-lens; FE 4 render-site, plan sót 2.
|
||||||
- **S101 H18/H19 WF2 + LANE-C:** content-hash ≠ commit-SHA (hex strip/no-strip) · exact-token sweep miss paraphrase → widen concept-phrase. → `archive/2026-07.md`.
|
- **S101 H18/H19 WF2 + LANE-C:** content-hash ≠ commit-SHA (hex strip/no-strip) · exact-token sweep miss paraphrase → widen concept-phrase. → `archive/2026-07.md`.
|
||||||
- [S103 H20+crystallized-backfill review](project_s103_h20_crystallized_backfill_review.md) — WF2 PASS; 6/6 hash recompute MATCH + 4 already-met verified. Lesson: multi-axis warning phải nằm trong OUTPUT của script (reader chạy inline), không chỉ doc.
|
- [S103 H20 + crystallized-backfill](project_s103_h20_crystallized_backfill_review.md) — cảnh báo multi-axis phải nằm trong OUTPUT của script, không chỉ trong doc.
|
||||||
- [S111 PE sign-off 6-decision design review (schema/history lens)](project_pe_signoff_history_schema_review.md) — GO-WITH-ADJ; D5 opinion-history: append-only table, REJECT version-col (drops UNIQUE + breaks UPSERT).
|
- [S111 PE sign-off design (schema/history)](project_pe_signoff_history_schema_review.md) — opinion-history append-only; REJECT version-col (phá UNIQUE + UPSERT).
|
||||||
- **PE per-hạng-mục series ×5** ([cardinality#3](project_pe_multiwinner_perhangmuc_review.md) · [BE-financial](project_s114_pe_multi_ncc_perhangmuc_financial_review.md) · [spec-l3](project_pe_multi_ncc_perhangmuc_specreview.md) · [IMPL](project_s114_multi_ncc_perhangmuc_impl_review.md) · [neg-quote](project_pe_negative_quote_correctness_review.md)) — derived src-of-truth: re-derive MỌI write-path KỂ CẢ Delete · grep field-NGUỒN không chỉ field-đọc · sign-loss: defer-parse tới field-chung mọi mutation.
|
- **PE per-hạng-mục ×5** ([1](project_pe_multiwinner_perhangmuc_review.md) · [2](project_s114_pe_multi_ncc_perhangmuc_financial_review.md) · [3](project_pe_multi_ncc_perhangmuc_specreview.md) · [4](project_s114_multi_ncc_perhangmuc_impl_review.md) · [5](project_pe_negative_quote_correctness_review.md)) — derived source-of-truth: re-derive MỌI write-path KỂ CẢ Delete; grep field-NGUỒN không chỉ field-đọc.
|
||||||
- [S113 Supplier import v2 close-review (owner anh Kiệt)](project_s113_supplier_import_v2_review.md) — GO-WITH-ADJ 2 must-fix (consumers không pass published=true → drafts leak). Lesson: "filter added" ≠ "drafts hidden" — grep-all-consumer + create write-path.
|
- [S113 Supplier import v2](project_s113_supplier_import_v2_review.md) — "filter added" ≠ "drafts hidden": grep-all-consumer + create write-path.
|
||||||
- [S118 Procurement master-access seeder review](project_s118_procurement_master_access_review.md) — PASS(cond); MAJOR Reports.Read leaks financials + Suppliers.Update grant ≠ edit. Lesson: menu-flag grant ≠ API capability; bare `[Authorize]` GET = open-to-all.
|
- [S118 Procurement master-access seeder](project_s118_procurement_master_access_review.md) — menu-flag grant ≠ API capability; `[Authorize]` trần = mở cho mọi user.
|
||||||
- [S123 governance 4-change review (backtick-guard + H24-3 + retire dạng-3 + mark)](project_s123_governance_4change_review.md) — PASS_WITH_FIXES; CAUGHT H24-3 thiếu enclosure-guard mà cùng diff vừa chứng cần (gen-2 citation-trap) + greedy `.*` lấy `(S<N>` CUỐI≠MAX + lý-do-retire áp cho dạng-2 thì giết C8. Q5 "HOÀN-THÀNH mark" = nguỵ-biện. Q8 ENDORSE không-bump.
|
- [S123 governance 4-change](project_s123_governance_4change_review.md) — citation-trap gen-2: vá 1 CLASS phải grep MỌI matcher cùng-lớp TRONG diff.
|
||||||
- [S147 Project whitespace-dupe merge verify](project_project_dupe_merge_verify.md) — 7/7 PASS(2 FK-terminology corrections); "FK Projects" COMMENT ≠ physical FK (sys.foreign_keys=0 to Projects, all loose-Guid); seed-durability FORCES survivor=no-space blanket; INNER-join+filter=silent-vanish→repoint-before-soft-delete; prod ref-dist unverifiable (Server=localhost+secret).
|
- [S147 Project whitespace-dupe merge](project_project_dupe_merge_verify.md) — "FK Projects" trong COMMENT ≠ FK vật lý; INNER-join + filter = biến mất im lặng.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -94,8 +98,8 @@ Adversarial pre-commit reviewer SOLUTION_ERP. Read-only verify + live curl prod
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 🔄 Curate trigger
|
## 🔄 Curate trigger
|
||||||
- ⚠️ **@S167-K7 curate 2 pass: 22.1KB → 19.0KB** (move verbatim K5·K4c·K7·K6 → `archive/2026-07.md` §`@S167-K7-curate` + §`-curate-2`, mỗi pass probe moved-not-cut + control-âm TRƯỚC khi cắt, 4/4 pass). **CÒN NỢ ~1.9KB** để về 17.1KB — ứng viên kế: gộp khối `📁 Area memory` (nhiều dòng L2 đã stale >2 tháng). 🔴 **no-self-exempt CÓ ÁP**: entry K7 của CHÍNH TÔI là entry to nhất (3081B, phá luật "≤1.5K/entry" của chính file này) ⇒ nén trước, không miễn cho mình. 🔴 **Bài S166-K5 TÁI PHÁT NGAY trong pass này**: digest sinh bằng script đẻ chữ hỏng câm (`'ll3/13'` thay `'13/13'`) — byte-count không bắt được, chỉ ĐỌC LẠI BẰNG MẮT mới thấy. Script sinh chữ = luôn đọc lại output.
|
- ⚠️ **@S168-K8 đo lại: 18.3KB — VƯỢT cap 17.1KB ⇒ spawn sau curate NGAY** (đã append verbatim K8 vào `archive/2026-07.md`, L1 chỉ giữ digest).
|
||||||
- ✅ **NỢ CURATE @S166-K4b ĐÃ TRẢ @S166-K5** (23.4KB→~17KB): move VERBATIM 3 entry đã hẹn (K4b·K4a·vá-F-1) → `archive/2026-07.md` §`@S166-K5-curate`. Trình tự bài S102: **append archive TRƯỚC → probe 3/3 kèm control-âm → assert `line in arc` NGAY TRONG script cắt → mới cắt → hậu-kiểm L1=0/archive=1**. Bài mới: digest sinh bằng escape `\uXXXX` **đẻ chữ hỏng câm** (byte-count không bắt được) ⇒ **script sinh chữ phải ĐỌC LẠI bằng mắt**.
|
- ✅ **@S168 curate: 22.2KB → <17.1KB (ĐO, không đoán).** Trình tự S102 giữ nguyên: append archive TRƯỚC → probe moved-not-cut + control-âm → mới cắt. 🔴 **Bài mới:** (a) tôi ghi '~20.6KB' bằng ƯỚC LƯỢNG trong khi `wc -c` cho 22.2KB — **số trong sổ phải là số ĐO**, và baseline '19.0KB' thừa kế từ pass trước cũng đã stale; (b) probe moved-not-cut bằng token lấy từ DIGEST cho **0-hit GIẢ** (digest là paraphrase, bản gốc dùng chữ khác) ⇒ 0-hit phải **thử token thứ 2 từ thân bài** trước khi kết luận cut-not-moved — suýt giữ lại 938B vô ích; (c) **script sinh chữ lại đẻ chữ hỏng câm** (token `join` trong chuỗi làm entry vỡ đôi dòng) — đúng bài S166-K5, byte-count KHÔNG bắt, chỉ ĐỌC LẠI mới thấy.
|
||||||
- Hook-cap **>17.1KB** (24.4KB read-limit, S109) → archive recent → L2 `archive/<period>.md` (append additive) + `_INDEX.md` pointer. Stale >3mo → remove.
|
- Hook-cap **>17.1KB** (24.4KB read-limit, S109) → archive recent → L2 `archive/<period>.md` (append additive) + `_INDEX.md` pointer. Stale >3mo → remove.
|
||||||
- **Prev curates:** S166-K5 (probe 3/3) · S162 (2/2) · S145 · S140 · S134 · S128 · **S102 recovery: cut-not-moved ×5 `f229b07` ⇒ mọi L1-shrink kèm '→archive' BẮT BUỘC grep moved-not-cut TRƯỚC khi trust** · S116·S113·S109·S80·S71·S70·S40.
|
- **Prev curates:** S166-K5 (probe 3/3) · S162 (2/2) · S145 · S140 · S134 · S128 · **S102 recovery: cut-not-moved ×5 `f229b07` ⇒ mọi L1-shrink kèm '→archive' BẮT BUỘC grep moved-not-cut TRƯỚC khi trust** · S116·S113·S109·S80·S71·S70·S40.
|
||||||
|
|
||||||
|
|||||||
@ -231,3 +231,10 @@
|
|||||||
- **S167 (08-01) gate K7 cầu KHKK→HĐ (13 file, 3 nhóm) — PASS-WITH-FLAGS 10 (2 MAJOR + 7 MINOR + 1 CLARIFY), 0 blocker:** ① 🔴 **1 con số đóng 2 câu hỏi**: baseline 622 (đo ở K4c CÙNG NGÀY) + 17 `[Fact]` mới (đếm `grep -c '^\s*\[Fact\]'`, không chép) = kỳ vọng 639, đo được **đúng 639/0** ⇒ chứng CẢ "17 test có chạy thật (không bị filter/skip câm)" LẪN "0 test cũ vỡ" — rẻ hơn 2 phép đo rời. ② **mỏ-neo tự-vô-hiệu TÁI PHÁT và lan 3 nơi** (K4b-class): 3 chú thích mới trỏ `DbInitializer.cs:2516`; `git show HEAD:<file> | grep -n` = 2516, `grep -n` trên cây = **2523** — CHÍNH diff này chèn 7 dòng ở `:2353-2359` đẩy nó xuống ⇒ đúng lúc viết, **sai ngay khoảnh khắc land**. Bài: phải đo ở CẢ 2 THẾ GIỚI (HEAD ⟂ cây) mới thấy, đo 1 thế giới là im. ③ **prod probe sửa được con số của chính comment**: `GET /api/permissions/by-role/{id}` ×13 vai → `Contracts.CanCreate` **1/13 true (chỉ Admin)** ⇒ tiền-đề seeder ĐÚNG (Drafter/Procurement=False, lỗ THẬT), nhưng comment "403 với 13/13 role… chỉ Admin qua nhờ bypass" sai 2 vế: thật là **12/13**, và Admin **đã có row True** chứ không chỉ bypass. Cùng probe đẻ **acceptance rẻ nhất cho sau deploy: log phải là `0 added + 2 upgraded`** — lệch = có thứ khác lật quyền. Khai giới hạn: prod CHƯA deploy ⇒ chỉ chứng TIỀN-ĐỀ, không chứng seeder chạy đúng. ④ **test có răng nhưng phủ NỬA**: T11 double-click assert 4 vế (Conflict + count=1 + line trỏ HĐ đầu + `LastSeq=1` không đốt mã) — nhưng là `await` lần 1 RỒI lần 2 ⇒ chỉ phủ ca TUẦN TỰ; ca ĐỒNG THỜI vẫn hở (`:128-129` đọc-rồi-ghi trong bộ nhớ, 0 unique-index, 0 RowVersion, 0 transaction bao ngoài — transaction duy nhất là của codegen, đã commit trước). Comment test khai đúng phạm vi ⇒ KHÔNG phải lane nói quá; rủi ro là người sau đọc "T-idem có" thành "idempotency đóng". ⑤ **ngoại lệ authz đúng cách**: map `Dictionary<action,policy>` + `Should().Be()` khớp TUYỆT ĐỐI (không `StartsWith`/skip-list) + action ngoài map vẫn rơi `else` đòi prefix module ⇒ **action tương lai không tự miễn trừ**; kèm `Keys.Should().BeSubsetOf(byName.Keys)` chống **cấu-hình-chết** — vế "vắng-mặt trông giống ổn" mà lane TỰ đặt. ⑥ **mirror byte-identical đẻ rủi ro riêng**: 3 đường dẫn cứng `/contracts/${id}` trong file dùng chung 2 app ⇒ phải grep route ở CẢ `fe-user/App.tsx:65` và `fe-admin/App.tsx:84` (đều có, không thủng) — SHA-identical KHÔNG bảo chứng route-identical. ⑦ residual: 3 `scripts/*.ps1` dirty ngoài cả 13-file-set lẫn lead-set. Tag `[s167, mot-so-dong-2-cau-hoi, moneo-tu-vo-hieu-lan-3-noi, probe-sua-so-cua-comment, rang-phu-nua, sha-khong-bao-chung-route]` **Evidence:** `runs/2026-07-31-S164-4gd-khkk-fanout/sub-reviewer-gate-k7.md`
|
- **S167 (08-01) gate K7 cầu KHKK→HĐ (13 file, 3 nhóm) — PASS-WITH-FLAGS 10 (2 MAJOR + 7 MINOR + 1 CLARIFY), 0 blocker:** ① 🔴 **1 con số đóng 2 câu hỏi**: baseline 622 (đo ở K4c CÙNG NGÀY) + 17 `[Fact]` mới (đếm `grep -c '^\s*\[Fact\]'`, không chép) = kỳ vọng 639, đo được **đúng 639/0** ⇒ chứng CẢ "17 test có chạy thật (không bị filter/skip câm)" LẪN "0 test cũ vỡ" — rẻ hơn 2 phép đo rời. ② **mỏ-neo tự-vô-hiệu TÁI PHÁT và lan 3 nơi** (K4b-class): 3 chú thích mới trỏ `DbInitializer.cs:2516`; `git show HEAD:<file> | grep -n` = 2516, `grep -n` trên cây = **2523** — CHÍNH diff này chèn 7 dòng ở `:2353-2359` đẩy nó xuống ⇒ đúng lúc viết, **sai ngay khoảnh khắc land**. Bài: phải đo ở CẢ 2 THẾ GIỚI (HEAD ⟂ cây) mới thấy, đo 1 thế giới là im. ③ **prod probe sửa được con số của chính comment**: `GET /api/permissions/by-role/{id}` ×13 vai → `Contracts.CanCreate` **1/13 true (chỉ Admin)** ⇒ tiền-đề seeder ĐÚNG (Drafter/Procurement=False, lỗ THẬT), nhưng comment "403 với 13/13 role… chỉ Admin qua nhờ bypass" sai 2 vế: thật là **12/13**, và Admin **đã có row True** chứ không chỉ bypass. Cùng probe đẻ **acceptance rẻ nhất cho sau deploy: log phải là `0 added + 2 upgraded`** — lệch = có thứ khác lật quyền. Khai giới hạn: prod CHƯA deploy ⇒ chỉ chứng TIỀN-ĐỀ, không chứng seeder chạy đúng. ④ **test có răng nhưng phủ NỬA**: T11 double-click assert 4 vế (Conflict + count=1 + line trỏ HĐ đầu + `LastSeq=1` không đốt mã) — nhưng là `await` lần 1 RỒI lần 2 ⇒ chỉ phủ ca TUẦN TỰ; ca ĐỒNG THỜI vẫn hở (`:128-129` đọc-rồi-ghi trong bộ nhớ, 0 unique-index, 0 RowVersion, 0 transaction bao ngoài — transaction duy nhất là của codegen, đã commit trước). Comment test khai đúng phạm vi ⇒ KHÔNG phải lane nói quá; rủi ro là người sau đọc "T-idem có" thành "idempotency đóng". ⑤ **ngoại lệ authz đúng cách**: map `Dictionary<action,policy>` + `Should().Be()` khớp TUYỆT ĐỐI (không `StartsWith`/skip-list) + action ngoài map vẫn rơi `else` đòi prefix module ⇒ **action tương lai không tự miễn trừ**; kèm `Keys.Should().BeSubsetOf(byName.Keys)` chống **cấu-hình-chết** — vế "vắng-mặt trông giống ổn" mà lane TỰ đặt. ⑥ **mirror byte-identical đẻ rủi ro riêng**: 3 đường dẫn cứng `/contracts/${id}` trong file dùng chung 2 app ⇒ phải grep route ở CẢ `fe-user/App.tsx:65` và `fe-admin/App.tsx:84` (đều có, không thủng) — SHA-identical KHÔNG bảo chứng route-identical. ⑦ residual: 3 `scripts/*.ps1` dirty ngoài cả 13-file-set lẫn lead-set. Tag `[s167, mot-so-dong-2-cau-hoi, moneo-tu-vo-hieu-lan-3-noi, probe-sua-so-cua-comment, rang-phu-nua, sha-khong-bao-chung-route]` **Evidence:** `runs/2026-07-31-S164-4gd-khkk-fanout/sub-reviewer-gate-k7.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`
|
- **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`
|
||||||
|
|
||||||
|
|
||||||
|
## @S168-curate — verbatim moved from L1 (reviewer MEMORY.md)
|
||||||
|
|
||||||
|
- **S168 gate GĐ3-D cắm cây vào Panel-3 ×3 trang — PASS-WITH-FLAGS 10, 0 blocker (digest):** **khuôn dùng-lại phải đo LẠI theo HOST**, vì `max-h`/`sticky` của khuôn được hiệu-chỉnh cho host CŨ: `lg:max-h-[calc(100vh-16rem)]` cộng header+search = 100vh−158, còn Panel-3 chỉ có 100vh−184 ở InboxPage (dải "Vai trò" +29px) ⇒ **2 thanh cuộn lồng**; 2 trang kia thừa 5px/13px = **khít đến mức mong manh** (đổi 1 Button `h-8`→`h-10` là lật). **Class chết ÂM THẦM khi đổi kiểu cha**: `self-start` (cha `lg:block` không phải flex) + `lg:sticky` (cây là con DUY NHẤT của chính scroll-container ⇒ quãng dính ≈0) — tsc/build KHÔNG bắt được loại này, chỉ đọc CSS-chain mới thấy. **`aside` lồng `aside` CÓ `aria-label` vẫn giữ `role=complementary`** (chỉ tụt `generic` khi KHÔNG tên) ⇒ 2 landmark lồng. Prod-probe biến 1 lo-ngại thành 1 con số: `canPe` false → panel đổi câu chỉ-dẫn thành than-phiền-quyền, nhưng `/permissions/by-role` ×13 vai cho `PurchaseEvaluations.canRead`=**13/13 True** ⇒ ca chưa xảy ra (control-âm: `Permissions`/`Users` False 12/13, chỉ Admin True ⇒ phép đo có phân biệt). **`{!selectedId && <X/>}` = unmount ⇒ mất state thuần** (`search`) mà `localStorage` (`expandedSet`) vẫn sống ⇒ trade-off spec khai THIẾU. Thước hỏng gặp lại: python Windows đọc JSON UTF-8 phải khai `encoding` (cp1252 nổ) · `while read` bỏ dòng CUỐI khi file thiếu newline (mất 1/13 vai) · python `'\n'.join` ghi text-mode Windows đẻ CRLF vào tên file. Tag `[s168, khuon-do-lai-theo-host, class-chet-am-tham, probe-bien-lo-ngai-thanh-so, unmount-mat-state]` **Evidence:** `.claude/workflows/runs/2026-07-31-S164-4gd-khkk-fanout/sub-reviewer-gate-gd3d.md`
|
||||||
|
|
||||||
|
- **S168 gate K8-blocker Lines-editor KHKK — PASS-WITH-FLAGS 8, 0 blocker (verbatim):** vá 5 file (command+validator+handler · route PUT `{id}/lines/{lineId}` policy `KeHoachKyKet.Update` · +3 `[Fact]` · FE ×2 app SHA-pair `21e0ca05` khớp byte). **(1) Con số 642 đóng 2 câu hỏi**: baseline S167 = 639, +3 Fact ⇒ 642 khớp chằn chặn ⇒ chứng CẢ "test mới chạy thật" LẪN "0 test cũ vỡ" bằng MỘT phép đo. **(2) Quy-ước-của-chính-file là thước rẻ nhất**: mọi write changelog sub-entity trong module điền `EntityId` (`:1069`, `:1107`, `:1215`, `:1295`, `Service:550`) — chỗ mới là chỗ DUY NHẤT bỏ trống ⇒ MAJOR F-1 tìm được bằng 1 lệnh grep, không cần suy luận. **(3) `<select>` controlled NUỐT giá trị đã lưu**: `value={l.catalogEntryId}` + option-list từ query khác ⇒ khi option vắng (đang tải · query lỗi KHÔNG toast · entry `IsActive=false` sau khi gán) trình duyệt rơi về `<option value="">` placeholder ⇒ **dòng đã gán trông như chưa gán**, mà trước vá luôn hiện `tenHangMuc` ⇒ regression hiển thị do ĐỔI KIỂU Ô (text→select), tsc+build+test 0 bắt. Lớp "vắng-mặt trông giống ổn". **(4) Bình luận trong config TỰ HẸN mắt xích tương lai** (`LineConfiguration:35` hẹn "validator K4b khi Lines-editor ra đời") — Lines-editor land mà lời hẹn KHÔNG trả ⇒ trùng UNIQUE sẽ ra 500 + rò message SQL; nhưng **đo khả-chạm trước khi hô hoán**: PE có UNIQUE `(PeId, SupplierId)` ⇒ winners khác NCC ⇒ đường tạo dòng DUY NHẤT (`Lines.Add` 1 chỗ) không sinh nổi ca trùng ⇒ hạ xuống MINOR "lời hẹn chưa trả", KHÔNG phải lỗ đang chảy. **(5) Khe RỖNG phải khai là rỗng** ×2: đổi `ApprovalGroup` sau khi gán = bất khả (chỉ set lúc tạo, Update-draft không nhận field) · BE thiếu rào `line.ContractId` nhưng `DaDuyet` terminal ⇒ dòng đã bắc cầu luôn nằm ngoài phase sửa được. **(6) Test mới phủ NỬA vế `if`**: guard là `!IsActive || sai-nhóm`, helper `AddCatalogAsync` luôn `IsActive=true` ⇒ **một nửa vế chưa từng chạy lần nào**; ca "gán đè" (chính câu spec hỏi) đúng hành-vi nhưng 0 test ghim. **(7) Thước hỏng tự bắt**: `echo "ADMIN_EXIT=$?"` sau `tsc | tail` đo exit của `tail` ⇒ vô nghĩa; đo lại bằng `tsc -b --force` (chống cả incremental-skip) ⇒ tín hiệu hợp lệ = 0 dòng lỗi. **(8) Bẫy KHÔNG dính (ghi để khỏi soi lại)**: FE gửi `?approvalGroup=` khớp `[FromQuery] int? approvalGroup` của ContractCatalogController — KHÔNG lẫn với `[FromQuery(Name="group")]` của list KHKK; payload `List<T>` trần khớp type FE; `Summary` ~550 < maxlength 1000 (SQLite nuốt maxlength, prod mới nổ — kiểm chứ không đoán). Tag `[s168, mot-so-dong-2-cau-hoi, quy-uoc-file-la-thuoc, select-nuot-gia-tri-da-luu, loi-hen-trong-comment, khe-rong-khai-rong]` **Evidence:** `.claude/workflows/runs/2026-07-31-S164-4gd-khkk-fanout/sub-reviewer-gate-lineeditor.md`
|
||||||
|
|||||||
@ -0,0 +1,69 @@
|
|||||||
|
K8-DRYRUN: ĐANG CHẠY — B0 PASS (backup 26,6MB + roster LIVE + login admin OK); B1.. đổ ruột liên tục bên dưới
|
||||||
|
|
||||||
|
# sub-k8-dryrun — K8 dry-run E2E toàn trình (S168, 2026-08-01)
|
||||||
|
|
||||||
|
> Mandate owner @S168 (verbatim): *"Chạy dry-run hết luôn đi rồi tao giao tiếp plan khác"*.
|
||||||
|
> Kịch bản = spec-cụm3 §③ K8 + bảng B0-B16 (`sub-invest-fable-b2-cum3.md` K8.1) + **vá-9..15 spec-final THẮNG nền**.
|
||||||
|
> 🔴 Vá-12 owner-ratified @S164 *"tạo PE Test SOLUTION-PE-1,2,3… không test trên phiếu thật"* ⇒ **bộ test RIÊNG** (Project+NCC+gói ZZTEST/SOLUTION-PE-{n}), **KHÔNG đụng `PE/2026/A/049`** — câu giờ-G K8.2 của bản nền coi như ĐÃ trả.
|
||||||
|
> Người bấm mọi bước hôm nay = **admin (duyệt thay — SignedByUserId track, banner "Admin duyệt thay" by-design)**; cột "Ai (thật)" trong form = roster LIVE để buổi chạy với người thật dùng lại.
|
||||||
|
|
||||||
|
## B0 — PRE-FLIGHT (fail-closed) — ✅ PASS 16:2x-16:3x +07
|
||||||
|
|
||||||
|
- (i) **Backup prod:** `C:\Backups\SolutionErp\SolutionErp_preK8_S168.bak` — 27.877.888 B @ 2026-08-01 16:28 (BACKUP DATABASE 3393 pages OK). *(Script `scripts/backup-sql.ps1` default `D:\` — VPS KHÔNG có ổ D ⇒ dùng `C:\Backups`; default ĐÃ VÁ `C:\` @S168 cùng lượt.)*
|
||||||
|
- (ii) **Workflow LIVE (sqlcmd -E; ⚠️ `$env:PROD_DB_PASSWORD` KHÔNG tồn tại cả 3 scope trên VPS — persona-note stale cùng lớp F-9):**
|
||||||
|
- type-1 PE: `QT-DN-V2-001` active+selectable · 3 trạm (P1: 4NV L1 + tra.bui L2[SkipToFinal,Delete] · P2: 4NV L1 + **chuong.phan L2 [Finalize=1,SkipToFinal,Delete]** · P3: truong.nguyen [SkipToFinal]).
|
||||||
|
- type-10 KHKK: **đủ 8 nhóm N1-N8 active**; `KHKK-N4` 3 trạm 11 người (Cung ứng: 4NV+tra.bui · Kiểm soát CP: 4NV+**chuong.phan [Finalize=1]** · BGĐ: truong.nguyen) — khớp anchor OG-3/OG-9.
|
||||||
|
- type-3 HĐ: `QT-HD-V2-001` active — **1 trạm 1 người: binh.le (Bước 1 - Phòng CCM)**.
|
||||||
|
- (ii-b) Catalog: `ContractCatalogEntries` = **86** dòng; nhóm-4 mẫu B1-01 Đá · B1-02 Cát · B1-03 Cọc tràm · B1-04 Bê tông · **B1-05 Xi măng (chọn cho line test)** · B1-06 Thép.
|
||||||
|
- (iii) Dữ liệu test sẵn có: Projects ZZTEST = **0** · Suppliers TEST = **0** ⇒ TẠO MỚI qua API (GĐ0 dưới). Contracts prod = **0** (HĐ dry-run sẽ là HĐ ĐẦU TIÊN trên prod — seq cháy thuộc cặp prefix TEST, vô hại data thật).
|
||||||
|
- (iv) **Dặn team:** em KHÔNG có kênh Zalo/email — thay bằng ZZTEST-prefix mọi bản ghi + mục này để anh nhắn team *"notification phiếu ZZTEST trong hôm nay — bỏ qua"*. KHAI trade-off, không im.
|
||||||
|
- (v) Login API prod: `POST /api/auth/login` admin OK (accessToken 468c — field `accessToken` đúng bài S160).
|
||||||
|
|
||||||
|
## B0-FORM 14 dòng {bước · người THẬT (roster LIVE) · tài khoản · ai bấm hôm nay} — vá-9 fail-closed
|
||||||
|
|
||||||
|
| B | Ai (thật, theo roster LIVE) | Tài khoản | Hôm nay |
|
||||||
|
|---|---|---|---|
|
||||||
|
| B1-B3 | BCH drafter (đại diện Cung ứng: Nguyễn Văn Duy) | duy.nguyen@solutions.com.vn | admin (tạo thay) |
|
||||||
|
| B4 | Trạm-1 PE/KHKK — 1 trong {duy.nguyen · binh.lethanh · long.chau · phuong.nguyen} + L2 tra.bui | (4+1 tài khoản trên) | admin duyệt thay |
|
||||||
|
| B5 | Trạm-2 — 1 trong {luu.tran · nguyen.ho · binh.le · truong.letrandang} | (4 tài khoản) | admin duyệt thay |
|
||||||
|
| B6 | **TP.CCM Phan Văn Chương (Finalize)** | chuong.phan@solutions.com.vn | admin duyệt thay (tick finalize) |
|
||||||
|
| B7 | CEO Nguyễn Văn Trường (nhánh B) | truong.nguyen@solutions.com.vn | admin duyệt thay |
|
||||||
|
| B8 | BCH/Admin kiểm khóa | — | admin |
|
||||||
|
| B9 | Người có `Contracts.Create` (câu III default: Drafter/Procurement/Admin — seeder K7 CanCreate=3) | — | admin |
|
||||||
|
| B10 | Người bấm B9 | — | admin |
|
||||||
|
| B11 | Approver HĐ: **Lê Văn Bình** (QT-HD-V2-001 trạm duy nhất) | binh.le@solutions.com.vn | admin duyệt thay |
|
||||||
|
| B12 | HRA/CCM (HardCopyActorRoles) | — | admin |
|
||||||
|
| B13 | Bất kỳ + Admin (mở cây) | — | admin |
|
||||||
|
| B14 | Lead chốt sổ | — | lead |
|
||||||
|
|
||||||
|
⇒ 14/14 dòng CÓ CHỦ — fail-closed ĐẠT (không dòng nào trống).
|
||||||
|
|
||||||
|
## Nhật ký bước (đổ ruột liên tục — ID ghi TẠI CHỖ cho rollback vá-11/13)
|
||||||
|
|
||||||
|
### 🔴 PHÁT HIỆN TRƯỚC GIỜ CHẠY — BLOCKER toàn trình: Lines-editor KHÔNG TỒN TẠI (vá tại chỗ @S168)
|
||||||
|
|
||||||
|
Truy vết trước khi bấm B1 (đúng tinh thần "K8 lộ wave thiếu"):
|
||||||
|
- `ContractSigningPlanFeatures.cs` auto-gen line mồi `CatalogEntryId = null` + comment HẸN *"Người soạn gán sau qua `UpdateContractSigningPlanLineCommand`"* — **command đó chỉ tồn tại TRONG COMMENT** (grep toàn src + FE = 0 impl, 0 route, 0 call).
|
||||||
|
- `ContractSigningPlanWorkflowService.SubmitAsync` (vá-5) **CHẶN THẬT** line null: `"Còn dòng chưa gán hạng mục…"` + đếm-so-khớp nhóm.
|
||||||
|
- FE `KhkkDetailPage` cột Hạng mục tự khai *"Đường GÁN (Lines-editor) = K4b"* — nhưng K4b THẬT ship việc khác (`88e7ecf` = nối `?group=` sidebar, đo `git show --name-only`). ⇒ mảnh rơi giữa build-order.
|
||||||
|
- Hệ quả nếu không vá: **MỌI phiếu KHKK tạo sau K2 kẹt Nháp vĩnh viễn** — dry-run chết ở B3. Đây chính là chỗ cicd-K2 khai *"mục-4 chân-lý-rỗng — đo lại khi K8 đẻ phiếu thật"*.
|
||||||
|
|
||||||
|
**Vá @S168 (BE + FE ×2 app + 3 test):** `UpdateContractSigningPlanLineCommand(PlanId, LineId, CatalogEntryId)` handler (EnsureDraftEditable + fail-fast entry sống/đúng-nhóm 409 + denorm `TenHangMuc` + changelog Line) · route `PUT /contract-signing-plans/{id}/lines/{lineId}` policy `KeHoachKyKet.Update` · FE select gán tại bảng (Nháp/TraLai, khoá dòng đã bắc cầu HĐ, options lọc `?approvalGroup=`) mirror SHA-pair `21e0ca0507d4` ×2 app · 3 Fact (dương gán→submit-QUA · âm khác-nhóm 409 · âm phase). Gate + commit riêng trước khi chạy chuỗi.
|
||||||
|
|
||||||
|
### GĐ0 — dựng bộ test — ✅ PASS (script `k8run.py gd0`, state `k8-state.json` scratchpad)
|
||||||
|
|
||||||
|
| Vật | ID |
|
||||||
|
|---|---|
|
||||||
|
| Project `ZZTEST-K8` "ZZTEST Du an dry-run K8 (SOLUTION-PE)" | `9c6caa7a-22b9-4482-bc74-2ef36fbebc54` |
|
||||||
|
| Supplier `ZZTEST-NCC-K8` | `10b60f58-1bb0-4164-b191-3cb9af3896fe` |
|
||||||
|
| WorkItem `ZZTEST-WI-K8` | `a29c7932-411c-4763-9ca6-5546798c3d5c` |
|
||||||
|
| Catalog line-test | B1-05 Xi măng `69a6db3a-30b1-4289-878b-a364a1fd1eab` |
|
||||||
|
| Workflow ids | PE `8a0aa922…` · KHKK-N4 `bf90b962…` · HĐ `12aee3bf…` |
|
||||||
|
|
||||||
|
### GĐ1 — PE 2 nhánh — ✅ TRỌN (B1-B7 phần PE)
|
||||||
|
|
||||||
|
- **PE-1 nhánh A = `PE/2026/A/052`** (`4e4ad413-30cb-4eda-b5cf-31f2936e8ec2`; supplier-row `512f6b41` · detail `55285053` · quote 110tr-VAT isSelected · Bảng-so-sánh att `50bbcf46`): submit → 3 lượt trạm → lượt 4 **giá-chốt NCC 110tr + finalize tại CCM (chuong.phan)** ⇒ **DaDuyet KHÔNG qua CEO** (OG-9 nhánh A chứng ✓) → winner ZZTEST-NCC ✓.
|
||||||
|
- **PE-2 nhánh B = `PE/2026/A/053`** (`a53db8b9-df60-42d0-92c0-e2ca4c80f2e5`; row `9e3d8060` · detail `1f98f925` · att `4a79abfd`): submit → **5 lượt ĐỦ đường** với `applyLevelFinalize=false` ở mọi lượt (checkbox opt-out F-1 `7a903cf` chứng chạy thật) ⇒ qua **CEO Trường** → DaDuyet → winner ✓.
|
||||||
|
- Sự-cố script tự bắt + sửa dọc đường (đều là THUỐC THỬ tốt cho form B0 buổi người-thật): ① PE detail đòi `GroupCode/GroupName` required · ② `QuoteBody.BgVat/ChuaVat` = decimal non-null · ③ submit-guard đòi đính kèm **Bảng so sánh** (purpose=4) · ④ cấp cuối đòi **chọn giá chốt** (Ncc/ProMin/ProMax/Ccm/ProMinMax — Mig 66) · ⑤ auth rate-limit **429** khi login mỗi-stage ⇒ cache token (JWT 1h) vào state · ⑥ 1 phiếu nháp orphan (script fail giữa) ⇒ **DELETE 204 dọn liền** — allow-list Nháp xoá được, đúng thiết kế.
|
||||||
|
|
||||||
|
### GĐ2+ (KHKK → bridge → HĐ → bản-cứng → QĐ9) — CHỜ deploy line-editor (commit + push + cicd) rồi chạy stages `khkk1…qd9-2`
|
||||||
@ -0,0 +1,187 @@
|
|||||||
|
GATE-GD3D: PASS-WITH-FLAGS-10 — Đúng phương án D, khuôn nguyên vẹn (SHA-pair identical), import sạch; 0 blocker, nhưng `max-h` của khuôn vượt chiều cao Panel-3 trên InboxPage (~26px) đẻ 2 thanh cuộn lồng nhau + 2 class khuôn chết âm thầm.
|
||||||
|
|
||||||
|
# Sub-reviewer gate GĐ3-D (S168, 2026-08-01)
|
||||||
|
|
||||||
|
Scope: 3 file FE uncommitted, phương án D "cây thay chỗ Panel-3 khi chưa chọn HĐ".
|
||||||
|
Spec nguồn: `.claude/workflows/runs/2026-07-31-S164-4gd-khkk-fanout/sub-implfe-gd3.md` §1 bảng 4 phương án (D).
|
||||||
|
Vai: reviewer đối-kháng, **propose-only** — không sửa code, không commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Đo trước (baseline)
|
||||||
|
|
||||||
|
- `git status --porcelain` toàn repo: 5 `M` — `.claude/WAL.md`, `.claude/agent-memory/memory-budget.json`, + 3 file FE.
|
||||||
|
- `git status --porcelain --untracked-files=all -- fe-admin/src fe-user/src`: **ĐÚNG 3 file**, 0 untracked.
|
||||||
|
- `git diff --stat`: mỗi file FE 13 dòng đổi (+6/−7), tổng 3 file.
|
||||||
|
|
||||||
|
## R1 — Đúng-D (điều kiện render) → **PASS**
|
||||||
|
|
||||||
|
Cả 3 file giữ NGUYÊN cặp điều kiện:
|
||||||
|
|
||||||
|
```
|
||||||
|
{!selectedId && <PipelineTreePanel currentStage={3} />}
|
||||||
|
{selectedId && detail.data && <WorkflowHistoryPanel contract={detail.data} />}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Cây CHỈ render khi `!selectedId` — đúng D.
|
||||||
|
- Chọn HĐ → `WorkflowHistoryPanel` như cũ; vế điều kiện **không đổi một ký tự**.
|
||||||
|
- Trạng thái "đã chọn HĐ nhưng `detail` đang tải" render **RỖNG ở CẢ bản cũ lẫn bản mới** (bản cũ: `!selectedId` sai nên ô dashed tắt, `detail.data` chưa có nên panel trống) ⇒ hành vi loading giữa chừng KHÔNG đổi.
|
||||||
|
- `currentStage={3}` đúng GĐ Hợp đồng cho cả 3 trang (xem F-10 cho lưu ý riêng về InboxPage).
|
||||||
|
|
||||||
|
## R2 — 0-đụng-khuôn → **PASS**
|
||||||
|
|
||||||
|
`sha256sum` (16 ký tự đầu), fe-admin ⟂ fe-user:
|
||||||
|
|
||||||
|
| File | admin | user | Kết quả |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `components/pipeline/PipelineTreePanel.tsx` | `d70ac315bae485e6` | `d70ac315bae485e6` | IDENTICAL |
|
||||||
|
| `components/pipeline/PipelineStageFolders.tsx` | `bed52f39d595c86a` | `bed52f39d595c86a` | IDENTICAL |
|
||||||
|
| `hooks/usePipelineStages.ts` | `76e1823345d4e78b` | `76e1823345d4e78b` | IDENTICAL |
|
||||||
|
|
||||||
|
`git status --porcelain` trên 4 đường dẫn khuôn = **RỖNG** ⇒ khuôn chưa bị sửa. SHA-pair identical vừa chứng mirror 2 app còn khớp, vừa chứng không ai chạm vào khuôn trong đợt này.
|
||||||
|
|
||||||
|
## R3 — Unused-import `X` → **PASS**
|
||||||
|
|
||||||
|
- Cây làm việc: 0 hit mã ở cả 3 file. Hit duy nhất là chuỗi `type=X` trong comment `ContractsListPage.tsx:6` (không phải icon).
|
||||||
|
- **Control-âm:** cùng regex chạy trên `git show HEAD:<file>` cho **2 hit/file** (dòng `import … X …` + `<X className="mx-auto mb-2 h-5 w-5" />`) ⇒ phép đo CÓ răng, không phải 0-hit vì regex hỏng.
|
||||||
|
- Không gỡ nhầm icon khác: `FileText` / `Search` / `Plus` / `Inbox` / `AlertTriangle` / `Clock` / `ClipboardList` đều còn nguyên trong dòng import mới.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FINDINGS
|
||||||
|
|
||||||
|
### F-1 (MAJOR) — R4: `max-h` của khuôn hiệu-chỉnh cho host page-flow, vượt chiều cao Panel-3 trên InboxPage ⇒ 2 thanh cuộn lồng nhau
|
||||||
|
|
||||||
|
Chiều cao TỐI ĐA của khuôn khi đặt ở lg:
|
||||||
|
|
||||||
|
| Thành phần | File:dòng | Lớp CSS | px |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Đầu panel | `PipelineTreePanel.tsx:181` | `px-3 py-2.5` (20) + `icon-chip h-7!` (28) + `border-b` (1) | 49 |
|
||||||
|
| Ô lọc | `PipelineTreePanel.tsx:204` | `p-2` (16) + `input h-8` (32) + `border-b` (1) | 49 |
|
||||||
|
| Thân cây | `PipelineTreePanel.tsx:215` | `lg:max-h-[calc(100vh-16rem)]` | 100vh − 256 |
|
||||||
|
| **Tổng** | | | **100vh − 158** |
|
||||||
|
|
||||||
|
Chiều cao KHẢ DỤNG của Panel-3 = `100vh − 4rem` (vỏ trang) − đầu trang − dải phụ − `p-4` (32):
|
||||||
|
|
||||||
|
| Trang | Vỏ / đầu trang | Đầu trang | Dải phụ | Khả dụng | So với 100vh−158 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `fe-user/src/pages/contracts/MyContractsPage.tsx` | `:105` / `:107` `py-3` + `Button size=md h-8` + border | 57px | 0 | 100vh − 153 | **thừa 5px** (khít) |
|
||||||
|
| `fe-admin/src/pages/contracts/ContractsListPage.tsx` | `:106` / `:107` `py-3` + h1 24px + border | 49px | 0 | 100vh − 145 | **thừa 13px** |
|
||||||
|
| `fe-user/src/pages/InboxPage.tsx` | `:166` / `:168` `py-3` + `StatPill` 34px (`:50` `px-3 py-1.5` + `h-5`) + border | 59px | **29px** — dải "Vai trò bạn đang xử lý" `:183-187` (`py-1.5` + `text-[11px]` + border) | 100vh − 184 | **THIẾU ~26px** |
|
||||||
|
|
||||||
|
Hệ quả trên `/inbox`: `<aside className="hidden overflow-y-auto … p-4 lg:block">` mọc **thanh cuộn dọc thứ hai** bao ngoài thanh cuộn của thân cây, tức trong cột nội dung rộng 328px (360 − 2×16) có hai thanh cuộn lồng nhau. Nội dung vẫn với tới được (không vỡ layout, không mất dữ liệu), nhưng người dùng phải cuộn hai tầng, và đầu panel cùng ô lọc trôi khỏi tầm nhìn khi cuộn tầng ngoài.
|
||||||
|
|
||||||
|
Dải "Vai trò" chỉ render khi `user?.roles.length` — **đo prod**: `/api/roles` trả 13 vai, mọi tài khoản thật đều thuộc ít nhất một vai ⇒ nhánh này gần như LUÔN bật, không phải ca hiếm.
|
||||||
|
|
||||||
|
Hai trang còn lại chỉ thừa 5px và 13px ⇒ **mong manh**: bất kỳ thay đổi nào ở đầu trang (đổi `Button` sang `size=lg` = `h-10`, hoặc thêm một chip trạng thái làm `flex-wrap` xuống dòng ở bề rộng 1024px) đều đẩy chúng sang đúng trạng thái của InboxPage.
|
||||||
|
|
||||||
|
**Tiêu chí nghiệm thu đề nghị:** ở 1280×800 và 1366×768, mở `/inbox` khi chưa chọn HĐ — số thanh cuộn dọc trong vùng Panel-3 phải bằng **1**, và cuộn tới đáy cây không cần cuộn `<aside>` ngoài. Lặp lại phép đo cho `/my-contracts` và fe-admin `/contracts`.
|
||||||
|
|
||||||
|
### F-2 (MINOR) — R4: hai class của khuôn chết ÂM THẦM trong host mới
|
||||||
|
|
||||||
|
`PipelineTreePanel.tsx:177` — `cn('card-accent flex flex-col self-start overflow-hidden lg:sticky lg:top-4', className)`:
|
||||||
|
|
||||||
|
- `self-start` là `align-self:flex-start`, chỉ có tác dụng khi cha là flex hoặc grid. Host mới có cha là `<aside … lg:block>` ⇒ `display:block` ⇒ **vô hiệu**.
|
||||||
|
- `lg:sticky lg:top-4`: tổ tiên cuộn gần nhất **chính là** `<aside overflow-y-auto>`, mà cây lại là con DUY NHẤT của nó ⇒ khối chứa gần bằng chính nó ⇒ quãng "dính" xấp xỉ 0 ⇒ **vô hiệu**.
|
||||||
|
|
||||||
|
Ở ba host cũ (`KhkkListPage.tsx:197` và `HardCopiesPage.tsx:116`, đều là `lg:grid-cols-[19rem_minmax(0,1fr)]`) cha là grid và trang cuộn ở `<main className="flex-1 overflow-auto">`, nên **cả hai class đều sống**. Đây không phải lỗi chức năng, nhưng là dấu hiệu khuôn đang chạy ngoài giả định thiết kế của nó — cùng gốc với F-1. Nên ghi một dòng chú thích ở chỗ cắm để người sau không tưởng rằng cây có bám dính ở đây.
|
||||||
|
|
||||||
|
### F-3 (MINOR) — R4 / a11y: `<aside>` lồng trong `<aside>`
|
||||||
|
|
||||||
|
Host Panel-3 là `<aside>`, và gốc khuôn cũng là `<aside>` (`PipelineTreePanel.tsx:176`) có kèm `aria-label="Cây toàn trình theo gói thầu"` (`:179`). Theo HTML-AAM, một `aside` nằm trong sectioning content chỉ tụt xuống `role=generic` khi KHÔNG có tên khả truy cập; ở đây nó có tên nên **giữ nguyên `role=complementary`** ⇒ sinh ra hai landmark `complementary` lồng nhau. Điều này hợp lệ về HTML nhưng gây nhiễu khi người dùng điều hướng theo landmark bằng trình đọc màn hình. Ba host cũ không dính vì cha của chúng là `<div className="grid …">`.
|
||||||
|
|
||||||
|
### F-4 (MINOR) — R5: `!canPe` đổi thông điệp Panel-3 từ chỉ dẫn sang than phiền quyền; đo prod cho thấy hiện chưa vai nào dính
|
||||||
|
|
||||||
|
Đường quyền, đọc thẳng từ mã chứ không suy:
|
||||||
|
|
||||||
|
- `usePipelineStages.ts:83` — `canPe = PE_MENU_KEYS.some(k => can(k))`, với `PE_MENU_KEYS` ở `:55` là `['PurchaseEvaluations','Pe_DuyetNcc_List','Pe_DuyetNccPhuongAn_List']`.
|
||||||
|
- Khi `canPe` sai, `PipelineTreePanel.tsx:111` đặt `enabled: canPe` nên truy vấn `pipeline-pe-index` **không chạy** ⇒ **không sinh rác 403**, đúng ý đồ đã khai ở `usePipelineStages.ts:5-8`.
|
||||||
|
- Nhưng Panel-3 khi đó hiển thị khung cây rỗng, huy hiệu "**0 gói**", và câu ở `:216-220`: "Bạn chưa có quyền xem phiếu Duyệt NCC nên cây toàn trình chưa hiển thị được." Câu này nằm **trên trang Hợp đồng**, thay cho câu cũ "Quy trình duyệt sẽ hiện khi chọn HĐ." ⇒ người dùng mất chỉ dẫn hữu ích và nhận về một lời than phiền lạc đề.
|
||||||
|
|
||||||
|
**Đo prod** `/api/permissions/by-role/{id}` trên 13 vai (tất cả trả `http=200`): `PurchaseEvaluations`.canRead bằng True ở **13/13 vai** ⇒ nhánh `!canPe` **hiện không xảy ra với vai nào**.
|
||||||
|
**Control-âm của cùng phép đo:** `Permissions`.canRead và `Users`.canRead bằng False ở **12/13 vai** (chỉ `Admin` True; số dòng quyền là 169 so với 191) ⇒ phép đo có phân biệt thật, không phải "True tuốt vì đọc sai trường".
|
||||||
|
Kết luận: F-4 là **rủi ro tương lai** (nếu ai đó gỡ quyền PE của một vai), không phải lỗi đang chảy máu. Không chặn commit.
|
||||||
|
|
||||||
|
### F-5 (MINOR) — R5: cây bơm thêm tối đa ba truy vấn index vào lượt tải đầu của ba trang
|
||||||
|
|
||||||
|
`PipelineTreePanel` gọi `usePipelineStages`, hàm này gọi `usePipelineLinkage`, sinh ra ba truy vấn — `pipeline-pe-index` (`PipelineTreePanel.tsx:100`), `pipeline-khkk-index` (`usePipelineStages.ts:90`) và `pipeline-contract-index` (`:108`) — mỗi truy vấn lấy `pageSize=200`.
|
||||||
|
|
||||||
|
Trên InboxPage, MyContractsPage và ContractsListPage, truy vấn `pipeline-contract-index` gọi `GET /contracts?pageSize=200` **song song** với truy vấn danh sách của chính trang (`/contracts/inbox` ở `InboxPage.tsx:71`, `/contracts` ở MyContractsPage) — cùng một tài nguyên nhưng khác `queryKey`, nên mỗi lần vào trang mà chưa chọn HĐ là hai lần nạp danh sách hợp đồng. Đo prod cho thấy 13/13 vai đều có đủ `canPe`, `canKhkk` và `canContract` ⇒ **cả ba truy vấn đều bắn cho mọi vai**, không vai nào được miễn. Mức này chấp nhận được vì `staleTime` là 60 giây và đây là hệ quả nội tại của phương án D, nhưng nên biết trước để không bất ngờ khi UAT phản ánh trang tải chậm hơn.
|
||||||
|
|
||||||
|
### F-6 (MINOR) — R1 phụ: trade-off của D khai thiếu một vế — ô lọc cây mất chữ khi chọn HĐ rồi bỏ chọn
|
||||||
|
|
||||||
|
Vì điều kiện là `{!selectedId && <PipelineTreePanel …>}`, việc chọn một HĐ sẽ **gỡ bỏ (unmount)** component. Hệ quả:
|
||||||
|
|
||||||
|
- `expandedSet` **sống sót** nhờ `localStorage` (`PipelineTreePanel.tsx:119-126` đọc, `:170` ghi).
|
||||||
|
- `search` (`:94`) và `mobileOpen` (`:95`) là state thuần nên **mất trắng**: người dùng gõ "FLOCK" để lọc cây, bấm xem một HĐ, quay lại thì ô lọc rỗng và cây bung về nhánh mặc định.
|
||||||
|
|
||||||
|
Spec D ở `sub-implfe-gd3.md` §1 chỉ khai trade-off "cây nhảy chỗ khi chọn HĐ", không khai việc mất chữ đang lọc. Đề nghị bổ sung vế này khi báo owner để owner chốt trên thông tin đầy đủ.
|
||||||
|
|
||||||
|
### F-7 (MINOR) — R8 mobile: không hồi quy, nhưng lệch với ba host cũ
|
||||||
|
|
||||||
|
- Host là `<aside className="hidden overflow-y-auto … lg:block">` nên dưới 1024px Panel-3 **không render**. Bản cũ (ô dashed) nằm trong **đúng cùng** `<aside>` đó, vì vậy **không có hồi quy** trên mobile.
|
||||||
|
- Nhưng khuôn có sẵn cả một nhánh mobile riêng: nút "Hiện cây / Ẩn cây" gắn `lg:hidden` (`:193-200`), state `mobileOpen` khống chế thân cây (`:203`), và `max-h-[28rem]` dành cho mobile (`:215`). Trong host này **toàn bộ nhánh mobile của khuôn chết**.
|
||||||
|
- Hệ quả nghiệp vụ: người dùng mobile CÓ cây ở GĐ2 (`KhkkListPage`) và GĐ4 (`HardCopiesPage`, hai trang page-flow nên cây xếp ở trên), nhưng **không có cây ở GĐ3**.
|
||||||
|
|
||||||
|
Điều này đúng chủ đích của HOST (bố cục 3-panel vốn chỉ dành cho desktop) nhưng lệch chủ đích của KHUÔN. Owner cần biết để chốt, đây không phải lỗi code.
|
||||||
|
|
||||||
|
### F-8 (INFO — phán quyết: lead ĐÚNG) — R6: `WorkflowMatrixViewPage` không áp D là quyết định đúng
|
||||||
|
|
||||||
|
`fe-user/src/pages/pe/WorkflowMatrixViewPage.tsx:104` cho thấy gốc trang là `<div className="space-y-4 px-2 py-5">`: đây là trang page-flow, **không có `<aside>`, không có lưới 3-panel, không có Panel-3**. Vì vậy "cây thay chỗ Panel-3" **không có chỗ cắm**, và bỏ qua trang này là đúng theo đúng định nghĩa của phương án D. Đây không phải chỗ lead bỏ sót.
|
||||||
|
|
||||||
|
Tuy nhiên có một câu cần owner chốt: trang này được mount ở **ba route** (`fe-user/src/App.tsx:71` là `/purchase-evaluations/workflow-matrix`, `:73` là `/contracts/workflow-matrix`, `:83` là `/khkk/workflow-matrix`), tức nó là trang giai đoạn của cả ba GĐ. Nếu ý owner khi nói "(a) cứ mở rộng" là "mọi trang giai đoạn đều phải có cây", thì đây là trang duy nhất còn thiếu, và cách cắm đúng phải là lưới page-flow theo khuôn `KhkkListPage.tsx:197` chứ không phải D. Việc này nằm ngoài phạm vi wave hiện tại, nêu ra để owner quyết.
|
||||||
|
|
||||||
|
### F-9 (INFO — phán quyết: KHÔNG mâu thuẫn K6) — R7: sửa trang đang bị ẩn khỏi sidebar
|
||||||
|
|
||||||
|
- `fe-admin/src/components/Layout.tsx:227-240` — tập `ADMIN_HIDDEN_KEYS` có chứa `'Contracts'`; thêm nữa, `:248` trong `isAdminHidden` còn chặn mọi key bắt đầu bằng `Ct_`. Vì vậy admin không nhìn thấy mục Hợp đồng ở sidebar.
|
||||||
|
- **Route thì vẫn sống**: `fe-admin/src/App.tsx:82` map `/contracts` sang `ContractsListPage`, kèm `:83` cho `/contracts/new` và `:84` cho `/contracts/:id`.
|
||||||
|
- **Có tiền lệ khớp**: key `'HopDongCung'` cũng nằm trong `ADMIN_HIDDEN_KEYS` (`:231`), vậy mà `HardCopiesPage` bên fe-admin vẫn được S162 cắm cây (`fe-admin/src/pages/contracts/HardCopiesPage.tsx:117`) và route của nó vẫn sống (`App.tsx:87`).
|
||||||
|
|
||||||
|
Kết luận: K6 ẩn phần **hiển thị**, chứ không xoá **trang**. Sửa một trang bị ẩn chính là giữ mirror theo `docs/rules.md §3.9`, nên **không có mâu thuẫn**.
|
||||||
|
Một lưu ý thực dụng: thay đổi bên fe-admin gần như **vô hình với admin** vì phải gõ thẳng URL mới tới được. Giá trị của nó là parity giữa hai app, nên đừng tính nó vào "ba trang người dùng nhìn thấy" khi báo owner, và UAT phải làm ở fe-user chứ không phải fe-admin.
|
||||||
|
|
||||||
|
### F-10 (INFO) — R1 phụ: `currentStage={3}` trên InboxPage, trong khi hộp thư trộn GĐ1 và GĐ3
|
||||||
|
|
||||||
|
`fe-user/src/pages/InboxPage.tsx:71` gọi `/contracts/inbox` và `:79` gọi `/purchase-evaluations/inbox`, nên trang liệt kê cả hợp đồng lẫn phiếu Duyệt NCC (bấm vào phiếu PE thì `:283` điều hướng sang `/purchase-evaluations/:id`). Giá trị `currentStage={3}` làm cây mở sẵn folder GĐ3.
|
||||||
|
|
||||||
|
Cách đặt này vẫn hợp lý, vì Panel-3 là chỗ dành cho HĐ đang chọn (`selectedId` được dùng cho `/contracts/${selectedId}` ở `:84`), tức ngữ cảnh của panel đúng là GĐ3. Nêu ra để tránh hiểu nhầm nếu sau này owner coi Inbox là trang GĐ1. Không tính là lỗi.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tổng kết theo trục
|
||||||
|
|
||||||
|
| Trục | Kết quả | Ghi chú |
|
||||||
|
|---|---|---|
|
||||||
|
| R1 Đúng-D + loading không đổi | **PASS** | điều kiện render không đổi một ký tự; kèm F-6 và F-10 dạng thông tin |
|
||||||
|
| R2 0-đụng-khuôn + SHA-pair | **PASS** | 3/3 cặp IDENTICAL, `git status` trên khuôn rỗng |
|
||||||
|
| R3 Unused-import `X` | **PASS** | 0 hit mã, control-âm cho 2 hit/file ở HEAD |
|
||||||
|
| R4 Ngữ cảnh render / CSS | **FLAG** | F-1 (MAJOR), F-2, F-3 |
|
||||||
|
| R5 Permission-path / 403 rác | **PASS có cờ** | không sinh rác 403; F-4 là rủi ro tương lai (prod 13/13 chưa dính), F-5 là chi phí truy vấn |
|
||||||
|
| R6 Trang không áp | **PASS** | F-8 — quyết định của lead là đúng |
|
||||||
|
| R7 fe-admin ẩn sidebar | **PASS** | F-9 — không mâu thuẫn K6, có tiền lệ HardCopiesPage |
|
||||||
|
| R8 Mobile | **PASS** | F-7 — không hồi quy, nhưng nhánh mobile của khuôn chết trong host này |
|
||||||
|
|
||||||
|
**Blocker: 0.** Không finding nào chặn commit. F-1 là thứ đáng xử lý nhất trước khi UAT, hoặc phải khai rõ với owner như một trade-off đã biết.
|
||||||
|
|
||||||
|
**Ranh vai:** không sửa file mã nào, không commit, không push. Mọi finding đều ở dạng mô tả vấn đề kèm tiêu chí nghiệm thu.
|
||||||
|
|
||||||
|
END sub-reviewer-gate-gd3d — VERDICT=PASS-WITH-FLAGS-10 — TOTAL=10 finding
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LEAD DISPOSITION @S168 (từng dòng — không số tổng)
|
||||||
|
|
||||||
|
| F | Xử | Chi tiết |
|
||||||
|
|---|---|---|
|
||||||
|
| F-1 MAJOR | ✅ **VÁ** | Nới khuôn đúng ngoại-lệ "thiếu prop cho GĐ3" artifact lane khoanh: `PipelineTreePanel` +prop `bodyMaxHClass?` (default GIỮ NGUYÊN — 3 host giấy-cuộn cũ 0 đổi hành vi), 3 host Panel-3 pass `max-h-[28rem] lg:max-h-none` ⇒ **panel cuộn = đúng 1 thanh, bền mọi trang** (không phụ thuộc 5px/13px mong manh). Mirror ×2 app SHA-pair mới `7baf6247af3d`. Lệch tiêu-chí-CHỮ của gate ("cuộn tới đáy cây không cần cuộn aside ngoài") — đạt tiêu-chí-Ý (1 thanh cuộn, không lồng): chọn panel-cuộn thay cây-tự-cuộn vì bền cấu-trúc, đầu cây + ô lọc trôi theo là trade-off khai |
|
||||||
|
| F-2 MINOR | ✅ VÁ (comment) | Chú thích tại 3 chỗ cắm: sticky/self-start vô hiệu ở host display:block — by-design |
|
||||||
|
| F-3 MINOR | 🔸 CHẤP NHẬN-khai | aside-lồng-aside 2 landmark complementary: hợp lệ HTML, nhiễu screen-reader — không đổi khuôn lượt này (đổi root khuôn = đụng 4 host); ghi nợ a11y cùng lô a11y-`<a>`-label K5 |
|
||||||
|
| F-4 MINOR | 🔸 CHẤP NHẬN | `!canPe` than-phiền-quyền thay chỉ-dẫn: prod 13/13 vai có PE.Read (gate đo + control-âm) — rủi ro tương lai, không chảy máu |
|
||||||
|
| F-5 MINOR | 🔸 CHẤP NHẬN | +tối đa 3 query index lượt tải đầu (staleTime 60s, enabled canPe) — giá của D |
|
||||||
|
| F-6 MINOR | 🔸 CHẤP NHẬN-khai | chọn HĐ → cây unmount → bỏ chọn → ô lọc mất chữ (state trong component) — trade-off D "cây nhảy chỗ" đã khai với owner |
|
||||||
|
| F-7 MINOR | 🔸 CHẤP NHẬN | mobile: panel-3 hidden dưới lg = khuôn 3-panel by-design (mobile đi fullpage route) |
|
||||||
|
| F-8 INFO | ✓ (gate phán lead ĐÚNG) | WorkflowMatrixView không áp D |
|
||||||
|
| F-9 INFO | ✓ (gate phán KHÔNG mâu-thuẫn K6) | fe-admin /contracts ẩn sidebar — route sống, tiền lệ HardCopiesPage |
|
||||||
|
| F-10 INFO | 🔸 GIỮ `currentStage={3}` | /inbox trộn GĐ1+GĐ3 nhưng trang chủ-đích là HĐ Pending; PE item navigate sang trang PE riêng |
|
||||||
|
|
||||||
|
Vá F-1/F-2 nằm trong commit GĐ3-D (build+test re-verify sau vá). 6 finding chấp-nhận đi vào UAT-NÓI-TRƯỚC.
|
||||||
@ -0,0 +1,147 @@
|
|||||||
|
GATE-LINEEDITOR: PASS-WITH-FLAGS 8 — vá K8 chạy thật, đóng đúng blocker, 642/642 test xanh, 2 app tsc sạch, SHA-pair khớp; 0 blocker mới, nhưng có 2 MAJOR (changelog thiếu `EntityId` · `<select>` nuốt hạng mục đã lưu).
|
||||||
|
|
||||||
|
# Gate reviewer — K8 blocker vá: KHKK Lines-editor (S168, 2026-08-01)
|
||||||
|
|
||||||
|
## A. Diff scope (ĐO bằng `git diff --stat`, đo lại NGAY TRƯỚC khi chốt verdict)
|
||||||
|
|
||||||
|
Cây làm việc bẩn 14 file vì trộn 2 lane. **Phần thuộc vá K8 = 5 file:**
|
||||||
|
|
||||||
|
| File | Δ |
|
||||||
|
|---|---|
|
||||||
|
| `src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs` | +65 |
|
||||||
|
| `src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs` | +15 |
|
||||||
|
| `tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanGroupAndCatalogTests.cs` | +86 |
|
||||||
|
| `fe-admin/src/pages/khkk/KhkkDetailPage.tsx` | +58 / −... |
|
||||||
|
| `fe-user/src/pages/khkk/KhkkDetailPage.tsx` | +58 / −... |
|
||||||
|
|
||||||
|
**Ngoài scope K8** (lane GĐ3-D, KHÔNG tính vào gate này): `PipelineTreePanel.tsx` ×2 · `ContractsListPage.tsx` · `InboxPage.tsx` · `MyContractsPage.tsx` · `.claude/*`.
|
||||||
|
⇒ Không có file BE/test nào ngoài phạm vi spec ⇒ **anti-fiddle PASS**.
|
||||||
|
|
||||||
|
## B. Phép đo cứng đã chạy
|
||||||
|
|
||||||
|
| Phép đo | Lệnh | Kết quả |
|
||||||
|
|---|---|---|
|
||||||
|
| Suite BE | `dotnet test SolutionErp.slnx` | Domain **45/45** + Infrastructure **597/597** = **642 PASS / 0 FAIL** |
|
||||||
|
| Đối chiếu baseline | sổ S167 = 639 | 639 + 3 `[Fact]` mới = **642 khớp chằn chặn** ⇒ một con số đóng hai câu hỏi: test mới CHẠY THẬT **và** 0 test cũ vỡ |
|
||||||
|
| Build BE | (trong lượt test) | 4 project build, **0 error** |
|
||||||
|
| tsc fe-admin | `npx tsc -b --force` | **0 dòng lỗi** |
|
||||||
|
| tsc fe-user | `npx tsc -b --force` | **0 dòng lỗi** |
|
||||||
|
| SHA-pair FE (R6) | `sha256sum` 2 file | `21e0ca0507d4074965805c94a60c7bc58aeb2cd58a55fd1627ee25e062a42469` — **hai app BẰNG NHAU byte-level** ⇒ R6 PASS |
|
||||||
|
|
||||||
|
⚠️ **Thước hỏng tự khai:** lượt đầu tôi viết `echo "ADMIN_EXIT=$?"` sau `tsc | tail` — số đó là exit của `tail`, vô nghĩa. Đã bỏ và đo lại bằng `--force` (chống cả bẫy incremental-skip của `tsc -b`); tín hiệu hợp lệ = **0 dòng output lỗi**, cả hai app.
|
||||||
|
|
||||||
|
## C. R1 — Handler: race · idempotency · ngữ nghĩa mã lỗi
|
||||||
|
|
||||||
|
Code đo: `ContractSigningPlanFeatures.cs:577-641`.
|
||||||
|
|
||||||
|
- **Gán ĐÈ hạng mục cũ**: HỢP LỆ — handler không chặn `line.CatalogEntryId != null`, chọn nhầm thì chọn lại được. Đúng ý spec, **nhưng chưa có test ghim** (F-4).
|
||||||
|
- **NotFound ⟂ Conflict**: phiếu 404 · dòng 404 · hạng mục không tồn tại / đã xoá mềm 404 (global filter `!IsDeleted`, `ContractCatalogEntryConfiguration.cs:29`) · hạng mục `IsActive=false` hoặc sai nhóm 409. **Ngữ nghĩa đúng.**
|
||||||
|
- **`AsNoTracking` trên entry**: đúng — chỉ đọc `IsActive`/`ApprovalGroup`/`TenVi`; `line` lấy từ `plan.Lines` nên vẫn tracked, không có bẫy "sửa mà không lưu".
|
||||||
|
- **Race hai request cùng một dòng**: last-write-wins, không `RowVersion`; cả hai giá trị đều đã qua rào nên vô hại.
|
||||||
|
- **Race gán ∥ trình**: xem F-5.
|
||||||
|
- **Changelog lặp**: gán lại đúng hạng mục cũ vẫn đẻ một dòng nhật ký (không so giá trị trước). Nhiễu, không sai.
|
||||||
|
|
||||||
|
## D. R2 — AUTHZ (3 tầng, không có lỗ "sửa dòng của phiếu người khác")
|
||||||
|
|
||||||
|
1. Class `[Authorize(Policy="KeHoachKyKet.Read")]` — `ContractSigningPlansController.cs:29`.
|
||||||
|
2. Action mới `[Authorize(Policy="KeHoachKyKet.Update")]` — `:109`. **Đúng khuôn 2 tầng (gotcha #82/#85), dùng đúng key policy của endpoint, KHÔNG OR `Khkk_*`.**
|
||||||
|
3. `ContractSigningPlanScope.EnsureDraftEditable` (`:244-252`) = (chủ phiếu ∥ Admin) **và** phase ∈ {Nháp, Trả lại}.
|
||||||
|
|
||||||
|
Người khác chủ phiếu, dù có `KeHoachKyKet.Update`, vẫn ăn `ForbiddenException` ở tầng 3; người ngoài còn không đọc nổi phiếu nháp (`EnsureCanViewAsync` `:231-242` — nháp là riêng tư, S89). Guard này **đã có test trực tiếp sẵn** trong chính file test (~`:595-615`: stranger → Forbidden; Admin vẫn bị chặn theo PHASE), và `[Fact]` mới `GanHangMuc_PhieuDaTrinh_BiChanPhase` chứng minh handler **có gọi** guard ⇒ vế chủ-phiếu đi kèm cùng hàm. **R2 PASS.**
|
||||||
|
|
||||||
|
## E. R3 — Guard phối hợp (fail-fast tầng gán ⟂ submit-guard đếm-so-khớp)
|
||||||
|
|
||||||
|
Submit-guard: `ContractSigningPlanWorkflowService.cs:176-187`.
|
||||||
|
|
||||||
|
1. **Hạng mục bị xoá mềm SAU khi gán** → submit-guard bắt (global filter loại ⇒ `okCount` hụt) ⇒ **bịt**.
|
||||||
|
2. **Hạng mục `IsActive=false` SAU khi gán** → submit-guard **không** kiểm `IsActive` ⇒ phiếu vẫn trình được. Đánh giá: **đúng thiết kế** (tên đã denorm là ảnh chụp lúc gán; ngừng hiệu lực không được phép làm kẹt phiếu đang chạy). Không phải lỗi — nhưng đẻ ra hệ quả hiển thị F-2(c).
|
||||||
|
3. **Đổi nhóm duyệt của phiếu sau khi gán** → **bất khả**: `ApprovalGroup` chỉ set lúc tạo (`:459`); `UpdateContractSigningPlanDraftCommand` chỉ nhận `GhiChu`/`HoSoLink`/`ApprovalWorkflowId`. Khe này **RỖNG — và tôi khai rõ là rỗng**, không tính công.
|
||||||
|
|
||||||
|
## F. FINDINGS (8)
|
||||||
|
|
||||||
|
### F-1 [MAJOR] Changelog dòng `Line` bỏ trống `EntityId` — nhật ký không biết là DÒNG NÀO
|
||||||
|
`ContractSigningPlanFeatures.cs:627-636` ghi `EntityType = Line` nhưng không gán `EntityId`.
|
||||||
|
**Đo bằng chính quy ước của file:** mọi lần ghi nhật ký cho thực thể con đều điền — `:1069`, `:1107` (`EntityId = item.Id`), `:1215` (`= attId`), `:1295` (`= att.Id`), `ContractSigningPlanWorkflowService.cs:550` (`= actingLevelId`). Chỗ mới là **chỗ DUY NHẤT lệch**.
|
||||||
|
Hệ quả thật: phiếu liên danh nhiều dòng (đúng ca chính của module này) sẽ có N dòng nhật ký "Gán hạng mục '…' cho dòng NCC" mà **không cách nào biết dòng nào**; `Summary` cũng không nêu tên nhà cung cấp. Cột sinh ra để trả lời đúng câu hỏi đó thì để null.
|
||||||
|
**Acceptance:** gán `EntityId = line.Id`; khuyến nghị thêm mốc nhận dạng nhà cung cấp vào `Summary`.
|
||||||
|
|
||||||
|
### F-2 [MAJOR] `<select>` nuốt mất hạng mục ĐÃ LƯU khi option không có trong danh sách
|
||||||
|
`KhkkDetailPage.tsx:559-578` (cả hai app): `value={l.catalogEntryId ?? ''}`, danh sách option = `catalogOptions.data ?? []`, và có sẵn `<option value="">— Chọn hạng mục —</option>`. Khi `catalogEntryId` **không khớp option nào**, trình duyệt rơi về placeholder ⇒ **dòng đã gán trông như chưa gán**. Ba ca:
|
||||||
|
- (a) đang tải danh mục ⇒ nháy tạm (nhẹ);
|
||||||
|
- (b) query `/catalogs/contract-catalog` lỗi mạng/5xx ⇒ **mất hẳn** tên hạng mục của mọi dòng, mà lỗi này **không có toast** (chỉ mutation mới có `onError`);
|
||||||
|
- (c) hạng mục bị ngừng hiệu lực sau khi gán ⇒ **vĩnh viễn** hiển thị như chưa gán, dù DB vẫn giữ `TenHangMuc` và submit-guard vẫn cho trình (mục E.2).
|
||||||
|
|
||||||
|
Trước vá, ô này luôn hiện `l.tenHangMuc`. Sau vá, dữ liệu đã lưu **biến mất khỏi màn hình** trong ba ca trên — đúng lớp bệnh "vắng mặt trông giống ổn".
|
||||||
|
**Acceptance:** khi `l.catalogEntryId` không nằm trong options thì chèn một `<option value={l.catalogEntryId}>` nhãn `l.tenHangMuc`; hoặc render text `l.tenHangMuc` cho tới khi `catalogOptions.isSuccess`. Kèm hiển thị lỗi khi query danh mục thất bại.
|
||||||
|
|
||||||
|
### F-3 [MINOR] Nhóm không còn hạng mục hiệu lực ⇒ dropdown rỗng, không một chữ giải thích
|
||||||
|
`enabled` chỉ gác `planDraftEditable && approvalGroup != null`. Nếu nhóm duyệt của phiếu không còn dòng danh mục `IsActive` nào, người soạn thấy dropdown trống, không hiểu vì sao, và **không bao giờ trình được phiếu** (submit-guard chặn null) — tái lập đúng lớp bệnh K8-blocker dưới lớp áo khác. Hôm nay khó chạm vì seeder `SeedContractCatalogAsync` (`DbInitializer.cs:3503`) nạp 86 dòng phủ 8 nhóm; rào là để chống hồi quy khi quản trị ngừng hiệu lực cả nhóm.
|
||||||
|
**Acceptance:** khi `isSuccess && data.length === 0` → hiện dòng chữ "Nhóm duyệt N{n} chưa có hạng mục đang hiệu lực — liên hệ quản trị danh mục".
|
||||||
|
|
||||||
|
### F-4 [MINOR] Ba `[Fact]` mới phủ đúng một nửa số nhánh của chính guard vừa viết
|
||||||
|
Có test: sai nhóm (409) · phiếu đã trình (409) · ca dương gán rồi trình QUA.
|
||||||
|
Không có test: (a) `!entry.IsActive` — helper `AddCatalogAsync` luôn đặt `IsActive = true`, tức **một nửa vế của `if` mới chưa từng chạy lần nào**; (b) `NotFoundException` cho hạng mục rác/đã xoá mềm; (c) **gán đè** — chính câu R1 hỏi; hành vi đang đúng nhưng chưa bị ghim, người sau thêm `if (line.CatalogEntryId != null) throw` sẽ không có test nào đỏ; (d) `lineId` thuộc phiếu khác → 404.
|
||||||
|
Ghi nhận mặt tốt: ca dương **nối thẳng tới cửa ra thật** (`ActAsync … "submit"` rồi assert `ChoDuyet`) chứ không đo gán suông — đúng bài "răng phải cắn tới cửa ra".
|
||||||
|
**Acceptance:** thêm `[Fact]` `IsActive=false` → 409; thêm `[Fact]` gán đè (gán A rồi gán B ⇒ `CatalogEntryId=B` và `TenHangMuc` đổi theo).
|
||||||
|
|
||||||
|
### F-5 [MINOR] Cửa sổ tranh chấp giữa gán và trình (không có `RowVersion`)
|
||||||
|
Handler đọc phiếu ở trạng thái Nháp → qua guard → lượt khác `submit` đẩy sang Chờ duyệt → `SaveChanges` của gán vẫn ghi ⇒ dòng bị sửa sau khi phiếu rời tay người soạn. Xác suất thấp (một người soạn), giá trị ghi vào vẫn hợp lệ, và cả repo chưa dùng `RowVersion` (đang nằm ở backlog Tier 4) ⇒ **không đòi sửa lượt này**, ghi để không ai tưởng khe này đã đóng.
|
||||||
|
|
||||||
|
### F-6 [MINOR] Backend không rào `line.ContractId == null`, chỉ frontend khoá
|
||||||
|
FE khoá dòng đã bắc cầu hợp đồng (`isDraft && !l.contractId`) kèm bình luận "đổi hạng mục sau khi có HĐ = đổi lịch sử", nhưng BE không kiểm `ContractId`. Hôm nay **không thủng**: cầu sang hợp đồng đòi `plan.Phase == DaDuyet` (`CreateContractFromSigningPlanFeatures.cs:97`), và `DaDuyet` là trạng thái cuối (`return`/`reject` đều đòi Chờ duyệt; `submit` đòi Nháp/Trả lại) ⇒ dòng có `ContractId` luôn nằm trong phiếu mà `EnsureDraftEditable` đã chặn. Khe rỗng hôm nay, nhưng ý định đang được giữ bởi mỗi FE — đúng lớp "ẩn menu không đóng được API". Đề xuất rào một dòng ở BE cho khớp lời bình luận.
|
||||||
|
|
||||||
|
### F-7 [MINOR] Thiếu rào trùng `(Plan, Supplier, CatalogEntry)` ⇒ nếu chạm sẽ là 500 kèm rò lỗi SQL
|
||||||
|
`ContractSigningPlanLineConfiguration.cs:37-39` có UNIQUE `(ContractSigningPlanId, SupplierId, CatalogEntryId)` lọc `[IsDeleted]=0`, và bình luận ngay trên đó **tự hẹn** mắt xích thứ hai là "[K4b — thì tương lai] validator đường gán hạng mục khi Lines-editor ra đời". Lines-editor land lượt này mà validator trùng **không có**; vi phạm sẽ ném `DbUpdateException` → `GlobalExceptionMiddleware` rơi nhánh mặc định ⇒ **500 + đẩy nguyên message SQL ra `detail`**.
|
||||||
|
**Đo khả chạm:** `PurchaseEvaluationConfiguration.cs:105` có UNIQUE `(PurchaseEvaluationId, SupplierId)` ⇒ mỗi nhà cung cấp trúng thầu chỉ một dòng ⇒ đường tạo dòng DUY NHẤT (`Lines.Add` chỉ ở `:475`) không thể sinh hai dòng cùng nhà cung cấp ⇒ **hôm nay bất khả chạm**. Khai đúng mức: không phải lỗ đang chảy, mà là **lời hẹn trong chính bình luận chưa được trả**, và sẽ thành 500 ngay khi có đường thêm dòng theo hạng mục — đúng thứ Mig 71 mở đường tới.
|
||||||
|
|
||||||
|
### F-8 [MINOR] `aria-label` trùng nhau trên mọi dòng
|
||||||
|
`aria-label="Gán hạng mục SP-002 cho dòng"` giống hệt cho N dòng ⇒ trình đọc màn hình đọc N hộp chọn cùng tên, không phân biệt được dòng của nhà cung cấp nào. **Acceptance:** nhét tên nhà cung cấp vào nhãn.
|
||||||
|
|
||||||
|
## G. Chín điểm ĐÃ CHỐNG được soi (ghi nhận dương)
|
||||||
|
|
||||||
|
1. **Tên tham số query đúng** — FE gửi `?approvalGroup=`, BE `ContractCatalogController.cs:28` nhận `[FromQuery] int? approvalGroup`. Bẫy "K4b ship `?group=`" (đó là tham số của **list phiếu KHKK**, `[FromQuery(Name="group")]`) **không dính**.
|
||||||
|
2. **Hình dạng payload khớp** — BE trả `List<ContractCatalogEntryDto>` trần, không bọc paging; FE khai `{id, code, tenVi}[]` ⇒ `.map` an toàn.
|
||||||
|
3. **DTO có sẵn field** — `KhkkLineDto.catalogEntryId` và `.contractId` tồn tại trên đĩa (`types/khkk.ts:212,218`), BE map ở `:737` ⇒ hợp đồng FE↔BE không đứt.
|
||||||
|
4. **Query-key không va** — `['contract-catalog', group]` so với trang danh mục dùng `['catalogs', kind, search]`.
|
||||||
|
5. **Route không đụng route cũ** — `PUT {id}/lines/{lineId}` là đường duy nhất chứa `lines` trong controller.
|
||||||
|
6. **`Summary` không tràn cột** — tối đa khoảng 20 + 500 + phụ ≈ 550 ký tự, dưới `HasMaxLength(1000)` ⇒ không dính bẫy "SQLite nuốt maxlength, SQL Server mới ném" (đã kiểm, không đoán).
|
||||||
|
7. **Denorm `TenHangMuc` lúc gán** đúng khuôn lịch sử: danh mục đổi tên về sau không viết lại phiếu cũ.
|
||||||
|
8. **`EnsureDraftEditable` chạy TRƯỚC mọi truy vấn khác** ⇒ không rò sự tồn tại của hạng mục cho người không có quyền sửa.
|
||||||
|
9. **Body chỉ nhận `CatalogEntryId`**, `planId`/`lineId` lấy từ route ⇒ không có lỗi lệch id giữa route và body.
|
||||||
|
|
||||||
|
## H. Bảng 6 category
|
||||||
|
|
||||||
|
| Category | Kết quả | Ghi chú |
|
||||||
|
|---|---|---|
|
||||||
|
| 1. Wire BE / feature claim | **PASS** | Không còn dấu vết giả lập: `git diff` không có `// Mock` / `alert(` / `TODO wire`; có `api.put` thật ở FE và `[HttpPut]` thật ở BE; đường gán nối tới submit-guard được test dương chứng minh |
|
||||||
|
| 2. Schema integrity | **PASS** | Vá 0 migration (đúng — chỉ dùng cột `CatalogEntryId` của Mig 71); không đụng snapshot ⇒ luật 3-file không áp |
|
||||||
|
| 3. Security | **PASS** | 3 tầng ở mục D; không rò dữ liệu; không nối chuỗi SQL |
|
||||||
|
| 4. Code quality | **PASS** | BE 0 error · tsc 2 app sạch · 642 test xanh · không `--no-verify` · 0 file lạc ngoài spec · mirror 2 app khớp byte |
|
||||||
|
| 5. Test coverage | **PASS-với-cờ** | +3 `[Fact]`, đúng luật test-after; thiếu 4 nhánh ở F-4 |
|
||||||
|
| 6. Writing quality | **N-A** | Không có nội dung hướng ra ngoài trong diff |
|
||||||
|
|
||||||
|
## I. Khuyến nghị
|
||||||
|
|
||||||
|
- **Cho commit lượt này** — không có blocker; đường gán đã đóng đúng lỗ "phiếu mới kẹt Nháp vĩnh viễn" mà dry-run K8 chỉ ra.
|
||||||
|
- **Nên vá ngay trước commit** (rẻ, mỗi cái vài dòng): **F-1** (`EntityId = line.Id`) và **F-2** (option dự phòng cho hạng mục đã gán).
|
||||||
|
- **Để lượt sau**: F-3, F-4, F-6, F-7, F-8. **F-5** ghi nhận, không sửa.
|
||||||
|
- Nếu chỉ chọn được một thứ để làm: **F-1**, vì nhật ký sai là thứ không backfill lại được.
|
||||||
|
|
||||||
|
END sub-reviewer-gate-lineeditor — VERDICT=PASS-WITH-FLAGS (0 blocker · 2 MAJOR · 6 MINOR) — TOTAL=8
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LEAD DISPOSITION @S168 (từng dòng)
|
||||||
|
|
||||||
|
| F | Xử | Chi tiết |
|
||||||
|
|---|---|---|
|
||||||
|
| F-1 MAJOR | ✅ VÁ | `EntityId = line.Id` + lookup `Suppliers.Name` vào Summary ("cho dòng NCC {tên}") |
|
||||||
|
| F-2 MAJOR | ✅ VÁ | option-fallback từ dữ liệu đã lưu khi `catalogEntryId ∉ options` (3 ca loading/error/ngừng-hiệu-lực) + dòng lỗi đỏ `isError` kèm nút thử-lại |
|
||||||
|
| F-3 MINOR | ✅ VÁ | `isSuccess && length===0` → dòng amber "Nhóm N{n} chưa có hạng mục đang hiệu lực" |
|
||||||
|
| F-4 MINOR | ✅ VÁ | +2 Fact: `IsActive=false → 409` (nhánh guard độc lập) · gán-ĐÈ đổi cả Id lẫn `TenHangMuc` snapshot |
|
||||||
|
| F-5 MINOR | 🔸 GHI NỢ | race gán-vs-trình không RowVersion — cùng gói nợ K7 `race-2-request` (unique/RowVersion nhịp sau, đã có tên trong sổ) |
|
||||||
|
| F-6 MINOR | ✅ VÁ | rào BE `line.ContractId != null → 409` (FE khoá chỉ là hiển thị — bài #82 2-layers) |
|
||||||
|
| F-7 MINOR | 🔸 GHI NỢ | unique `(Plan,Supplier,CatalogEntry)` — hôm nay bất-khả-chạm (UNIQUE PE-side + đường tạo dòng duy nhất, gate tự đo); vào cùng gói RowVersion/unique nhịp sau |
|
||||||
|
| F-8 MINOR | ✅ VÁ | `aria-label` += `l.supplierName` per-dòng |
|
||||||
|
|
||||||
|
Sau vá: mirror SHA-pair mới `0d6147d0d43c` ×2 app · suite + build ×2 re-verify (vòng chốt).
|
||||||
@ -86,9 +86,15 @@ function buildTree(rows: PeListItem[]): YearNode[] {
|
|||||||
export function PipelineTreePanel({
|
export function PipelineTreePanel({
|
||||||
currentStage,
|
currentStage,
|
||||||
className,
|
className,
|
||||||
|
bodyMaxHClass,
|
||||||
}: {
|
}: {
|
||||||
currentStage: PipelineStageNo
|
currentStage: PipelineStageNo
|
||||||
className?: string
|
className?: string
|
||||||
|
// [S168 GĐ3-D gate F-1] Host 3-panel (Panel-3 TỰ CUỘN) truyền class trần riêng cho THÂN
|
||||||
|
// cây (vd `lg:max-h-none` — panel giữ đúng 1 thanh cuộn; max-h mặc định 100vh−16rem là
|
||||||
|
// hiệu-chỉnh cho trang giấy-cuộn, vượt chỗ Panel-3 /inbox ~26px ⇒ 2 cuộn lồng nhau).
|
||||||
|
// Mặc định giữ nguyên — 3 host giấy-cuộn cũ không đổi hành vi.
|
||||||
|
bodyMaxHClass?: string
|
||||||
}) {
|
}) {
|
||||||
const { canPe, buildStages } = usePipelineStages()
|
const { canPe, buildStages } = usePipelineStages()
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
@ -212,7 +218,7 @@ export function PipelineTreePanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[28rem] overflow-y-auto lg:max-h-[calc(100vh-16rem)]">
|
<div className={cn('overflow-y-auto', bodyMaxHClass ?? 'max-h-[28rem] lg:max-h-[calc(100vh-16rem)]')}>
|
||||||
{!canPe && (
|
{!canPe && (
|
||||||
<p className="px-3 py-4 text-[12px] text-slate-500">
|
<p className="px-3 py-4 text-[12px] text-slate-500">
|
||||||
Bạn chưa có quyền xem phiếu Duyệt NCC nên cây toàn trình chưa hiển thị được.
|
Bạn chưa có quyền xem phiếu Duyệt NCC nên cây toàn trình chưa hiển thị được.
|
||||||
|
|||||||
@ -14,9 +14,10 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { FileText, Search, X } from 'lucide-react'
|
import { FileText, Search } from 'lucide-react'
|
||||||
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
|
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
|
||||||
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
|
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
|
||||||
|
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
|
||||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||||
import { SlaTimer } from '@/components/SlaTimer'
|
import { SlaTimer } from '@/components/SlaTimer'
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
@ -240,12 +241,12 @@ export function ContractsListPage() {
|
|||||||
|
|
||||||
{/* Panel 3 — Workflow + history */}
|
{/* Panel 3 — Workflow + history */}
|
||||||
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
|
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
|
||||||
{!selectedId && (
|
{/* [S168 GĐ3-D — owner slot (63) chốt D] Chưa chọn HĐ ⇒ cây toàn trình (GĐ3)
|
||||||
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
|
thế chỗ ô trống; chọn HĐ thì nhường chỗ cho quy trình duyệt. Trade-off D
|
||||||
<X className="mx-auto mb-2 h-5 w-5" />
|
(cây rời panel khi chọn HĐ) — sub-implfe-gd3.md §1. */}
|
||||||
Quy trình duyệt sẽ hiện khi chọn HĐ.
|
{/* [gate F-1/F-2] Panel-3 TỰ CUỘN ⇒ thân cây `lg:max-h-none` giữ đúng 1 thanh cuộn;
|
||||||
</div>
|
sticky/self-start của khuôn vô hiệu ở host display:block này — by-design. */}
|
||||||
)}
|
{!selectedId && <PipelineTreePanel currentStage={3} bodyMaxHClass="max-h-[28rem] lg:max-h-none" />}
|
||||||
{selectedId && detail.data && <WorkflowHistoryPanel contract={detail.data} />}
|
{selectedId && detail.data && <WorkflowHistoryPanel contract={detail.data} />}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -167,6 +167,31 @@ export function KhkkDetailPage() {
|
|||||||
onError: (e) => toast.error(getErrorMessage(e)),
|
onError: (e) => toast.error(getErrorMessage(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// [S168 K8-blocker — Lines-editor] Gán hạng mục SP-002 cho dòng: options lọc ĐÚNG nhóm
|
||||||
|
// duyệt của phiếu (BE fail-fast tầng-gán + submit-guard chốt cuối). Chỉ tải khi phiếu
|
||||||
|
// còn sửa được (Nháp/Trả lại) — tránh 1 request thừa trên phiếu đã trình/duyệt.
|
||||||
|
const planDraftEditable =
|
||||||
|
plan.data?.phase === KhkkPhase.DangSoanThao || plan.data?.phase === KhkkPhase.TraLai
|
||||||
|
const catalogOptions = useQuery({
|
||||||
|
queryKey: ['contract-catalog', plan.data?.approvalGroup],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await api.get<{ id: string; code: string; tenVi: string }[]>('/catalogs/contract-catalog', {
|
||||||
|
params: { approvalGroup: plan.data!.approvalGroup },
|
||||||
|
})
|
||||||
|
).data,
|
||||||
|
enabled: planDraftEditable && plan.data?.approvalGroup != null,
|
||||||
|
})
|
||||||
|
const assignLine = useMutation({
|
||||||
|
mutationFn: async (p: { lineId: string; catalogEntryId: string }) =>
|
||||||
|
api.put(`/contract-signing-plans/${id}/lines/${p.lineId}`, { catalogEntryId: p.catalogEntryId }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Đã gán hạng mục')
|
||||||
|
invalidate()
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
const upsertItem = useMutation({
|
const upsertItem = useMutation({
|
||||||
mutationFn: async (body: UpsertKhkkDossierItemInput) => {
|
mutationFn: async (body: UpsertKhkkDossierItemInput) => {
|
||||||
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ')
|
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ')
|
||||||
@ -503,9 +528,9 @@ export function KhkkDetailPage() {
|
|||||||
<thead className="border-b border-slate-200 bg-slate-50/70">
|
<thead className="border-b border-slate-200 bg-slate-50/70">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th>
|
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th>
|
||||||
{/* [K2 S164] Hạng mục SP-002 của dòng — HIỂN THỊ-ONLY lượt này.
|
{/* [K2 S164 → S168] Hạng mục SP-002 của dòng — đường GÁN (Lines-editor) land
|
||||||
Đường GÁN (Lines-editor) = **K4b**; K2 chỉ đẻ cột + cho thấy dòng
|
@S168 (mảnh hẹn-K4b bị rơi, dry-run K8 bắt): Nháp/Trả lại gán tại bảng,
|
||||||
nào còn trống (dòng trống KHÔNG trình duyệt được — submit-guard BE). */}
|
dòng trống KHÔNG trình duyệt được (submit-guard BE). */}
|
||||||
<th className="label-eyebrow px-3 py-2 text-left">Hạng mục</th>
|
<th className="label-eyebrow px-3 py-2 text-left">Hạng mục</th>
|
||||||
<th className="label-eyebrow px-3 py-2 text-right">Giá tham chiếu (phiếu NCC)</th>
|
<th className="label-eyebrow px-3 py-2 text-right">Giá tham chiếu (phiếu NCC)</th>
|
||||||
<th className="label-eyebrow px-3 py-2 text-right">Giá đề xuất</th>
|
<th className="label-eyebrow px-3 py-2 text-right">Giá đề xuất</th>
|
||||||
@ -532,7 +557,55 @@ export function KhkkDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-xs text-slate-700">
|
<td className="px-3 py-2 text-xs text-slate-700">
|
||||||
{l.tenHangMuc ? l.tenHangMuc : <span className="text-slate-300">—</span>}
|
{/* Dòng đã bắc cầu HĐ thì khoá (đổi hạng mục sau khi có HĐ = đổi lịch sử). */}
|
||||||
|
{isDraft && !l.contractId ? (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
value={l.catalogEntryId ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
e.target.value &&
|
||||||
|
assignLine.mutate({ lineId: l.id, catalogEntryId: e.target.value })
|
||||||
|
}
|
||||||
|
disabled={assignLine.isPending}
|
||||||
|
aria-label={`Gán hạng mục SP-002 cho dòng ${l.supplierName ?? l.id}`}
|
||||||
|
className="h-8 w-full min-w-[12rem] rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
|
||||||
|
>
|
||||||
|
<option value="">— Chọn hạng mục —</option>
|
||||||
|
{/* [gate-lineeditor F-2] Hạng mục ĐÃ LƯU mà không có trong options
|
||||||
|
(đang tải / query lỗi / entry hết hiệu lực) ⇒ chèn option đại diện
|
||||||
|
từ dữ liệu đã lưu — không thì select rơi về placeholder và dòng
|
||||||
|
đã gán TRÔNG như chưa gán (vắng-mặt trông giống ổn). */}
|
||||||
|
{l.catalogEntryId &&
|
||||||
|
!(catalogOptions.data ?? []).some((c) => c.id === l.catalogEntryId) && (
|
||||||
|
<option value={l.catalogEntryId}>
|
||||||
|
{l.tenHangMuc ?? '(hạng mục đã gán)'}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
{(catalogOptions.data ?? []).map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.code} — {c.tenVi}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{catalogOptions.isError && (
|
||||||
|
<div className="mt-1 text-[11px] text-red-600">
|
||||||
|
Không tải được danh mục hạng mục —{' '}
|
||||||
|
<button type="button" className="underline" onClick={() => catalogOptions.refetch()}>
|
||||||
|
thử lại
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{catalogOptions.isSuccess && (catalogOptions.data?.length ?? 0) === 0 && (
|
||||||
|
<div className="mt-1 text-[11px] text-amber-700">
|
||||||
|
Nhóm duyệt N{k.approvalGroup} chưa có hạng mục đang hiệu lực — liên hệ quản trị danh mục.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : l.tenHangMuc ? (
|
||||||
|
l.tenHangMuc
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-300">—</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{formatVnd(l.peReferenceAmount)}</td>
|
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{formatVnd(l.peReferenceAmount)}</td>
|
||||||
<td className="px-3 py-2 text-right tabular-nums text-brand-800">{formatVnd(l.proposedAmount)}</td>
|
<td className="px-3 py-2 text-right tabular-nums text-brand-800">{formatVnd(l.proposedAmount)}</td>
|
||||||
@ -545,7 +618,7 @@ export function KhkkDetailPage() {
|
|||||||
)}
|
)}
|
||||||
<div className="mt-3 text-xs text-muted-foreground">
|
<div className="mt-3 text-xs text-muted-foreground">
|
||||||
Giá tham chiếu là SNAPSHOT tổng báo giá được chọn của NCC trên phiếu Duyệt NCC tại thời điểm lập kế hoạch.
|
Giá tham chiếu là SNAPSHOT tổng báo giá được chọn của NCC trên phiếu Duyệt NCC tại thời điểm lập kế hoạch.
|
||||||
{' '}Cột Hạng mục hiện chưa gán được từ màn này (ô gán thuộc bước Lines-editor).
|
{' '}Cột Hạng mục gán ngay tại bảng khi phiếu ở Nháp/Trả lại — mọi dòng phải gán xong mới trình duyệt được.
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
|
|||||||
@ -86,9 +86,15 @@ function buildTree(rows: PeListItem[]): YearNode[] {
|
|||||||
export function PipelineTreePanel({
|
export function PipelineTreePanel({
|
||||||
currentStage,
|
currentStage,
|
||||||
className,
|
className,
|
||||||
|
bodyMaxHClass,
|
||||||
}: {
|
}: {
|
||||||
currentStage: PipelineStageNo
|
currentStage: PipelineStageNo
|
||||||
className?: string
|
className?: string
|
||||||
|
// [S168 GĐ3-D gate F-1] Host 3-panel (Panel-3 TỰ CUỘN) truyền class trần riêng cho THÂN
|
||||||
|
// cây (vd `lg:max-h-none` — panel giữ đúng 1 thanh cuộn; max-h mặc định 100vh−16rem là
|
||||||
|
// hiệu-chỉnh cho trang giấy-cuộn, vượt chỗ Panel-3 /inbox ~26px ⇒ 2 cuộn lồng nhau).
|
||||||
|
// Mặc định giữ nguyên — 3 host giấy-cuộn cũ không đổi hành vi.
|
||||||
|
bodyMaxHClass?: string
|
||||||
}) {
|
}) {
|
||||||
const { canPe, buildStages } = usePipelineStages()
|
const { canPe, buildStages } = usePipelineStages()
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
@ -212,7 +218,7 @@ export function PipelineTreePanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[28rem] overflow-y-auto lg:max-h-[calc(100vh-16rem)]">
|
<div className={cn('overflow-y-auto', bodyMaxHClass ?? 'max-h-[28rem] lg:max-h-[calc(100vh-16rem)]')}>
|
||||||
{!canPe && (
|
{!canPe && (
|
||||||
<p className="px-3 py-4 text-[12px] text-slate-500">
|
<p className="px-3 py-4 text-[12px] text-slate-500">
|
||||||
Bạn chưa có quyền xem phiếu Duyệt NCC nên cây toàn trình chưa hiển thị được.
|
Bạn chưa có quyền xem phiếu Duyệt NCC nên cây toàn trình chưa hiển thị được.
|
||||||
|
|||||||
@ -7,9 +7,10 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { Inbox, AlertTriangle, Clock, FileText, Search, X, ClipboardList } from 'lucide-react'
|
import { Inbox, AlertTriangle, Clock, FileText, Search, ClipboardList } from 'lucide-react'
|
||||||
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
|
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
|
||||||
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
|
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
|
||||||
|
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
|
||||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||||
import { SlaTimer } from '@/components/SlaTimer'
|
import { SlaTimer } from '@/components/SlaTimer'
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
@ -338,12 +339,13 @@ export function InboxPage() {
|
|||||||
|
|
||||||
{/* Panel 3 — Workflow + history */}
|
{/* Panel 3 — Workflow + history */}
|
||||||
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
|
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
|
||||||
{!selectedId && (
|
{/* [S168 GĐ3-D — owner slot (63) chốt D] Chưa chọn HĐ ⇒ cây toàn trình (GĐ3)
|
||||||
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
|
thế chỗ ô trống; chọn HĐ thì nhường chỗ cho quy trình duyệt. Trade-off D
|
||||||
<X className="mx-auto mb-2 h-5 w-5" />
|
(cây rời panel khi chọn HĐ) — sub-implfe-gd3.md §1. */}
|
||||||
Quy trình duyệt sẽ hiện khi chọn HĐ.
|
{/* [gate F-1/F-2] Panel-3 TỰ CUỘN ⇒ thân cây `lg:max-h-none` giữ đúng 1 thanh cuộn
|
||||||
</div>
|
(chính /inbox là trang THIẾU ~26px vì dải stats + banner vai trò); sticky/self-start
|
||||||
)}
|
của khuôn vô hiệu ở host display:block này — by-design. */}
|
||||||
|
{!selectedId && <PipelineTreePanel currentStage={3} bodyMaxHClass="max-h-[28rem] lg:max-h-none" />}
|
||||||
{selectedId && detail.data && <WorkflowHistoryPanel contract={detail.data} />}
|
{selectedId && detail.data && <WorkflowHistoryPanel contract={detail.data} />}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,9 +7,10 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { FileText, Plus, Search, X } from 'lucide-react'
|
import { FileText, Plus, Search } from 'lucide-react'
|
||||||
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
|
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
|
||||||
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
|
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
|
||||||
|
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
|
||||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||||
import { SlaTimer } from '@/components/SlaTimer'
|
import { SlaTimer } from '@/components/SlaTimer'
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
@ -216,12 +217,12 @@ export function MyContractsPage() {
|
|||||||
|
|
||||||
{/* Panel 3 — Workflow + history */}
|
{/* Panel 3 — Workflow + history */}
|
||||||
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
|
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
|
||||||
{!selectedId && (
|
{/* [S168 GĐ3-D — owner slot (63) chốt D] Chưa chọn HĐ ⇒ cây toàn trình (GĐ3)
|
||||||
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
|
thế chỗ ô trống; chọn HĐ thì nhường chỗ cho quy trình duyệt. Trade-off D
|
||||||
<X className="mx-auto mb-2 h-5 w-5" />
|
(cây rời panel khi chọn HĐ) — sub-implfe-gd3.md §1. */}
|
||||||
Quy trình duyệt sẽ hiện khi chọn HĐ.
|
{/* [gate F-1/F-2] Panel-3 TỰ CUỘN ⇒ thân cây `lg:max-h-none` giữ đúng 1 thanh cuộn;
|
||||||
</div>
|
sticky/self-start của khuôn vô hiệu ở host display:block này — by-design. */}
|
||||||
)}
|
{!selectedId && <PipelineTreePanel currentStage={3} bodyMaxHClass="max-h-[28rem] lg:max-h-none" />}
|
||||||
{selectedId && detail.data && <WorkflowHistoryPanel contract={detail.data} />}
|
{selectedId && detail.data && <WorkflowHistoryPanel contract={detail.data} />}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -167,6 +167,31 @@ export function KhkkDetailPage() {
|
|||||||
onError: (e) => toast.error(getErrorMessage(e)),
|
onError: (e) => toast.error(getErrorMessage(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// [S168 K8-blocker — Lines-editor] Gán hạng mục SP-002 cho dòng: options lọc ĐÚNG nhóm
|
||||||
|
// duyệt của phiếu (BE fail-fast tầng-gán + submit-guard chốt cuối). Chỉ tải khi phiếu
|
||||||
|
// còn sửa được (Nháp/Trả lại) — tránh 1 request thừa trên phiếu đã trình/duyệt.
|
||||||
|
const planDraftEditable =
|
||||||
|
plan.data?.phase === KhkkPhase.DangSoanThao || plan.data?.phase === KhkkPhase.TraLai
|
||||||
|
const catalogOptions = useQuery({
|
||||||
|
queryKey: ['contract-catalog', plan.data?.approvalGroup],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await api.get<{ id: string; code: string; tenVi: string }[]>('/catalogs/contract-catalog', {
|
||||||
|
params: { approvalGroup: plan.data!.approvalGroup },
|
||||||
|
})
|
||||||
|
).data,
|
||||||
|
enabled: planDraftEditable && plan.data?.approvalGroup != null,
|
||||||
|
})
|
||||||
|
const assignLine = useMutation({
|
||||||
|
mutationFn: async (p: { lineId: string; catalogEntryId: string }) =>
|
||||||
|
api.put(`/contract-signing-plans/${id}/lines/${p.lineId}`, { catalogEntryId: p.catalogEntryId }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Đã gán hạng mục')
|
||||||
|
invalidate()
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
const upsertItem = useMutation({
|
const upsertItem = useMutation({
|
||||||
mutationFn: async (body: UpsertKhkkDossierItemInput) => {
|
mutationFn: async (body: UpsertKhkkDossierItemInput) => {
|
||||||
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ')
|
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ')
|
||||||
@ -503,9 +528,9 @@ export function KhkkDetailPage() {
|
|||||||
<thead className="border-b border-slate-200 bg-slate-50/70">
|
<thead className="border-b border-slate-200 bg-slate-50/70">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th>
|
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th>
|
||||||
{/* [K2 S164] Hạng mục SP-002 của dòng — HIỂN THỊ-ONLY lượt này.
|
{/* [K2 S164 → S168] Hạng mục SP-002 của dòng — đường GÁN (Lines-editor) land
|
||||||
Đường GÁN (Lines-editor) = **K4b**; K2 chỉ đẻ cột + cho thấy dòng
|
@S168 (mảnh hẹn-K4b bị rơi, dry-run K8 bắt): Nháp/Trả lại gán tại bảng,
|
||||||
nào còn trống (dòng trống KHÔNG trình duyệt được — submit-guard BE). */}
|
dòng trống KHÔNG trình duyệt được (submit-guard BE). */}
|
||||||
<th className="label-eyebrow px-3 py-2 text-left">Hạng mục</th>
|
<th className="label-eyebrow px-3 py-2 text-left">Hạng mục</th>
|
||||||
<th className="label-eyebrow px-3 py-2 text-right">Giá tham chiếu (phiếu NCC)</th>
|
<th className="label-eyebrow px-3 py-2 text-right">Giá tham chiếu (phiếu NCC)</th>
|
||||||
<th className="label-eyebrow px-3 py-2 text-right">Giá đề xuất</th>
|
<th className="label-eyebrow px-3 py-2 text-right">Giá đề xuất</th>
|
||||||
@ -532,7 +557,55 @@ export function KhkkDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-xs text-slate-700">
|
<td className="px-3 py-2 text-xs text-slate-700">
|
||||||
{l.tenHangMuc ? l.tenHangMuc : <span className="text-slate-300">—</span>}
|
{/* Dòng đã bắc cầu HĐ thì khoá (đổi hạng mục sau khi có HĐ = đổi lịch sử). */}
|
||||||
|
{isDraft && !l.contractId ? (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
value={l.catalogEntryId ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
e.target.value &&
|
||||||
|
assignLine.mutate({ lineId: l.id, catalogEntryId: e.target.value })
|
||||||
|
}
|
||||||
|
disabled={assignLine.isPending}
|
||||||
|
aria-label={`Gán hạng mục SP-002 cho dòng ${l.supplierName ?? l.id}`}
|
||||||
|
className="h-8 w-full min-w-[12rem] rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
|
||||||
|
>
|
||||||
|
<option value="">— Chọn hạng mục —</option>
|
||||||
|
{/* [gate-lineeditor F-2] Hạng mục ĐÃ LƯU mà không có trong options
|
||||||
|
(đang tải / query lỗi / entry hết hiệu lực) ⇒ chèn option đại diện
|
||||||
|
từ dữ liệu đã lưu — không thì select rơi về placeholder và dòng
|
||||||
|
đã gán TRÔNG như chưa gán (vắng-mặt trông giống ổn). */}
|
||||||
|
{l.catalogEntryId &&
|
||||||
|
!(catalogOptions.data ?? []).some((c) => c.id === l.catalogEntryId) && (
|
||||||
|
<option value={l.catalogEntryId}>
|
||||||
|
{l.tenHangMuc ?? '(hạng mục đã gán)'}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
{(catalogOptions.data ?? []).map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.code} — {c.tenVi}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{catalogOptions.isError && (
|
||||||
|
<div className="mt-1 text-[11px] text-red-600">
|
||||||
|
Không tải được danh mục hạng mục —{' '}
|
||||||
|
<button type="button" className="underline" onClick={() => catalogOptions.refetch()}>
|
||||||
|
thử lại
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{catalogOptions.isSuccess && (catalogOptions.data?.length ?? 0) === 0 && (
|
||||||
|
<div className="mt-1 text-[11px] text-amber-700">
|
||||||
|
Nhóm duyệt N{k.approvalGroup} chưa có hạng mục đang hiệu lực — liên hệ quản trị danh mục.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : l.tenHangMuc ? (
|
||||||
|
l.tenHangMuc
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-300">—</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{formatVnd(l.peReferenceAmount)}</td>
|
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{formatVnd(l.peReferenceAmount)}</td>
|
||||||
<td className="px-3 py-2 text-right tabular-nums text-brand-800">{formatVnd(l.proposedAmount)}</td>
|
<td className="px-3 py-2 text-right tabular-nums text-brand-800">{formatVnd(l.proposedAmount)}</td>
|
||||||
@ -545,7 +618,7 @@ export function KhkkDetailPage() {
|
|||||||
)}
|
)}
|
||||||
<div className="mt-3 text-xs text-muted-foreground">
|
<div className="mt-3 text-xs text-muted-foreground">
|
||||||
Giá tham chiếu là SNAPSHOT tổng báo giá được chọn của NCC trên phiếu Duyệt NCC tại thời điểm lập kế hoạch.
|
Giá tham chiếu là SNAPSHOT tổng báo giá được chọn của NCC trên phiếu Duyệt NCC tại thời điểm lập kế hoạch.
|
||||||
{' '}Cột Hạng mục hiện chưa gán được từ màn này (ô gán thuộc bước Lines-editor).
|
{' '}Cột Hạng mục gán ngay tại bảng khi phiếu ở Nháp/Trả lại — mọi dòng phải gán xong mới trình duyệt được.
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,9 @@
|
|||||||
param(
|
param(
|
||||||
[string]$Server = ".",
|
[string]$Server = ".",
|
||||||
[string]$Database = "SolutionErp",
|
[string]$Database = "SolutionErp",
|
||||||
[string]$BackupDir = "D:\Backups\SolutionErp",
|
# [S168 K8 B0] Default C:\ — VPS prod KHONG co o D (do that: mkdir D:\ fail
|
||||||
|
# "Cannot find drive"); D:\ cu la gia dinh chua tung chay tren may nay.
|
||||||
|
[string]$BackupDir = "C:\Backups\SolutionErp",
|
||||||
[int]$RetentionDays = 30
|
[int]$RetentionDays = 30
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -102,6 +102,18 @@ public class ContractSigningPlansController(
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [S168 K8-blocker] Gán hạng mục SP-002 cho DÒNG — mảnh Lines-editor (comment auto-gen hẹn
|
||||||
|
// K4b, chưa từng land; submit-guard vá-5 chặn null ⇒ thiếu route này phiếu mới kẹt Nháp).
|
||||||
|
// Ghi vào phiếu ⇒ policy Update (khuôn dossier-items dưới — "tạo" ở module này = tạo PHIẾU).
|
||||||
|
[HttpPut("{id:guid}/lines/{lineId:guid}")]
|
||||||
|
[Authorize(Policy = "KeHoachKyKet.Update")]
|
||||||
|
public async Task<IActionResult> UpdateLine(
|
||||||
|
Guid id, Guid lineId, [FromBody] UpdateSigningPlanLineBody body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(new UpdateContractSigningPlanLineCommand(id, lineId, body.CatalogEntryId), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "KeHoachKyKet.Delete")]
|
[Authorize(Policy = "KeHoachKyKet.Delete")]
|
||||||
public async Task<IActionResult> Delete(
|
public async Task<IActionResult> Delete(
|
||||||
@ -257,6 +269,9 @@ public class ContractSigningPlansController(
|
|||||||
string? Comment = null,
|
string? Comment = null,
|
||||||
bool? ApplyLevelFinalize = null);
|
bool? ApplyLevelFinalize = null);
|
||||||
|
|
||||||
|
// [S168 K8-blocker] Body gán hạng mục cho dòng — PlanId/LineId lấy từ route (1 nguồn sự thật).
|
||||||
|
public record UpdateSigningPlanLineBody(Guid CatalogEntryId);
|
||||||
|
|
||||||
// [K7 S167] Cầu → HĐ. `PlanId` lấy từ route (không nhận trong body — 1 nguồn sự thật).
|
// [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")
|
// `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.
|
// thay vì 500 NullReference ở tầng dưới.
|
||||||
|
|||||||
@ -574,6 +574,84 @@ public class UpdateContractSigningPlanDraftCommandHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [S168 K8-blocker] Gán hạng mục SP-002 cho DÒNG kế hoạch — mảnh "Lines-editor" mà comment
|
||||||
|
// auto-gen (`:481-484`) HẸN nhưng chưa từng land (build-order K4b đổi việc sang `?group=`):
|
||||||
|
// submit-guard vá-5 đã CHẶN line CatalogEntryId=null ⇒ thiếu command này thì MỌI phiếu tạo
|
||||||
|
// sau K2 kẹt Nháp vĩnh viễn. Dry-run K8 bắt lỗ này trước giờ chạy.
|
||||||
|
public record UpdateContractSigningPlanLineCommand(
|
||||||
|
Guid PlanId,
|
||||||
|
Guid LineId,
|
||||||
|
Guid CatalogEntryId) : IRequest;
|
||||||
|
|
||||||
|
public class UpdateContractSigningPlanLineCommandValidator
|
||||||
|
: AbstractValidator<UpdateContractSigningPlanLineCommand>
|
||||||
|
{
|
||||||
|
public UpdateContractSigningPlanLineCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.PlanId).NotEmpty();
|
||||||
|
RuleFor(x => x.LineId).NotEmpty();
|
||||||
|
RuleFor(x => x.CatalogEntryId).NotEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateContractSigningPlanLineCommandHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser) : IRequestHandler<UpdateContractSigningPlanLineCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(UpdateContractSigningPlanLineCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var plan = await db.ContractSigningPlans
|
||||||
|
.Include(x => x.Lines)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == request.PlanId, ct)
|
||||||
|
?? throw new NotFoundException("ContractSigningPlan", request.PlanId);
|
||||||
|
|
||||||
|
ContractSigningPlanScope.EnsureDraftEditable(plan, currentUser);
|
||||||
|
|
||||||
|
var line = plan.Lines.FirstOrDefault(l => l.Id == request.LineId)
|
||||||
|
?? throw new NotFoundException("ContractSigningPlanLine", request.LineId);
|
||||||
|
|
||||||
|
// [gate-lineeditor F-6] Rào BE thật (FE khoá chỉ là hiển thị): dòng đã bắc cầu HĐ
|
||||||
|
// thì hạng mục bất biến — đổi sau khi có HĐ = đổi lịch sử pháp lý của HĐ đã sinh.
|
||||||
|
if (line.ContractId != null)
|
||||||
|
throw new ConflictException("Dòng đã được đưa vào hợp đồng — không đổi hạng mục được nữa.");
|
||||||
|
|
||||||
|
// Fail-fast tầng-gán (submit-guard vẫn là chốt CUỐI chống data cũ/lách API):
|
||||||
|
// hạng mục phải CÒN SỐNG + đang hoạt động + ĐÚNG nhóm duyệt của phiếu.
|
||||||
|
var entry = await db.ContractCatalogEntries.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == request.CatalogEntryId, ct)
|
||||||
|
?? throw new NotFoundException("ContractCatalogEntry", request.CatalogEntryId);
|
||||||
|
if (!entry.IsActive || entry.ApprovalGroup != plan.ApprovalGroup)
|
||||||
|
throw new ConflictException(
|
||||||
|
$"Hạng mục không hợp lệ — phải đang hoạt động và thuộc nhóm duyệt N{plan.ApprovalGroup} của phiếu.");
|
||||||
|
|
||||||
|
line.CatalogEntryId = entry.Id;
|
||||||
|
// Denorm tên TẠI THỜI ĐIỂM GÁN (khuôn `SignedByFullName` Mig 26 — entity `:25-27`):
|
||||||
|
// danh mục đổi tên sau KHÔNG viết lại lịch sử phiếu.
|
||||||
|
line.TenHangMuc = entry.TenVi;
|
||||||
|
|
||||||
|
// [gate-lineeditor F-1] Phiếu liên danh N dòng ⇒ nhật ký PHẢI trỏ được DÒNG NÀO:
|
||||||
|
// EntityId = line.Id (quy ước mọi changelog thực-thể-con của file) + tên NCC vào Summary.
|
||||||
|
var supplierName = await db.Suppliers.AsNoTracking()
|
||||||
|
.Where(x => x.Id == line.SupplierId)
|
||||||
|
.Select(x => x.Name)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
|
||||||
|
{
|
||||||
|
ContractSigningPlanId = plan.Id,
|
||||||
|
EntityType = ContractSigningPlanEntityType.Line,
|
||||||
|
EntityId = line.Id,
|
||||||
|
Action = ChangelogAction.Update,
|
||||||
|
PhaseAtChange = plan.Phase,
|
||||||
|
UserId = currentUser.UserId,
|
||||||
|
UserName = currentUser.FullName ?? currentUser.Email,
|
||||||
|
Summary = $"Gán hạng mục '{entry.Code} — {entry.TenVi}' cho dòng NCC {supplierName ?? "(không rõ tên)"}",
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================ Region 3: GET detail ==================================
|
// ============================ Region 3: GET detail ==================================
|
||||||
|
|
||||||
public record GetContractSigningPlanQuery(Guid Id) : IRequest<ContractSigningPlanDetailDto>;
|
public record GetContractSigningPlanQuery(Guid Id) : IRequest<ContractSigningPlanDetailDto>;
|
||||||
|
|||||||
@ -900,4 +900,144 @@ public class ContractSigningPlanGroupAndCatalogTests
|
|||||||
.Select(p => new PlanScope(p.MaKeHoach!, p.ApprovalGroup, p.ApprovalWorkflowId!.Value))
|
.Select(p => new PlanScope(p.MaKeHoach!, p.ApprovalGroup, p.ApprovalWorkflowId!.Value))
|
||||||
.ToListAsync(CancellationToken.None);
|
.ToListAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// [S168 K8-blocker] Lines-editor `UpdateContractSigningPlanLineCommand` — mảnh comment
|
||||||
|
// auto-gen HẸN cho K4b nhưng chưa từng land, dry-run K8 bắt: submit-guard vá-5 chặn
|
||||||
|
// line null mà 0 đường gán ⇒ phiếu mới kẹt Nháp vĩnh viễn. 3 phép đo: DƯƠNG đi trọn
|
||||||
|
// (gán → submit QUA — nối thẳng vào guard thật, không đo gán suông) · ÂM sai nhóm ·
|
||||||
|
// ÂM sai phase. Handler THẬT, không mock (khuôn Act-helpers file này).
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
private static async Task AssignLineAsync(
|
||||||
|
KhkkGroupFixture f, TestCurrentUser actor, Guid planId, Guid lineId, Guid catalogId)
|
||||||
|
{
|
||||||
|
await using var db = f.NewDb(actor);
|
||||||
|
var handler = new UpdateContractSigningPlanLineCommandHandler(db, actor);
|
||||||
|
await handler.Handle(
|
||||||
|
new UpdateContractSigningPlanLineCommand(planId, lineId, catalogId), CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GanHangMuc_DungNhom_SnapshotTen_RoiTrinhQuaGuard()
|
||||||
|
{
|
||||||
|
using var f = new KhkkGroupFixture();
|
||||||
|
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||||
|
var approver = await AddUserAsync(f, "NV duyệt Cấp 1");
|
||||||
|
var wf = await SeedWorkflowAsync(f, "QT-KHKK-LINE-A", steps: [[[approver.Id]]]);
|
||||||
|
var catalog = await AddCatalogAsync(f, approvalGroup: 4, tenVi: "Xi măng");
|
||||||
|
var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/101",
|
||||||
|
ContractSigningPlanPhase.DangSoanThao, approvalGroup: 4,
|
||||||
|
lines: [new LineSeed(100m, 100m, CatalogId: null)]);
|
||||||
|
|
||||||
|
var lineId = (await ReadPlanAsync(f, planId)).Lines.Single().Id;
|
||||||
|
await AssignLineAsync(f, drafter, planId, lineId, catalog);
|
||||||
|
|
||||||
|
var line = (await ReadPlanAsync(f, planId)).Lines.Single();
|
||||||
|
line.CatalogEntryId.Should().Be(catalog);
|
||||||
|
line.TenHangMuc.Should().Be("Xi măng",
|
||||||
|
"tên hạng mục phải denorm TẠI THỜI ĐIỂM GÁN (khuôn SignedByFullName Mig 26)");
|
||||||
|
|
||||||
|
// Nối trọn vào submit-guard THẬT: gán xong thì trình phải QUA (ca dương của vá-5,
|
||||||
|
// chống thế giới nơi gán ăn nhưng submit vẫn đỏ vì lý do khác).
|
||||||
|
await ActAsync(f, drafter, planId, "submit");
|
||||||
|
(await ReadPlanAsync(f, planId)).Phase.Should().Be(ContractSigningPlanPhase.ChoDuyet);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GanHangMuc_KhacNhomDuyet_BiChan409_LineGiuNguyen()
|
||||||
|
{
|
||||||
|
using var f = new KhkkGroupFixture();
|
||||||
|
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||||
|
// ApproverUserId cần ROW Users THẬT (FK Restrict — ghi chú fixture đầu file).
|
||||||
|
var approver = await AddUserAsync(f, "NV duyệt Cấp 1");
|
||||||
|
var wf = await SeedWorkflowAsync(f, "QT-KHKK-LINE-B", steps: [[[approver.Id]]]);
|
||||||
|
var catalogN2 = await AddCatalogAsync(f, approvalGroup: 2, tenVi: "Nhân công cốp pha");
|
||||||
|
var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/102",
|
||||||
|
ContractSigningPlanPhase.DangSoanThao, approvalGroup: 4,
|
||||||
|
lines: [new LineSeed(100m, 100m, CatalogId: null)]);
|
||||||
|
var lineId = (await ReadPlanAsync(f, planId)).Lines.Single().Id;
|
||||||
|
|
||||||
|
var act = () => AssignLineAsync(f, drafter, planId, lineId, catalogN2);
|
||||||
|
await act.Should().ThrowAsync<ConflictException>(
|
||||||
|
"hạng mục nhóm N2 không được gán vào phiếu nhóm N4 — fail-fast tầng-gán, "
|
||||||
|
+ "submit-guard chỉ là chốt cuối");
|
||||||
|
|
||||||
|
(await ReadPlanAsync(f, planId)).Lines.Single().CatalogEntryId.Should().BeNull(
|
||||||
|
"gán fail thì line phải GIỮ NGUYÊN — không ghi dở");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GanHangMuc_EntryNgungHieuLuc_BiChan409()
|
||||||
|
{
|
||||||
|
using var f = new KhkkGroupFixture();
|
||||||
|
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||||
|
var approver = await AddUserAsync(f, "NV duyệt Cấp 1");
|
||||||
|
var wf = await SeedWorkflowAsync(f, "QT-KHKK-LINE-D", steps: [[[approver.Id]]]);
|
||||||
|
// Entry ĐÚNG nhóm nhưng IsActive=false — nhánh guard còn lại (gate-lineeditor F-4).
|
||||||
|
Guid inactive;
|
||||||
|
{
|
||||||
|
await using var db = f.NewDb(TestCurrentUser.System());
|
||||||
|
var entry = new ContractCatalogEntry
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), Code = "K2-" + Guid.NewGuid().ToString("N")[..8],
|
||||||
|
TenVi = "Hạng mục đã ngừng", GroupCode = "B1", ApprovalGroup = 4,
|
||||||
|
SignerRole = "TP.PRO", IsActive = false, SortOrder = 1,
|
||||||
|
};
|
||||||
|
db.ContractCatalogEntries.Add(entry);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
inactive = entry.Id;
|
||||||
|
}
|
||||||
|
var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/104",
|
||||||
|
ContractSigningPlanPhase.DangSoanThao, approvalGroup: 4,
|
||||||
|
lines: [new LineSeed(100m, 100m, CatalogId: null)]);
|
||||||
|
var lineId = (await ReadPlanAsync(f, planId)).Lines.Single().Id;
|
||||||
|
|
||||||
|
var act = () => AssignLineAsync(f, drafter, planId, lineId, inactive);
|
||||||
|
await act.Should().ThrowAsync<ConflictException>(
|
||||||
|
"entry IsActive=false phải bị chặn dù ĐÚNG nhóm — nhánh guard độc lập với nhánh khác-nhóm");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GanHangMuc_GanDe_DoiCaIdLanTenSnapshot()
|
||||||
|
{
|
||||||
|
using var f = new KhkkGroupFixture();
|
||||||
|
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||||
|
var approver = await AddUserAsync(f, "NV duyệt Cấp 1");
|
||||||
|
var wf = await SeedWorkflowAsync(f, "QT-KHKK-LINE-E", steps: [[[approver.Id]]]);
|
||||||
|
var catA = await AddCatalogAsync(f, approvalGroup: 4, tenVi: "Xi măng");
|
||||||
|
var catB = await AddCatalogAsync(f, approvalGroup: 4, tenVi: "Thép hình");
|
||||||
|
var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/105",
|
||||||
|
ContractSigningPlanPhase.DangSoanThao, approvalGroup: 4,
|
||||||
|
lines: [new LineSeed(100m, 100m, CatalogId: null)]);
|
||||||
|
var lineId = (await ReadPlanAsync(f, planId)).Lines.Single().Id;
|
||||||
|
|
||||||
|
await AssignLineAsync(f, drafter, planId, lineId, catA);
|
||||||
|
// Gán ĐÈ là đường hợp lệ (người soạn chọn nhầm rồi chọn lại) — cả Id lẫn snapshot đổi theo.
|
||||||
|
await AssignLineAsync(f, drafter, planId, lineId, catB);
|
||||||
|
|
||||||
|
var line = (await ReadPlanAsync(f, planId)).Lines.Single();
|
||||||
|
line.CatalogEntryId.Should().Be(catB);
|
||||||
|
line.TenHangMuc.Should().Be("Thép hình", "snapshot phải đổi theo lần gán CUỐI");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GanHangMuc_PhieuDaTrinh_BiChanPhase()
|
||||||
|
{
|
||||||
|
using var f = new KhkkGroupFixture();
|
||||||
|
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||||
|
// ApproverUserId cần ROW Users THẬT (FK Restrict — ghi chú fixture đầu file).
|
||||||
|
var approver = await AddUserAsync(f, "NV duyệt Cấp 1");
|
||||||
|
var wf = await SeedWorkflowAsync(f, "QT-KHKK-LINE-C", steps: [[[approver.Id]]]);
|
||||||
|
var catalog = await AddCatalogAsync(f, approvalGroup: 4, tenVi: "Thép hình");
|
||||||
|
var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/103",
|
||||||
|
ContractSigningPlanPhase.ChoDuyet, approvalGroup: 4,
|
||||||
|
lines: [new LineSeed(100m, 100m, CatalogId: null)],
|
||||||
|
stepIndex: 0, levelOrder: 1);
|
||||||
|
var lineId = (await ReadPlanAsync(f, planId)).Lines.Single().Id;
|
||||||
|
|
||||||
|
var act = () => AssignLineAsync(f, drafter, planId, lineId, catalog);
|
||||||
|
await act.Should().ThrowAsync<ConflictException>(
|
||||||
|
"phiếu đã trình thì dòng bất biến — chỉ Nháp/Trả lại sửa được (EnsureDraftEditable)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user