**HEADLINE:** GO-WITH-ADJUSTMENTS — A1 quy-tắc `drafterSlots.Min` là đúng về state-machine (không kẹt phiếu, không vỡ 5-state, creator ký tay chạy đúng qua ApproveV2Async có sẵn), nhưng cần 3 điều-chỉnh code chính-xác + viết lại 4 test bypass + 2 điểm chờ owner chốt (cấp không liền nhau + cơ-chế history) trước khi deploy prod live.

## Lăng-kính STATE-MACHINE — Kết luận từng trục

### 1. Validate A1 (decision 1): sửa bypass chỉ-subordinate — ĐÚNG, không kẹt phiếu

Đọc chi tiết `PurchaseEvaluationWorkflowService.cs:571-681`. Code HIỆN TẠI dùng `k = drafterSlots.Max(l => l.Order)` (`:592`) rồi bypass + **auto-ký hộ** mọi cấp `1..k` (kể cả cấp của chính creator, `:617-642` UPSERT opinion canned comment). A1 muốn: chỉ bypass NV DƯỚI creator, creator KÝ TAY cấp mình.

**Chuyển sang quy-tắc `Min` là chính xác và an-toàn state-machine.** Cụ thể:

- `:592` đổi `k = drafterSlots.Max(...)` → `var minOwn = drafterSlots.Min(l => l.Order);`
- `:595` đổi `Where(o => o <= k)` → `Where(o => o < minOwn)` (chỉ cấp NV strictly dưới cấp thấp nhất của creator).
- `:597-643` — vòng `foreach`: vì mọi `order < minOwn` KHÔNG thể là slot của creator (minOwn là cấp thấp nhất của creator), nên nhánh `ownSlot is not null` (`:610-611`, `:617-642`) trở thành **dead code** → chỉ giữ Approval row "(bỏ qua — phiếu do người duyệt cấp cao hơn cùng phòng soạn)", XÓA hẳn khối UPSERT opinion. Không ghi hộ chữ ký ai.
- `:645-680` — thay TOÀN BỘ khối advance bằng: `if (minOwn > 1) { evaluation.CurrentApprovalLevelOrder = minOwn; await LogTransitionAsync(...ChoDuyet→ChoDuyet... "Bỏ qua Cấp 1..{minOwn-1}, chờ người soạn ký Cấp {minOwn}"); }`. **XÓA cả nhánh advance-sang-Bước-2 (`:657-666`) LẪN nhánh terminal-DaDuyet-khi-submit (`:667-680`)** — vì creator không còn được bypass qua chính cấp mình; pointer DỪNG tại `minOwn` để creator ký tay.

**Không kẹt phiếu — chứng minh:** `minOwn` luôn là Order của một `drafterSlot` (level có thật trong firstStep, có ≥1 approver là creator). Pointer `(0, minOwn)` luôn hợp-lệ và luôn có approver ký được. Không có nhánh nào advance QUA creator, nên phiếu luôn dừng ở cấp creator ký được → không kẹt.

**Creator ký tay chạy đúng qua `ApproveV2Async` có sẵn:** gate `:728-739` match `actorUserId ∈ pendingLevelGroup.ApproverUserId`. Creator LÀ approver tại `minOwn` → pass. Decision 2 (không guard cứng self-approve) → không có guard nào chặn creator duyệt cấp mình. FE cũng đã hỗ-trợ: `PeWorkflowPanel.tsx:98-103` `actorInV2Level = currentUser.id ∈ currentApproval.approvers` → creator THẤY nút "Duyệt" ngay tại cấp mình. **Không kẹt cả BE lẫn UX.** Không cần sửa FE cho luồng lõi A1.

**Không vỡ 5-state:** submit vẫn set `Phase=ChoDuyet` (`:243`); bypass chỉ ghi Approval/Changelog + dịch `CurrentApprovalLevelOrder`, KHÔNG đổi Phase. Case terminal (1 bước, creator là cấp cuối) NAY tiến DaDuyet qua bước duyệt-tay bình-thường thay vì auto-terminal lúc submit → vẫn về đúng DaDuyet, chỉ khác thời-điểm (creator bấm Duyệt).

