# sub-test-specialist-0 — STAGE A T0 REPRODUCE (PE budget-freeze @DaDuyet)

**Role:** test-specialist · **Task:** viết ĐÚNG 1 test T0 REPRODUCE (test-before cho bug "phiếu DaDuyet đổi số ngân sách theo bảng LIVE"). Kỳ vọng **RED** trên code hiện tại.

**Boundary:** CHỈ ghi `tests/SolutionErp.Infrastructure.Tests/Services/PeBudgetFreezeTests.cs` + file này. KHÔNG chạm production (`src/Backend`, `fe-*`), KHÔNG sửa test cũ.

---

## 1. Investigation (code = single source of truth)

**Bug cơ chế (verified qua đọc code):**
- Query `GetPurchaseEvaluationQuery` (KHÔNG phải `GetPurchaseEvaluationDetailQuery` — tên trong SPEC hơi lệch; query THẬT = `GetPurchaseEvaluationQuery`, trả `PurchaseEvaluationDetailBundleDto`). Handler `PurchaseEvaluationFeatures.cs:853`.
- `PurchaseEvaluationFeatures.cs:911-912` — `pairRec` load **LIVE** từ `db.PeWorkItemBudgets.FirstOrDefaultAsync(b => b.ProjectId == e.ProjectId && b.WorkItemId == wiKey)` — KHÔNG phân biệt Phase.
- `:964-972` — `PeBudgetSummaryDto` build từ `pairRec` (positional 17-field). Field cần assert:
  - `ProInitialAmount` = arg 16 ← `pairRec?.ProInitialAmount` (DTO def `PurchaseEvaluationDtos.cs:348`)
  - `InitialAmount` = arg 4 ← `pairRec?.InitialAmount` (CCM ban hành, DTO def `:335`)
- Property bundle: `PurchaseEvaluationDetailBundleDto.BudgetSummary` (`PurchaseEvaluationDtos.cs:260`).
- ⇒ mọi phiếu cùng cặp (ProjectId×WorkItemId) đọc CHUNG record live. Phiếu DaDuyet đổi số theo bản cập nhật mới nhất = BUG.

**Finalize path (đưa phiếu tới DaDuyet ĐÚNG cách — KHÔNG seed thẳng):**
- `PurchaseEvaluationWorkflowService.cs:855-873` nhánh level-finalize: `if (matchingLevel.AllowApproverFinalize && applyLevelFinalize)` → `:861 evaluation.Phase = DaDuyet`. Đây là 1 trong 4 site DaDuyet (SPEC: :861/:899/:931/:1053). Hiện KHÔNG có snapshot ngân sách.
- Mirror `PeApproverFinalizeTests` test #5 (AtLastSlot): workflow V2 1 Bước 1 Cấp, `AllowApproverFinalize=true`, approver duyệt + giá chốt (`approvedPriceAmount=400tr, source="Ncc"` — `ValidApprovedPriceSources` :954) → TỰ DaDuyet.
- `TransitionAsync` sig `:37-52` (14 param, `applyLevelFinalize=true` default `:51`).

**Commands đổi pair (SAU khi duyệt — KHÔNG có phase-guard nên chạy được mọi phase):**
- `UpdatePeBudgetProCommand(Guid PeId, decimal? ProInitialAmount, decimal? ProAdjustmentAmount, string? ProNote)` handler `(IApplicationDbContext, ICurrentUser)` — `PeWorkItemBudgetFeatures.cs:63/80`. Role-gate Admin|Procurement.
- `UpdatePeBudgetCcmCommand(Guid PeId, decimal? InitialAmount, decimal? AdjustmentAmount, string? CcmNote)` handler `:134/151`. Role-gate Admin|CostControl. Absolute-set (null=clear). EnsureTrackedAsync reuse pair đã seed.

**Helpers reuse:** `IdentityFixture.CreateUserAsync(email, fullName, deptId, roles[])` · `TestCurrentUser(userId, fullName?, email?, params roles)` (Common) · `FixedDateTime` (Common/SqliteDbFixture.cs:71) · `NoOpNotificationService` (Services namespace, PurchaseEvaluationWorkflowServiceGuardTests.cs:188).

