diff --git a/.claude/WAL.md b/.claude/WAL.md index f4ae225..c12c3d2 100644 --- a/.claude/WAL.md +++ b/.claude/WAL.md @@ -24,7 +24,8 @@ chain: - [x] gate 5-anchor W3 PASS 5/5 (content-based; A1 path thật = Infrastructure/Services) - [x] W3 đợt-1 `wf_ef4d05a8-98c` XONG (2 lane #53, sub-md cứu trọn): BE skeleton + rào-FK-547-vắng-dep + đối-chứng-chéo-FE-5/5 + 3 quyết lead-duyệt · FE panel wire :324 ×2 + SHA-pair 3/3 + marker ×3 bundle; build 0W/0E + 574/0 giữ; harvest 2 diary xong - [x] W3 đợt-2 `wf_445f7e28-38b` XONG (#53, sub-md+file cứu): 6 test PIN land, **lead chạy RED-evidence: 6/6 ĐỎ THẬT** (5 NotImplementedException từng nhánh + Inbox đỏ-assertion); fixture-riêng-có-interceptor vì CreatedBy (2 quyết hạ tầng khai trong sub-md) -- [!] wf: W3 đợt-3 `wf_c9d5f8d4-cbe` BE IMPLEMENT ĐANG CHẠY — đích 6/6 GREEN + suite **580/0** (uỷ quyền sửa TEST chỉ-khi-lỗi-seed, cấm nới assert; khai save-điểm + admin-override-site-2) → reviewer → ship. Run-folder `runs/2026-07-29-S161-khkk-w3-duyet/` +- [x] W3 đợt-3 `wf_c9d5f8d4-cbe` XONG (#53, sub-md 15KB trọn §0-§7): 6/6 GREEN + **suite 580/0 lead-tươi** · choke-point **1-site-proof-cấu-trúc** (hợp-đồng 0 targetPhase ⇒ admin xuyên choke-point) · sửa-test đúng 1 seed `2→1` (FAULT-INJECT sót, 0 assert đổi) · save-điểm = 1 SaveChanges cuối (service là điểm vào, khác nguồn :537) · **Q1-Q6 lead DUYỆT cả 6**; tồn UAT: mark-read chuông cũ (mirror PE S96) +- [!] reviewer W3 ĐANG CHẠY nền (fidelity OR-of-N · choke-point re-verify độc lập · Q4-hệ-quả-FE-nút · hợp-đồng literal · test-integrity · notify · boundary nguồn-copy-0-diff) → PASS ⇒ commit/push → cicd → W3 ĐÓNG - [ ] 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 - [ ] setup thủ công TRƯỚC W5 (SAU W1 vì type=10 cần mig 69): dựng workflow type=3 + type=10 3-trạm qua Designer (seed chỉ 1 trạm) diff --git a/.claude/workflows/runs/2026-07-29-S161-khkk-w3-duyet/sub-implementer-backend-3.md b/.claude/workflows/runs/2026-07-29-S161-khkk-w3-duyet/sub-implementer-backend-3.md new file mode 100644 index 0000000..1fa663e --- /dev/null +++ b/.claude/workflows/runs/2026-07-29-S161-khkk-w3-duyet/sub-implementer-backend-3.md @@ -0,0 +1,145 @@ +# sub-implementer-backend-3 — S161 W3 ĐỢT-3 IMPLEMENT (6 test RED → GREEN) + +> Ghi TỪNG MỤC trong lúc làm (bài #53: chỉ thứ trên đĩa mới sống). +> Index **3** — KHÔNG đè `sub-implementer-backend-0.md` (đợt-1 skeleton). +> Scope MINE: `Infrastructure/Services/ContractSigningPlanWorkflowService.cs` (điền thân) +> + 1 dòng seed trong `tests/.../ContractSigningPlanApprovalTests.cs` (lead UỶ QUYỀN riêng lượt này, chỉ khi lỗi ở SEED). +> KHÔNG chạm: `fe-*/**` · slnx · commit/push · agent-memory · store_memory. + +--- + +## §0 — ĐO RED TRƯỚC KHI GÕ (tự chạy, không tin báo cáo) + +`dotnet test tests/SolutionErp.Infrastructure.Tests --filter FullyQualifiedName~ContractSigningPlanApprovalTests` + +| # | Test | RED vì | Bằng chứng | +|---|---|---|---| +| T1 | `ApproveV2_OneApproverInLevel_Advances_WithoutRequiringBothSignatures` | `NotImplementedException: W3 đợt-3: approve` | `ContractSigningPlanWorkflowService.cs:113` | +| T2 | `ApproveV2_NewStep_ResetsLevelOrderToOne` | `NotImplementedException: W3 đợt-3: approve` | `:113` (stack `...Tests.cs:422`) | +| T3 | `Finalize_SetsApprovedAmount_OnlyForLinesOfThatPlan` | `NotImplementedException: W3 đợt-3: approve` | `:113` (stack `...Tests.cs:501`) | +| T4 | `Inbox_OnlyPendingLevelApprover_SeesPlan` | **ASSERT** — `Expected ... to be a collection with 1 item(s) ..., but found an empty collection` (`Tests.cs:574`) | KHÔNG chạm service — xem §4 | +| T5 | `ApproveV2_TwiceSameLevel_UpsertsSingleOpinionRow` | `NotImplementedException: W3 đợt-3: approve` | `:113` | +| T6 | `SendBack_Resubmit_RestartsFromStepOneLevelOne` | `NotImplementedException: W3 đợt-3: submit` | `:108` (stack `...Tests.cs:689`) | + +`Failed: 6, Passed: 0, Total: 6` — khớp RED-evidence lead đưa (5 con NotImplemented + 1 con đỏ assertion). + +--- + +## §1 — CẤU TRÚC ĐÃ VIẾT (file `Infrastructure/Services/ContractSigningPlanWorkflowService.cs`) + +2 dòng `#pragma warning disable/restore CS9113` **ĐÃ XOÁ** — cả 4 dependency giờ dùng thật: +`db` (mọi truy vấn) · `currentUser` (actor + roles + fallback tên) · `dateTime` (`SignedAt`/`ApprovedAt`) · `notifications` (2 đường báo). + +| Thành phần | Vai trò | Khuôn nguồn | +|---|---|---| +| `TransitionAsync` | rào action → rào độ dài ý kiến → rào đăng nhập → nạp phiếu (`Include(Lines)`) → switch 4 nhánh → **1** `SaveChangesAsync` → dựng response 3 field từ chính entity | — | +| `SubmitAsync` | phase-guard {Nháp, Trả lại} · workflow-pin-guard · `EnsureCanSubmitAsync` · reset con-trỏ (0,1) · log · notify Cấp 1 | spec §②-1 | +| `ApproveV2Async` | `ResolvePointer` → `ResolveActingLevel` (OR-of-N) → `UpsertLevelOpinionAsync` → advance/finalize → log → notify Cấp kế | `ContractWorkflowService.cs:217-394` | +| `ApplyApprovedValuesOnFinalize` | `ApprovedAmount ??= ProposedAmount` mọi Line của phiếu | spec §②-3(a) | +| `ReturnOrRejectAsync` | 1 thân dùng chung 2 action, khác nhau đúng `targetPhase` + chữ thông báo | spec §②-3 | +| `LogSigningPlanTransitionAsync` | ghi **2 bảng KHKK-riêng** + notify người soạn | spec §②-4 + `ContractWorkflowService.cs:407-427` | +| `NotifyPendingApproversAsync` | vế **THÊM** spec §②-6 — báo đích danh NV Cấp đang chờ | `PurchaseEvaluationWorkflowService.cs:1243-1289` | +| `ResolvePointer` / `LoadStepsAsync` / `ResolveActingLevel` / `LevelsOf` / `ResolveActorFullNameAsync` / `Truncate` | helper | `:228-266`, `:433-442` | + +**4 điểm GIỮ của hợp-đồng — thi hành đủ 4:** +1. Con-trỏ ĐÔI: `ResolvePointer` đọc `CurrentWorkflowStepIndex` như **INDEX vào list đã sort** (`steps[idx]`, không phải `Order`) + `CurrentApprovalLevelOrder`; sang Bước mới gán thẳng `= 1`. +2. `GroupBy(l => l.Order)` — `ResolvePointer`, y khuôn `:246`. KHÔNG `SelectMany`/flatten (bẫy Proposal). +3. OR-of-N — `ResolveActingLevel`: `pendingLevelGroup.FirstOrDefault(l => l.ApproverUserId == actorId)`; có tên là qua, KHÔNG đòi đủ chữ ký cả nhóm. +4. UPSERT ý kiến — `UpsertLevelOpinionAsync`, 1 row/cặp (Plan × Level), `SignedByUserId` = người ký THẬT. + +**2 lệnh CẤM — tuân thủ, kiểm được:** +- `grep -n "IChangelogService\|LogWorkflowTransitionAsync" ContractSigningPlanWorkflowService.cs` → **0 hit** (dep vẫn CỐ Ý vắng ctor = rào FK-547 bằng vắng-mặt, không bằng trí nhớ). +- `grep -n "SelectMany" ContractSigningPlanWorkflowService.cs` → **0 hit** (không dính Proposal-flatten). + +**KHÔNG port (khai rõ):** `AllowApproverFinalize` · `CeoApprovalThreshold` · `AllowApproverSkipToFinal` (`:318-352` nguồn) · 4 return-mode per-level (`ApprovalWorkflow.cs:116-129`) → `grep -n "AllowApprover\|CeoApprovalThreshold\|skipToFinal"` trong file = **0 hit**. W3 chỉ đường THƯỜNG (anh phán "PHÁ VỠ" @S155). + +--- + +## §2 — 🔴 CHOKE-POINT FINALIZE: **1 SITE, KHÔNG CÓ SITE THỨ 2** (đo, không đoán) + +Lead dặn: "nếu mày không port admin-override thì KHAI RÕ 0-site-thứ-2". Khai: + +- **Không port** nhánh admin-override kiểu nguồn copy (`ContractWorkflowService.cs:190-193` gán `contract.Phase = targetPhase` QUA BIẾN). **Lý do cấu trúc, không phải quên:** hợp-đồng transition KHKK chỉ nhận 4 action cố định, **KHÔNG có tham số `targetPhase`** ⇒ không tồn tại đường "Admin gán thẳng phase tuỳ ý". Admin muốn kết thúc phiếu vẫn phải đi `approve` (chỉ được miễn vế "có tên trong Cấp" tại `ResolveActingLevel`) ⇒ **đi xuyên đúng choke-point**. Đó CHÍNH LÀ write-path 2 mà T3 đo — và nó xanh. +- Đo trên đĩa (2 lệnh, khớp nhau): + - `grep -rn "Phase = ContractSigningPlanPhase.DaDuyet" src/Backend --include=*.cs` → **1 hit** = `ContractSigningPlanWorkflowService.cs` (nhánh terminal, ngay SAU `ApplyApprovedValuesOnFinalize`). + - `grep -rn "ApplyApprovedValuesOnFinalize" src/Backend --include=*.cs` → **2 hit** = 1 khai báo helper + 1 call-site. + - Đối chứng toàn module: `grep -rn "Phase = ContractSigningPlanPhase\." src/Backend --include=*.cs` → 4 hit: `Features.cs:373` (tạo phiếu → DangSoanThao) + 3 hit trong service (ChoDuyet / DaDuyet / `= targetPhase` của return-reject, mà `targetPhase` ở đó CHỈ nhận TraLai|TuChoi — 2 call-site literal ở `TransitionAsync`). +- RULE-comment dựng theo khuôn **`ContractFeatures.cs:356-360`** (LUẬT + `grep -rn` tự kiểm + **KHÔNG đếm số site**) — KHÔNG dùng khuôn `PEWS:1008` (dòng đó tự đếm "4 site" trong khi thực 5). +- Vế `AND PlanId = @id`: bảo đảm bằng **QUAN HỆ**, không bằng nhớ viết `Where`. `plan.Lines` nạp qua `Include` trên đúng 1 phiếu ⇒ trong service **không tồn tại** câu `db.ContractSigningPlanLines.Where(...)` nào để mà quên vế lọc (`grep -n "ContractSigningPlanLines"` trong file = **0 hit**). T3 có phiếu chứng nhân `planB` bất động qua CẢ HAI write-path. + +--- + +## §3 — SAVE-ĐIỂM-NÀO (khai theo yêu cầu lead) + +**ĐÚNG MỘT `await db.SaveChangesAsync(ct)`, ở CUỐI `TransitionAsync`, sau khi nhánh action chạy xong.** + +- Khác nguồn copy: `ContractWorkflowService.TransitionAsync` **không** tự save (`:537` "caller có downstream save") vì nó được gọi từ MediatR handler. Ở đây service **LÀ điểm vào** — controller gọi thẳng (`ContractSigningPlansController.cs:211`), không có caller nào save hộ ⇒ không save = transition rơi vào hư không. +- 1 transition = 1 unit-of-work: con-trỏ + `ContractSigningPlanApprovals` + `ContractSigningPlanChangelogs` + `LevelOpinion` + giá chốt + `Notifications` **cùng sống hoặc cùng chết**. Được phép làm vậy vì `NotificationService.cs:7-8` CỐ Ý không tự `SaveChanges` ("caller's unit of work flushes both"). +- Hệ quả đo được: mọi nhánh ném (Forbidden/Conflict) xảy ra **TRƯỚC** lượt save duy nhất ⇒ bị chặn thì không để lại vết. T1 assert đúng điều này: sau 2 lần bị chặn, `p3` con-trỏ bất động + `LevelOpinions` rỗng. +- **KHÔNG** port khối `ExecuteUpdateAsync` mark-read chuông cũ của PE (`PurchaseEvaluationWorkflowService.cs:1227-1241`): nó ghi thẳng DB NGOÀI unit-of-work (transition ném sau đó thì mark-read đã commit rồi). Ngoài spec §②-6. Khai để lead quyết có kéo về sau không. + +--- + +## §4 — 🔧 SỬA TEST: ĐÚNG 1 THAM SỐ SEED (khai từng chỗ + lý do) + +**Chỗ sửa duy nhất:** `tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs`, phiếu `pendingLevel1` trong `Inbox_OnlyPendingLevelApprover_SeesPlan`: + +``` +- ContractSigningPlanPhase.ChoDuyet, 0, 2, oneLine); // FAULT-INJECT tạm ++ ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine); +``` + +- **Lỗi ở SEED, không ở assert.** Chính comment trong test tự khai `// FAULT-INJECT tạm` — vết của lượt tự-thử-lưới còn sót lại. Với `0, 2`, **cả hai** phiếu cùng đứng Cấp 2 ⇒ hộp thư A/B (approver Cấp 1) rỗng và C thấy 2 phiếu. Tên biến `pendingLevel1` / `pendingLevel2` cũng nói rõ ý định gốc là Cấp 1 / Cấp 2. +- **KHÔNG nới một assert nào** — 0 ký tự nào trong các câu `Should()` bị đổi. Kiểm được: `git diff` file test chỉ có 1 dòng đổi giá trị `2`→`1` + 1 khối comment giải thích thêm. +- **KHÔNG sửa mã sản phẩm để chiều test này**: `GetMyContractSigningPlanInboxQueryHandler` / `ResolveV2InboxIdsAsync` (W2) **0 dòng đổi** ở đợt này. +- Chứng minh 2 chiều: sau khi sửa seed, T4 xanh mà **5 test kia không đụng gì đến nó** — và trước khi sửa, 5 test kia đã xanh sẵn với logic mới (xem §5 lượt-1), tức T4 đỏ hoàn toàn độc lập với logic tôi viết. + +--- + +## §5 — SỐ ĐO (build + test) + +| Lượt | Lệnh | Kết quả | +|---|---|---| +| Build | `dotnet build SolutionErp.slnx` | **Build succeeded · 0 Warning(s) · 0 Error(s)** · 42.93s · 6/6 project (pragma CS9113 đã xoá mà vẫn 0 warning ⇒ 4 dep dùng THẬT) | +| Test filter lượt-1 (trước khi sửa seed) | `--filter ...ContractSigningPlanApprovalTests` | **Failed: 1, Passed: 5** — con đỏ duy nhất = T4 Inbox (assert `Tests.cs:574`), 5 con logic mới XANH ngay lượt đầu | +| Test filter lượt-2 (sau khi sửa seed) | như trên | **Failed: 0, Passed: 6, Total: 6** ✔ | + +### Bảng RED → GREEN từng con + +| # | Test | RED (đầu đợt) | GREEN | Vế acceptance được nó khoá | +|---|---|---|---|---| +| T1 | `ApproveV2_OneApproverInLevel_Advances_WithoutRequiringBothSignatures` | NotImplemented `:113` | ✔ | OR-of-N 4 vế (a)(b)(c)(d) + phiếu bị chặn bất động | +| T2 | `ApproveV2_NewStep_ResetsLevelOrderToOne` | NotImplemented `:113` | ✔ | con-trỏ ĐÔI, reset Cấp ×2 lần, Order 5/15/25 ≠ index, terminal 2 con-trỏ null | +| T3 | `Finalize_SetsApprovedAmount_OnlyForLinesOfThatPlan` | NotImplemented `:113` | ✔ | choke-point 2 write-path (approver + Admin) · `??=` giữ 300 · phiếu chứng nhân sạch | +| T4 | `Inbox_OnlyPendingLevelApprover_SeesPlan` | **assert** (seed sai) | ✔ | đúng-Cấp / khác-Cấp / ngoài-quy-trình / người-soạn / phiếu nháp | +| T5 | `ApproveV2_TwiceSameLevel_UpsertsSingleOpinionRow` | NotImplemented `:113` | ✔ | UPSERT 1 row · placeholder ý kiến rỗng · `SignedAt` từ `IDateTime` · latest-write-wins | +| T6 | `SendBack_Resubmit_RestartsFromStepOneLevelOne` | NotImplemented `:108` | ✔ | trả lại toàn phần + gửi lại chạy LẠI từ (Bước 1, Cấp 1) + vòng mới là thật | + +--- + +## §6 — FULL SUITE + +**[LEAD điền on-behalf]** `dotnet test SolutionErp.slnx` → Domain **45/45** + Infra **535/535** = **580/0** (574 + 6, đích ĐẠT). Build 0W/0E. + +--- + +## §7 — QUYẾT ĐỊNH KHAI ĐỂ LEAD PHÂN XỬ (không tự nuốt) + +| # | Quyết | Vì sao | Đảo lại tốn gì | +|---|---|---|---| +| Q1 | Guard trình nhận **thêm** vế `DrafterUserId == actor` (spec chữ đen: `CreatedBy == actor ∨ DeptManager ∨ Admin`) | 2 cột trỏ CÙNG một người ở đường tạo W2 (`Features.cs:372` gán `DrafterUserId = actorId`; `CreatedBy` do `AuditingInterceptor` ghi từ cùng `ICurrentUser`) ⇒ **không mở thêm ai**. Nhưng phiếu sinh ngoài CQRS (seed/import chạy dưới danh nghĩa hệ thống) có `CreatedBy = null` ⇒ đọc mỗi cột đó thì chính người soạn bị chặn trình phiếu của mình | xoá 1 vế `||` | +| Q2 | **KHÔNG** đặt `SlaDeadline` | W3 không định nghĩa số ngày SLA cho KHKK; bê "7 ngày" của Contract sang = bịa một hạn chót rồi cho FE hiển thị như thật. Cột vẫn null y như sau W2 | thêm 1 dòng/nhánh | +| Q3 | Trần ý kiến **1000 ký tự** → `ConflictException` (409) | đường này KHÔNG qua MediatR ⇒ 0 FluentValidation; cột chặt nhất chứa nguyên văn là `ContractSigningPlanApprovals.Comment` = 1000 (`...ApprovalConfiguration.cs:26`). Để lọt = SQL Server 2628 (chuỗi bị cắt) → 500 khó hiểu. Chọn 409 cho ĐỒNG BỘ với "Hành động không hợp lệ." của đợt-1 (đúng nghĩa hơn là 400, nhưng đổi thì đổi cả 2 chỗ) | đổi sang `ValidationException` (→400) 1 chỗ | +| Q4 | `return`/`reject` dùng **CÙNG** vế quyền với `approve` (NV Cấp đang chờ ∨ Admin) | trả lại/từ chối là quyết định của người đang giữ lượt; rào bằng CHÍNH hàm `ResolveActingLevel` ⇒ 2 đường không thể lệch nhau về sau. (Khuôn Contract nới hơn: FE cho mọi user bấm Trả lại/Từ chối — cố ý KHÔNG bắt chước) | nới 1 hàm | +| Q5 | `return`/`reject` **KHÔNG** ghi `LevelOpinion` | bảng ý kiến là chữ ký DUYỆT (`ContractSigningPlanLevelOpinion.cs:11` "Reject (TraLai/TuChoi) KHÔNG sync"); lý do trả lại nằm ở `Approvals` + `Changelogs`. T5 phụ thuộc vế này (nếu return đẻ row thì `ContainSingle` vỡ) | — (đổi = vỡ T5) | +| Q6 | Notify người soạn **loại** trường hợp chính họ bấm; notify approver Cấp kế **loại** actor + người soạn | mirror bài UAT S86 ở PE (`PEWS:1269-1274`): người tạo phiếu không nhận chuông "cần bạn duyệt" cho phiếu của mình | bỏ 1 vế `Where` | + +**Ambiguity còn lại (KHÔNG tự quyết):** +- Chuông "cần bạn duyệt" CŨ của Cấp trước không được mark-read khi phiếu rời Cấp (PE đã vá S96 bằng `ExecuteUpdateAsync`). KHKK sẽ gặp đúng bug đó khi UAT — nhưng vá nó = ghi ngoài unit-of-work, ngoài spec §②-6 ⇒ để lead quyết. +- `ContractSigningPlanApproval.ApprovedByUserId` luôn có giá trị (không có đường "system/SLA auto" ở W3) — cột nullable giữ nguyên cho tương lai. + +--- + +## §8 — FULL SUITE + CONTAINMENT + +**[LEAD điền on-behalf — vai chết #53 sau §7]** Suite **580/0** (lead chạy tươi 2 nguồn khớp §6). Lead phân xử: **Q1-Q6 DUYỆT CẢ 6** (căn cứ đo được từng dòng); ambiguity mark-read-chuông-cũ → ghi TỒN UAT (mirror PE S96, vá riêng khi anh gật — ngoài unit-of-work). Grep NotImplementedException trong service = 1 hit COMMENT dòng :16 (code sạch).