---
name: baseline-history
description: L2 archive — verbose test-count history chain + aged FIFO entries (S54→D2). On-demand only; MEMORY.md index keeps one-liners.
metadata:
  type: project
---

# Baseline count history (verbose) + aged FIFO

> Moved out of `MEMORY.md` index 2026-06-26 (index over 24KB read cap). Each entry was a full test-design note; detail also recoverable from the test files + session logs. Newest first.

## Count progression (one-line chain)
254 (S60) → 263 (S61 +22 PeWorkItemBudget, −14 BudgetPolicy Domain drop) → 286 (S67 +23 HRM) → 292 (S69 +6 Office) → 306 (S69b +14 PE) → 334 (S72 +28 PE Mig54) → 339 (S74 +5 CcmNote) → 344 (S76 +5 PRO-split) → 351 (S77 +7 suggested-price NOTE) → 354 (S77b symmetric, superseded) → 366 (S78-S84, MEMORY chưa ghi từng delta) → 371 (S85 +5 urgent drafter-unset) → 374 (S85b +3 UpdateDraft WF-preserve) → 377-395 (S86-S86d) → 402 (S87 +7 CCM-budget-period) → **419 (D2 +6 PE Mig58 multi-winner + translation-smoke)**.

## S155 — PE xoa-khi-dang-duyet + man "Da xoa" (dot 2), +10 -> 561 (1 FAIL GIU DO = bug prod)
**S155 (2026-07-27) ĐỢT 2 PE xóa-khi-đang-duyệt + màn "Đã xóa" [run 2026-07-27-S155-pe-delete-approver · sub-d2-test-4, diary 13.9KB ghi-trong-lúc-làm]:** +10 `PeDeleteByApproverTests.cs` (T21 lũy kế **4 HƯỚNG KHÁC NHAU** · T22/T22b/T22c màn Đã xóa · T23/T24/T24b cờ+lượt · T25 authz reflection · T25b chiều thuận · T26 changelog). Full **561 = 560 PASS / 1 FAIL** (45D + 516I); 0 hồi quy (Infra cũ 506 nguyên vẹn). **1 FAIL = T26 GIỮ ĐỎ = BUG PROD**: `PeSoftDeleteFeatures.cs` Add changelog (:126) → `Remove(pe)` (:142) → `CascadeDeleteTiming.Immediate` cascade **ngay tại Remove**; dependent đang `Added` bị cascade ⇒ **Detached** (không phải Deleted) ⇒ 0 row; `AuditingInterceptor` chạy ở SavingChanges = SAU điểm mất ⇒ không cứu. Đo bằng ChangeTracker diag (`Added→Detached`, `rowsInDb=0`), KHÔNG suy đoán. Grep cùng-lớp: site DUY NHẤT có hình "Add con → Remove cha → 1 SaveChanges". REPORT lead, KHÔNG tự sửa. **Hạ tầng MỚI:** `SoftDeleteFixture` (ngay trong file test, ~90 LOC) = fixture ĐẦU TIÊN của repo wire `AuditingInterceptor` — BẮT BUỘC khi đo `Remove()`=xóa-mềm, vì `TestApplicationDbContext` KHÔNG wire ⇒ mặc định **XÓA CỨNG** ⇒ màn "Đã xóa" rỗng ⇒ assert đúng ở CẢ HAI thế giới (đúng bẫy vacuous). Số học T21 nhớ kỹ: xóa phiếu ChoDuyet ⇒ PrevSubmitted↓ · PendingSelected↓ · **PrevSelected + PendingSubmitted BẤT ĐỘNG** (seed thêm peer DaDuyet + TraLai để 2 số "không đổi" ≠ 0 ở cả 2 phía).

## S153 — T4 ctx-adopt FAULT-INJECT may `mind-check` (KHONG phai test C#, baseline C# khong doi)
**S153 (2026-07-26) T4 ctx-adopt FAULT-INJECT máy `mind-check` #8-#17+#21 [run 2026-07-26-S153-ctx-adopt · lane trước đứt sau #7, file đĩa nguyên → tiếp được] `[engine: opus-5]`:** **18/18 PASS 0 FAIL**. KHÔNG phải test C# (**baseline 509 KHÔNG đổi**) — đối tượng = `scripts/session_ctx.py mind-check`, mọi mutation trong cây tạm qua seam `--root`, repo read-only. Bắt **1 GAP THẬT**: ở closed-mode **XOÁ con-trỏ → cờ/exit 0** còn **GIỮ con-trỏ đang-chờ trung thực → TRƯỢT/exit 1** ⇒ đường-rẻ-nhất-để-xanh là đường SAI (chữ luật cấm, gradient thưởng) → REPORT em main, KHÔNG tự sửa. **Bài học mới quan trọng nhất:** fixture `draft-ts` MALFORMED ra exit 0 — `inject_A` neo `## MIND-1` sau khi `renum(...,2)` đã đổi heading ⇒ `str.find`=-1 ⇒ chèn GIỮA heading ⇒ `ts:` không ở đầu dòng ⇒ máy ĐÚNG mà vẫn xanh. **Fixture-hỏng cho-xanh trông Y HỆT máy-đúng cho-xanh** ⇒ phải soi RUỘT fixture (`cat -A`/`repr` từng dòng), KHÔNG chỉ đọc exit code. 2 kỹ thuật xài lại: (i) **cặp CÙNG-RUỘT khác 1 biến** — cap=32 vs cap=1 trên cùng tệp = chứng đọc-config-LIVE; `_end` giả `_pause=9` vs không = chứng nguồn-đếm-là-ĐĨA, dùng `diff` 2 output để chứng dòng đo BYTE-IDENTICAL; (ii) **đo wall-clock phải gồm nhánh ĐẮT** — lệnh literal đi nhánh early-exit (repo chưa có `_mind`) = tự đo nhánh rẻ rồi gọi là chi phí thật; đo 55-64ms/lượt vs ước 140-300ms ⇒ lệch 2.2-5.4×, giao lead điền lại.

