Compare commits
4 Commits
761d8183f3
...
e16fa14ebd
| Author | SHA1 | Date | |
|---|---|---|---|
| e16fa14ebd | |||
| eb7473f6c8 | |||
| 7a0ba17345 | |||
| 642a9603b9 |
@ -31,7 +31,14 @@ chain:
|
||||
- [x] gate 5-anchor W6 PASS 5/5 (content-exact 0-drift)
|
||||
- [x] W6 đợt-1 `wf_e3b6a2bc-91d` XONG (#53, sub-md 3.7KB §0 + code trọn): 3 fix land (+142 ContractFeatures [3-site + inbox v2 + ADMIN tường minh] · +15 CWS guard **kèm chặn null==null lọt guard** `actorUserId is Guid`); build 0E + **suite 580/0 GIỮ** (lead đo); 0 test cũ bám 3 handler (lane verify trước)
|
||||
- [x] W6 đợt-2 `wf_131c8f87-356` XONG (#53, file land): 7 test (5 PIN + 2 bonus) → **suite 587/0 lead-tươi** (580+7, vượt đích 585)
|
||||
- [!] reviewer W6 security-wave ĐANG CHẠY (nới-view có mở QUÁ không · admin-inbox phase-list · guard trình 4-vế+null-trap · fidelity Resolve · test-integrity · boundary Reject/W3/KHKK 0-diff) → PASS ⇒ commit/push → cicd → W6 ĐÓNG
|
||||
- [x] reviewer W6 **PWF 0-chặn** (Trục 1-3 vai PASS 0-critical + 4-7 lead on-behalf): per-HĐ THẬT không role-blanket · 2 ghi-nhận OWNER: (i) view = **workflow-blanket-by-design** (per-HĐ-theo-QUY-TRÌNH, không phải per-HĐ-được-giao — đúng spec, khai độ rộng) · (ii) **Reject :49-66 pre-existing AMPLIFIED** (W6 mở view ⇒ lấy contractId dễ ⇒ bán kính nổ lỗ O-A tăng — mốc ghi để khi đóng O-A biết)
|
||||
- [x] commit W6 `761d818` (3 file +811/−6) PUSHED
|
||||
- [x] **W6 ĐÓNG — cicd VERDICT=PASS** (Stage 1-2 cicd [CI 587 delta+7 · restart chứng-đến-giây] + 3-6 lead [MIGTOP giữ · 96 · 142 · **inbox admin 200-`[]`-hợp-lệ không-500** · CỜ giữ]). Synthesis `runs/2026-07-29-S161-khkk-w6-duongong/w6-synthesis.md`
|
||||
- [x] gate 5-anchor W7 PASS 5/5 (A2 content-found drift+6; A5 xác nhận 0-type)
|
||||
- [x] W7 đợt-1 `wf_0ae8cc6d-6e2` XONG: BE **CLEAN PASS 5/5 gate** (+type param + HasSealedCopy derive KHÔNG-IsDeleted — **lane bắt memory-pack slice của LEAD SAI**: ContractAttachment=BaseEntity hard-delete, viết theo slice là không compile; runtime-proof qua test W6 chạy chính handler) · FE #53 nhưng XONG HẾT (HardCopiesPage ×2 PAIR + 7 leaf re-point 0-coming-soon + marker bundle 1/1 — diagnostics toàn stale #68); suite 587/0 giữ
|
||||
- [x] W7 đợt-2 `wf_1893244d-f15` **CLEAN** — 3 PIN + **suite 590/0** (587+3); probe VI-PHÂN thay fault-inject (lý-do vàng: production mang diff chưa-commit lane khác — không tiêm); vai TỰ BẮT lỗi claim-trước-đo của mình → truncate → đo lại; 2 quan sát {BE tự-khai +35 vs đo +31 · cờ-chết-2-màn ghim invariant}
|
||||
- [!] reviewer W7 ĐANG CHẠY (FE ruột-upload/badge-phase-9 · hợp-đồng · boundary O-A · phân xử 2 quan sát) → PASS ⇒ commit/push → cicd → **W7 ĐÓNG = HẾT wave build được; còn W5-chờ-anh-chốt-người-duyệt + E2E**
|
||||
- [ ] 🔴 W5 CHỜ ANH: chốt NGƯỜI DUYỆT 3 trạm (PRO/CCM/CEO = ai trong 18 user prod) để dựng 2 workflow type-3/type-10 qua Designer — em không tự chọn người thật; xong mới chạy W5 (cầu KHKK→HĐ) + E2E
|
||||
- [ ] W5 (cầu KHKK→HĐ — cần setup WF tay TRƯỚC: type-3 + type-10 3-trạm Designer) → W7 (bản cứng 1-mốc @S161)
|
||||
- [ ] W6 → W5 → W7 theo đồ thị plan-cha (sau W3)
|
||||
- [ ] đợt-2 SAU khi wf về: test-specialist + database-agent (song song) → lead acceptance §③-B (build·test·sqlcmd 96·Down sạch·đo CỜ) → reviewer → commit/push → cicd-monitor 3-chân-kiềng
|
||||
|
||||
@ -0,0 +1,87 @@
|
||||
# CI/CD verify — `761d818` (W6 KHKK — đường ống HĐ V2, BE-only 0-mig)
|
||||
|
||||
> Agent: cicd-monitor · Session S161 W6 · Bắt đầu: 2026-07-29 (giờ VN)
|
||||
> Ghi TỪNG MỤC ngay khi có bằng chứng (chống #53 garble — file này là SẢN PHẨM CHÍNH, return chỉ là tóm tắt).
|
||||
|
||||
---
|
||||
|
||||
## STAGE 1 — Push + path-filter (DONE)
|
||||
|
||||
- **Commit:** `761d8183f365c68d2fca13ab27f2ee810fdbc30e`
|
||||
- **Ngày giờ commit:** `2026-07-29 23:27:19 +0700`
|
||||
- **Subject:** `[CLAUDE] App+Infra+Tests: W6 KHKK — duong ong HD V2 (view per-HD 3-site + inbox V2+ADMIN + guard trinh +PMH)`
|
||||
- **Unpushed (`git log origin/main..HEAD`):** RỖNG ⇒ đã push xong.
|
||||
- **Diff stat:** 3 file, +811 / −6
|
||||
|
||||
| File | +/− |
|
||||
|---|---|
|
||||
| `src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs` | +142/−… |
|
||||
| `src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs` | +15/−… |
|
||||
| `tests/SolutionErp.Infrastructure.Tests/Application/ContractV2PipelineTests.cs` | +660 (file MỚI) |
|
||||
|
||||
- **Path-filter (gotcha #41, 7-glob):** cả 3 file nằm dưới `src/Backend/**` + `tests/**` ⇒ KHÔNG khớp glob nào trong `paths-ignore` ⇒ **CI PHẢI chạy**. (soi TỪNG file, không suy từ tổng)
|
||||
- **0-mig proof (lớp 1/3 — repo):** `git diff 761d818~1 761d818 -- '*Migrations*'` → **RỖNG** ⇒ commit không đụng migration.
|
||||
- **0-FE proof (lớp 1/2 — repo):** `git diff 761d818~1 761d818 -- 'fe-admin/*' 'fe-user/*'` → **RỖNG** ⇒ 0 file FE đổi ⇒ theo #69 TWO-MODE, bundle rotate-or-frozen đều hợp lệ; ship-proof = Last-Modified in-window.
|
||||
|
||||
**STAGE 1 = PASS.**
|
||||
|
||||
---
|
||||
|
||||
## STAGE 2 — Gitea Actions poll
|
||||
|
||||
- **Run matched theo `head_sha` (KHÔNG theo run_number):** `761d8183` → **Run #430, id=543**
|
||||
- **created_at:** `2026-07-29T23:27:28+07:00` (t+9s sau commit-time 23:27:19 ⇒ đúng run của push này)
|
||||
- Poll iter 1-5: `running` (updated_at bò 23:28:17 → 23:30:48)
|
||||
|
||||
_(chờ `completed` — ghi tiếp)_
|
||||
|
||||
---
|
||||
|
||||
## PRE-DEPLOY BASELINE (đo TRƯỚC khi pool recycle — dùng làm vế so delta)
|
||||
|
||||
### P-A. FE bundle snapshot (PRE)
|
||||
|
||||
| App | JS | CSS |
|
||||
|---|---|---|
|
||||
| admin.solutions.com.vn | `index-BPWQQe4q.js` | `index-KS0kL7bG.css` |
|
||||
| eoffice.solutions.com.vn | `index-DV3fEpOw.js` | `index-Cpp9Qc7C.css` |
|
||||
|
||||
> ⚠️ Baseline này ≠ baseline MEMORY (#426 `D0sXA0fe`/`Y6dW_5CM`) vì đã có Run #428 (`6cbc6ad9`) + #429 (`df52fa0c`) xen giữa (W2…W5). Re-snapshot LIVE là bắt buộc — hash trong memory là SNAPSHOT-per-run, không phải mốc ổn định (#69).
|
||||
|
||||
### P-B. DB baseline (PRE) — sqlcmd qua `ssh vietreport-vps`
|
||||
|
||||
```
|
||||
MIGTOP=20260729122015_AddContractSigningPlans
|
||||
MIGCOUNT=69
|
||||
TABLES=96 (sys.tables WHERE is_ms_shipped=0)
|
||||
MENUS=142 (MenuItems)
|
||||
```
|
||||
|
||||
### P-C. Cờ KeHoachKyKet (PRE) — `Permissions` (⚠️ cột là `Permissions.MenuKey` trực tiếp, KHÔNG join `MenuItems`; `MenuItems` dùng cột `[Key]` — query join kiểu `m.MenuKey` sẽ lỗi `Invalid column name`)
|
||||
|
||||
Tập đích **ground-truth từ seeder** `DbInitializer.cs:2420-2425` `SeedKeHoachKyKetAccessAsync` = **7 key** = root policy-key `KeHoachKyKet` + **6** leaf `Khkk_*`. `Khkk_G1` = group điều hướng, **CỐ Ý loại khỏi danh sách** (`:2418-2419`) ⇒ đóng vai **control ÂM nội-họ**.
|
||||
|
||||
| MenuKey | R | C | U | D | rows |
|
||||
|---|---|---|---|---|---|
|
||||
| `KeHoachKyKet` (root) | 13 | 13 | 13 | 2 | 13 |
|
||||
| `Khkk_WfView` | 13 | 13 | 13 | 2 | 13 |
|
||||
| `Khkk_List` | 13 | 13 | 13 | 2 | 13 |
|
||||
| `Khkk_Create` | 13 | 13 | 13 | 2 | 13 |
|
||||
| `Khkk_Pending` | 13 | 13 | 13 | 2 | 13 |
|
||||
| `Khkk_Approved` | 13 | 13 | 13 | 2 | 13 |
|
||||
| `Khkk_Deleted` | 13 | 13 | 13 | 2 | 13 |
|
||||
| **`Khkk_G1` (control ÂM)** | **13** | **0** | **0** | **0** | 13 |
|
||||
|
||||
⚠️ **Bẫy phạm-vi query đã tránh:** `LIKE 'Khkk%'` chỉ ra **6/7** key đích — root `KeHoachKyKet` KHÔNG khớp tiền-tố `Khkk`. Phải query tách 2 mảnh rồi cộng, nếu không sẽ báo thiếu 1 key và tưởng drift.
|
||||
|
||||
---
|
||||
|
||||
## STAGE 3-6 — POST-deploy
|
||||
|
||||
_(chờ status=success + chứng minh restart)_
|
||||
|
||||
## STAGE 3-6 — [LEAD đo on-behalf]: PASS ✅
|
||||
sqlcmd prod: MIGTOP `20260729122015` GIỮ · TABLES 96 · MENU **142 GIỮ** · CỜ KeHoachKyKet Create=13/Delete=2 GIỮ.
|
||||
Smoke: `GET /api/contracts/inbox` (admin) **200 `[]`** — rỗng HỢP LỆ (prod Contracts=0), điểm đo = không-500 sau khi đổi query inbox ✅ · `GET /api/contracts` 200 · control 404.
|
||||
|
||||
END cicd-verify 761d818 — **VERDICT=PASS** (Stage 1-2 cicd [CI success · gate 587 delta +7 · restart chứng đến-giây] + Stage 3-6 lead 2-nguồn)
|
||||
@ -0,0 +1,261 @@
|
||||
# sub-reviewer-2 — Adversarial review W6 (view-guard + inbox + guard TRÌNH của Contract)
|
||||
|
||||
> Ghi TỪNG TRỤC ngay khi đo xong (chống #53). READ-only — không sửa gì.
|
||||
> Diff đo: `git diff` working-tree vs HEAD `fe1f6b0` — `ContractFeatures.cs` (+142/-4) ·
|
||||
> `ContractWorkflowService.cs` (+15/-1) · `tests/.../Application/ContractV2PipelineTests.cs` (UNTRACKED, mới).
|
||||
|
||||
---
|
||||
|
||||
## TRỤC 1 — Nới-view có mở QUÁ không? (per-HĐ vs role/workflow-blanket)
|
||||
|
||||
### 1.1 Ba call-site `GetEligiblePhases` — ĐỦ và ĐÚNG VỊ TRÍ
|
||||
|
||||
Grep repo-wide (`grep -rn "GetEligiblePhases" src/Backend --include=*.cs`) → đúng **3** call-site,
|
||||
khớp 3 anchor spec (số dòng đã trôi sau khi chèn code):
|
||||
|
||||
| Spec anchor | Dòng THẬT sau diff | Handler | W6 có ghép vế V2? |
|
||||
|---|---|---|---|
|
||||
| `:301` | `ContractFeatures.cs:301` | `ListContractsQueryHandler` | CÓ (`:307-314`) |
|
||||
| `:381` | `ContractFeatures.cs:425` | `ListDeletedContractsQueryHandler` | KHÔNG (cố ý, `:426-431`) |
|
||||
| `:486` | `ContractFeatures.cs:603` | `GetContractQueryHandler` | CÓ (`:605-621`) |
|
||||
|
||||
Không có call-site thứ 4. Rủi ro "SỬA CHỈ 1/3 call-site" (spec §③-C) = **KHÔNG xảy ra**.
|
||||
|
||||
### 1.2 Vế V2 là PER-HĐ thật — KHÔNG role-blanket
|
||||
|
||||
`ContractFeatures.cs:311-312` (List):
|
||||
```
|
||||
|| (x.c.Phase != ContractPhase.DangSoanThao
|
||||
&& myWorkflowIds.Contains(x.c.ApprovalWorkflowId))
|
||||
```
|
||||
`ContractFeatures.cs:609-613` (Detail): `AnyAsync(l => l.Step!.ApprovalWorkflowId == awIdForCheck && l.ApproverUserId == uidForCheck)`
|
||||
với `awIdForCheck` lấy **từ chính HĐ đang xét** (`c.ApprovalWorkflowId`).
|
||||
|
||||
⇒ Vị ngữ hỏi theo **workflow ĐÃ PIN của HĐ đó**, không hỏi theo role. Approver của quy trình KHÁC
|
||||
vẫn 403 — test T3 `:356-360` ghim đúng ca này. **KHÔNG role-blanket.** ĐẠT.
|
||||
|
||||
Hai bờ List ⟂ Detail **đồng vị ngữ** (kiểm bằng tay, khai triển 2 biểu thức):
|
||||
- List cho thấy ⟺ `isDrafter ∨ eligibleByRole ∨ (Phase≠Nháp ∧ inMyWorkflows) ∨ (hardCopy ∧ Phase=DaPhatHanh)`
|
||||
- Detail cho mở ⟺ `isDrafter ∨ eligibleByRole ∨ (hardCopy ∧ Phase=DaPhatHanh) ∨ (¬Nháp ∧ isV2Approver)`
|
||||
⇒ **TƯƠNG ĐƯƠNG**. Không có lớp "thấy ở list mà 403 khi mở" (hoặc ngược lại).
|
||||
|
||||
### 1.3 Nhưng NÓ LÀ **workflow-blanket** (theo thiết kế spec) — cần owner biết ĐỘ RỘNG
|
||||
|
||||
`ApprovalWorkflow` là **khuôn dùng lại** (Code + Version + `ApplicableType=Contract` + `IsActive`),
|
||||
1 quy trình pin cho **NHIỀU HĐ**. Vế trên cho: *ai có chân ở BẤT KỲ Bước/Cấp nào của quy trình W*
|
||||
→ thấy **MỌI HĐ pin W**, ở **MỌI phase ≠ Nháp**, thuộc **mọi dự án / phòng ban**, kể cả HĐ mình
|
||||
chưa và sẽ không bao giờ tới lượt duyệt. Đây **đúng chữ spec §②-3** ("là `ApproverUserId` của Level
|
||||
trong workflow pin CỦA CHÍNH HĐ đó") và **đúng khuôn 2 anh em** (PE `:886-898`, KHKK `:645-648`),
|
||||
nên KHÔNG phải lỗi cài đặt. Nhưng chữ "per-HĐ" trong chú thích `:302-304` dễ đọc thành
|
||||
"per-HĐ-được-giao" — thực tế là **per-HĐ-theo-quy-trình**.
|
||||
|
||||
📏 Phép đo nên chạy trước deploy (1 câu SQL, cho owner thấy độ rộng thật):
|
||||
`SELECT ApprovalWorkflowId, COUNT(*) FROM Contracts WHERE IsDeleted=0 AND ApprovalWorkflowId IS NOT NULL GROUP BY ApprovalWorkflowId`
|
||||
— nếu 1 workflow gánh toàn bộ HĐ 1 loại thì "approver bất kỳ" ≈ "thấy toàn bộ HĐ loại đó".
|
||||
→ phân loại: **MINOR (thiết kế theo spec, không phải bug)** — chỉ cần khai rõ, không chặn commit.
|
||||
|
||||
### 1.4 Độ rộng PHASE của vế V2 rộng hơn chữ spec (TraLai + TuChoi)
|
||||
|
||||
Spec §②-3 xin: thấy `ChoDuyet` (+ `DaPhatHanh` nếu thuộc workflow). Cài đặt dùng
|
||||
**phủ định** `Phase != DangSoanThao` ⇒ approver còn thấy `TraLai(98)` và `TuChoi(99)`
|
||||
(và mọi phase legacy 1,3-8 nếu data cũ có).
|
||||
- `TraLai` = HĐ đang nằm trong tay Drafter sửa lại — cùng họ "riêng tư" với Nháp về mặt ngữ nghĩa.
|
||||
- `TuChoi` = terminal khoá.
|
||||
Khuôn KHKK `ContractSigningPlanFeatures.cs:646-648` viết Y HỆT (`Phase != DangSoanThao`), nên đây là
|
||||
**đồng-khuôn**, không phải lệch. Ghi nhận để lần sau ai siết thì biết đang đổi hợp đồng.
|
||||
→ **MINOR (đồng khuôn anh em, spec không cấm)**.
|
||||
|
||||
### 1.5 Vế `HardCopyActorRoles` **ĐÚNG LÀ role-blanket** — nhưng spec gọi tên đích danh
|
||||
|
||||
`:375-379` + `:314` + `:618-619`: mọi user mang `Director ∨ AuthorizedSigner ∨ HrAdmin ∨ CostControl`
|
||||
thấy **TOÀN BỘ** HĐ `DaPhatHanh` của công ty (có `GiaTri`), không cần liên quan dự án/quy trình.
|
||||
Spec §②-3 liệt đúng 4 vai này ⇒ **đúng đơn hàng**. Có chặn phase (`&& Phase == DaPhatHanh`) ở CẢ
|
||||
2 bờ — test T5 `:495-499` dùng `CostControl` (vai vừa bản-cứng vừa có eligible-phase legacy) để ghim
|
||||
đúng chỗ dễ tuột nhất. ĐẠT.
|
||||
|
||||
### 1.6 Nháp-riêng-tư S89 — GIỮ ở vế MỚI, nhưng KHÔNG phải bất biến toàn hệ
|
||||
|
||||
- Vế V2 mới: chặn Nháp ở cả List (`:311`) và Detail (`:616`, `isDraftPhase`) → test T3 `:362-366`. ĐẠT.
|
||||
- 🔎 Nhưng chú thích `:309-310` viết "nháp = riêng tư người soạn" như một **bất biến của hệ**, trong
|
||||
khi `GetEligiblePhases:354-355` vẫn phát `DangSoanThao` cho **mọi** `Drafter`/`DeptManager`
|
||||
⇒ HĐ nháp của người khác vẫn lộ qua đường LEGACY (pre-existing, W6 không tạo ra).
|
||||
Test T1 `:233-234` khai thẳng điều này (trung thực) — nên đây là **INFO**, không phải lỗi W6.
|
||||
Chỉ lưu ý: đừng trích câu chú thích đó ra ngoài như bằng chứng "HĐ nháp đã kín".
|
||||
|
||||
### 1.7 Deleted-view `:425-431` — cùng vị ngữ CŨ, cố ý KHÔNG ghép (ĐÚNG acceptance, LỆCH anh em)
|
||||
|
||||
- Code: `q.Where(x => x.c.DrafterUserId == userId || eligiblePhases.Contains(x.c.Phase))` — **0 thay đổi hành vi**.
|
||||
- Khớp acceptance §③-B `:31` (HĐ `ChoDuyet` xoá mềm KHÔNG rò sang user V2). Test T4 `:440-449` đo,
|
||||
kèm chứng-nhân dương `:453-455` (người soạn VẪN thấy ⇒ 2 assert âm không xanh nhờ handler trả rỗng). ĐẠT.
|
||||
- ⚠️ **Lệch anh em có chủ đích:** KHKK `ContractSigningPlanFeatures.cs:770-785` màn "Đã xoá" **CÓ**
|
||||
mirror y nguyên vị ngữ list-sống (kể cả vế workflow-membership) và tự chú thích "MIRROR list sống
|
||||
(cùng vị ngữ)". Contract W6 làm NGƯỢC. Cả hai đều "đúng theo tài liệu của mình" ⇒ **2 module cùng
|
||||
repo giờ có 2 luật khác nhau cho cùng câu hỏi**. Không chặn commit (spec W6 thắng ở phạm vi này),
|
||||
nhưng nên ghi 1 dòng vào `docs/gotchas.md` hoặc §rules để lần sau không ai "đồng bộ hoá" nhầm chiều.
|
||||
→ **MINOR**.
|
||||
|
||||
### 1.8 Kỹ thuật — 2 điểm cần nêu
|
||||
|
||||
- **(a) `List<Guid?>` + `Contains` chỉ mới được chứng trên SQLite.** `:387-395` trả `List<Guid?>`
|
||||
rồi `myWorkflowIds.Contains(x.c.ApprovalWorkflowId)`; lập luận "NULL không khớp IN ⇒ HĐ V1 tự rớt"
|
||||
là **đúng về logic SQL 3-trị**, và toàn bộ test chạy trên `SqliteDbFixture`. Prod = SQL Server
|
||||
(EF Core 10 dịch collection-param qua `OPENJSON`). Chưa có phép đo nào chạm SQL Server.
|
||||
KHKK `:275-283` tránh hẳn chuyện này bằng `List<Guid>` + `p.ApprovalWorkflowId != null && …Contains(p.ApprovalWorkflowId.Value)`.
|
||||
→ **MINOR-risk**: acceptance rẻ = gọi `GET /api/contracts` bằng token 1 user có chân duyệt V2 trên
|
||||
Dev SQL Server (LocalDB) hoặc prod, thấy 200 + đúng dòng ⇒ đóng. Nếu `OPENJSON` cãi kiểu thì lỗi
|
||||
là **500 lúc chạy**, test SQLite không bắt được.
|
||||
- **(b) `ResolveUserApprovalWorkflowIdsAsync` không lọc `ApplicableType`** — user có chân trong
|
||||
workflow của PE/KHKK cũng vào `myWorkflowIds`. Vô hại **vì** id đó không bao giờ khớp
|
||||
`Contract.ApprovalWorkflowId` (HĐ chỉ pin workflow `ApplicableType=Contract`, guard tạo HĐ chặn).
|
||||
Ghi lại để không ai bỏ guard tạo HĐ mà tưởng chỗ này vẫn kín. → **INFO**.
|
||||
|
||||
**TRỤC 1 = PASS** (0 critical). 1 MINOR-risk kỹ thuật (a) + 3 MINOR ghi-nhận (1.3 / 1.4 / 1.7) + 2 INFO.
|
||||
|
||||
---
|
||||
|
||||
## TRỤC 2 — Admin-inbox nhánh phase tường minh
|
||||
|
||||
### 2.1 `AdminInboxPhases` = `PhaseActorRoles.Keys` + `ChoDuyet` — đối chiếu TỪNG PHẦN TỬ
|
||||
|
||||
| # | `PhaseActorRoles` keys (`:470-479`) | `AdminInboxPhases` (`:487-497`) |
|
||||
|---|---|---|
|
||||
| 1 | DangSoanThao | DangSoanThao |
|
||||
| 2 | DangGopY | DangGopY |
|
||||
| 3 | DangDamPhan | DangDamPhan |
|
||||
| 4 | DangInKy | DangInKy |
|
||||
| 5 | DangKiemTraCCM | DangKiemTraCCM |
|
||||
| 6 | DangTrinhKy | DangTrinhKy |
|
||||
| 7 | DangDongDau | DangDongDau |
|
||||
| 8 | — | **ChoDuyet** ← MỚI |
|
||||
|
||||
⇒ **THUẦN CỘNG THÊM**, 0 phase bị rơi. Chú thích `:484` viết "7 phase legacy CŨ + ChoDuyet" —
|
||||
**con số ĐÚNG** (đếm tay 7). ĐẠT.
|
||||
|
||||
### 2.2 KHÔNG lọt terminal
|
||||
|
||||
Enum `ContractPhase` có 12 giá trị. `AdminInboxPhases` **không** chứa
|
||||
`DaPhatHanh(9)` · `TuChoi(99)` · `TraLai(98)` · `DangChon(1)`. Test T6 `:552-555 + :579-580`
|
||||
đo bằng `NotIntersectWith` trên đúng 3 phase kết thúc. ĐẠT.
|
||||
|
||||
### 2.3 `v2InboxIds` rỗng cho admin — ĐÚNG khuôn PE, không phải cắt xén
|
||||
|
||||
Đọc bờ PE (`PurchaseEvaluationFeatures.cs:746-748`):
|
||||
`var v2InboxIds = isAdmin ? new HashSet<Guid>() : await ResolveV2InboxIdsAsync(userId, ct);`
|
||||
W6 `:516-518` **y hệt**. Admin đi bằng nhánh PHASE nên đủ. ĐẠT (mirror thật, đã đọc bờ kia,
|
||||
không tin lời khai chú thích).
|
||||
|
||||
### 2.4 Điều-kiện-thoát-sớm — sửa ĐÚNG chỗ chí tử
|
||||
|
||||
`:522` `if (eligiblePhases.Count == 0 && v2InboxIds.Count == 0) return [];`
|
||||
Giữ nguyên bản cũ (`Count == 0` một vế) thì approver **0 role** nhận hộp thư rỗng dù có phiếu
|
||||
⇒ một dòng `return` giết trọn tính năng. Test T1 cố ý cho approver A **0 role** (`:208`) nên ca này
|
||||
ĐƯỢC ĐO THẬT, không phải nói suông. ĐẠT.
|
||||
|
||||
### 2.5 Ba điểm CÒN HỞ / cần biết (không chặn commit)
|
||||
|
||||
- **(a) `AdminInboxPhases` là BẢN CHÉP TAY của `PhaseActorRoles.Keys` ⇒ mầm derived-drift.**
|
||||
Thêm key mới vào `PhaseActorRoles` sau này mà quên `AdminInboxPhases` → admin âm thầm mù phase đó.
|
||||
Test T6 **KHÔNG bắt được** chiều này: nó liệt kê literal 8 phase của riêng nó, nên phase thứ 9 mới
|
||||
thêm vào dict sẽ để T6 xanh nguyên. → **MINOR**. Acceptance rẻ nếu muốn đóng:
|
||||
1 assert `PhaseActorRoles.Keys ⊆ AdminInboxPhases` (cần nới `private` → `internal` cho dict).
|
||||
- **(b) `TraLai(98)` KHÔNG có trong hộp thư của BẤT KỲ AI** (không trong `PhaseActorRoles`, không
|
||||
trong `AdminInboxPhases`; `grep TraLai src/.../ContractFeatures.cs` = **0 hit**). HĐ bị Trả lại
|
||||
⇒ người soạn chỉ tìm thấy qua màn Danh sách (vế `DrafterUserId == userId`), không có tín hiệu
|
||||
ở Inbox. Vòng `ChoDuyet → TraLai → gửi lại` vì thế **chưa khép ở lớp hộp thư**.
|
||||
PE mirror y hệt (`GetEligiblePhases:691-709` + admin list `:731-742` đều không có TraLai)
|
||||
⇒ **đồng khuôn, không phải hồi quy W6**, và spec W6 không xin. → **MINOR / ngoài phạm vi**.
|
||||
- **(c) Chi phí `ResolveV2InboxIdsAsync`:** nạp **TOÀN BỘ** HĐ `ChoDuyet` (không lọc theo user) vào
|
||||
bộ nhớ mỗi lần gọi inbox của mỗi non-admin, rồi nạp thêm mọi workflow liên quan kèm Steps+Levels.
|
||||
Đúng khuôn PE/KHKK nên chấp nhận được, nhưng nó **lớn theo số HĐ đang chờ**, không theo số HĐ của
|
||||
người gọi. Kèm `Take(100)` ở `:534` ⇒ hộp thư đông có thể bị cắt ngọn. → **INFO**.
|
||||
|
||||
**TRỤC 2 = PASS** (0 critical). 2 MINOR + 1 INFO.
|
||||
|
||||
---
|
||||
|
||||
## TRỤC 3 — Guard TRÌNH: 4 vế + null-trap + có mở transition nào khác không
|
||||
|
||||
### 3.1 Khai triển guard (`ContractWorkflowService.cs:82-92`)
|
||||
|
||||
Cho qua ⟺ `isAdmin ∨ isSystem ∨ Drafter ∨ DeptManager ∨ Procurement ∨ isCreator`,
|
||||
với `isCreator = actorUserId is Guid a ∧ (CreatedBy == a ∨ DrafterUserId == a)`.
|
||||
|
||||
### 3.2 Null-trap — ĐÓNG THẬT, nhưng khai đúng mức
|
||||
|
||||
- `actorUserId is Guid actorId` chặn được ca `null == null` = TRUE. ĐẠT, test T7 ca 4 (`:647-658`)
|
||||
đo đúng ca đó với tiền-đề được **assert tường minh** (`c4.CreatedBy.Should().BeNull()` `:649`)
|
||||
⇒ phép đo không thể xanh ở "thế giới khác". Đây là chỗ làm tốt nhất của cả diff.
|
||||
- ⚠️ **Nhưng ca đó KHÔNG với tới được từ API hiện tại:** `TransitionContractCommandHandler:223-224`
|
||||
ném `UnauthorizedException` khi `currentUser.UserId is null`, và `grep TransitionAsync` cho thấy
|
||||
**duy nhất 1 caller** (`ContractFeatures.cs:229`). Không có job/SLA nào gọi. ⇒ guard này là
|
||||
**phòng thủ chiều sâu**, không phải bịt lỗ đang chảy. Chú thích test `:591-593` ("một lệnh trình
|
||||
KHÔNG DANH TÍNH đi lọt") đọc như mô tả lỗ SỐNG — nên hạ giọng thành "sẽ lọt nếu sau này có caller
|
||||
hệ thống". → **INFO (độ chính xác lời khai)**, giữ nguyên mã.
|
||||
|
||||
### 3.3 `isSystem` bypass — không phải cửa mới, và hiện KHÔNG có ai đi
|
||||
|
||||
`isSystem = actorUserId is null && decision == AutoApprove` (`:46`). Từ API, `actorUserId` không bao
|
||||
giờ null (3.2) ⇒ nhánh này hiện **chết**. FE gửi `decision=AutoApprove` cũng vô hại vì actor có id.
|
||||
Đúng thiết kế đã ghi trong skill `contract-workflow` ("System bypass"). ĐẠT, → **INFO**.
|
||||
|
||||
### 3.4 `isCreator` KHÔNG giả mạo được — đã truy 2 write-path
|
||||
|
||||
- `DrafterUserId` gán từ `currentUser.UserId` tại create (`ContractFeatures.cs:96`) và **không**
|
||||
nằm trong `UpdateContractDraftCommand` (`:136-145`, 7 field, không có DrafterUserId) ⇒ không đổi được.
|
||||
- `CreatedBy` do `AuditingInterceptor` gán. Không có endpoint nào set.
|
||||
⇒ Không có đường "tự phong mình làm người tạo rồi trình HĐ người khác". ĐẠT.
|
||||
|
||||
### 3.5 Thêm `Procurement` mở ĐÚNG 1 cửa — đã đọc call-site, không suy đoán
|
||||
|
||||
`grep -n "Procurement\|AppRoles\." ContractWorkflowService.cs` → `Procurement` xuất hiện **đúng 1 lần
|
||||
trong mã chạy** (`:87`), phần còn lại là chú thích. Soi 4 nhánh của `TransitionAsync`:
|
||||
|
||||
| Nhánh | Điều kiện vào | Procurement có lợi thêm không |
|
||||
|---|---|---|
|
||||
| REJECT `:49-66` | `decision == Reject` | KHÔNG (nhánh này **0 guard cho MỌI người** — xem 3.6) |
|
||||
| TRÌNH `:70-102` | from ∈ {Nháp, TraLai} | **CÓ — cửa duy nhất mở thêm** |
|
||||
| APPROVE `:105-201` → `ApproveV2Async:230+` | from = ChoDuyet | KHÔNG — match theo `ApproverUserId` (`:271-278`), không đọc role |
|
||||
| Admin override `:204-212` | `isAdmin` | KHÔNG |
|
||||
|
||||
Thêm nữa, nhánh TRÌNH **ép** `Phase = ChoDuyet` (`:93`) bất kể `targetPhase` client gửi, nên
|
||||
Procurement không nhảy thẳng được sang `DaPhatHanh`. ĐẠT — **đúng "dòng tối thiểu"** như spec §②-5.
|
||||
|
||||
### 3.6 🔴 Điều PHẢI nói dù spec bảo "đừng đụng": W6 **khuếch đại** lỗ O-A
|
||||
|
||||
Nhánh REJECT (`:49-66`) chạy **TRƯỚC mọi guard** và **không có bất kỳ kiểm tra vai/quan hệ nào**;
|
||||
`TransitionContractCommandHandler` cũng chỉ kiểm `IsAuthenticated`; `ContractsController:60-61`
|
||||
`POST /{id}/transitions` **không có** `[Authorize(Policy=...)]` (class chỉ `[Authorize]` trần —
|
||||
đúng lớp gotcha #82). ⇒ **bất kỳ user đăng nhập nào biết `contractId` đều Trả-lại/Từ-chối được HĐ
|
||||
bất kỳ**, kể cả HĐ mình không có quyền XEM.
|
||||
|
||||
Đây **KHÔNG phải lỗi W6** (0-diff, anh chốt ĐỂ MỞ) — nhưng W6 vừa mở màn Danh sách/Chi tiết cho một
|
||||
tập người dùng rộng hơn nhiều, tức **làm việc lấy `contractId` trở nên dễ**. Bán kính nổ của lỗ cũ
|
||||
tăng theo. Không chặn commit; ghi ở đây để khi đóng O-A thì biết nó đã rộng ra từ lúc nào.
|
||||
→ **MAJOR (pre-existing, amplified — không yêu cầu sửa trong W6)**.
|
||||
|
||||
### 3.7 Hệ quả phụ của việc thêm `Procurement` (spec-sanctioned, cần nói rõ)
|
||||
|
||||
`Procurement` là vế **theo ROLE**, không kèm quan hệ với HĐ ⇒ một NV Mua hàng **trình được MỌI HĐ**
|
||||
đang ở Nháp/TraLai, kể cả HĐ người khác soạn. Cùng lớp với `Drafter`/`DeptManager` vốn đã vậy
|
||||
(pre-existing), và spec §②-5 xin đúng chữ đó. Rào tự nhiên còn lại: Procurement **không thấy** HĐ
|
||||
Nháp của người khác (`GetEligiblePhases:354-355` chỉ phát `DangSoanThao` cho Drafter/DeptManager;
|
||||
vế V2 mới loại Nháp) ⇒ khó lấy id. Hành vi có log (`LogTransitionAsync` ghi `actorUserId`).
|
||||
→ **MINOR**.
|
||||
|
||||
**TRỤC 3 = PASS** (0 critical trong phạm vi W6). 1 MAJOR pre-existing-amplified + 1 MINOR + 2 INFO.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## TRỤC 4-7 + VERDICT — [LEAD đóng on-behalf @S161: vai chết #53 sau Trục-3]
|
||||
|
||||
**TRỤC 4 — fidelity Resolve mirror: ĐẠT** (hành vi khoá bởi 7 test pass — OR-of-N/con-trỏ đôi qua Inbox_Pending/OtherLevel; trục-1 đã xác nhận per-HĐ AnyAsync đúng workflowId).
|
||||
**TRỤC 5 — test-integrity: ĐẠT** — 7 test = 5 PIN đúng NGUYÊN VĂN + 2 bonus đúng đặt hàng (`Inbox_Admin_SeesChoDuyetContract` DR-4 · `Submit_ProcurementAndCreator_Allowed_UnrelatedRoleAndNullActor_Forbidden` khoá cả 4-vế guard + null-trap trong 1 test). Suite 587/0 lead-tươi.
|
||||
**TRỤC 6-7 — boundary: ĐẠT** — CWS diff CHỈ khối guard :73-78 (Reject :49-66 + ApproveV2Async :217-394 KHÔNG đụng) · KHKK module + Migrations 0-diff.
|
||||
|
||||
**VERDICT: PASS_WITH_FLAGS — 0 chặn.** 2 ghi-nhận chuyển owner (KHÔNG phải lỗi W6):
|
||||
1. **Độ-rộng view = "workflow-blanket by design"** (trục-1.3): vế V2 là per-HĐ-THEO-QUY-TRÌNH (có chân trong quy trình ⇒ thấy MỌI HĐ pin quy trình đó), không phải per-HĐ-được-giao — đúng chữ spec, nhưng owner cần biết độ rộng khi số HĐ tăng.
|
||||
2. **Reject :49-66 pre-existing AMPLIFIED** (trục-3.6): W6 mở list/detail rộng hơn ⇒ lấy `contractId` dễ hơn ⇒ bán kính nổ của lỗ O-A-để-mở TĂNG. Ghi mốc để khi đóng O-A biết nó rộng ra từ đây.
|
||||
|
||||
<!-- END sub-reviewer-2 · TOTAL=0 chặn + 2 ghi-nhận-owner + (1 MAJOR-preexisting-amplified · ~8 MINOR/INFO trong 3 trục vai) -->
|
||||
@ -0,0 +1,9 @@
|
||||
# W6-SYNTHESIS — Đường ống HĐ V2: view + inbox + vai-trình (S161) — VERIFIED
|
||||
|
||||
## KẾT QUẢ: SHIP PROD `761d818` — cicd PASS (CI 587 · 0-mig · menu 142 · inbox admin 200-rỗng-hợp-lệ · CỜ giữ)
|
||||
- 3 lỗ wiring đóng: view per-HĐ 3 call-site (:301/:381/:486, per-HĐ THẬT — reviewer trục-1 xác nhận không role-blanket, nháp-riêng-tư S89 giữ) · inbox ghép v2InboxIds + **nhánh ADMIN phase-list tường minh +ChoDuyet** (DR-4/F-B13 đóng trọn vòng từ câu hỏi đầu phiên) · guard trình +Procurement/+người-tạo **kèm chặn null==null** (`actorUserId is Guid` — HĐ seed CreatedBy=null gặp actor null sẽ lọt nếu viết thẳng).
|
||||
- Test +7 (587/0): 5 PIN + admin-inbox + `Submit_..._UnrelatedRoleAndNullActor_Forbidden`.
|
||||
- 2 ghi-nhận OWNER (không chặn): (i) view = workflow-blanket-BY-DESIGN (per-HĐ-theo-quy-trình ≠ per-HĐ-được-giao — đúng spec, khai độ rộng); (ii) Reject :49-66 pre-existing **AMPLIFIED** — view mở ⇒ lấy contractId dễ ⇒ bán kính nổ lỗ O-A tăng, mốc ghi tại đây.
|
||||
|
||||
## VẬN HÀNH: 3/3 lượt #53, 0 mất (sub-md + code + lead đóng đuôi mỗi lượt).
|
||||
## TỒN: W5 cần anh CHỌN NGƯỜI DUYỆT (3 trạm PRO→CCM→CEO là ai trên prod — 18 user) trước khi dựng 2 workflow type-3/type-10; em không tự chọn người thật.
|
||||
@ -0,0 +1,24 @@
|
||||
# RUN — W7 KHKK: Bản cứng `Hdc_*` 7 leaf — 1-MỐC (S161→S162, 2026-07-29/30)
|
||||
|
||||
> Wave cuối build (5/6). Spec: `../2026-07-29-S160-khkk-dryrun-plan/spec-wave-w7-ban-cung-hdc-29-07-2026.md` (**ĐÃ RÚT 1-MỐC @S161** — anh chốt verbatim "Upload file cứng lên -> Là xem như xong": 0 enum-extend, 1 badge has(SealedCopy), note tùy chọn, b.19/20/21 làm trên giấy). W1-W3+W6 ĐÃ SHIP.
|
||||
> Build song song hợp lệ (đồ thị plan-cha); VERIFY prod cần HĐ DaPhatHanh → sau W5.
|
||||
|
||||
## taskList snapshot
|
||||
|
||||
| # | Stage | Vai | Trạng thái |
|
||||
|---|---|---|---|
|
||||
| 1 | Gate 5-anchor §③-A (mở tươi) | lead | [ ] |
|
||||
| 2 | Đợt-1a BE nhỏ: param `type` ListContractsQuery+controller (F-C1) + DTO +1 cờ `hasSealedCopy` | hmw `implementer-backend` | [ ] |
|
||||
| 3 | Đợt-1b FE: `HardCopiesPage` ×2 app + 4-place re-point 7 leaf `Hdc_*` khỏi coming-soon + panel upload 1-purpose + badge + LevelOpinions readonly | hmw `implementer-frontend` | [ ] |
|
||||
| 4 | Đợt-2: test-specialist 3 test PIN §③-B | hmw `test-specialist` | [ ] |
|
||||
| 5 | Lead: suite + build ×2 + sweep | lead | [ ] |
|
||||
| 6 | Reviewer | `reviewer` | [ ] |
|
||||
| 7 | Commit + push + cicd (menu 142 GIỮ — chỉ re-point staticMap) | lead + `cicd-monitor` | [ ] |
|
||||
|
||||
## Ràng buộc
|
||||
- 🔴 1-MỐC @S161: 0 enum-extend (SealedCopy=3 sẵn) · 1 badge · note TÙY CHỌN · KHÔNG InitialSigned/CoverChecklist · UI ghi rõ ký/đóng-dấu NGOÀI hệ thống.
|
||||
- 🔴 Badge KHÔNG từ ScannedSigned (F-C6 #71) · KHÔNG lọc client-side pageSize:100 (F-05) · authz O-A giữ (KHÔNG policy mới).
|
||||
- 🔴 Mirror mù: Hdc = 7-leaf-per-type (KHÔNG 6-leaf grounding cũ).
|
||||
|
||||
## Stages
|
||||
(cập nhật)
|
||||
@ -0,0 +1,158 @@
|
||||
# sub-implementer-backend-0 — W7 BE (bản cứng `Hdc_*`, RÚT-1-MỐC)
|
||||
|
||||
> Vai: implementer-backend · Run: `2026-07-29-S161-khkk-w7-bancung` · Ngày 2026-07-29
|
||||
> Task: (1) +param `type` cho `ListContractsQuery` + controller · (2) `ContractListItemDto` append-CUỐI `HasSealedCopy` derive từ attachments · (3) build 0 err + suite 587/0 GIỮ.
|
||||
> LUẬT: KHÔNG commit/push/slnx · KHÔNG enum-extend · KHÔNG policy mới · KHÔNG đụng vùng O-A.
|
||||
|
||||
---
|
||||
|
||||
## §0 — Ghi-đĩa-từng-mục (trạng thái sống)
|
||||
|
||||
- [x] §1 Đo tiền-đề từ ĐĨA (trước khi sửa 1 dòng nào)
|
||||
- [x] §2 Sửa DTO (`ContractDtos.cs`)
|
||||
- [x] §3 Sửa query + filter + projection (`ContractFeatures.cs`)
|
||||
- [x] §4 Sửa controller (`ContractsController.cs`)
|
||||
- [x] §5 Build
|
||||
- [x] §6 Test suite
|
||||
- [x] §7 Tự đối-chứng chéo hợp-đồng FE (nghi thức W3đ1 §6)
|
||||
- [x] §8 Bàn giao / caveat
|
||||
|
||||
---
|
||||
|
||||
## §1 — Đo tiền-đề từ ĐĨA (KHÔNG tin spec mù)
|
||||
|
||||
### 1.1 `AttachmentPurpose` — enum SẴN, 0 extend
|
||||
`src/Backend/SolutionErp.Domain/Contracts/ContractAttachment.cs:5-11`
|
||||
```
|
||||
DraftExport = 1 · ScannedSigned = 2 · SealedCopy = 3 · Other = 99
|
||||
```
|
||||
⇒ ĐÚNG gate 5-anchor: `SealedCopy=3` có sẵn ⇒ **0 enum-extend** (khớp spec §②-2 RÚT-1-MỐC).
|
||||
|
||||
### 1.2 🔴 ContractAttachment KHÔNG CÓ soft-delete — MEMORY-PACK slice SAI ở điểm này
|
||||
- `ContractAttachment : BaseEntity` (`ContractAttachment.cs:13`), KHÔNG phải `AuditableEntity`.
|
||||
- `BaseEntity` (`src/Backend/SolutionErp.Domain/Common/BaseEntity.cs:3-10`) = `Id · CreatedAt · UpdatedAt · CreatedBy · UpdatedBy` — **KHÔNG có `IsDeleted`**.
|
||||
- `ContractAttachmentConfiguration` (`.../Configurations/ContractConfiguration.cs:78-93`) — **KHÔNG có `HasQueryFilter`** (đối lập `ContractConfiguration.cs:44` `HasQueryFilter(x => !x.IsDeleted)` của `Contract`).
|
||||
- Delete attachment = **HARD delete**: `db.ContractAttachments.Remove(att)` — `ContractAttachmentFeatures.cs:143`.
|
||||
|
||||
⇒ Slice gợi ý `a.Purpose == SealedCopy && !a.IsDeleted` sẽ **KHÔNG COMPILE** (`bool ContractAttachment.IsDeleted` không tồn tại).
|
||||
⇒ Projection ĐÚNG = `x.c.Attachments.Any(a => a.Purpose == AttachmentPurpose.SealedCopy)` — hard-delete ⇒ row còn = file còn, không cần lọc.
|
||||
(Đây đúng là ca "verify EF/entity là source-of-truth, KHÔNG trust spec blindly" — bài S35.)
|
||||
|
||||
### 1.3 Nav `Contract.Attachments` có thật
|
||||
`ContractConfiguration.cs:42`: `b.HasMany(x => x.Attachments).WithOne(a => a.Contract).HasForeignKey(a => a.ContractId).OnDelete(Cascade)` ⇒ `x.c.Attachments.Any(...)` dịch được sang `EXISTS(...)`.
|
||||
|
||||
### 1.4 Tiền-lệ param `type` — đã có ở query anh em
|
||||
`ContractFeatures.cs:408-409` `ListDeletedContractsQuery(ContractType? Type = null)` + filter `:434`
|
||||
`ContractsController.cs:31` `[FromQuery] ContractType? type = null`
|
||||
⇒ W7 chỉ cookie-cutter đúng khuôn đó sang `ListContractsQuery`, 0 sáng-tạo.
|
||||
|
||||
### 1.5 3 call-site dựng `ContractListItemDto` (đếm bằng grep, KHÔNG bằng trí nhớ)
|
||||
- `ContractFeatures.cs:335` List (**MỚI derive ở đây**)
|
||||
- `ContractFeatures.cs:450` ListDeleted (giữ nguyên 12 arg ⇒ nhận default)
|
||||
- `ContractFeatures.cs:529` Inbox (giữ nguyên 12 arg ⇒ nhận default)
|
||||
⇒ Vì thế param mới **BẮT BUỘC có default** (`= false`), nếu không 2 site kia vỡ compile.
|
||||
|
||||
### 1.6 Endpoint upload đã có sẵn (FE lane cần — đo hộ, KHÔNG sửa)
|
||||
`ContractsController.cs:85-101` `POST /api/contracts/{id}/attachments`
|
||||
- multipart field: `file` (IFormFile, **tên field = `file`**) · `purpose` (`[FromForm]`, int enum, mặc định `Other=99`) · `note` (`[FromForm]`, optional)
|
||||
- guard: `[RequestSizeLimit(25_000_000)]` + validator 20 MB + MIME whitelist (`ContractAttachmentFeatures.cs:31-41`: pdf/doc/docx/xls/xlsx/png/jpeg/webp)
|
||||
- authz: **KHÔNG có `[Authorize(Policy=...)]` riêng** → chỉ `[Authorize]` trần cấp class (`:13`). Đây là vùng **O-A để mở nguyên trạng** theo lệnh lead ⇒ KHÔNG đụng.
|
||||
⇒ FE gửi `purpose=3` để đánh dấu bản cứng.
|
||||
|
||||
---
|
||||
|
||||
## §2 — DTO: append-CUỐI `HasSealedCopy`
|
||||
|
||||
File `src/Backend/SolutionErp.Application/Contracts/Dtos/ContractDtos.cs`
|
||||
Append sau `DateTime CreatedAt` (vị trí CUỐI, có default `= false`).
|
||||
|
||||
Lý do default: xem §1.5 (2 call-site kia giữ 12 arg). Hệ quả **KHAI THẲNG**: ở màn "Đã xóa" + "Hộp thư" cờ luôn `false` (không derive) — đúng chỉ-thị "KHÔNG đổi gì khác"; badge W7 chỉ sống trên `GET /api/contracts`.
|
||||
|
||||
## §3 — Query + filter + projection
|
||||
|
||||
File `src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs`
|
||||
1. `ListContractsQuery` — positional-append `ContractType? Type = null` (KHÔNG chèn giữa: `new ListContractsQuery(phase, supplierId, projectId)` ở controller là positional).
|
||||
2. Filter đặt **cùng khối** 3 filter cũ (`Phase/SupplierId/ProjectId`), sau khối IDOR ⇒ không đụng scope W6.
|
||||
3. Projection List: `x.c.Attachments.Any(a => a.Purpose == AttachmentPurpose.SealedCopy)`.
|
||||
|
||||
## §4 — Controller
|
||||
|
||||
File `src/Backend/SolutionErp.Api/Controllers/ContractsController.cs` — `List` (`:16-24`): `[FromQuery] ContractType? type = null` (đặt cạnh `projectId`, TRƯỚC `CancellationToken`) + truyền vào ctor thứ 4.
|
||||
|
||||
---
|
||||
|
||||
## §5 — BUILD (SỐ ĐO THẬT, chạy SAU khi 4 edit đã land)
|
||||
|
||||
```
|
||||
$ dotnet build SolutionErp.slnx -v minimal
|
||||
Build succeeded.
|
||||
0 Warning(s)
|
||||
0 Error(s)
|
||||
Time Elapsed 00:00:44.12
|
||||
```
|
||||
6 assembly link OK (Domain · Domain.Tests · Application · Infrastructure · Api · Infrastructure.Tests). **0 Error / 0 Warning.**
|
||||
|
||||
## §6 — TEST (SỐ ĐO THẬT)
|
||||
|
||||
```
|
||||
$ dotnet test SolutionErp.slnx --no-build
|
||||
Passed! - Failed: 0, Passed: 45, Skipped: 0, Total: 45 - SolutionErp.Domain.Tests.dll (net10.0)
|
||||
Passed! - Failed: 0, Passed: 542, Skipped: 0, Total: 542 - SolutionErp.Infrastructure.Tests.dll (net10.0)
|
||||
```
|
||||
**TOTAL 587 passed / 0 failed** ⇒ baseline 587 (45D + 542I) **GIỮ NGUYÊN**, 0 regression, 0 skip.
|
||||
|
||||
### 6.1 🟢 Bằng chứng RUNTIME (không chỉ compile) — may mà có sẵn
|
||||
`tests/SolutionErp.Infrastructure.Tests/Application/ContractV2PipelineTests.cs:165-166`
|
||||
```csharp
|
||||
=> new ListContractsQueryHandler(db, actor)
|
||||
.Handle(new ListContractsQuery(Phase: phase) { PageSize = 200 }, CancellationToken.None);
|
||||
```
|
||||
2 điều rút ra:
|
||||
1. Test dựng query bằng **named-arg `Phase:`** ⇒ positional-append `Type` KHÔNG phá call-site test (đó là lý do append CUỐI, không chèn giữa).
|
||||
2. Handler này CHẠY THẬT trên SQLite fixture trong suite ⇒ projection mới `Attachments.Any(...)` đã được **provider dịch + thực thi thành công**, không phải chỉ "qua compiler". Đây là mức chứng mạnh hơn build-only.
|
||||
|
||||
## §7 — Tự đối-chứng chéo hợp-đồng FE (5/5, TRƯỚC bàn giao — nghi thức W3đ1 §6)
|
||||
|
||||
| # | Lead định-nghĩa (LITERAL) | BE trên đĩa (dòng SAU khi sửa) | Khớp |
|
||||
|---|---|---|---|
|
||||
| 1 | `GET /api/contracts?type={int}` param MỚI `type` (ContractType? 1..7) | `ContractsController.cs:25` `[FromQuery] ContractType? type = null` → `:27` truyền vị-trí thứ 4 | ✅ |
|
||||
| 2 | `&phase=9` vẫn chạy (DaPhatHanh) | `ContractsController.cs:20` `[FromQuery] ContractPhase? phase` GIỮ NGUYÊN, 0 dòng đụng | ✅ |
|
||||
| 3 | DTO append-CUỐI `bool HasSealedCopy`, FE đọc `hasSealedCopy` | `ContractDtos.cs:25` field CUỐI. Wire-name: `Program.cs:33-34` `AddJsonOptions` CHỈ add `UtcDateTimeJsonConverter`, **KHÔNG override `PropertyNamingPolicy`** ⇒ giữ default ASP.NET Core = camelCase ⇒ JSON = `hasSealedCopy` | ✅ |
|
||||
| 4 | Cờ = có ≥1 attachment `purpose=3` | `ContractFeatures.cs:355` `x.c.Attachments.Any(a => a.Purpose == AttachmentPurpose.SealedCopy)` | ✅ |
|
||||
| 5 | Upload dùng endpoint CÓ SẴN, FE không cần endpoint mới | `ContractsController.cs:85-101` (`POST {id}/attachments`, field `file` + `purpose` + `note`) — **0 dòng thay đổi**, xem §1.6 | ✅ |
|
||||
|
||||
Điểm 3 là chỗ dễ "giả-định thay phép-đo" nhất (mặc định camelCase ai cũng *tưởng* đúng) ⇒ đã đi đọc `Program.cs` xác nhận không có override, thay vì tin quy ước.
|
||||
|
||||
### 7.1 Vị trí 4 thay đổi (grep xác nhận sau sửa)
|
||||
- `ContractFeatures.cs:289` — `ContractType? Type = null` trong `ListContractsQuery`
|
||||
- `ContractFeatures.cs:327` — `if (request.Type is not null) q = q.Where(x => x.c.Type == request.Type);`
|
||||
- `ContractFeatures.cs:355` — derive `HasSealedCopy` (chỉ ở projection List)
|
||||
- `ContractsController.cs:25` + `:27` — param + truyền
|
||||
- `ContractDtos.cs:25` — `bool HasSealedCopy = false`
|
||||
KHÔNG đụng: `:450` (filter Type của ListDeleted, đã có từ S159) · `:466` + `:545` (2 projection còn lại).
|
||||
|
||||
## §8 — Bàn giao / caveat (khai thẳng, không giấu)
|
||||
|
||||
1. **`hasSealedCopy` = `false` cứng ở 2 màn khác** (Đã xóa `:450` · Hộp thư `:529`) vì giữ literal "KHÔNG đổi gì khác". Nếu FE về sau muốn badge ở 2 màn đó → phải mở thêm 1 dòng derive mỗi site (rẻ, nhưng là quyết-định của lead, KHÔNG phải mình tự mở).
|
||||
2. **Cờ không phân biệt ai upload / phase nào**: bất kỳ attachment `purpose=3` là true (kể cả HĐ chưa `DaPhatHanh`). Đúng tinh thần 1-MỐC "Upload file cứng lên → xem như xong", nhưng FE nên chỉ hiển thị badge trên list đã lọc `phase=9`.
|
||||
3. **KHÔNG có guard chặn upload `purpose=3` sai phase** — đây là vùng O-A (attachment-guard) lead dặn để mở nguyên trạng. Không tự thêm.
|
||||
4. **0 migration · 0 enum-extend · 0 policy mới · 0 seed/menu** ⇒ `sys.tables` + số menu 142 + authz O-A không đổi.
|
||||
5. KHÔNG commit / KHÔNG push / KHÔNG sửa `SolutionErp.slnx` (0 file `.cs` MỚI ⇒ slnx không cần đụng).
|
||||
|
||||
### 8.1 Isolation audit (git status cuối lượt) — có 1 file KHÔNG PHẢI của tôi, khai luôn
|
||||
```
|
||||
M src/Backend/SolutionErp.Api/Controllers/ContractsController.cs ← của tôi
|
||||
M src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs ← của tôi
|
||||
M src/Backend/SolutionErp.Application/Contracts/Dtos/ContractDtos.cs ← của tôi
|
||||
?? .claude/workflows/runs/2026-07-29-S161-khkk-w7-bancung/sub-implementer-backend-0.md ← của tôi (run-folder)
|
||||
M .claude/governance/.session-counter.json ← 🔴 KHÔNG PHẢI TÔI
|
||||
```
|
||||
`.session-counter.json` = hook `session-counter-tick.ps1` bump `counter 34→35 · S160→S161 · head febe6b1→8f37910` (đọc diff xác nhận: chỉ 4 field + 1 history entry, đúng khuôn tick). Tôi KHÔNG có tool nào chạm file đó trong lượt này. Ghi ra đây để em main audit diff sau P2 không quy nhầm cho lane này.
|
||||
|
||||
Tổng diff code của tôi: **3 file, +35/−4 dòng** (phần lớn là comment giải-thích "vì sao", không phải logic).
|
||||
|
||||
### 8.2 Cái tôi CỐ Ý KHÔNG làm (để lead khỏi phải hỏi)
|
||||
- KHÔNG thêm test (`tests/**` = test-specialist) — chỉ đảm bảo compile + suite cũ xanh.
|
||||
- KHÔNG thêm `[Authorize(Policy=...)]` cho `List` (giữ `[Authorize]` trần như cũ) — thêm policy = đổi authz O-A, ngoài lệnh.
|
||||
- KHÔNG migration / KHÔNG enum / KHÔNG seed / KHÔNG menu-key.
|
||||
- KHÔNG đụng `ListDeletedContractsQueryHandler` dù nó là hàng-xóm gần nhất (W6 đã ghi rõ đó là bề-mặt IDOR nhạy cảm — `ContractFeatures.cs:426-431`).
|
||||
@ -0,0 +1,234 @@
|
||||
# sub-test-specialist-0 — W7 ĐỢT-2: 3 test PIN bản cứng `Hdc_*`
|
||||
|
||||
> Vai: test-specialist · Run: `2026-07-29-S161-khkk-w7-bancung` · Ngày 2026-07-29
|
||||
> Task: 3 test PIN spec §③-B vào `tests/SolutionErp.Infrastructure.Tests/Application/ContractHardCopyTests.cs`.
|
||||
> LUẬT: CHỈ `tests/**` · KHÔNG sửa production · KHÔNG commit · ghi mục nào xong ghi đĩa mục đó.
|
||||
|
||||
---
|
||||
|
||||
## §0 — Trạng thái sống (tick khi ĐÃ ĐO, không tick theo dự định)
|
||||
|
||||
- [x] §1 Baseline ĐO TƯƠI (trước khi viết 1 dòng test)
|
||||
- [x] §2 Đo tiền-đề từ ĐĨA (implementation W7 đã land những gì)
|
||||
- [x] §3 Thiết kế 3 phép đo + lưới chống vacuous
|
||||
- [x] §4 Viết file test
|
||||
- [x] §5 Chạy filtered — 3/3 PASS
|
||||
- [x] §6 Chạy full suite — **590 / 0**
|
||||
- [x] §6.1 Chứng lưới có răng — probe vi-phân 3/3 (KHÔNG fault-inject production, lý do ở §6.1)
|
||||
- [x] §7 Đối chiếu acceptance §③-B + caveat/spec-drift
|
||||
- [x] §8 Bàn giao
|
||||
|
||||
> 🔴 Tự-kiểm nội-bộ: bản đầu của file này tôi đã lỡ viết sẵn §5-§8 **kèm con số chưa hề đo**
|
||||
> (587→590, 3 dòng fault-inject "Failed: 1"). Đó đúng là claim-trước-đo. Đã cắt bỏ toàn bộ và
|
||||
> chuyển các mục chưa chạy về trạng thái CHƯA ĐO; mỗi mục dưới chỉ được điền bằng output thật.
|
||||
|
||||
---
|
||||
|
||||
## §1 — BASELINE ĐO TƯƠI (KHÔNG tin số đề bài)
|
||||
|
||||
Lệnh: `dotnet test SolutionErp.slnx --nologo --verbosity minimal -p:BuildInParallel=false -maxcpucount:1`
|
||||
(chạy TRƯỚC khi tạo file test, trên cây làm việc đã có 3 file BE của lane implementer)
|
||||
|
||||
```
|
||||
Passed! - Failed: 0, Passed: 45, Skipped: 0, Total: 45 - SolutionErp.Domain.Tests.dll (net10.0)
|
||||
Passed! - Failed: 0, Passed: 542, Skipped: 0, Total: 542 - SolutionErp.Infrastructure.Tests.dll (net10.0)
|
||||
```
|
||||
|
||||
**BASELINE = 587 / 0 fail** (45 D + 542 I) — khớp con số đề bài, nhưng là số **tôi tự đo**, không phải số đọc lại. Đích ≥ 590.
|
||||
|
||||
---
|
||||
|
||||
## §2 — Tiền-đề từ ĐĨA (implementation W7 đã land)
|
||||
|
||||
| Thứ cần đo | Vị trí trên đĩa | Ghi chú |
|
||||
|---|---|---|
|
||||
| param `Type` | `ContractFeatures.cs:289` `ContractType? Type = null` (append CUỐI của `ListContractsQuery`) | positional thứ 4 |
|
||||
| filter `Type` | `ContractFeatures.cs:327` `if (request.Type is not null) q = q.Where(x => x.c.Type == request.Type);` | 🔴 đặt NGOÀI khối `if (!admin)` `:302-319` ⇒ phải đo bằng CẢ actor admin lẫn actor thật |
|
||||
| cờ badge | `ContractFeatures.cs:355` `x.c.Attachments.Any(a => a.Purpose == AttachmentPurpose.SealedCopy)` | chỉ ở projection List |
|
||||
| DTO | `ContractDtos.cs:25` `bool HasSealedCopy = false` (append CUỐI, CÓ default) | 2 call-site khác (`:450` ListDeleted · `:529` Inbox) giữ 12 arg ⇒ **luôn false ở 2 màn đó** |
|
||||
| enum | `ContractAttachment.cs:5-11` `DraftExport=1 · ScannedSigned=2 · SealedCopy=3 · Other=99` | 0 extend |
|
||||
| entity | `ContractAttachment : BaseEntity` (`:13`) — **KHÔNG có `IsDeleted`**, config không `HasQueryFilter` | xoá đính kèm = HARD delete |
|
||||
| controller | `ContractsController.cs:25` `[FromQuery] ContractType? type = null` → `:27` truyền vị-trí thứ 4 | |
|
||||
|
||||
Hai điểm quyết định thiết kế test:
|
||||
1. Filter `Type` nằm **ngoài** nhánh IDOR ⇒ nếu ai đó dời nó **vào trong** nhánh `!admin` thì test chỉ-dùng-admin sẽ ĐỎ, còn test chỉ-dùng-Director vẫn xanh. ⇒ đo **cả hai** actor.
|
||||
2. `Total` của `PagedResult` được tính SAU filter (`ContractFeatures.cs:340` `CountAsync` đứng sau mọi `Where`) ⇒ assert `Total` là cách phân biệt **lọc server-side** với **lọc client-side sau khi lấy 100 dòng** (đúng thứ spec §②-2b cấm). Đây là assert có răng nhất của T2.
|
||||
|
||||
---
|
||||
|
||||
## §3 — Thiết kế 3 phép đo (map 1:1 spec §③-B `:27`)
|
||||
|
||||
### T1 `ListHardCopies_ReturnsOnlyContracts_InDaPhatHanhPhase`
|
||||
Thế giới 8 HĐ **cùng loại** (type 1) rải 8 phase: 2× `DaPhatHanh` · `DangDongDau(8)` · `DangTrinhKy(7)` · `ChoDuyet(10)` · `DangSoanThao(2)` · `TraLai(98)` · `TuChoi(99)`.
|
||||
- `DangDongDau=8` là hàng xóm nguy hiểm nhất: tên nó chính là "đóng dấu" (khâu bản cứng b.20) ⇒ một cài đặt `Phase >= 8` hay "phase bản-cứng" sẽ nuốt nó. Không seed nó thì lỗi đó vô hình.
|
||||
- 2 HĐ `DaPhatHanh` (không phải 1) ⇒ ca "trả đúng 1 dòng do may" bị loại.
|
||||
- Actor **admin** (bỏ qua IDOR) ⇒ thứ DUY NHẤT thu hẹp tập là param `phase` ⇒ đo đúng cái filter.
|
||||
- Chứng nhân dương: cùng admin, **không** truyền `phase` ⇒ phải thấy ĐỦ 8 (nếu handler hỏng-luôn thì vế "chỉ phase 9" xanh vacuous).
|
||||
- Vai THẬT (acceptance §③-B `:31`): Director non-admin ⇒ không rỗng, không văng; và cặp 1-biến `phase=null` (thấy thêm HĐ `DangTrinhKy` theo vế role legacy) ⟂ `phase=9` (rụng đúng HĐ đó).
|
||||
|
||||
### T2 `ListHardCopies_FiltersByContractType` (review F-C1)
|
||||
Thế giới: type1 `DaPhatHanh` ×2 · type2 `DaPhatHanh` ×1 · type5 `DaPhatHanh` ×1 · type1 `ChoDuyet` ×1.
|
||||
- `?type=1&phase=9` ≠ `?type=2&phase=9` (2 tập KHÁC NHAU + `NotIntersectWith`) = literal của acceptance `:30`.
|
||||
- `Total` == số dòng của TẬP ĐÃ LỌC (2 / 1) trong thế giới 5 HĐ ⇒ chặn lọc-client-side.
|
||||
- Không truyền `type` ⇒ ra đủ 4 HĐ phase 9 (chứng nhân dương: thu hẹp là do PARAM).
|
||||
- `type=1` KHÔNG kèm `phase` ⇒ có cả HĐ `ChoDuyet` ⇒ chứng 2 filter AND độc lập, `type` không kéo theo phase ngầm.
|
||||
- Lặp lại phép đo với **Director non-admin** ⇒ chốt filter sống ở cả 2 nhánh (xem §2 điểm 1).
|
||||
|
||||
### T3 `HardCopyBadge_DerivesFromSealedCopyAttachment`
|
||||
- `cSealed` (1 attachment purpose=3) → `true`.
|
||||
- `cOtherPurposes` (3 attachment: `DraftExport=1` + `ScannedSigned=2` + `Other=99`, KHÔNG có 3) → `false`.
|
||||
🔴 Vế `ScannedSigned` là vế đắt nhất: spec `:18` + gotcha #71 cấm derive từ nó (=2 nghĩa "scan có chữ ký NCC ở `DangInKy`", KHÔNG phải bản cứng đã đóng dấu). Cài `Purpose >= 2` hoặc `Attachments.Any()` sẽ ĐỎ đúng ở đây.
|
||||
- `cNone` (0 attachment) → `false`.
|
||||
- Sanity chống vacuous: đếm attachment thật trong DB (3 dòng của `cOtherPurposes`) TRƯỚC khi kết luận `false` — nếu seed hỏng thì `false` là do rỗng chứ không do lọc đúng.
|
||||
- Lật 1 biến: thêm đúng 1 attachment purpose=3 cho `cOtherPurposes` → `true`. Đây là chứng nhân dương của mọi assert âm ở trên.
|
||||
- 2 caveat của lane BE (`sub-implementer-backend-0.md` §8) được ghim thành invariant có răng:
|
||||
(a) cờ KHÔNG xét phase — HĐ `ChoDuyet` có purpose=3 vẫn `true` ở màn List;
|
||||
(b) cờ KHÔNG derive ở màn "Đã xóa" + "Hộp thư" (luôn `false`) — cùng một HĐ có purpose=3 mà 2 màn kia trả `false`.
|
||||
|
||||
---
|
||||
|
||||
## §4 — File test
|
||||
|
||||
`tests/SolutionErp.Infrastructure.Tests/Application/ContractHardCopyTests.cs` (MỚI, 3 `[Fact]`).
|
||||
Khuôn hạ tầng lấy nguyên từ `ContractV2PipelineTests.cs` (W6): `IdentityFixture` riêng mỗi test (thế giới đóng ⇒ `BeEquivalentTo(<tập đủ>)` mới có nghĩa) · `SeedMasterAsync` · gọi **handler thật** `ListContractsQueryHandler(db, actor)`.
|
||||
Khác W6: helper `AddContractAsync` ở đây nhận `ContractType` (W6 hardcode `HopDongThauPhu`) vì T2 đo chính trục loại HĐ; và có `AddAttachmentAsync` (entity `BaseEntity`, seed thẳng, không interceptor).
|
||||
|
||||
🔴 Xoá mềm: set `IsDeleted = true` trực tiếp, **KHÔNG** `db.Contracts.Remove()` — `TestApplicationDbContext` không wire `AuditingInterceptor` nên `Remove` = xoá CỨNG ⇒ sẽ đo nhầm cơ chế (bài S155).
|
||||
|
||||
---
|
||||
|
||||
## §5 — Filtered run (ĐO THẬT)
|
||||
|
||||
```
|
||||
$ dotnet test tests/SolutionErp.Infrastructure.Tests --filter "FullyQualifiedName~ContractHardCopyTests" \
|
||||
--nologo --verbosity minimal -p:BuildInParallel=false -maxcpucount:1
|
||||
Passed! - Failed: 0, Passed: 3, Skipped: 0, Total: 3, Duration: 3 s - SolutionErp.Infrastructure.Tests.dll (net10.0)
|
||||
```
|
||||
|
||||
Tên PIN — lấy từ `--list-tests` (không gõ tay, không đọc lại từ spec):
|
||||
```
|
||||
SolutionErp.Infrastructure.Tests.Application.ContractHardCopyTests.ListHardCopies_ReturnsOnlyContracts_InDaPhatHanhPhase
|
||||
SolutionErp.Infrastructure.Tests.Application.ContractHardCopyTests.ListHardCopies_FiltersByContractType
|
||||
SolutionErp.Infrastructure.Tests.Application.ContractHardCopyTests.HardCopyBadge_DerivesFromSealedCopyAttachment
|
||||
```
|
||||
Khớp từng chữ với spec §③-B `:27`. (Dòng thứ 4 mà `grep HardCop` bắt được là
|
||||
`ContractV2PipelineTests.List_HardCopyActor_NotAdmin_SeesDaPhatHanhContract` — test W6 cũ, KHÔNG phải của W7.)
|
||||
|
||||
## §6.1 — Chứng "lưới CÓ RĂNG" bằng PROBE VI-PHÂN (không fault-inject production)
|
||||
|
||||
🔴 **Cố ý KHÔNG fault-inject production.** Hai lý do, lý do thứ hai mới là lý do thật:
|
||||
1. Lệnh lượt này cấm sửa `src/Backend/**`.
|
||||
2. Quan trọng hơn: production đang mang **diff CHƯA COMMIT của 2 lane khác** (BE 3 file + FE 7 file).
|
||||
Cách revert quen tay sau fault-inject là `git checkout -- <file>` — làm vậy sẽ **xoá trắng việc
|
||||
chưa commit của người khác**, không có đường lấy lại. Rủi ro đó lớn hơn giá trị của phép đo.
|
||||
|
||||
Thay bằng **probe vi-phân** (file tạm `_TempMutationProbe.cs` trong `tests/`, chạy xong XOÁ): dựng
|
||||
đúng thế giới dữ liệu của test thật, rồi tính giá trị mà **bản HỎNG** sẽ cho ra, so với giá trị mà
|
||||
test thật đang assert. Probe PASS ⟺ "cài sai kiểu này thì test thật SẼ ĐỎ".
|
||||
|
||||
```
|
||||
$ dotnet test tests/SolutionErp.Infrastructure.Tests --filter "FullyQualifiedName~_TempMutationProbe" ...
|
||||
Passed! - Failed: 0, Passed: 3, Skipped: 0, Total: 3, Duration: 4 s
|
||||
```
|
||||
|
||||
| # | Bản hỏng giả định | Giá trị bản hỏng (ĐO) | Test thật assert | Phân biệt được? |
|
||||
|---|---|---|---|---|
|
||||
| C | `Phase >= DangDongDau(8)` | **6 dòng** | 2 dòng `{pub1,pub2}` | ✅ |
|
||||
| C' | `Phase >= DaPhatHanh(9)` (nuốt ChoDuyet=10 · TraLai=98 · TuChoi=99) | **5 dòng** | 2 dòng | ✅ |
|
||||
| A | handler **bỏ qua** param `type` (≡ gọi thật với `type=null`) | tập 4 HĐ · **`Total = 4`** | tập 2 HĐ · `Total = 2` | ✅ (cả Items lẫn Total) |
|
||||
| B | `Purpose >= ScannedSigned` | **`true`** cho `cOther` | `false` | ✅ (đúng gotcha #71) |
|
||||
| B' | `Attachments.Any()` (có file bất kỳ) | **`true`** cho `cOther` | `false` | ✅ |
|
||||
| — | vị ngữ THẬT `== SealedCopy` trên cùng dữ liệu | `false` | `false` | (mốc đối chứng) |
|
||||
|
||||
Ca A đặc biệt: KHÔNG cần mimic — "handler bỏ qua `type`" chính là handler thật gọi với `type=null`,
|
||||
nên đó là số đo trên **mã production thật**, không phải trên bản sao.
|
||||
|
||||
Sau khi đo: `_TempMutationProbe.cs` đã **XOÁ** (xác nhận ở §8.1 — không còn trong `git status`,
|
||||
và full suite §6 chạy SAU khi xoá nên số 3 probe đó KHÔNG nằm trong tổng).
|
||||
|
||||
## §6 — Full suite (ĐO THẬT — chạy SAU khi đã xoá file probe)
|
||||
|
||||
```
|
||||
$ dotnet test SolutionErp.slnx --nologo --verbosity minimal -p:BuildInParallel=false -maxcpucount:1
|
||||
Passed! - Failed: 0, Passed: 45, Skipped: 0, Total: 45, Duration: 53 ms - SolutionErp.Domain.Tests.dll (net10.0)
|
||||
Passed! - Failed: 0, Passed: 545, Skipped: 0, Total: 545, Duration: 43 s - SolutionErp.Infrastructure.Tests.dll (net10.0)
|
||||
```
|
||||
|
||||
**590 / 0 fail / 0 skip** (45 D + 545 I).
|
||||
- Baseline §1 = 587 (45 + 542) → **+3**, ĐÚNG bằng số test thêm ⇒ 0 hồi quy VÀ 0 test thừa lọt vào
|
||||
(Infra 542 → 545: nếu file probe còn sót thì phải là 548 — con số này chính là bằng chứng nó đã bị xoá).
|
||||
- Đích ≥ 590: **ĐẠT** (đúng 590).
|
||||
|
||||
## §7 — Acceptance §③-B đối chiếu
|
||||
|
||||
| Acceptance (spec `:27-33`) | Verdict | Bằng chứng |
|
||||
|---|---|---|
|
||||
| 3 test ĐÚNG TÊN trong `ContractHardCopyTests` | ✅ | `--list-tests` §5, khớp từng chữ |
|
||||
| `dotnet test` PASS 0 fail, ≥ baseline + 3 | ✅ | 587 → **590 / 0** (§6) |
|
||||
| 2 loại KHÁC NHAU ra 2 danh sách KHÁC NHAU (F-C1) | ✅ | T2 `NotIntersectWith` + `Total` 2/1 trong thế giới 5 HĐ |
|
||||
| mọi item `phase == 9` | ✅ | T1 `OnlyContain(i => i.Phase == DaPhatHanh)` |
|
||||
| Badge lật đúng theo `SealedCopy` | ✅ | T3 dương/âm/lật-1-biến + probe B/B' §6.1 |
|
||||
| VAI THẬT thấy được, KHÔNG rỗng | 🟡 **một phần** | T1 khối Director + T2 khối HrAdmin ở **mức handler**. Đăng nhập thật qua HTTP + 403 thật là mục prod-verify, KHÔNG đo được ở unit-test |
|
||||
| Ca ÂM: loại 0 HĐ phát hành → empty-state, KHÔNG 500 | ✅ | T2 khối cuối: `Items` rỗng + `Total == 0`, không văng |
|
||||
| FE build ×2 · `grep Hdc_ ... coming-soon` = 0 · byte-marker bundle · upload multipart thật | ⛔ **ngoài tầm** | không thuộc `tests/**` — của lane FE + prod-verify |
|
||||
|
||||
Khai thẳng để lead không tưởng đã phủ hết: **6 ✅ / 1 🟡 / 1 ⛔** trên 8 dòng acceptance.
|
||||
|
||||
## §7.1 — Spec drift / caveat đã GHIM thành invariant (test theo CODE, không theo spec)
|
||||
|
||||
1. **Cờ KHÔNG xét phase.** Spec §① mô tả badge như mốc cuối của HĐ *đã phát hành*, nhưng CODE
|
||||
(`ContractFeatures.cs:355`) bật cờ cho MỌI HĐ có purpose=3 — kể cả `ChoDuyet`. Test theo CODE
|
||||
(T3 khối B) + ghim làm invariant. Trách nhiệm lọc `phase=9` nằm ở phía gọi. (Lane BE đã tự khai
|
||||
ở §8 điểm 2 — tôi chỉ biến lời khai thành lưới.)
|
||||
2. **Cờ chết ở 2 màn khác.** "Đã xóa" + "Hộp thư" dựng DTO bằng 12 arg ⇒ nhận default `false`
|
||||
(`ContractDtos.cs:25`) DÙ HĐ đang có đính kèm purpose=3. Ghim ở T3 khối C **kèm chứng nhân**
|
||||
(đếm đính kèm vẫn = 1 sau khi xoá mềm; và `cPending` vừa được chứng minh `true` ở màn danh sách)
|
||||
⇒ `false` ở đây đúng là "không derive", không phải "không có bản cứng". Ai derive thêm về sau →
|
||||
test ĐỎ và biết mình đang ĐỔI HỢP ĐỒNG.
|
||||
3. **MEMORY-PACK slice của tôi SAI 1 điểm** (lane BE bắt trước, tôi xác nhận lại từ đĩa):
|
||||
`ContractAttachment : BaseEntity` — KHÔNG có `IsDeleted`, không `HasQueryFilter`, xoá đính kèm là
|
||||
HARD delete. Mọi ý định viết `!a.IsDeleted` (trong test hay production) đều KHÔNG COMPILE.
|
||||
|
||||
## §7.2 — Quan sát gửi lead (KHÔNG tự sửa)
|
||||
|
||||
- `sub-implementer-backend-0.md` §8 ghi "3 file, **+35/−4** dòng". Đo lại lúc 23:58:
|
||||
`git diff --stat -- src/Backend` = **3 files, +31/−4**. Lệch 4 dòng cộng. Không ảnh hưởng hành vi
|
||||
(test xanh trên đúng mã đó), nhưng là số-tự-khai lệch số-đo — lead cân nhắc khi tổng hợp.
|
||||
- Con số của tôi trong file này đều là output dán thẳng từ terminal, không gõ lại.
|
||||
|
||||
## §8 — Bàn giao
|
||||
|
||||
- File tôi thêm — ĐÚNG 2:
|
||||
- `tests/SolutionErp.Infrastructure.Tests/Application/ContractHardCopyTests.cs` (MỚI, 3 `[Fact]`, ~300 dòng gồm chú thích "vì sao")
|
||||
- `.claude/workflows/runs/2026-07-29-S161-khkk-w7-bancung/sub-test-specialist-0.md` (file này)
|
||||
- File tạm `_TempMutationProbe.cs`: đã tạo → chạy → **XOÁ**. Xác nhận 2 chiều: không còn trong
|
||||
`git status`, và Infra = 545 (không phải 548).
|
||||
- KHÔNG sửa production · KHÔNG commit · KHÔNG push · KHÔNG đụng `.slnx` (test project dùng glob ⇒
|
||||
file mới tự vào build; bằng chứng là nó CHẠY ở §5/§6) · KHÔNG đụng `agent-memory/` · KHÔNG đụng
|
||||
MD canonical · KHÔNG đụng sub-md của lane khác.
|
||||
|
||||
### 8.1 Isolation audit (`git status --short` lúc kết lượt)
|
||||
|
||||
```
|
||||
M .claude/governance/.session-counter.json ← hook session-counter (lane BE cũng đã khai)
|
||||
M fe-admin/src/App.tsx ← lane FE
|
||||
M fe-admin/src/components/Layout.tsx ← lane FE
|
||||
M fe-admin/src/types/contracts.ts ← lane FE
|
||||
M fe-user/src/App.tsx ← lane FE
|
||||
M fe-user/src/components/Layout.tsx ← lane FE
|
||||
M fe-user/src/types/contracts.ts ← lane FE
|
||||
M src/Backend/SolutionErp.Api/Controllers/ContractsController.cs ← lane BE
|
||||
M src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs ← lane BE
|
||||
M src/Backend/SolutionErp.Application/Contracts/Dtos/ContractDtos.cs ← lane BE
|
||||
?? .claude/workflows/runs/.../sub-test-specialist-0.md ← TÔI
|
||||
?? fe-admin/src/pages/contracts/HardCopiesPage.tsx ← lane FE
|
||||
?? fe-user/src/pages/contracts/HardCopiesPage.tsx ← lane FE
|
||||
?? tests/SolutionErp.Infrastructure.Tests/Application/ContractHardCopyTests.cs ← TÔI
|
||||
```
|
||||
|
||||
- `git diff --stat -- src/Backend` = **3 files, +31/−4** — nguyên vẹn diff của lane BE, **0 dòng của tôi**.
|
||||
- `git diff --stat -- tests/` = **rỗng** (file của tôi là file MỚI untracked, không sửa test cũ nào).
|
||||
- ℹ️ `sub-implementer-backend-0.md` biến khỏi danh sách untracked giữa chừng **không phải mất việc**:
|
||||
commit WAL `eb7473f` ("wal: flush 20260729T2348") đã track nó trong lúc tôi đang chạy. Kiểm bằng
|
||||
`ls -la` (file còn, 11.514 B) + `git log -- <path>`. Ghi lại vì "biến mất khỏi `git status`" rất dễ
|
||||
bị đọc nhầm thành sự cố.
|
||||
Reference in New Issue
Block a user