**HEADLINE:** LANE C: Trả-lại V2 đã có 4 mode (Drafter/OneLevel/OneStep/Assignee); Assignee ĐÃ khớp ý owner "về đúng commenter → re-comment → đi tiếp" nhưng bị chặn 3 điều kiện: default=Drafter (full-restart), phải admin bật Allow*-per-slot, rejecter tự pick tay. Opinion KHÔNG BAO GIỜ bị xoá (UPSERT) → tồn tại comment-cũ-stale khi trả về.

## LANE C — REJECT / TRẢ-LẠI (Phase 98) — luồng hiện tại vs mong muốn

### 1. Entry point + guard (file:line)
- `PurchaseEvaluationsController.cs:130-143` POST `/{id}/transitions` → `TransitionPeBody` (`:343-355`: `TargetPhase, Decision, Comment, ReturnMode?, ReturnTargetUserId?, ...`) → `TransitionAsync`.
- `PurchaseEvaluationWorkflowService.cs:67-75` **Guard#1**: target=TraLai/TuChoi BẮT BUỘC `decision=Reject` (gotcha #45 — nếu FE gửi Approve → BE lỡ vào ApproveV2Async = "trả về nhưng vẫn duyệt").
- `:84-89` **Guard#2**: **"Từ chối" (TuChoi) đã GỠ HẲN S60** (throw cả Admin). ⇒ đường reject DUY NHẤT còn lại = **Trả lại → Phase TraLai=98**.
- `:98` `EnsureCanRejectV2Async` (impl `:316-341`): chỉ approver Cấp hiện tại (`actor.Id == level.ApproverUserId`, discriminate OR-of-N `:337`) HOẶC Admin được Trả lại. Non-approver → Forbidden. **Early-return (KHÔNG throw) khi `Phase != ChoDuyet` `:321`** — nhớ điểm này cho EDGE-5.

### 2. Reject branch = `:92-146` → 4 mode trong `ApplyReturnModeAsync :348-548`
`effectiveMode = returnMode ?? Drafter` (`:117`). Mode khả dụng gate per-slot bằng `curLevel.AllowReturn*` (`:397-410`, non-admin flag off → Conflict).

| Mode | Phase sau | Pointer sau | Người NHẬN lại | ref |
|---|---|---|---|---|
| **Drafter** (default/fallback) | **TraLai=98** | **xoá (null,null)** + SLA null | Người soạn — gửi lại chạy **LẠI từ Bước0/Cấp1 (full restart)** | `:418-425` |
| OneLevel | giữ ChoDuyet | curLevel−1 (hết→prevStep max; Bước0Cấp1→reset 0/1 no-op) | approver Cấp liền trước | `:437-463` |
| OneStep | giữ ChoDuyet | prevStep, max level | approver Bước trước (cấp cuối) | `:465-483` |
| **Assignee** | giữ ChoDuyet | Step/Level của NV được pick | **1 NV cụ thể ĐÃ ký** (`returnTargetUserId`) | `:485-510` |

- `:132-141` add `PurchaseEvaluationApproval` row (Decision=Reject, prefix `[Bước x — Cấp y]`) cho Lịch-sử-duyệt.
- V1 legacy (`ApprovalWorkflowId==null`) → luôn fallback Drafter (`:359-369`).

### 3. Comment (LevelOpinion) khi trả về — **GIỮ, KHÔNG XOÁ**
- **Grep `.Remove(`/`RemoveRange` trên LevelOpinions toàn backend = 0 match.** Opinion key theo `ApprovalWorkflowLevelId` (slot workflow, ổn định qua reject). Re-approve = **UPSERT overwrite** cùng row (`ApproveV2Async:766-790`: comment+SignedAt+SignedByUserId).
- ⇒ Người nhận lại **re-comment ĐƯỢC**, nhưng bằng cách **DUYỆT LẠI** (comment gắn liền hành động Duyệt — Q1=1B). KHÔNG có bước "comment mà chưa forward".