## 2. Test design (1 [Fact] T0 REPRODUCE)
- (a) seed Project + WorkItem + PeWorkItemBudget pair (ProInitial=100tr, CCM InitialAmount=200tr).
- (b) build PE ChoDuyet gắn cặp + workflow finalize; đưa tới DaDuyet QUA `svc.TransitionAsync` (approver duyệt, applyLevelFinalize default true, giá chốt 400tr/"Ncc").
- (c) đổi pair LIVE: PRO 100tr→999tr (`UpdatePeBudgetProCommand`), CCM 200tr→888tr (`UpdatePeBudgetCcmCommand`), Admin. + sanity assert record live ĐÃ đổi (chống vacuous).
- (d) query `GetPurchaseEvaluationQuery` bằng **drafter** (user thường, isDrafter=true qua authz `:874/:891`) → assert `BudgetSummary.ProInitialAmount==100tr` && `InitialAmount==200tr`.
- Kỳ vọng **RED** (code hiện tại đọc live → 999tr/888tr).

## 3. Kết quả chạy — ✅ RED (đúng kỳ vọng T0)

**Lệnh:** `dotnet test SolutionErp.slnx --filter "FullyQualifiedName~PeBudgetFreeze" --nologo --verbosity minimal -p:BuildInParallel=false -maxcpucount:1`

**Build:** succeeded (test compile sạch → mọi API call/using đúng). **Kết quả:** `Failed! - Failed: 1, Passed: 0, Skipped: 0, Total: 1, Duration: 7 s`.

**Assert-failure nguyên văn (EVIDENCE):**
```
[xUnit.net 00:00:10.89]     SolutionErp.Infrastructure.Tests.Services.PeBudgetFreezeTests.DaDuyet_BudgetSummary_KeepsSnapshotValue_DoesNotFollowLivePairUpdate [FAIL]
  Failed SolutionErp.Infrastructure.Tests.Services.PeBudgetFreezeTests.DaDuyet_BudgetSummary_KeepsSnapshotValue_DoesNotFollowLivePairUpdate [7 s]
  Error Message:
   Expected s.ProInitialAmount to be 100000000M because phiếu DaDuyet phải GIỮ ngân sách PRO tại thời điểm duyệt (100tr), KHÔNG chạy theo record live (999tr), but found 999000000.0M (difference of 899000000.0).
  Stack Trace:
     ... at SolutionErp.Infrastructure.Tests.Services.PeBudgetFreezeTests.DaDuyet_BudgetSummary_KeepsSnapshotValue_DoesNotFollowLivePairUpdate() in ...\PeBudgetFreezeTests.cs:line 184
Failed!  - Failed:     1, Passed:     0, Skipped:     0, Total:     1, Duration: 7 s - SolutionErp.Infrastructure.Tests.dll (net10.0)
```

**Đọc kết quả:**
- Fail tại `PeBudgetFreezeTests.cs:184` = assert snapshot PRO `s.ProInitialAmount == 100tr` → **found 999tr** (record live). Bug tái hiện chính xác: phiếu DaDuyet chạy theo bảng live.
- 2 assert TRƯỚC ĐÃ PASS (nếu fail sẽ báo dòng khác): `pe.Phase == DaDuyet` (finalize path chạy đúng, KHÔNG seed thẳng) + `liveRec.ProInitialAmount == 999tr`/`InitialAmount == 888tr` (update ăn → assert (d) không vacuous). Chứng minh flow đầy đủ, CHỈ thiếu hành-vi snapshot.
- Assert CCM (`InitialAmount == 200tr`, dòng sau) chưa chạy tới vì FluentAssertions throw ở assert PRO đầu tiên — bình thường cho RED.
- `Domain.Tests`: "No test matches filter" = đúng (test nằm ở Infrastructure.Tests). KHÔNG phải lỗi.