### 2. Case creator chiếm cấp KHÔNG liền nhau (Cấp1 + Cấp3) — MURKY, cần chốt

Với quy-tắc `Min`: `drafterSlots={1,3}` → `minOwn=1` → `bypassedOrders = {o < 1} = {}` (rỗng) → KHÔNG bypass gì, pointer đứng `(0,1)`. Diễn-tiến: creator ký tay Cấp 1 → advance Cấp 2 (người KHÁC ký) → advance Cấp 3 (creator ký tay lần 2) → advance tiếp.

- **Không kẹt** (pointer luôn advance), **không vỡ 5-state**. Đây là hành-vi AN-TOÀN NHẤT và khớp ĐÚNG câu chữ decision ("bypass order < drafterSlots.Min").
- **Nhưng murky về nghiệp-vụ:** creator ký 2 lần (Cấp 1 + Cấp 3), và người Cấp 2 (kẹp giữa 2 slot của creator) KHÔNG được bypass dù creator cũng ngồi Cấp 3 cao hơn. Rationale gốc S60 ("sếp tạo thì NV dưới khỏi duyệt lại") không phủ rõ tình-huống creator trải nhiều cấp không liền.
- **Đề xuất:** GIỮ quy-tắc `Min` (conservative, đúng câu chữ, không tự-ý bypass Cấp 2 mà creator chưa "vượt" ở slot thấp nhất). Ghi 1 comment trong code nêu rõ hành-vi non-adjacent. **Cần anh Kiệt xác-nhận** chấp-nhận "creator ký 2 lần + Cấp 2 vẫn duyệt". (Config này hiếm — Designer chỉ chặn duplicate cùng-cấp, cho phép 1 người ở Cấp 1 và Cấp 3.)

### 3. Reject-return SAU A1 — CÒN ĐÚNG

`EnsureCanRejectV2Async:316-341` + `ApplyReturnModeAsync:348-548` thao-tác theo POINTER hiện-tại, không phụ-thuộc cách bypass tính. Sau A1 pointer dừng tại `minOwn` (một cấp có thật) → mọi mode OneLevel/OneStep/Assignee/Drafter resolve bình-thường.

- Lưu ý (pre-existing, KHÔNG do A1): nếu approver "Trả lại 1 Cấp" từ `(0, minOwn)` xuống cấp `< minOwn` (đã bypass lúc submit), phiếu re-pend tại cấp NV từng bị bỏ qua. Đây là hành-vi ĐÚNG của Return (approver chủ-động yêu-cầu cấp dưới xem lại) — bypass chỉ là tiện-ích lúc submit, không phải skip vĩnh-viễn. Hành-vi này y-hệt code hiện-tại, A1 không đổi → out-of-scope, chỉ note.

### 4. Decision 3 (HARD-LOCK EDGE-5) — BẮT BUỘC thêm guard, đã verify đóng đúng lỗ

**Xác nhận lỗ EDGE-5 CÓ THẬT:** hiện `EnsureCanRejectV2Async:321` `if (Phase != ChoDuyet) return;` → phiếu **DaDuyet + Reject** bỏ qua guard → vào `ApplyReturnModeAsync`, pointer DaDuyet đã null → mode Drafter default (`:418-424`) set `Phase=TraLai`. **Phiếu ĐÃ DUYỆT bị hồi về Trả-lại** — đúng bug decision 3 muốn khoá. Tương-tự TuChoi + Reject → hồi TraLai; và Admin override `:290-298` chuyển DaDuyet đi bất-kỳ đâu.

**Fix chính-xác:** chèn ngay SAU `:55` (sau khi tính `isSystem`), TRƯỚC block `:57`:
```
if (fromPhase == PurchaseEvaluationPhase.DaDuyet || fromPhase == PurchaseEvaluationPhase.TuChoi)
    throw new ConflictException("Phiếu đã ở trạng thái cuối (Đã duyệt / Từ chối) — không thể Trả lại / Từ chối / chuyển trạng thái.");
```
Đặt ở ĐẦU chặn CẢ reject-branch (`:92`), approve-step (`:268`) LẪN admin-override (`:290`) → "chặn MỌI transition" như decision. `EnsureCanRejectV2Async:321` giữ nguyên (redundant cho terminal nhưng vô-hại + còn phòng-thủ phase non-terminal khác).