### 4. "Đi tiếp" forward lại
- Drafter: TraLai → Drafter sửa → resubmit (`TransitionAsync:148-152`: DangSoanThao|TraLai→ChoDuyet) → **restart Bước0/Cấp1**; mọi cấp duyệt lại lần lượt, UPSERT đè opinion.
- OneLevel/OneStep/Assignee: giữ ChoDuyet, pointer lùi; NV nhận re-approve `ApproveV2Async` → advance `:926-957` đi tiếp +1 cấp/bước, **re-pass (UPSERT đè) các cấp phía trước**.

### 5. FE (`PeWorkflowPanel.tsx`, fe-user == fe-admin — diff rỗng)
- `:88-90` `signedApprovers` = distinct `evaluation.levelOpinions` ⇒ dropdown Assignee **chỉ hiện người ĐÃ KÝ**.
- `:523-598` picker CHỈ hiện mode được admin bật (`CurrentLevelOptions` DTO `PurchaseEvaluationDtos.cs:300`, build từ **slot Cấp hiện tại của rejecter** `PurchaseEvaluationFeatures.cs:1023-1039`). Không mode nào bật → **chỉ còn Drafter**.
- `:193-195` chỉ gửi `returnMode`/`returnTargetUserId` khi `isTraLaiAction`.

---

## MONG MUỐN owner vs HIỆN TẠI — cần đổi gì
> Owner: reject → phiếu về **ĐÚNG người commenter** → họ **COMMENT LẠI** → **ĐI TIẾP forward**.

**Kết luận cốt lõi: mode `Assignee` ĐÃ implement gần đủ ý owner** (về NV cụ thể đã ký, giữ ChoDuyet, re-comment qua UPSERT, forward tiếp từ slot đó). Requirement **phần lớn đã có sẵn** — nhưng bị 3 rào:

| # | GAP | Vị trí | Đổi gì |
|---|---|---|---|
| G1 | **Default = Drafter (full-restart)**, KHÔNG khớp "về đúng người, đi tiếp từ đó" | `:117` `?? Drafter` + FE `:585` (Drafter luôn hiện) | Đổi default sang OneLevel/Assignee, HOẶC bắt admin bật Allow* cho slot. Cần owner chốt hành-vi-mặc-định. |
| G2 | **Rejecter tự pick tay** người nhận (dropdown) | FE `:571-580` + `Assignee:485-510` | Nếu owner muốn AUTO về "người liền trước" → dùng OneLevel (auto prev Cấp) làm mặc-định; nếu muốn auto về Cấp-1/creator → cần logic mới target=firstStep/drafter. |
| G3 | **Re-comment = phải DUYỆT** (comment gắn Duyệt), chưa có "comment-in-own-box rồi Save mới advance" | `ApproveV2Async:751,766-790` (add Approval Approve + advance cùng lúc) | = đúng điểm (2) của SPEC tổng (decouple save↔advance) — **cross-lane với Lane A/B**. Nếu tách: reject-return chỉ cần đưa pointer về đúng người; re-comment+save+advance dùng chung cơ chế mới. |

**Việc cần làm (Lane C thuần):** (a) BE reject handler — quyết default mode + có auto-return-target không (G1/G2); (b) FE nút Trả lại — nếu default đổi thì bỏ ràng "phải admin bật Allow*" cho flow chuẩn này; (c) state — Assignee đã giữ ChoDuyet + lùi pointer đúng, KHÔNG cần cột DB mới cho luồng cơ bản.

---