**Verdict:** T0 RED PASS-as-designed. KHÔNG PREMISE-FAIL (không GREEN bất ngờ). KHÔNG chạm production. Sẽ GREEN sau khi implementer thêm snapshot ngân sách tại 4 site DaDuyet + display-gate đọc cột snapshot.
**Baseline:** 509 → 510 test (test này RED tới khi fix land — test-before đúng quy trình bug-fix docs/rules.md §7).

---

# STAGE C — INV-C T1-T9 (BE ĐÃ LAND, T0 ĐÃ GREEN → phủ nhánh/guard/edge còn lại)

Role: test-specialist. KHÔNG đụng src/ · KHÔNG sửa T0/test cũ. Thêm T1-T9 vào cùng file `PeBudgetFreezeTests.cs`.

## Anchors verified (đọc code truth — BE post-fix)
- Domain `PurchaseEvaluation.cs:122-132` — 11 cột snapshot: ApprovedBudgetProInitialAmount/ProAdjustmentAmount/ProNote · CcmInitialAmount/CcmAdjustmentAmount/CcmNote · PrevSubmittedTotal/Count · PrevSelectedTotal/Count · SnapshotAt.
- Service helper `ApplyBudgetSnapshotOnFinalizeAsync` def :994; gọi 4 site: :861 (level-finalize AllowApproverFinalize) · :900 (CCM-threshold delegation) · :933 (all-steps-done) · :1108 (V1 legacy — CHỈ snapshot, KHÔNG ApplyApprovedPrice). Helper: WorkItemId null → 10 cột null + SnapshotAt set; pair thiếu → 6 pair cols null; 4 lũy kế qua PeBudgetAccumulator; LUÔN overwrite + LUÔN SnapshotAt=dateTime.UtcNow.
- Helper NAMING: pair.InitialAmount/AdjustmentAmount = CCM; pair.ProInitialAmount/ProAdjustmentAmount = PRO.
- Display-gate `PurchaseEvaluationFeatures.cs:928` `frozen = Phase==DaDuyet && ApprovedBudgetSnapshotAt!=null`. Frozen branch :929-954 → CanEditPro/Ccm HARDCODE false,false (KỂ CẢ Admin) + BudgetFrozen=true + lũy kế từ snapshot + CurrentProposalTotal VẪN live-own-quotes. Live branch :956-984 → accumulator + canEditPro/Ccm theo role + BudgetFrozen=false.
- Guard 7a `SetPeCcmBudgetPeriodCommandHandler` PeWorkItemBudgetFeatures.cs:235 — `Phase is DaDuyet or TuChoi` → Conflict TRƯỚC role-gate (chặn CẢ Admin lẫn CCM).
- Guard 7b `AdjustPurchaseEvaluationBudgetCommandHandler` PurchaseEvaluationFeatures.cs:365 — cùng message, TRƯỚC isAdmin block.
- Accumulator: peers cùng (ProjectId,WorkItemId), Id!=this, CreatedAt<this. ⚠️ TestApplicationDbContext KHÔNG wire audit interceptor → set CreatedAt thủ công (peers earlier, current later).
- V1 legacy: PurchaseEvaluationWorkflowDefinition + Steps + Approvers(Kind=User AssignmentValue=userId). Match qua userManager.FindByIdAsync → cần user THẬT (fix.CreateUserAsync).