**Không regression:** grep tests chỉ thấy 2 nơi set `Phase=DaDuyet` (`PeFinalizeProjectionTests.cs:84`, `PeListWinnerNamesProjectionTests.cs:56`) — đều là test LIST/projection, KHÔNG gọi `TransitionAsync` trên phiếu DaDuyet. Contract-from-evaluation là command riêng, không qua `TransitionAsync`. Delete guard (`PurchaseEvaluationFeatures.cs:1347`) độc-lập, không đụng. An-toàn.

### 5. Decision 4+5 (1-nút + history) tương-tác state-machine

- **1-nút (decision 4):** `ApproveV2Async` vốn đã là 1 hành-động save=advance → giữ nguyên, không đụng state-machine. Sau A1 creator chỉ thêm 1 cú bấm Duyệt (trước auto-ký) — đúng ý decision 1.
- **Opinion history (decision 5) — cảnh-báo schema + state-neutral:** `PurchaseEvaluationLevelOpinion` UPSERT theo key `(PE, ApprovalWorkflowLevelId)` (`ApproveV2Async:763-790`), entity có UNIQUE composite `(PEId, LevelId)`. Muốn giữ history:
  - **Nếu chọn append-only cùng bảng** → PHẢI drop/sửa UNIQUE index → cần migration 3-file (gotcha #17). Flag Category 2.
  - **Đề-xuất MVP không migration:** trước khi overwrite opinion, ghi 1 `PurchaseEvaluationChangelog` snapshot comment cũ (bảng audit-log đã sẵn) → giữ history mà không đụng schema.
  - **State-machine guardrail BẮT BUỘC:** cơ-chế sửa/cập-nhật opinion PHẢI đi endpoint/command RIÊNG, chỉ ghi opinion + history + changelog, **TUYỆT ĐỐI không route qua `TransitionAsync`** (tránh vô-tình advance pointer / đổi Phase). Đây là rủi-ro state-machine lớn nhất của decision 5.

### 6. BONUS CATCH — thay-đổi hành-vi giá-chốt (cần biết trước UAT)

Nhánh terminal-on-submit hiện-tại (`:667-680`) set DaDuyet mà **KHÔNG gọi `ApplyApprovedPriceOnFinalize`** → phiếu solo-drafter 1-bước hiện về DaDuyet với `ApprovedPriceAmount = null` (lỗ hổng thiếu giá chốt). Sau A1, creator ký tay cấp cuối đi qua `ApproveV2Async:942` → **BẮT BUỘC chọn giá chốt** (`:970-972` throw nếu thiếu, non-system). Đây là **đóng gap tốt** (creator = người duyệt cuối thật thì phải chọn giá) + FE đã lo (`PeWorkflowPanel.tsx:115,174` `currentIsFinalApprover` hiện bộ chọn giá). Nhưng là behavior-change: cần test + heads-up UAT "duyệt cấp cuối của phiếu mình phải chọn giá chốt".

---

## Test cần cập-nhật (`PeSubmitGuardAndBypassTests.cs`)

Baseline hiện encode hành-vi CŨ (auto-ký cấp creator). Sau A1:

| Test | Hiện | Sau A1 | Hành-động |
|---|---|---|---|
| (9) `Submit_DrafterIsTopLevelOfFirstStep_...MovesToStep2` (`:464`) | k=2 max → StepIdx=1, 2 AutoApprove, 1 opinion | minOwn=2, bypass Cấp1, pointer `(0,2)`, 1 AutoApprove, **0 opinion** (creator chưa ký) | **Viết lại** |
| (10) `Submit_DrafterIsLevel1...MovesToLevel2SameStep` (`:509`) | k=1, pointer Cấp2, 1 AutoApprove, 1 opinion | minOwn=1, **0 bypass**, pointer đứng `(0,1)`, 0 AutoApprove, 0 opinion | **Viết lại** (case "Creator Cấp-1 → đứng (0,1)") |
| (11) `Submit_DrafterNotInFirstStep_NoBypass` (`:544`) | pointer `(0,1)`, 0 bypass | KHÔNG đổi | **Giữ** |
| (12) `Submit_OneStepWorkflow_DrafterIsLastLevel_TerminalDaDuyet` (`:579`) | terminal DaDuyet lúc submit, pointers null, 2 AutoApprove | minOwn=2, bypass Cấp1, pointer `(0,2)` Phase=**ChoDuyet** (KHÔNG DaDuyet), 1 AutoApprove | **Viết lại** |
| (14) `Resubmit_FromTraLai_ReAppliesBypass` (`:642`) | mỗi submit terminal DaDuyet | submit→ChoDuyet `(0,1)`, KHÔNG terminal | **Viết lại** (resubmit vẫn 0-bypass vì solo Cấp1) |

**Test MỚI cần thêm:**
1. `Submit_DrafterAtLevel2_SignsOwnManually_ThenApproveReachesNext` — sau submit pointer `(0,minOwn)`; gọi `ApproveV2Async` bằng chính creator → advance đúng (creator ký tay chạy được).
2. `Submit_DrafterNonAdjacentLevels_Cap1AndCap3_NoBypass_SignsTwice` — chốt hành-vi murky mục 2.
3. `Transition_FromDaDuyet_AnyReject_ThrowsHardLock` + `Transition_FromTuChoi_ThrowsHardLock` + `Transition_FromDaDuyet_AdminOverride_AlsoBlocked` — regression EDGE-5 (test-before-fix theo §7 vì là bug fix).
4. `SoloDrafterFinalApprove_RequiresApprovedPrice` — behavior-change mục 6.

## Risk trước deploy prod live
- **Cao:** decision 5 nếu route qua TransitionAsync → vỡ pointer. Enforce endpoint riêng.
- **Trung:** 4 test cũ FAIL nếu không sửa cùng commit → CI đỏ (đúng, vì spec-change → update test cùng commit §7). Đừng để lọt.
- **Thấp:** non-adjacent chờ chốt; nếu deploy trước khi chốt, quy-tắc Min vẫn KHÔNG kẹt phiếu (an-toàn để ship, chỉnh nghiệp-vụ sau nếu cần).

## Open Questions
Còn 4 quyết-định chờ owner (anh Kiệt) chốt trước/ngay khi fan-out:

1. **[anh Kiệt] Cấp không liền nhau (Cấp1+Cấp3):** xác-nhận quy-tắc `Min` — creator ký TAY cả Cấp 1 lẫn Cấp 3, người Cấp 2 (kẹp giữa) VẪN phải duyệt. Nếu không muốn vậy thì phải định-nghĩa rule khác (nhưng mọi rule "bypass tới Max" đều mâu-thuẫn decision 1 "ký tay"). Khuyến-nghị: giữ Min.

2. **[anh Kiệt + AI_INFRA/schema] Cơ-chế opinion history:** chọn (a) reuse `PurchaseEvaluationChangelog` snapshot — KHÔNG migration (đề-xuất MVP), hay (b) bảng history mới / append-only cùng bảng — CẦN migration 3-file + drop UNIQUE `(PEId, LevelId)`. Quyết định này chặn thiết-kế endpoint sửa opinion.

3. **[anh Kiệt] Opinion có được sửa SAU khi phiếu DaDuyet không?** Căng với decision 3 ("đã duyệt là cứng"). Hard-lock chặn TRANSITION, nhưng sửa ý-kiến (không đổi phase) là vùng xám — cho phép hay khoá luôn?

4. **[anh Kiệt] Xác-nhận behavior-change giá-chốt:** phiếu solo-drafter / creator-là-cấp-cuối NAY bắt creator chọn giá chốt khi bấm Duyệt lần cuối (trước đây auto-DaDuyet bỏ qua). Đúng ý "người duyệt cuối phải chọn 1 giá" — chỉ cần xác-nhận không phá UAT.