## ≥3 EDGE cases
1. **OR-of-N cùng Cấp bị reject:** Cấp có N approver (N row cùng Order); `ApproveV2Async:763-764` chỉ 1 người ký → chỉ 1 opinion row/Cấp; advance khi BẤT KỲ 1 người ký (`:927`). ⇒ `signedApprovers` chỉ liệt kê người-đã-ký; N−1 đồng-cấp KHÔNG ký thì **không pick được làm return-target**. Nếu owner muốn trả về đúng 1 co-approver chưa ký → không có trong dropdown.
2. **Reject nhảy NHIỀU cấp/bước:** OneLevel chỉ lùi 1 Cấp, OneStep 1 Bước. Nhảy từ Bước3 về Bước1-Cấp1 trong 1 thao tác **bắt buộc Assignee** (và target phải có LevelOpinion). Khi forward lại, mọi cấp trung gian **vẫn hiện opinion CŨ (pass đầu) cho tới khi được re-approve đè** → người xem tưởng đã ký hợp lệ.
3. **Comment cũ (stale) khi bị trả về:** Opinion KHÔNG bao giờ clear. Drafter-mode full-restart mà pass-2 lại bị reject giữa chừng → các cấp phía sau vẫn giữ opinion **pass-1 (stale)**. FE `levelOpinions` không phân biệt "ký vòng này" vs "ký vòng trước" (không có round/version stamp). ⇒ owner có thể muốn **clear opinion phía-trước-pointer khi trả về**, hoặc thêm cột round/superseded.
4. **Reject-loop vô hạn:** KHÔNG có cap số lần reject. Drafter resubmit ↔ reject vô hạn. Mỗi vòng thêm Approval+Changelog row (audit phình) nhưng opinion UPSERT (không nổ row). Không phải bug data nhưng **thiếu guard giới-hạn/escalation**.
5. **Phiếu đã DaDuyet (terminal) — LATENT HOLE:** DaDuyet xoá pointer (null,null). Reject branch (`:92`) chạy khi `decision==Reject` **bất kể fromPhase — KHÔNG có guard `fromPhase==DaDuyet`**; adjacency-check (`:300`) bị reject-branch bỏ qua hoàn toàn; `EnsureCanRejectV2Async` early-return vì `Phase!=ChoDuyet` (`:321`). ⇒ 1 request forge `Reject+target=TraLai` từ DaDuyet + Drafter-mode sẽ **MỞ LẠI phiếu DaDuyet về TraLai**. FE không gửi (không render nút khi `currentApproval==null`) nhưng BE **thiếu defense-in-depth**. (3 pointer-mode kia sẽ throw Conflict do pointer null `:429-433` — chỉ Drafter mode lọt.)

## Open Questions
1. **Hành vi mặc định khi Trả lại** (G1): giữ Drafter=full-restart, hay đổi default sang "về người liền trước / về đúng commenter"? Ảnh hưởng có cần đổi `?? Drafter` (`Service:117`) + bỏ ràng buộc admin-phải-bật-Allow* per-slot.
2. **Ai chọn người nhận** (G2): rejecter pick tay (như Assignee hiện tại) hay AUTO về canonical (Cấp liền trước = OneLevel / hay về Cấp-1 creator)? Nếu auto-về-creator cần logic target mới.
3. **Comment cũ khi trả về** (EDGE-3): GIỮ opinion cũ (hiện tại) hay CLEAR các cấp phía-trước-pointer để buộc ký lại sạch? Hay thêm round/version-stamp để phân biệt vòng ký? — quyết định này chạm schema (có thể cần cột mới trên LevelOpinion).
4. **Re-comment tách khỏi Duyệt** (G3): owner có muốn "comment-in-own-box → Save riêng → mới advance" (điểm 2 SPEC tổng) áp cho CẢ luồng reject-return không? Đây là cross-lane với Lane A/B (decouple save↔advance) — nếu có thì reject-return chỉ lo đưa pointer về đúng người.
5. **Giới hạn reject-loop** (EDGE-4) + **chặn reject phiếu DaDuyet** (EDGE-5): có cần thêm guard `fromPhase==DaDuyet → throw` và/hoặc cap số lần trả lại không? (bảo mật/toàn vẹn).
