470 lines
32 KiB
Markdown
470 lines
32 KiB
Markdown
GATE-K7: PASS-WITH-FLAGS 10
|
||
|
||
# Gate K7 — adversarial review cầu KHKK → HĐ (S167, 2026-08-01)
|
||
|
||
Vai: reviewer (READ-only). Base đo: HEAD = `f5ee146` (`wal: flush 20260801T1242`), diff UNCOMMITTED.
|
||
Nguyên tắc: mọi mục dưới đây là phép đo TỰ CHẠY của reviewer, không chép claim của lane.
|
||
|
||
---
|
||
|
||
## §0 — Mốc cây + residual scope
|
||
|
||
Đo bằng `git status --porcelain` + `git diff --stat` (2026-08-01, đầu lượt review).
|
||
|
||
13 file thuộc phạm vi K7 — CÓ ĐỦ, đúng như spec giao:
|
||
|
||
| Nhóm | File | Trạng thái |
|
||
|---|---|---|
|
||
| BE | `src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs` | M (+33) |
|
||
| BE | `src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs` | M (+23/-2) |
|
||
| BE | `src/Backend/SolutionErp.Application/ContractSigningPlans/CreateContractFromSigningPlanFeatures.cs` | **?? MỚI** (252 dòng) |
|
||
| BE | `src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs` | M (+86) |
|
||
| FE | `fe-{admin,user}/src/hooks/usePipelineStages.ts` | M (+61/-?) ×2 |
|
||
| FE | `fe-{admin,user}/src/pages/khkk/KhkkDetailPage.tsx` | M (+308) ×2 |
|
||
| FE | `fe-{admin,user}/src/types/khkk.ts` | M (+49) ×2 |
|
||
| Test | `tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs` | M (+48/-?) |
|
||
| Test | `tests/SolutionErp.Infrastructure.Tests/Api/KhkkBridgeAuthzTests.cs` | **?? MỚI** |
|
||
| Test | `tests/SolutionErp.Infrastructure.Tests/Application/CreateContractFromSigningPlanTests.cs` | **?? MỚI** |
|
||
|
||
**FLAG RESIDUAL (F-R1, MINOR)** — 3 file dirty NGOÀI cả 13-file-set LẪN lead-set đã khai
|
||
(`.claude/*` + `docs/STATUS.md`):
|
||
|
||
- `scripts/distill-shard-probe.ps1` (+1/-1)
|
||
- `scripts/measure-agent-memory.ps1` (+1/-1)
|
||
- `scripts/memory-archive-gate.ps1` (+11/-2)
|
||
|
||
Đọc diff: cả 3 là việc **governance memory-index rotation `_INDEX-<period>.md` @S167**, KHÔNG
|
||
liên quan K7. Không phải lỗi nội dung; rủi ro là **`git add -A` sẽ nuốt chúng vào commit K7**.
|
||
Đề nghị: hoặc khai chúng vào lead-set, hoặc commit tách. Untracked trong `src`/`tests` = đúng
|
||
3 file MỚI đã khai, **0 file lạ**.
|
||
|
||
---
|
||
|
||
## §1 — TRỌNG TÂM 1: hội-2-khoá 2 tầng (BE ∧ FE)
|
||
|
||
### 1a. Tầng server — ĐO ĐƯỢC, ĐẠT
|
||
|
||
- Class-level: `ContractSigningPlansController.cs:29` → `[Authorize(Policy = "KeHoachKyKet.Read")]`.
|
||
- Action mới: `ContractSigningPlansController.cs:227-228` →
|
||
`[HttpPost("{id:guid}/create-contract")]` + `[Authorize(Policy = "Contracts.Create")]`.
|
||
- ASP.NET Core cộng dồn class + action theo AND ⇒ hội 2 khoá là THẬT, không phải claim.
|
||
- Policy `Contracts.Create` có tồn tại: `MenuKeys.cs:12` khai const `Contracts`, và
|
||
`MenuKeys.cs:228` đưa `Contracts` VÀO mảng `All` ⇒ `Program.cs` sinh đủ 4 policy.
|
||
(Nếu key ngoài `All` thì runtime 500 chứ không 403 — đã kiểm, không rơi vào ca đó.)
|
||
|
||
### 1b. Tầng FE — ĐỌC DIFF TỪNG DÒNG (lead ghi §3-§4 on-behalf; đây là lưới THẬT đầu tiên)
|
||
|
||
`fe-user/src/pages/khkk/KhkkDetailPage.tsx` (bản admin byte-identical, xem §3c):
|
||
|
||
- `:138-140` — `const { can } = usePermission()`; `canReadPlan = can('KeHoachKyKet','Read')`;
|
||
`canCreateContract = can('Contracts','Create')`. Đúng 2 key của server, KHÔNG OR `Khkk_*`
|
||
(gotcha #85 giữ được).
|
||
- `:299` — `showBridge = phase === KhkkPhase.DaDuyet && canReadPlan`.
|
||
- `:303-312` — `bridgeBlockReason`: nhánh ĐẦU TIÊN là `!canCreateContract` ⇒ nút render nhưng
|
||
`disabled`.
|
||
- `:352-363` — `<Button disabled={bridgeBlockReason != null} onClick={() => setBridgeOpen(true)}>`.
|
||
|
||
**Truy đường lách:** grep toàn file cho `setBridgeOpen(true)` → xuất hiện ĐÚNG 1 lần, tại
|
||
`onClick` của nút đã disabled. Dialog `CreateContractFromKhkkDialog` render có điều kiện
|
||
`{bridgeOpen && ...}` (`:781`) ⇒ **không có đường nào tới `create.mutate()` khi thiếu
|
||
`Contracts.Create`**. React không bắn `onClick` trên `<button disabled>`.
|
||
⇒ FE **KHÔNG lỏng hơn server**. ĐẠT.
|
||
|
||
**Verify hook đọc gì:** `fe-user/src/hooks/usePermission.ts` — `findNode(menu, key)` đệ quy trên
|
||
cây THÔ từ `AuthContext`, trả `false` khi không thấy node; `Create` → `node.canCreate`. Nghĩa là
|
||
gate FE phụ thuộc **row Permission của key `Contracts`**, đúng thứ seeder mới cấp.
|
||
|
||
**CLARIFY-1 (không phải lỗi mã, là điều kiện nghiệm thu):** cây menu FE nạp lúc login và cache
|
||
`localStorage` (`AuthContext.loadMenu`). Comment seeder khai "grant ăn @restart, KHÔNG phải
|
||
deploy FE" — đúng cho BE, nhưng **nút vẫn xám cho tới khi user logout/login lại** vì `can()` đọc
|
||
menu cache cũ. UAT phải nói rõ bước đăng-xuất/đăng-nhập, nếu không sẽ bị đọc thành "seeder không
|
||
ăn".
|
||
|
||
---
|
||
|
||
## §2 — TRỌNG TÂM 2: seeder grant, gotcha #84 nghiêm
|
||
|
||
`DbInitializer.cs:2738-2814` = `SeedContractCreateAccessAsync`; call-site `:2361`.
|
||
|
||
### 2c. THỨ TỰ (vế nặng nhất) — ĐO BẰNG SỐ DÒNG THẬT, ĐẠT
|
||
|
||
`awk` liệt kê mọi `await Seed*/Revoke*` trong khối 2250-2420:
|
||
|
||
```
|
||
2331: await SeedAllRolesReviewReadPermissionsAsync(...)
|
||
2341: await RevokeTemporarilyHiddenModulesAsync(...) ← revoker
|
||
2347: await SeedProcurementMasterAccessAsync(...)
|
||
2354: await SeedKeHoachKyKetAccessAsync(...)
|
||
2361: await SeedContractCreateAccessAsync(...) ← MỚI, SAU revoker ✔
|
||
```
|
||
|
||
Và trong cửa sổ 2361→2520 **không còn call nào khác** ⇒ seeder mới là seeder-quyền CHẠY CUỐI,
|
||
không ai lật lại nó. ĐẠT.
|
||
|
||
Đọc thêm thân revoker `:2550-2589` để không chỉ tin thứ tự: filter là
|
||
`MenuKey.StartsWith("Hrm") || StartsWith("Off") || == Personal` — **nhánh `Contracts`/`Ct_*` đã
|
||
bị GỠ từ S159**. Vậy claim "revoker hiện không còn nhánh Contracts" là ĐÚNG, và việc giữ thứ tự
|
||
sau revoker là phòng-thủ dư (đúng chiều, không hại).
|
||
|
||
### 2b. UPGRADE-ONLY + IDEMPOTENT — ĐẠT
|
||
|
||
- Nhánh row-đã-có (`:2790-2797`): chỉ `if (!row.CanRead) row.CanRead = true` và
|
||
`if (!row.CanCreate) row.CanCreate = true`. **Không chạm `CanUpdate`/`CanDelete`, không hạ cờ
|
||
nào.** Không có `= false` ở bất kỳ đâu trong hàm.
|
||
- Nhánh row-chưa-có (`:2800-2808`): tạo `CanRead=true, CanCreate=true, CanUpdate=false,
|
||
CanDelete=false`.
|
||
- Idempotent: lượt 2 trở đi `added=0, upgraded=0` ⇒ **không gọi `SaveChangesAsync`** (`:2811`),
|
||
không log rác. ĐẠT.
|
||
- Guard role-chưa-seed (`:2779-2784`): `roles.Count == 0` → log warning + `return`, không ném.
|
||
|
||
### 2a/2d. TIỀN ĐỀ "chưa role nào có Contracts.Create" — ĐO ĐƯỢC TỪ MÃ
|
||
|
||
`DbInitializer.cs:2474` `reviewKeys = MenuKeys.All.Where(InReviewScope)…` ⇒ `Contracts` nằm
|
||
trong tập. `IsPeKey("Contracts") == false` ⇒ nhánh insert dùng `CanCreate = isPe` (`:2523`) =
|
||
**false**, và nhánh row-đã-có là **skip-existing** cho key non-Pe (`:2515`) ⇒ không ai nâng
|
||
`CanCreate` lên. Tiền đề của lane ĐÚNG.
|
||
|
||
Bộ vai: `roleNames = { Drafter, Procurement, Admin }` (`:2775`) — đúng 3 vai spec nêu, kèm comment
|
||
"câu III default, chờ anh ratify" và chỉ-dẫn sửa 1 chỗ. ĐẠT.
|
||
|
||
**MINOR F-2** — Admin thừa: `MenuPermissionHandler.cs:26-31` có Admin-bypass (`Succeed` ngay khi
|
||
role chứa `Admin`) ⇒ cấp row cho Admin **vô hại nhưng vô tác dụng**. Không đề nghị sửa; nêu để
|
||
người sau đừng đọc row Admin thành "bằng chứng Admin cần row".
|
||
|
||
**MINOR F-3** — comment `:2814` viết "Thu hồi khi cần: xoá call ở SeedAsync", nhưng call thật
|
||
nằm trong `SeedAdminPermissionsAsync` (thân 2288-2381), không phải `SeedAsync`. Chỉ-dẫn trỏ sai
|
||
tên hàm.
|
||
|
||
**MINOR F-4 (mở rộng ngoài Create)** — hàm nâng cả `CanRead`, trong khi tên hàm + comment chỉ hứa
|
||
`Contracts.Create`. Với 3 vai này `CanRead` gần như đã true từ S159 nên thực tế không đổi gì;
|
||
nhưng `reviewKeys` có ghi rõ "trên prod row false KHÔNG được nâng ở đây" ⇒ nếu prod có role nào
|
||
đang `Contracts.CanRead=false` thì hàm này **âm thầm mở lại quyền XEM**, rộng hơn nhãn.
|
||
|
||
---
|
||
|
||
## §3 — TRỌNG TÂM 3: chuỗi dữ liệu cây, khớp từng khớp nối
|
||
|
||
### 3a. Write-site → DTO (BE)
|
||
|
||
- Write-site: `CreateContractFromSigningPlanFeatures.cs:238` → `foreach (var l in lines) l.ContractId = contract.Id;`
|
||
Đây là gán THẬT trên entity đã tracked (`plan.Lines` nạp qua `Include` `:93`), nằm TRƯỚC
|
||
`SaveChangesAsync` `:248`. ĐẠT.
|
||
- Fallback: `:246` `if (pe is not null) pe.ContractId ??= contract.Id;` — `??=` đúng chiều
|
||
(HĐ đầu thắng, không cướp ô của cầu PE cũ).
|
||
- DTO list: `ContractSigningPlanFeatures.cs:75` `List<Guid> ContractIds` (append CUỐI record ⇒
|
||
positional-arg của các call-site cũ không lệch).
|
||
- Projection: `ContractSigningPlanFeatures.cs:836-842` — subquery
|
||
`Where(l => l.ContractSigningPlanId == p.Id && l.ContractId != null).OrderBy(CreatedAt)
|
||
.Select(l => l.ContractId!.Value).ToList()`. Filter `!= null` đứng TRƯỚC `.Value` ⇒ không NRE,
|
||
và line chưa bridge thì mảng RỖNG (không phải null-element). ĐẠT.
|
||
|
||
### 3b. DTO → FE (tên + null-safety)
|
||
|
||
- `types/khkk.ts:200` → `contractIds?: string[] | null`. JSON camelCase khớp `ContractIds`.
|
||
Khai optional dù BE non-null: đúng, vì cache React Query cũ / API chưa deploy không có field.
|
||
- Helper `types/khkk.ts:304-313` `khkkPlanContractIds(plan)`:
|
||
`for (const id of plan.contractIds ?? []) if (id) out.push(id)` +
|
||
`for (const line of plan.lines ?? []) if (line.contractId) out.push(line.contractId)`.
|
||
**Ca "line không chọn ⇒ ContractId null ⇒ mảng rỗng, không crash" = ĐẠT** — cả 2 vòng đều
|
||
`?? []` và đều lọc falsy trước khi push.
|
||
- Chữ ký `plan: KhkkListItemDto & Partial<Pick<KhkkDetailDto,'lines'>>` ⇒ truyền
|
||
`KhkkListItemDto` thuần vẫn hợp lệ (lines optional). ĐẠT.
|
||
- Union + dedupe: `usePipelineStages.ts:295-300` — `contractIdsOfPackage` là `Set<string>`, đổ
|
||
nguồn (1) `p.contractId` rồi nguồn (2) `khkkPlanContractIds(k)`. Chồng nhau ở HĐ đầu là CỐ Ý và
|
||
`Set` khử. ĐẠT.
|
||
- Dòng phụ: `usePipelineStages.ts:321-326` — `fallbackName` nay tra CẢ 2 nguồn
|
||
(`peItems.find(...)?.tenGoiThau ?? khkkItems.find(k => khkkPlanContractIds(k).includes(id))?.peTenGoiThau ?? null`).
|
||
Đo field tồn tại: `types/khkk.ts:171 peTenGoiThau: string | null`, BE `…Features.cs:39
|
||
string? PeTenGoiThau`. Khớp. ĐẠT.
|
||
|
||
### 3c. Mirror 2 app — TỰ ĐO SHA, KHÔNG TIN CLAIM
|
||
|
||
```
|
||
IDENTICAL src/types/khkk.ts 60ec8fa3a5aea94456e2180b1b80f63c1994257c55a424f4e1f7adf376cbbc07
|
||
IDENTICAL src/hooks/usePipelineStages.ts 76e1823345d4e78b87e802bc10d22ea0a42307bbb426b8787eb2d25b02e8c52f
|
||
IDENTICAL src/pages/khkk/KhkkDetailPage.tsx cf0a2272061f4bd8cc19c54b1a4992fdac331e9093665ec4ce708e119ef38123
|
||
```
|
||
|
||
3/3 byte-identical — claim của lane TÁI LẬP ĐƯỢC.
|
||
|
||
**Rủi ro riêng của mirror byte-identical (đã kiểm, KHÔNG thủng):** file dùng đường dẫn cứng
|
||
`/contracts/${id}` ở 3 chỗ (`:527` neo "✓ HĐ", `:975` neo "Đã có HĐ", `:855` `navigate`). Vì 2
|
||
app có bảng route KHÁC nhau, đây là chỗ dễ 404 một bên. Đo:
|
||
`fe-user/src/App.tsx:65` và `fe-admin/src/App.tsx:84` đều có `<Route path="/contracts/:id">`.
|
||
⇒ ĐẠT ở cả 2 app.
|
||
|
||
**MINOR F-5** — 2 neo dùng `<a href>` thay `<Link>` ⇒ full reload (mất SPA state); và neo
|
||
"Đã có HĐ" (`:975`) nằm LỒNG trong `<label>` (nested-interactive, cùng lớp a11y với finding K5 về
|
||
`<button>` trong `<summary>`). Radio của dòng đó `disabled` nên không sinh double-activate, nhưng
|
||
khuôn thì vẫn là interactive-lồng-interactive.
|
||
|
||
**MINOR F-6 (hiệu năng, không sai)** — `usePipelineStages.ts:324` gọi `khkkPlanContractIds(k)`
|
||
BÊN TRONG `find` mà `find` lại nằm trong vòng lặp theo `contractIdsOfPackage` ⇒ O(n·m) và cấp phát
|
||
mảng mới mỗi lượt. Quy mô hiện tại (list kẹp 200) vô hại.
|
||
|
||
### 3d. Chuông DEV — đọc kỹ vì dễ thành chuông-câm hoặc chuông-loạn
|
||
|
||
`usePipelineStages.ts:303-315`: chỉ chạy khi `import.meta.env.DEV`, có cờ module-level
|
||
`warnedMissingLineContractIds` (`:62`) chặn lặp; điều kiện
|
||
`approved.length > 0 && approved.every(k => k.contractIds == null)`.
|
||
`== null` bắt cả `null` lẫn `undefined` — ĐÚNG chủ đích "vắng-field ⟂ mảng-rỗng", vì sau khi BE
|
||
deploy field luôn là `[]` (non-null) ⇒ chuông tự im. Logic ĐẠT.
|
||
Lưu ý (không phải lỗi): prod strip `DEV` ⇒ đây **không phải lưới an toàn**, chỉ là đèn báo lúc dev.
|
||
|
||
---
|
||
|
||
## §4 — TRỌNG TÂM 4: guard 4-vế + idempotency
|
||
|
||
Đọc `CreateContractFromSigningPlanFeatures.cs` toàn file (252 dòng).
|
||
|
||
Rào theo thứ tự thực thi:
|
||
|
||
| # | Dòng | Nội dung | Nhận xét |
|
||
|---|---|---|---|
|
||
| 0 | :87-89 | `IsAuthenticated` + `UserId` | ném `UnauthorizedException` |
|
||
| 1 | :92-99 | phiếu tồn tại + `Phase == DaDuyet` | ĐẠT |
|
||
| 2 | :101-109 | dòng ∈ phiếu (đếm sau `Distinct`) | id lạ/của phiếu khác đều rơi vào đây — ĐẠT |
|
||
| 3 | :111-115 | 1 HĐ = 1 NCC | ĐẠT |
|
||
| 4 | :125-127 | `ApprovedAmount is null` → 409 | KHÔNG fallback `ProposedAmount` — ĐÚNG |
|
||
| 5 | :128-129 | `ContractId is not null` → "đã có Hợp đồng" | chống bridge-lặp |
|
||
| 6 | :134-148 | workflow pin **4 vế** | xem dưới |
|
||
|
||
**vá-3 (4 vế workflow) — ĐẠT, và ĐÚNG "throw tách":**
|
||
`:136` NotFound nếu không tồn tại · `:137-140` sai `ApplicableType` · `:143-145` `!IsActive` ném
|
||
riêng · `:146-148` `!IsUserSelectable` ném riêng. Bốn thông điệp bốn nguyên nhân — đúng yêu cầu
|
||
spec, và **rộng hơn khuôn tiền lệ** `ContractFeatures.cs:79-87` (khuôn cũ CHỈ có 2 vế đầu — tôi đã
|
||
đọc lại `ContractFeatures.cs:74-88` để xác nhận, claim của lane đúng).
|
||
|
||
**Idempotency / double-click — ĐÂY LÀ FINDING NẶNG NHẤT.** Rào (5) là kiểm tra
|
||
**đọc-rồi-ghi trong bộ nhớ**, không có khoá nào ở tầng CSDL:
|
||
|
||
- không `unique index` trên `ContractSigningPlanLines.ContractId`;
|
||
- không `RowVersion`/optimistic concurrency trên `Line`;
|
||
- không transaction bao ngoài (`SaveChangesAsync` trần `:248`; transaction duy nhất trong luồng
|
||
là transaction NỘI BỘ của `ContractCodeGenerator`, đã commit xong trước đó).
|
||
|
||
⇒ Hai request song song (double-click thật, hoặc 2 tab) đều đọc `l.ContractId == null` ở
|
||
`:128`, cùng đi tiếp, **tạo 2 Contract**, và lượt ghi sau đè `Line.ContractId`. Kết quả: 1 HĐ mồ
|
||
côi (đã tiêu 1 số sequence, đã có changelog) mà không dòng nào trỏ tới. Đây là **TOCTOU thật**,
|
||
không phải lo xa: nút "Tạo hợp đồng" chỉ chặn bằng `disabled={create.isPending}` (`:892`) — chặn
|
||
được double-click trong CÙNG tab, **không** chặn 2 tab / retry mạng.
|
||
|
||
Xếp hạng: **MAJOR F-1**, không phải blocker của gate (dữ liệu không mất, HĐ mồ côi xoá được, và
|
||
xác suất thấp vì thao tác có dialog), nhưng phải được KHAI, vì lane trình mục này như "đã đóng".
|
||
Acceptance đề nghị (không phải chỉ-dẫn sửa mã): một phép kiểm chứng minh 2 lệnh gọi đồng thời chỉ
|
||
sinh 1 HĐ — hoặc bằng ràng buộc CSDL, hoặc bằng cập-nhật-có-điều-kiện, hoặc khai công khai
|
||
"chấp nhận rủi ro, chặn ở UI".
|
||
|
||
---
|
||
|
||
## §5 — TRỌNG TÂM 5: vá-6
|
||
|
||
- **Project/Supplier NotFound guard:** `:153-154` (`Project`) và `:156-157` (`Supplier`), cả 2 ném
|
||
`NotFoundException` trước khi dùng `.Code`. ĐẠT — loose-Guid không có FK đỡ lưng nên guard này
|
||
là bắt buộc.
|
||
- **Codegen TRƯỚC `Add`:** `:212` `contract.MaHopDong = await codeGenerator.GenerateAsync(contract,
|
||
project.Code, supplier.Code, ct);` rồi `:214` `db.Contracts.Add(contract);`. Đúng thứ tự bài S88
|
||
(codegen tự-commit ⇒ không được để HĐ dở trong context). Chữ ký khớp
|
||
`IContractWorkflowService.cs:29 Task<string> GenerateAsync(Contract, string, string, CancellationToken)`.
|
||
ĐẠT.
|
||
- **SlaDeadline:** `:205-206` `DateTime.UtcNow.Add(workflow.GetPhaseSla(DangSoanThao) ?? TimeSpan.FromDays(7))`
|
||
— trùng từng chữ với 2 tiền lệ (`ContractFeatures.cs:110`, bridge PE `:109-110`). ĐẠT.
|
||
- **`WorkflowDefinitionId` pin:** `:163-166` truy `WorkflowDefinitions` active theo
|
||
`request.ContractType` → `:201` gán. Ngang bridge PE cũ (đã đọc lại
|
||
`CreateContractFromEvaluationFeatures.cs:67-71` + field-map `:105-113` để đối chiếu). Set CẢ
|
||
V1 + V2 (`:202` `ApprovalWorkflowId`) là ĐÚNG — service dispatch V2 trước. ĐẠT.
|
||
|
||
**KHAI-1 của test-lane (invest T1 ghi null nhưng vá-6 thắng): XÁC NHẬN ĐÚNG — ĐÓNG, không mở lại.**
|
||
Lý do đo được: vá-6 trong spec ②:16 yêu cầu pin `WorkflowDefinitionId` theo 2 tiền lệ; mã hiện
|
||
tại làm đúng thế; "để null" là hành vi mà chính spec loại bỏ. Không có mâu thuẫn còn lại.
|
||
|
||
**MINOR F-7 (field-map thiếu so với tiền lệ, có thể CỐ Ý):** bridge PE cũ carry
|
||
`DraftData = pe.PaymentTerms` (`CreateContractFromEvaluationFeatures.cs:104`); cầu K7 **không**
|
||
carry `DraftData`, dù đã nạp `pe` sẵn ở `:170`. Cũng không có `BypassProcurementAndCCM` (cái này
|
||
hợp lý vì command K7 không nhận tham số đó). Nếu bỏ `DraftData` là chủ đích thì nên có một dòng
|
||
comment, vì mọi khoản lệch khác so với tiền lệ đều đã được chú thích.
|
||
|
||
---
|
||
|
||
## §6 — TRỌNG TÂM 6+7 (test có răng · comment sạch số-đếm)
|
||
|
||
### 6b. Comment mang số-đếm / mỏ-neo — CÓ LỖI, ĐO ĐƯỢC BẰNG 1 LỆNH
|
||
|
||
**MAJOR F-8 — mỏ-neo TỰ VÔ HIỆU ngay lúc land (đúng lớp S166-K4b, tái phát):**
|
||
2 comment MỚI trỏ `DbInitializer.cs:2516` cho `CanCreate = isPe`:
|
||
- `ContractSigningPlansController.cs:224` ("seeder `DbInitializer.cs:2516`")
|
||
- `DbInitializer.cs:2743` ("`SeedAllRolesReviewReadPermissionsAsync:2516`")
|
||
|
||
Phép đo (2 lệnh, cho 2 thế giới):
|
||
|
||
```
|
||
git show HEAD:…/DbInitializer.cs | grep -n "CanCreate = isPe" → 2516 (TRƯỚC diff)
|
||
grep -n "CanCreate = isPe" …/DbInitializer.cs → 2523 (SAU diff)
|
||
```
|
||
|
||
Chính diff này chèn 7 dòng ở `:2353-2359`, đẩy dòng đích xuống 2523. Nghĩa là con trỏ ĐÚNG lúc
|
||
viết và **SAI ngay khoảnh khắc commit** — không ai làm gì sai sau đó cả. Đây là lỗi loại "đo
|
||
trước khi chèn". Acceptance: hoặc cập nhật thành 2523, hoặc bỏ số dòng và trỏ bằng TÊN
|
||
(`SeedAllRolesReviewReadPermissionsAsync` + token `CanCreate = isPe` là duy nhất, grep ra ngay).
|
||
|
||
**MINOR F-9 — số-đếm trong comment tự mâu thuẫn:** `DbInitializer.cs:2743-2744` viết "nút bridge
|
||
sẽ 403 với **13/13 role** (kể cả Drafter), chỉ Admin qua được nhờ bypass". Đếm thật:
|
||
`AppRoles.cs:23-28` liệt **13** vai và Admin nằm trong đó; `MenuPermissionHandler.cs:26-31` cho
|
||
Admin bypass ⇒ con số đúng phải là **12/13**. Một câu chứa cả con số lẫn phản-ví-dụ của chính nó.
|
||
|
||
Ngoài 2 mục trên, đã quét các con số khác trong comment MỚI và chúng kiểm được:
|
||
`ContractConfiguration TenHopDong = 500` khớp `MaximumLength(500)` (`:73`) và clamp `:184`;
|
||
`ContractTypeLabel` 1..7 (`fe-user/src/types/forms.ts:13-21`) khớp ĐÚNG
|
||
`ContractType.cs:5-11` 1..7 ⇒ `IsInEnum()` không bao giờ đá dropdown.
|
||
|
||
### 6a. Ba file test — ĐỌC HẾT, CÓ RĂNG
|
||
|
||
**Đếm:** `KhkkBridgeAuthzTests` 5 `[Fact]` + `CreateContractFromSigningPlanTests` 12 `[Fact]`
|
||
= **17** — khớp đúng con số "17 test mới" spec nêu (đếm bằng `grep -c '^\s*\[Fact\]'`, không chép).
|
||
|
||
**`KhkkBridgeAuthzTests.cs` — răng thật, và đúng loại răng:**
|
||
- `:33-48` `BridgePolicies()` **đọc 2 tên policy TỪ CHÍNH CONTROLLER** bằng reflection, không
|
||
hardcode ⇒ ai sửa attribute thì test đo theo cái mới mà vẫn hỏi đúng câu. Kèm
|
||
`actionPolicy.Should().NotBe(classPolicy)` (`:45`) — chặn ca "2 vế trùng nhau thì hội-2-khoá
|
||
còn 1 khoá mà nhìn vẫn như cũ".
|
||
- Chạy **máy quyết định THẬT** `MenuPermissionHandler` (`:57-68`), không mô phỏng.
|
||
- (2) `:170-171` và (3) `:195-196` mỗi test đều assert **vế còn lại VẪN QUA** trước khi assert vế
|
||
thiếu bị chặn ⇒ không thể xanh nhờ "chặn tất cả". Đây đúng là thứ tôi định đòi.
|
||
- (5) `:235-243` phép **2 thế giới 1 biến**: cùng bộ quyền, chỉ lật `IsActive`, kết quả phải đảo.
|
||
- (4) `:214` assert `Permissions.Count == 0` trước khi kết luận Admin qua bằng bypass.
|
||
|
||
**`CreateContractFromSigningPlanTests.cs` — 12 ca, assert phân biệt được thế giới hỏng:**
|
||
- T1 `:243-245` — 3 con số CỐ Ý khác nhau (750 chốt / 999 đề xuất / 900 NS) ⇒ lấy nhầm nguồn nào
|
||
cũng ra số khác. T1 `:266` soi **RUỘT mã HĐ** `{ProjectCode}/HĐTP/SOL&{SupplierCode}/01` —
|
||
chính là bằng chứng vá-6 đã nạp 2 bảng Project/Supplier, mạnh hơn `NotBeNull`.
|
||
- T1 `:255-257` — assert pin **đúng bản V1 active của đúng loại**, và seed sẵn 1 bản NHIỄU loại
|
||
khác (`:197-201`) để `!= null` không đủ để xanh. Đây là cách đúng để đóng KHAI-1.
|
||
- T3 `:360` + T7 `:491` + T11 `:661-662` — assert **`ContractCodeSequences` không cháy**, tức đo
|
||
được thứ tự "rào đứng TRƯỚC codegen" bằng side-effect, không bằng đọc code.
|
||
- T6 `:436-440` và T7 `:496-498` — **đối chứng dương cùng lượt**, đổi đúng 1 biến thì QUA.
|
||
- T10 `:625-627` — đo **CHIỀU của `??=`**: viết `=` thì mọi assert khác vẫn xanh, chỉ vế này đỏ.
|
||
- T8 `:549-551` + `:555-562` — mối nối cầu → đường ống V2, đo bằng **hộp thư của đúng người**
|
||
(Cấp 1 thấy, Cấp 2 chưa thấy), không phải đọc cột.
|
||
|
||
**T12 vs vá-7 — ĐÚNG, đủ 3 vế spec đòi:** (a) phiếu TỰ đi tới `DaDuyet` qua service THẬT
|
||
(`:724-730`), không seed thẳng; (b) `ApprovedAmount` đông cứng `{250, 111}` với tương phản chứng
|
||
minh `??=` (dòng chốt tay giữ 111, **không bị 999 đè**) `:735-736`, và `GiaTri = 361` với lập luận
|
||
"Σ Proposed = 1249 ⇒ mọi cách sai đều ra số khác" `:744-745`; (c) sau bắc cầu, bấm duyệt lại →
|
||
`Conflict`, rồi đọc lại **cả `ContractId` lẫn `ApprovedAmount` đều không đổi** `:753-758`. ĐẠT.
|
||
|
||
**T11 (double-click) — CÓ RĂNG NHƯNG CHỈ CHO NỬA TUẦN TỰ.** Assert thật, 4 vế: Conflict + đúng
|
||
thông điệp + `Contracts.Count == 1` + `LastSeq == 1`. Nhưng `:652-655` là `await` lần 1 **rồi mới**
|
||
lần 2 (`ChangeTracker.Clear()` mô phỏng request thứ 2). Test này **không đo được và không thể đo**
|
||
ca ĐỒNG THỜI — tức F-1 vẫn hở. Bản thân comment test khai đúng phạm vi ("bấm 2 lần liên tiếp"),
|
||
nên đây KHÔNG phải lane nói quá; rủi ro là **người đọc sau tưởng "idempotency đã đóng"**.
|
||
|
||
**Ngoại lệ `CreateContract` trong `AuthorizePolicyRegressionTests` — SIẾT, không mở lỗ:**
|
||
- `:370-373` là `Dictionary<string,string>` map **1 action → 1 chuỗi policy CHÍNH XÁC**, dùng
|
||
`authz.Policy.Should().Be(expectedPolicy)` (`:419`) — so khớp tuyệt đối, **không** `StartsWith`
|
||
/ `Contains` / danh sách bỏ-qua.
|
||
- Action nào không có tên trong map vẫn rơi vào `else` đòi prefix `KeHoachKyKet` (`:428`) ⇒
|
||
**action tương lai KHÔNG được miễn trừ tự động**. Đúng yêu cầu "chỉ miễn đúng 1 action".
|
||
- `MenuKeys.All.Should().Contain(parts[0])` (`:434`) nằm NGOÀI `if/else` ⇒ vẫn chạy cho cả nhánh
|
||
ngoại lệ (chặn ca policy không tồn tại → 500).
|
||
- `:477-479` `KhkkCrossModuleGates.Keys.Should().BeSubsetOf(byName.Keys)` — chống **cấu-hình-chết**
|
||
(đổi tên/xoá action mà quên dọn map thì mục đó im lặng không ai đo). Đây là vế "vắng-mặt trông
|
||
giống ổn" mà tôi hay phải tự đòi; lane tự đặt sẵn.
|
||
|
||
---
|
||
|
||
## §7 — Trọng tài tươi (reviewer tự chạy, không nhận số của lane)
|
||
|
||
| Phép đo | Lệnh | Kết quả |
|
||
|---|---|---|
|
||
| Build BE | `dotnet build SolutionErp.slnx -v q` | **Build succeeded — 0 Warning, 0 Error** (62s) |
|
||
| 3 lớp test K7 | `dotnet test --filter …KhkkBridgeAuthzTests\|…CreateContractFromSigningPlanTests\|…AuthorizePolicyRegressionTests` | **39/39 passed, 0 failed** |
|
||
| Toàn suite | `dotnet test SolutionErp.slnx --no-build` | Domain **45/45** + Infrastructure **594/594** = **639 pass / 0 fail** |
|
||
| tsc fe-user | `npx tsc --noEmit` | exit 0, 0 dòng lỗi |
|
||
| tsc fe-admin | `npx tsc --noEmit` | exit 0, 0 dòng lỗi |
|
||
|
||
🔴 **Phép cộng khớp:** baseline trước đợt này là **622** (đo ở gate K4c cùng ngày) và diff thêm
|
||
**17** `[Fact]` ⇒ kỳ vọng 639. Đo được đúng **639**. Nghĩa là 17 test mới **có chạy thật** (không
|
||
bị filter/skip câm) **và** 0 test cũ bị vỡ. Đây là 1 con số đóng cả 2 câu hỏi.
|
||
|
||
### Prod probe — TIỀN ĐỀ SEEDER, ĐO ĐƯỢC, XÁC NHẬN
|
||
|
||
`GET /api/permissions/by-role/{id}` cho **cả 13 vai** trên `api.solutions.com.vn` (token admin;
|
||
lưu ý field token là `accessToken`, không phải `token` — sai tên là "login failed" giả):
|
||
|
||
```
|
||
roles on prod: 13
|
||
Accounting/AuthorizedSigner/CatalogManager/CostControl/DeptManager/Director/
|
||
Drafter/Equipment/Finance/HrAdmin/Procurement/ProjectManager → Contracts R=True C=False
|
||
Admin → Contracts R=True C=True
|
||
--- roles with Contracts.CanCreate = true: 1 / 13
|
||
```
|
||
|
||
Kết luận đo được (không suy từ code):
|
||
- Tiền đề của lane **ĐÚNG**: hôm nay **Drafter = False, Procurement = False** ⇒ nút bridge sẽ 403
|
||
cho họ nếu không có seeder. Lỗ là THẬT.
|
||
- **Sửa lại F-9 bằng số sống:** không phải "13/13 role", mà **12/13**; và Admin qua **không chỉ**
|
||
nhờ bypass — Admin **đã có sẵn row `CanCreate=True`** trên prod.
|
||
- **Củng cố F-2 bằng số sống:** vì Admin đã True, nhánh Admin của seeder mới là **no-op**.
|
||
- **Dự đoán kiểm chứng được sau deploy+restart:** seeder phải log đúng **`0 added + 2 upgraded`**
|
||
(Drafter, Procurement). Lệch con số này = có thứ khác đang lật quyền. Đây là acceptance rẻ nhất
|
||
cho nghiệm thu #75/#76 "restart THẬT rồi đếm row".
|
||
|
||
Giới hạn khai thật: prod **chưa deploy** diff này ⇒ phép đo trên chứng minh TIỀN ĐỀ, **không**
|
||
chứng minh seeder chạy đúng. Không đo được bằng con mắt người dùng thường vì
|
||
`nv.test@solutions.com.vn` đăng nhập thất bại (tài khoản không còn/đổi mật khẩu) — đã dùng đường
|
||
admin thay thế.
|
||
|
||
---
|
||
|
||
## §8 — Tổng hợp finding (CHỐT)
|
||
|
||
| # | Sev | Vị trí | Một câu |
|
||
|---|---|---|---|
|
||
| F-1 | MAJOR | `CreateContractFromSigningPlanFeatures.cs:128-129` + `:248` | Chống-trùng chỉ là kiểm-tra-trong-bộ-nhớ, không ràng buộc CSDL / không transaction bao ngoài ⇒ 2 request ĐỒNG THỜI tạo 2 HĐ, 1 cái mồ côi; T11 chỉ phủ ca tuần tự. |
|
||
| F-8 | MAJOR | `ContractSigningPlansController.cs:224` · `DbInitializer.cs:2743` · `KhkkBridgeAuthzTests.cs:153` | Mỏ-neo `DbInitializer.cs:2516` đúng lúc viết, **sai ngay lúc land** (dòng thật 2523 — chính diff này chèn 7 dòng ở `:2353-2359` đẩy nó xuống); **3 nơi**, không phải 2. |
|
||
| F-R1 | MINOR | `scripts/{distill-shard-probe,measure-agent-memory,memory-archive-gate}.ps1` | Dirty ngoài cả 13-file-set lẫn lead-set (việc rotate `_INDEX-<period>.md`); `git add -A` sẽ nuốt vào commit K7. |
|
||
| F-9 | MINOR | `DbInitializer.cs:2743-2744` | "403 với **13/13** role… chỉ Admin qua nhờ bypass" tự mâu thuẫn; đo prod: **12/13** có `CanCreate=False`, và Admin **đã có sẵn row True** (không chỉ bypass). |
|
||
| F-2 | MINOR | `DbInitializer.cs:2775` | Cấp row cho Admin vô hại nhưng **vô tác dụng** — có Admin-bypass (`MenuPermissionHandler.cs:26-31`) và prod đo được Admin đã `CanCreate=True`. |
|
||
| F-3 | MINOR | `DbInitializer.cs:2814` | "Thu hồi khi cần: xoá call ở SeedAsync" — call thật nằm trong `SeedAdminPermissionsAsync` (thân 2288-2381). |
|
||
| F-4 | MINOR | `DbInitializer.cs:2792` | Hàm nâng cả `CanRead` trong khi tên + comment chỉ hứa `Contracts.Create` (rộng hơn nhãn). |
|
||
| F-5 | MINOR | `KhkkDetailPage.tsx:527` · `:975` | `<a href>` thay `<Link>` (full reload, mất state SPA) + neo "Đã có HĐ" lồng trong `<label>` (interactive-lồng-interactive, cùng lớp a11y với finding K5). |
|
||
| F-6 | MINOR | `usePipelineStages.ts:324` | `khkkPlanContractIds` gọi trong `find` trong vòng lặp — O(n·m) + cấp phát mảng mỗi lượt; vô hại ở list kẹp 200. |
|
||
| F-7 | MINOR | `CreateContractFromSigningPlanFeatures.cs:186-207` | Không carry `DraftData` như bridge PE tiền lệ (`CreateContractFromEvaluationFeatures.cs:104`) dù `pe` đã nạp sẵn, và không có dòng nào nói đó là chủ đích. |
|
||
| CLARIFY-1 | — | `usePermission.ts` + `AuthContext.loadMenu` | Sau khi seeder chạy, nút vẫn xám cho tới khi user **đăng-xuất/đăng-nhập lại** (menu cache `localStorage`) — UAT phải nêu, không thì bị đọc thành "seeder không ăn". |
|
||
|
||
**Không có blocker.** F-1 và F-8 phải xử lý nhưng không chặn commit: F-1 xác suất thấp + không mất
|
||
dữ liệu (HĐ mồ côi xoá được), F-8 là con trỏ trong chú thích.
|
||
|
||
**ĐẠT (đo xong, không có finding):** hội-2-khoá đủ 2 tầng và FE không lỏng hơn server (0 đường
|
||
gọi API lách gate) · thứ tự seeder SAU revoker (2341 → 2361) và là seeder-quyền chạy CUỐI ·
|
||
upgrade-only + idempotent (0 chỗ hạ cờ, lượt 2 không `SaveChanges`) · chuỗi
|
||
`Line.ContractId` → `ContractIds` → `khkkPlanContractIds` → `Set` union null-safe từng khớp ·
|
||
mirror 3/3 byte-identical (SHA tự đo) · route `/contracts/:id` tồn tại ở CẢ 2 app · guard 4 vế
|
||
workflow ném tách từng nguyên nhân · vá-6 đủ 4 khoản (2 NotFound guard · codegen trước `Add` ·
|
||
`SlaDeadline` · pin `WorkflowDefinitionId`) · 17 test mới có răng thật · ngoại lệ regression siết
|
||
đúng 1 action + chống cấu-hình-chết · build 0/0 · 639/0 test · tsc sạch 2 app.
|
||
|
||
**KHAI-1 (test-lane: invest T1 ghi `WorkflowDefinitionId` null nhưng vá-6 thắng): XÁC NHẬN ĐÚNG —
|
||
ĐÓNG, không mở lại.** Spec `②:4` chốt "16 vá THẮNG khi xung đột với nền"; mã theo vá-6 (`:163-166`
|
||
+ `:201`); và T1 `:255-257` assert đúng bản active + có bản nhiễu để chống xanh-giả.
|
||
|
||
---
|
||
|
||
## §9 — Đề nghị hành động (mô tả tiêu chí, KHÔNG chỉ-dẫn sửa mã)
|
||
|
||
1. **F-8** — bỏ số dòng, trỏ bằng TÊN (`SeedAllRolesReviewReadPermissionsAsync` + token
|
||
`CanCreate = isPe` là duy nhất trong file, grep ra ngay), hoặc cập nhật cả **3** nơi thành 2523.
|
||
Tiêu chí nghiệm thu: sau khi vá, `grep -n "CanCreate = isPe"` trên cây ĐÃ COMMIT phải khớp con
|
||
số ghi trong chú thích.
|
||
2. **F-1** — hoặc dựng một phép kiểm chứng minh 2 lệnh gọi đồng thời chỉ sinh 1 HĐ, hoặc khai công
|
||
khai "chấp nhận rủi ro, chỉ chặn ở UI" ngay tại `:128-129` để người sau không đọc T11 thành
|
||
"đã đóng".
|
||
3. **F-9 / F-2** — sửa "13/13" thành "12/13" và bỏ vế "chỉ Admin qua nhờ bypass" (prod đo được
|
||
Admin có row thật).
|
||
4. **F-R1** — tách 3 file `scripts/*.ps1` khỏi commit K7, hoặc khai chúng vào lead-set trước khi
|
||
`git add`.
|
||
5. **Nghiệm thu sau deploy** — restart THẬT rồi soi log seeder: kỳ vọng **`0 added + 2 upgraded`**;
|
||
và UAT phải có bước đăng-xuất/đăng-nhập lại (CLARIFY-1).
|
||
|
||
END gate-k7 — VERDICT=PASS-WITH-FLAGS 10
|