## S134 — PE luy-ke TAM TINH TEST-C, +8 -> 528
**S134 (2026-07-17) PE lũy-kế TẠM TÍNH TEST-C [wf_97d6a582-b7e · return sạch 0-garble] `[engine: opus-worker]`:** +8 `PePendingAccumulationTests.cs` C1-C8 (file MỚI, mirror seed PeBudgetFreezeTests :277-336): C1 ChoDuyet+winner→delta2+PriorPes · C2 TuChoi/DaDuyet loại sạch · C3 nháp list-only/TraLai delta1 · C4 window CreatedAt · C5 delta âm signed · **C6 complementary-no-double-count: gọi CẢ ComputeAsync + ComputePendingAsync cùng seed, assert EXACT-NUMBERS 2 phía (PrevSelected 90tr/1 bất động ⟂ PendingSelected 50tr/1)** · C7 frozen→0/null qua query handler · C8 D4 changelog positive+contrast (filter `ContextNote.Contains("[S134 pending-prior]")` #48). Full **528/0** (45D+483I, +8). Learned: (1) **direct-call accumulator = synthetic curPeId + CreatedAt muộn, KHÔNG cần row phiếu-đang-xét** (chỉ filter Id!=/CreatedAt<); (2) regression "bất động" mạnh nhất = C6-style exact-numbers 2 hàm cùng seed; (3) FluentAssertions `ContainInOrder(params T[])` + because-string overload-clash khi T=Guid → bọc `new[]{...}` trước. Surprise: D4 no-SaveChanges-in-helper → row chỉ persist sau TransitionAsync.

## S133 INV-C — PE budget-freeze T1-T9, +10 -> 519 (sub-file collision multi-invocation)
**S133 INV-C T1-T9 [wf_75c6bfd3-754 · #53 return-fail lần 3 → disk + lead tự chạy suite; entry on-behalf]:** +9 test vào PeBudgetFreezeTests (`:426` T1 đủ-11-cột · `:480` T2 CCM-threshold · `:524` T3 all-steps · `:555` T4 V1-legacy assert KHÔNG-ApprovedPrice · `:638` T5 guard CCM+Admin Conflict + ChoDuyet-allow · `:687` T6 AdjustBudget DaDuyet+TuChoi · `:730` T7 live-vs-frozen flags/editability · `:783` T8 null-safe no-WorkItem + no-pair · `:821` T9 overwrite + SnapshotAt refresh). FULL suite lead-verified exit-0: **Domain 45 + Infra 474 = 519, 0 fail** (464+10 khớp). ⚠️ Lesson mới: INV-C **ghi đè** `sub-test-specialist-0.md` của INV-A — cùng role + cùng task-index qua 2 invocation chung run-folder = sub-file collision; INV-A content sống nhờ journal + diary. Multi-invocation cùng run → cần label/index phân biệt (báo lead khi gặp lại).

## S133 INV-A — PE budget-freeze T0 REPRODUCE (test-before bug-fix, RED that)
**S133 (2026-07-17) PE budget-freeze T0 REPRODUCE [wf_d9639faa-97a INV-A, return sạch]:** RED thật `Failed 1/1` — expected ProInitial 100tr, found 999tr @`PeBudgetFreezeTests.cs:184`. Flow chuẩn: đưa phiếu tới DaDuyet QUA `svc.TransitionAsync` (V2 1-Bước-1-Cấp `AllowApproverFinalize=true` + giá chốt), KHÔNG seed thẳng Phase (post-fix snapshot CHỈ set qua transition); mutate pair qua UpdatePeBudgetPro/Ccm (2 handler KHÔNG phase-guard = đúng cơ chế bug); 2 sanity-assert chống vacuous (Phase==DaDuyet + liveRec đổi thật) TRƯỚC assert chính. Bắt được lỗi spec: query THẬT = `GetPurchaseEvaluationQuery` (bundle `.BudgetSummary`), spec ghi nhầm GetPurchaseEvaluationDetailQuery → lead sửa spec. Sau INV-B: T0 GREEN trong 44/44 filter.

## S118b — ReportsController company-wide-report authz REGRESSION (test-after SECURITY), +4 -> 509
**S118b (2026-07-14) ReportsController company-wide-report authz REGRESSION (test-after SECURITY — reviewer catch) — 505→509 PASS (+4 Infra), no-red, NO prod bug:** EXTEND `Api/AuthorizePolicyRegressionTests.cs` (Pattern 10 reflection, +4 [Fact] → file 15→19). Fix S118: 2 endpoint company-wide `Dashboard`+`ExportContracts` (lộ tài chính HĐ toàn cty: tổng giá trị, top NCC/dự án, xu-hướng-12-tháng, export-Excel) NAY gate `[Authorize(Policy="Reports.Read")]` (chỉ Admin, khớp S92); `MyDashboard` CỐ Ý giữ mở (chỉ class-level `[Authorize]` trần, scope currentUser). Trước: cả controller class-level `[Authorize]` trần → mọi user đăng nhập gọi được = lộ tài chính. **T1** class-level `[Authorize]` Policy=null+Roles=null (bare — nếu hardcode Policy sẽ khóa my-dashboard). **T2/T3** Dashboard+ExportContracts action-Policy==`Reports.Read` (gỡ→NotBeNull-fail = tái-lộ toàn cty). **T4** MyDashboard `(attr?.Policy).Should().BeNull()` — cho phép bare `[Authorize]` vô-hại NHƯNG chặn mọi Policy (thêm policy→khóa dashboard cá-nhân role không-Admin). csproj ĐÃ ref Api (line 25, từ S45) — KHÔNG vướng reference. KHÔNG touch prod.

## S118 — Procurement master-access seeder SCOPE-ISOLATION regression (test-after SECURITY), +4 -> 505
**S118 (2026-07-14) Procurement master-access seeder SCOPE-ISOLATION regression (test-after SECURITY — anh Kiệt FDC) — 501→505 PASS (+4 Infra), no-red, NO prod bug:** new `ProcurementMasterAccessSeedTests.cs` (4 [Fact]) guard seeder MỚI `SeedProcurementMasterAccessAsync` (private static, DbInitializer) — NGOẠI-LỆ role Procurement mở lại Danh mục SAU S92 revoke ("chỉ Admin thấy"). Chain thật: Revoke→SeedProcurement. **T1 upgrade-path** Procurement sau revoke→seed đúng ma trận (Suppliers R+C+U D=false + 8 read-only chỉ R). **T2 ⭐SCOPE-ISOLATION (core)** DeptManager đứng RIÊNG (KHÔNG kèm Procurement): pre-grant→revoke→seed → 9 key VẪN all-false (row tồn tại giữ vết) = grant KHÔNG rò role khác; +CONTRAST assert Procurement THẬT được nâng (chống vacuous-pass). **T3 insert-path** DB mới no-revoke → INSERT 9 row, Delete=false + count==9. **T4 idempotent** seed ×2 → bất biến + count==9 no-dup + role khác vẫn ẩn. Reflection harness MIRROR `AdminOnlyModulesRevokeTests` (NonPublic|Static, sig (ApplicationDbContext,RoleManager<Role>,ILogger), FK seed MenuItem→Role→Permission). No user cần (perm role-level). **Reflection KHÔNG vướng — KHÔNG cần đổi visibility.** KHÔNG touch prod.

## S117 — PE ProMinMax duyet-theo-KHOANG range-price (test-before-merge FINANCIAL), +5 -> 500
**S117 (2026-07-14) PE "duyệt theo KHOẢNG" ProMinMax range-price TEST-BEFORE-MERGE (financial-critical) — 495→500 PASS (+5 Infra, file 10→15), no-red, NO prod bug:** feature anh Kiệt: PRO có cả Min&Max → người duyệt cấp cuối chọn CẢ HAI (source="ProMinMax") → `ApprovedPriceAmount=Min`, `ApprovedPriceMaxAmount=Max`. Bất-biến `Max!=null ⟺ Source=="ProMinMax"`. Prod đã land: `ApplyApprovedPriceOnFinalize(...,decimal? maxAmount=null)` +`ValidApprovedPriceSources`+"ProMinMax"; `TransitionAsync`/`ApproveV2Async` +`approvedPriceMaxAmount` (SAU source). 5 new [Fact] qua reachable terminal path (mirror case-1 SeedSingleApproverWorkflow, KHÔNG gọi private): (a) happy Min100/Max130 set cả 2 · (a-boundary) **min==max OK** lock guard `>` vs `>=` (revert→RED) · (b) thiếu Max→Conflict "*phải có cả giá Min và giá Max*" not-finalized · (c) min>max→Conflict "*không được lớn hơn giá Max*" · (d) nguồn ĐƠN+Max→Conflict "*kèm giá Max*" wire-bug-guard. +regression assert `Max==null` vào Theory 3b (4 nguồn đơn). **⚠️ Gotcha (self-inflicted-avoided):** prod method 5-param nay → `InvokeApply` reflection helper phải pass 5 args (`{...,maxAmount}`); optional param KHÔNG auto-bind qua `MethodInfo.Invoke` → nếu giữ 4 args = `TargetParameterCountException` phá unit-test 4/4b/4c. Đã fix helper. KHÔNG touch prod.

## S116 — PE bao gia SO AM regression-lock (test-before guard R2-revised), +9 -> 495
**S116 (2026-07-13) PE báo giá SỐ ÂM regression-lock (test-before cho guard R2-revised — critical-algo/financial) — 486→495 PASS (+9 Infra), no-red, NO prod bug:** feature anh Kiệt cho ThanhTien ÂM (phát-sinh-giảm/hoàn tiền NCC = giá THẬT). BE ĐÃ đổi submit-guard `:202-208` `Sum()<=0`→`Count==0`(chưa chọn)/`else All(x==0)`(chưa có giá). 4 nhóm test khớp hành-vi MỚI: **T1** `PeWorkItemBudgetTests` (+1) quote ÂM −20tr KHÔNG chạm PeWorkItemBudget record (FullAmount ngân sách bất biến) NHƯNG CHẢY vào CurrentProposalTotal=−20tr (chứng tách-biệt cột đề-xuất vs ngân-sách, "0 code" kiến-trúc). **T2** `PeMultiWinnerTests` (+2) winnerQuoteTotal=SUM CÓ DẤU khác-winner(+50,−20=30) + cùng-winner-2-dòng(80,−30=50). **T3** `PeSubmitGuardAndBypassTests` (+4, +2 helper `SeedWinnerWithQuotesAsync` params + `BuildV2PeReadyToSubmitWithQuotesAsync`): all-zero[0,0]→BLOCKED "chưa có giá"; pure-neg[−500k]→ChoDuyet; mixed net>0→ChoDuyet; 🔒 **mixed net==0 từng-dòng≠0 [+500k,−500k]→ChoDuyet** = ranh giới phân-biệt All(==0) vs Sum()<=0 (revert→RED). **T4** `PeCcmThresholdFinalizeTests` (+2, +helper 2-signed-quote): NET threshold — [+1.2tỷ,−0.3tỷ]=0.9tỷ<ngưỡng→CCM finalize no-throw + CONTRAST [+1.2tỷ] alone=1.2tỷ≥ngưỡng→Conflict (dòng âm LOAD-BEARING, R-CEO giữ NET). KHÔNG touch prod.

## S113 — Supplier import v2 dedup + publish-guard (test-after critical-algo + security), +19 -> 477
**S113 (2026-07-12) Supplier import v2 dedup + publish-guard TEST-AFTER (critical-algo + security) — 458→477 PASS (+19 Infra), no-red, 🔴 1 BUG-FOUND (REPORTED em-main, NOT fixed):** 2 new files `Services/SupplierImportV2DedupTests.cs` (13) + `Application/SupplierPublishAndListTests.cs` (6). Existing S112 file UNTOUCHED (re-baked ExpectedHeaderTokens=30 \n-token → real tokens khớp → Case9 tự flip LayoutValid=true). **Dedup precedence** existing-MST→existing-Code→batch-MST→batch-Code→New: strip-ws-normalize MST-match · 2-blank-MST=2-insert (blank không index) · same-MST-batch=1-insert+fill-null-skip · **Code-backstop CI** (S112: dedup ở SERVICE OrdinalIgnoreCase, SQLite BINARY KHÔNG bắt "backstop"≈"BACKSTOP" → xanh=chứng không dựa DB) · **MST→A/Code→B = MST-precedence WINS, no silent-clobber** (FillNulls skip Code → A.Code giữ, B untouched). **Publish** `PublishSupplierCommand` (KHÔNG UpdateSupplier #73): gate D2 Publish+blank-Code→Conflict "Cần Mã NCC" · unique-exact→Conflict "đã tồn tại" (⚠️ `x.Code==newCode` DB-collation, SQLite bắt exact-case only — prod SQL CI bắt CI-dup; asymmetry vs import, observation không bug) · unpublish always. ListSuppliers Published t/f/null · GetSupplier DTO.IsPublic. **🔴 BUG:** `NewSupplier` `Code=r.Code!.Trim()` → NRE khi row.Code==NULL (path R4 nháp: parser sinh null ô rỗng + preview classify New no-error → confirm NRE → 500 CẢ batch dbCount=0). Empty ""=OK (T8/T14). Characterization test CHỐT NRE → fix null-safe thì flip NotThrow+draft.

## D4 — PE sign-off A1 + EDGE-5 + opinion-history (test-before-merge critical-algo), +8 -> 458
**D4 (2026-07-12) PE sign-off A1 + EDGE-5 + opinion-history TEST-BEFORE-MERGE (critical-algo bug-fix + security) — 450→458 PASS (+8 Infra, 4 updated in-place), no-red, NO prod bug:** BE đổi `ApplyDrafterBypassOnSubmitAsync` sang **A1** trong `PeSubmitGuardAndBypassTests.cs`. **4 updated + RENAMED** (tên cũ misleading): drafter bypass CHỈ cấp NV dưới THUẦN (Order<minOwn); pointer DỪNG tại minOwn (người soạn ký TAY qua ApproveV2Async); KHÔNG auto-sign opinion chính chủ / KHÔNG auto-terminal-on-submit / KHÔNG auto nhảy Bước. minOwn=1 (drafter Cấp1 hoặc non-contiguous Cấp1+3) → bypass RỖNG (opinion=0, AutoApprove=0). **8 new:** A1 happy-path (drafter ký Cấp2 comment THẬT — canned "duyệt tự động" đã GỠ khỏi prod, grep 0-match) · non-contiguous full-manual-chain đúng thứ tự · EDGE-5 ×4 (non-admin Reject từ DaDuyet/TuChoi → Conflict "trạng thái kết thúc"; **Admin + system[actorUserId=null+AutoApprove] MIỄN** → fall-through "không hỗ trợ" chứng minh qua-guard) · opinion-history ×2 (re-sign đè opinion non-empty → đúng 1 Changelog Workflow/Update EntityId=level.Id ContextNote chứa comment cũ; first-sign / đè-rỗng → 0 row, filter theo EntityId==levelId loại được Trả-lại-changelog EntityId=null). Helpers: `ApproveAsync` (approvedPrice khi ký cấp CUỐI terminal — ApplyApprovedPriceOnFinalize Conflict nếu thiếu, isSystem miễn) + `ReturnToDrafterAsync` (AllowReturnToDrafter default TRUE trên Level). Empty-opinion chỉ reachable qua seed-trực-tiếp (normalizedComment placeholder-hoá whitespace).

## D3 — Supplier Excel-import BE Phase B (test-before-merge case-collation bug-class), +10 -> 450
**D3 (2026-07-12) Supplier Excel-import BE (Phase B) TEST-BEFORE-MERGE (CRITICAL-ALGO + case-collation bug-class) — 440→450 PASS (+10 Infra), no-red, NO prod bug:** new `Services/SupplierExcelImportServiceTests.cs` (10 [Fact]). **Case-collation (THE bug):** ConfirmAsync lowercase `truonggiang` vs DB `TRUONGGIANG` → Inserted=0/Updated=1/count=1 (dedup ở SERVICE `OrdinalIgnoreCase`, KHÔNG dựa DB — SQLite fixture BINARY collation KHÔNG bắt CI-dup, prod SQL Server CI thì có → nếu Ordinal = 500 batch); Preview classify Update. **Fill-nulls SAFE** (non-null giữ / null điền). **All-or-nothing** (1 hard-error→Committed=false + DB unchanged). MapType unknown→NhaCungCap+warning · emoji-strip status → DangHoatDong · valid-layout absolute-index parse (LinkGuq RAW backslash) · no-column-drift (col9 empty→col12 vẫn TaxCode) · #REF! literal→null. **Pattern (reuse):** header xlsx dựng từ `ExpectedHeaderTokens` qua REFLECTION (`GetField(NonPublic|Static)`) → valid-layout test tự đồng bộ khi tokens sửa; ClosedXML in-mem `XLWorkbook.AddWorksheet`+`ws.Cell(r,c).Value=string` GIỮ TEXT (0.105 no numeric-coercion → leading-zero safe). **Spec-drift FLAGGED (không phải bug):** `ExpectedHeaderTokens` = BEST-GUESS ≠ 30 real row-4 token → Case 9 dynamic `LayoutValid==(NormalizeJoin(real)==NormalizeJoin(reflected))` robust 2-chiều, hiện LayoutValid=false; close-review sẽ sửa tokens → test tự flip true.

## S97 — PE EndsBeforeCeo CONFIG->RUNTIME (spec-change test), +0 (extend in-place)
**S97 (2026-07-01) PE EndsBeforeCeo CONFIG→RUNTIME (bug anh Kiệt FDC, spec-change test) — 440 PASS no-red, NET +0 [Fact] (extend in-place):** em-main BE thêm `PurchaseEvaluation.EndedByLevelFinalize` (Mig 60) set true DUY NHẤT nhánh level-finalize `ApproveV2Async`. ListApproved (DaDuyet-only) đọc THẲNG field (`CreateContractFromEvaluationFeatures.cs:196`); List/Inbox ternary `Phase==DaDuyet ? field : subquery`. **Spec-change RED handled:** `PeFinalizeProjectionTests` seed-helper +param `endedByLevelFinalize` → case cũ `peFin` set true; **REPRO case mới `peFinButWentCeo`**: CÙNG wfFin config-có-finalize NHƯNG field=FALSE → EndsBeforeCeo=FALSE (bug: config-true nhưng lên CEO thật). FinalizeStep/LevelName VẪN từ config-subquery (nhãn cấp, không phải cờ). **EF-translate OK** (ListApproved handler no-throw). Service `PeApproverFinalizeTests` +3 assert: #1 finalize→true / #2 advance-CEO→false / #6 opt-out→false (khớp projection repro). No prod bug.

## S92 — admin-only-modules revoke (test-before-merge SECURITY), +3 -> 434
**S92 (2026-06-29) +3 admin-only-modules revoke (test-before-merge SECURITY) — 431→434 PASS, no red:** anh chốt 5 nhóm menu (NHÂN SỰ/VĂN PHÒNG SỐ/CÁ NHÂN/HỢP ĐỒNG/DANH MỤC) CHỈ Admin thấy. Em-main mở rộng `RevokeTemporarilyHiddenModulesAsync` predicate +Contracts/Ct_*/Master/Suppliers/Projects/Departments/Catalog* (cộng dồn S58 Hrm*/Off*/Personal) + TẮT 2 re-grant S65/S69 + narrow `InReviewScope→Pe_*`. `AdminOnlyModulesRevokeTests.cs` (3): NonAdmin-S92Modules-4cờ-false LOAD-BEARING + Admin-untouched + Pe-module-NOT-revoked invariant (root `PurchaseEvaluations` + `Pe_DuyetNcc` không match predicate → giữ user). Reflection harness mirror Office/Hrm seed test. **Gotcha:** OfficeModulePermissionSeedTests + HrmProfilePermissionSeedTests VẪN PASS dù prod đã TẮT 2 call grant — vì test invoke method-CÔ-LẬP qua reflection (private static giữ cho reversibility), KHÔNG chạy SeedAsync chain; thêm 1 dòng header note mỗi file. (ChangePassword S91 10-test ĐÃ merge → trong baseline 431.)

## D2 — PE Mig 58 multi-winner (test-after + EF translation-smoke), +6 → 419
`CreateContractFromEvaluationMultiWinnerTests.cs` (Application ns, 5) cho `CreateContractFromEvaluationCommand` single→multi-winner trả `List<Guid>` (1 HĐ per đơn vị IsWinner). Handler 4-dep `(db, ICurrentUser, IContractWorkflowService, IContractCodeGenerator)` wire THẬT (mirror CreateContractCommandApplicableTypeTests S33): ContractCodeGenerator(db,clock)+ContractWorkflowService — BeginTransactionAsync SERIALIZABLE chạy SẠCH SQLite, KHÔNG try/skip. IdentityFixture. 5 case: (a)≥2 winner→2 ids·2 Contract·Phase=DangSoanThao·SupplierId khớp winner·**GiaTri per-winner = SUM ThanhTien báo giá của ĐÚNG winner (q.PESupplierId==winnerRow.Id)** 140/260·2 mã DISTINCT·pe.ContractId==ids[0] / (b)single→**GiaTri = SUM details.ThanhTienNganSach (LEGACY 800, NOT quote-total 140)**·1 HĐ·TenHopDong null→fallback TenGoiThau / (c)idempotency→ConflictException`*đã tạo HĐ rồi*`+count 2 / (d)0 winner→Conflict`*chưa chọn NCC/TP trúng thầu*`+0 HĐ / (e)not-DaDuyet→Conflict`*DaDuyet*`+0 HĐ. ⚠️**SEED-FK: PurchaseEvaluationQuote HARD FK PESupplierId→Supplier Restrict (cfg:135)** → mỗi Quote ref 1 PESupplier-row thật (random Guid→SQLite 19); Contract.SupplierId/ProjectId no FK vật lý nhưng handler load Project(NotFound)+supplierMap→seed real. `PeListWinnerNamesProjectionTests.cs` (1) TRANSLATION-SMOKE cho EF subquery `WinnerSupplierNames` collection-projection → **EF TRANSLATE OK no InvalidOperationException trên SQLite**: 2/1/0-winner. SqliteDbFixture read-only. No prod bug.

## S87 — PE Mig 59 CCM-budget-period setter (test-after authz), +7
`PeCcmBudgetPeriodSetterAuthzTests.cs` cho `SetPeCcmBudgetPeriodCommand` — Section B 3-cột Dự án|PRO|CCM, CCM nhập "NS kỳ này" cột riêng độc-lập PRO. Handler 2-dep: NotFound→role-gate Admin|CostControl else Forbidden`*CCM*`→absolute-set CcmBudgetPeriodAmount→Changelog→Save. 7 test: Admin/CostControl set · Drafter/Procurement→Forbidden+no-set · value=0 valid+validator(0-valid/âm-invalid) · null=clear (pre-seed 500tr, gotcha #73)+validator null-valid · unknownPE→NotFound TRƯỚC authz. Validator `GreaterThanOrEqualTo(0).When(HasValue)`. ⚠️Changelog.UserId Guid? no FK User → random-Guid an toàn. No prod bug.

## S86 — PE drafter-notify-exclusion REGRESSION (bug-fix test-alongside), +3
em main fix `PurchaseEvaluationWorkflowService.LogTransitionAsync` (~:1083-1135) khối notify "Phiếu cần bạn duyệt" +filter `&& l.ApproverUserId != evaluation.DrafterUserId` → người tạo phiếu KHÔNG nhận chuông cần-duyệt phiếu mình tạo. `PeApproverNotifyExcludesDrafterTests.cs` (Services ns) 3: (1)CORE 1-Step-2-Level Cấp1=approverL1(actor) Cấp2={drafter,otherL2}; actorL1 Approve→advance→NeedApproval recipients EXCLUDE drafter INCLUDE otherL2 / (2)Cấp2=CHỈ drafter→recipients EMPTY / (3)SANITY terminal DaDuyet drafter VẪN nhận chuông KẾT-QUẢ. ⚠️**actor-exclusion KHÔNG che bug** (actor=Cấp1, drafter=Cấp2). **RED-on-old-code PROVEN qua git stash**. Spy=`CapturingNotificationService` (records recipients+title). No prod touch (verify qua stash, restore exact).

## S85b — PE UpdateDraft WF-preserve ROOT-FIX bug-class S42 (test-after bug-fix), +3
em main fix `UpdatePurchaseEvaluationDraftCommandHandler` (PurchaseEvaluationFeatures.cs:285) ABSOLUTE-SET→NULL-SAFE `if(request.ApprovalWorkflowId is not null)`. `PeUpdateDraftWorkflowPreserveTests.cs` 3: (1)PRESERVE Nháp WF=W + null request → GIỮ W + sanity update / (2)CHANGE W1→W2 (≠null)→đổi / (3)bonus BudgetPeriodAmount null-safe S61. ⚠️`ApprovalWorkflowId` FK Restrict → PE seed PHẢI ref WF thật (`SeedWorkflowAsync` ApplicableType=DuyetNcc). No prod bug sau fix.

## S85 — PE urgent DRAFTER-self-unset (test-after authz), +5
em main restructure `PurchaseEvaluationUrgentFeatures.cs` thêm nhánh UNSET cho NGƯỜI KHAI (`isDrafter = currentUser.UserId == entity.DrafterUserId`) → drafter GỠ clear CẢ 2 cờ, SET vẫn role-only. EXTEND `PeUrgentToggleAuthzTests.cs` 11→16: FakeCurrentUser.UserId→init + SeedPeAsync +drafterUserId. NEW 5: Drafter UNSET→clear cả 2 no-Forbidden / Finance UNSET→Forbidden`*GỠ*` / Drafter SET→Forbidden`*đánh dấu*` / Drafter UNSET chỉ-ByCcm→clear cả 2 / Drafter+Procurement-no-DM UNSET→clear CẢ 2 qua isDrafter (isDrafter ưu tiên/độc lập isProTp). No prod bug.

## S77c — urgent-toggle SYMMETRIC→ASYMMETRIC CORRECTION
anh chốt FINAL gate BẤT-ĐỐI-XỨNG theo `request.IsUrgent`; em main sửa prod `PurchaseEvaluationUrgentFeatures.cs:49-55` `isPro = IsUrgent ? hasPro : (hasPro && isDeptManager)` (+isCcm). REWRITE `PeUrgentToggleAuthzTests.cs` 12→11: **SET = role-chức-năng-đủ** (plain Procurement/CostControl SET→OK; bỏ DeptManager-restriction) / **UNSET = role AND DeptManager** (plain-PRO/CCM UNSET→Forbidden+no-mutate; PRO+DM/CCM+DM UNSET→clear-own preserve-other; Admin→both) / Drafter/Finance SET→Forbidden / UnknownPe→NotFound. No prod touch — test theo CODE (S34).

## S77 — PE Mig 57 suggested-price NOTE (test-after, mirror CcmNote), +7
`UpdatePeSuggestedPricePro/CcmCommand` +`Note` (string?) absolute-set→`Pro/CcmSuggestedPriceNote` (incl null=clear); rides SAME role-gate as price (PRO/Admin · CCM/Admin) fail-closed TRƯỚC side-effect; validator `Note.MaximumLength(1000)`. Handler ctor 2-dep + record `Note=null` default → 5 existing call-sites still compile, KHÔNG cần vá arity. NEW 7 → `PeSuggestedPriceSetterAuthzTests.cs`: PRO-note+Min/Max persist (note KHÔNG clobber price) / Admin-PRO / CCM-note+price / null-clear PRO (pre-seed note→null, AsNoTracking re-read) / null-clear CCM / non-priv→Forbidden+unchanged / PRO↔CCM independent + validator 1001-invalid·1000-valid. No prod bug.

## S76 — PE Mig 56 PRO-column-split (spec-change + compile-fix arity), +5
`UpdatePeBudgetProCommand` record 3→4 param `(PeId, ProInitialAmount, ProAdjustmentAmount, ProNote)`; handler set ProInitial+ProAdjust absolute-set (KHÔNG còn ProEstimateAmount). Validator ProInitial>=0, ProAdjustment ÂM OK, ProNote max1000. Handler ctor 2-dep UNCHANGED. Capability `FullAmount` (PurchaseEvaluationFeatures.cs:849-864): hasCcm?CCM:proFull(ProInitial+ProAdjust); FullIsEstimate=!hasCcm&&hasPro; DTO +2 field. Compile-fix 5 PRO call-site +arg ProAdjust + assert `.ProEstimateAmount`→`.ProInitialAmount` + full-fallback seed 500→ProInitial. NEW 5: PRO set both ÂM-adjust persist / validator ProInitial<0 invalid / ProAdjust ÂM valid / full=proFull(150) / full=ÂM-adjust(70 no-clamp). No prod bug — ProEstimateAmount=LEGACY backfill→ProInitial.

## S74 — PE Mig 55 CcmNote (compile-fix arity + test-after), +5
`PeWorkItemBudget.CcmNote` (nvarchar1000) + `UpdatePeBudgetCcmCommand` record 3→4 `(PeId,Initial,Adjustment,CcmNote)` + DTO +CcmNote. Handler ctor 2-dep UNCHANGED. Compile-fix 3 call-site (PeWorkItemBudgetTests 388/407/421) +`null` arg4. NEW 5 (region 4b mirror ProNote): CostControl/Admin set→persist / null=clear (pre-seed prove) / Procurement→Forbidden+no-mutate (role-gate 152-157 TRƯỚC side-effect, AsNoTracking re-read) / all-persist-together. No prod bug.

## S72 — PE Mig 54 anh Kiệt FDC (spec-change + 2 test-after FINANCIAL), +28
Mig 54 PE fields: ProSuggestedMin/MaxPrice + CcmSuggestedPrice + ApprovedPriceAmount + ApprovedPriceSource; TransitionAsync/ApproveV2Async +3 param finalizeByCcmDelegation(bool)+approvedPriceAmount(decimal?)+approvedPriceSource(string?). **① CCM-finalize AUTO→OPT-IN — UPDATE `PeCcmThresholdFinalizeTests` 6→11:** chỉ finalize khi flag=true + fail-closed order (832-851: threshold-null→Conflict / role≠CostControl→Forbidden / winnerQuoteTotal>=ceoThreshold strict-`<`→Conflict) → ApplyApprovedPriceOnFinalize(human price BẮT BUỘC)→DaDuyet. **② NEW `PeApprovedPriceFinalizeTests` 10 — ApplyApprovedPriceOnFinalize private-static:** valid-price→bind / null-price→Conflict NOT-finalized / garbage-source→Conflict / Theory 4 source {Ncc,ProMin,ProMax,Ccm} / **isSystem null-price→no-throw qua REFLECTION** (⚠️OBSERVATION: isSystem KHÔNG reachable qua public ApproveV2Async — approve gate decision==Approve vs isSystem cần AutoApprove; PE no SLA-job → defensive/dead, test UNIT). Reflection unwrap TargetInvocationException→inner. **③ NEW `PeSuggestedPriceSetterAuthzTests` 13:** PRO/CCM setter handler 2-dep authz + validator + NotFound-TRƯỚC-authz. No prod bug — OPT-IN an-toàn-hơn AUTO.

## S69b — PE 2 feature anh Kiệt FDC (test-before-merge SECURITY+FINANCIAL), +14
**FEATURE B value-threshold CCM-finalize (`PeCcmThresholdFinalizeTests.cs` 5, ApproveV2Async 816-854):** CCM duyệt + ceoThreshold!=null + `winnerQuoteTotal < ngưỡng` STRICT-less-than (838) + chưa-slot-cuối → DaDuyet skip-CEO. Cover: <ngưỡng→DaDuyet / ==ngưỡng→advance / >ngưỡng→advance / threshold-null→advance / non-CCM→advance / CCM-last-slot→DaDuyet via normal-advance no-double. **FEATURE A urgent-toggle authz (`PeUrgentToggleAuthzTests.cs` 9, 4-dep):** PRO→ByPro / CCM→ByCcm / Admin→both / else→Forbidden. multi-role PRO+CCM no-Admin→else-if short-circuit chỉ ByPro. unknown→NotFound. No prod bug (strict-`<` + else-if priority intentional). [superseded by S77c for urgent SET/UNSET asymmetry]

## S69 — Office golive permission-seed regression (test-after SECURITY), +6
`OfficeModulePermissionSeedTests.cs` mirror HrmProfilePermissionSeedTests reflection (2 private-static revoke→seed via GetMethod NonPublic|Static; MenuItem TRƯỚC Permission FK Cascade). KHÁC HRM: Office grant Read AND Create trên 16-key allow-list. Load-bearing: excluded-3 (OffPhongHopManage/OffAttendanceReport/OffChamCong) stay hidden + upgrade-only preserves admin-raised. No prod bug.

## S60 — UAT anh Kiệt 2 feature PE submit branch (test-after), +14
`PeSubmitGuardAndBypassTests.cs`. **F1 Section 3 guard (8):** submit branch build `missing` 4 mục → ConflictException prefix `'Chưa đủ thông tin mục 3 ...'` join `' · '`. Cover thiếu cả 4 / winner-only / quote=0 / budget / comparison / attachment-gắn-NCC(PES_Id!=null) KHÔNG đếm / đủ-manual / đủ-BudgetId. **F2 drafter-bypass (6, V2-only):** k=drafterSlots.Max(Order) → auto Cấp 1..k. ⚠️GUARD-FIRST: bypass-test PHẢI dựng PE đủ 4 ĐK. Seed: SeedWinnerWithQuoteAsync / SeedComparisonAttachment(PES_Id=null) / SeedWorkflowAsync(Guid[][]). Opinion-only-ownSlot invariant. No prod bug.

## S57bis / S56 (notes kept)
- S57bis spec-drift: `NotEmpty()` on `Guid?` does NOT catch `Guid.Empty` (FV 7.2 treats default(Guid?)==null) → relies on create-handler FK-guard (defense-in-depth, no prod bug).
- S56 gotcha: `ExecuteUpdateAsync` BYPASS change tracker → stale-tracked-read needs `.AsNoTracking()` re-read; shipped tx = IsolationLevel.Serializable per em-main post-review.

## S54 — ItTicket reassign authz (test-before-merge SECURITY), +13
`ItTicketReassignAuthzTests.cs`. Pattern: authz-capability = seed 2-dept (IT+KT) + fake ICurrentUser role/dept matrix → CanReassign flag + Forbidden/Conflict guard + empty-staff 0-leak + no-mutation. Forbidden red-able BY-CONTRAST (non-IT vs IT-staff identical-setup). Data-driven handler-authz = enforcement point (KHÁC Pattern 10 reflection static `[Authorize]`). No prod bug.