## T1-T9 danh sách (tên test = scenario_expected)
- T1 `LevelFinalize_SnapshotsAll11Columns_FromPairAndAccumulator` (:861) — 6 pair cột + notes + 2 peer (PrevSubmitted 150tr/2, PrevSelected 90tr/1) + SnapshotAt.
- T2 `CcmThresholdFinalize_SetsBudgetSnapshot` (:900) — CCM delegation below-threshold → snapshot pair cols + SnapshotAt.
- T3 `AllStepsDoneFinalize_SetsBudgetSnapshot` (:933) — 1 Bước 1 Cấp no-finalize-flag, approve last slot → snapshot + DaDuyet.
- T4 `V1LegacyFinalize_SetsBudgetSnapshot_WithoutApprovedPrice` (:1108) — def+step+user-approver → snapshot set + ApprovedPriceAmount==null.
- T5 `SetCcmBudgetPeriodGuard_BlocksDaDuyet_ForCcmAndAdmin_AllowsChoDuyet`.
- T6 `AdjustBudgetGuard_BlocksDaDuyetAndTuChoi_ForAdmin`.
- T7 `BudgetSummary_LiveVsFrozen_FlagsAndEditability`.
- T8 `Finalize_NullSafe_NoWorkItemAndNoPairRecord`.
- T9 `ReFinalize_AlwaysOverwritesStaleSnapshot_RefreshesSnapshotAt`.

## Kết quả chạy — ✅ ALL GREEN (T1-T9 phủ đủ, 0 RED phase vì BE đã land)

**Filtered:** `dotnet test SolutionErp.slnx --filter "FullyQualifiedName~PeBudgetFreeze" --nologo -p:BuildInParallel=false -maxcpucount:1`
→ `Passed! - Failed: 0, Passed: 10, Skipped: 0, Total: 10` (T0 + T1-T9).

**FULL:** `dotnet test SolutionErp.slnx --nologo --verbosity minimal -p:BuildInParallel=false -maxcpucount:1`
→ Domain `Passed! - Failed: 0, Passed: 45, Skipped: 0, Total: 45`
→ Infra  `Passed! - Failed: 0, Passed: 474, Skipped: 0, Total: 474`
→ **Tổng 519 PASS / 0 fail / 0 skip** = baseline 510 (509 + T0) + 9 (T1-T9).

## Bảng T1-T9 (verdict)
| Test | Nhánh/guard/edge | Verdict |
|---|---|---|
| T1 `LevelFinalize_SnapshotsAll11Columns_FromPairAndAccumulator` | :861 level-finalize → 11 cột | PASS |
| T2 `CcmThresholdFinalize_SetsBudgetSnapshot` | :900 CCM-delegation | PASS |
| T3 `AllStepsDoneFinalize_SetsBudgetSnapshot` | :933 all-steps | PASS |
| T4 `V1LegacyFinalize_SetsBudgetSnapshot_WithoutApprovedPrice` | :1108 V1 + giá null | PASS |
| T5 `SetCcmBudgetPeriodGuard_BlocksDaDuyet_ForCcmAndAdmin_AllowsChoDuyet` | guard 7a | PASS |
| T6 `AdjustBudgetGuard_BlocksDaDuyetAndTuChoi_ForAdmin` | guard 7b | PASS |
| T7 `BudgetSummary_LiveVsFrozen_FlagsAndEditability` | display live/frozen | PASS |
| T8 `Finalize_NullSafe_NoWorkItemAndNoPairRecord` | null-safe (a)+(b) | PASS |
| T9 `ReFinalize_AlwaysOverwritesStaleSnapshot_RefreshesSnapshotAt` | overwrite semantics | PASS |

## Surprise / bẫy đã xử
- **UNIQUE(ApprovalWorkflows.Code, Version)**: T8 gọi `SeedAndFinalizeAsync` 2 lần/1 db → helper `SeedFinalizeWorkflowAsync` Code cố định "QT-BF-V2" v1 → SQLite Error 19 (RED lần 1). Fix: thêm param optional `code="QT-BF-V2"` (T0 call-site 2-arg GIỮ NGUYÊN, hành-vi T0 bất biến) + `SeedAndFinalizeAsync` truyền `$"QT-BF-V2-{suffix}"`. Không đụng T0-logic.
- KHÔNG chạm src/ production. KHÔNG sửa T0. Test cũ CŨ 0 regression (509 baseline vẫn xanh). KHÔNG REGRESSION-FOUND.

**Verdict:** INV-C COMPLETE — 9 test mới GREEN, phủ 4 nhánh snapshot + 2 guard + 2 edge + display live/frozen. Tổng 519/519.

