9.3 KiB
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-143POST/{id}/transitions→TransitionPeBody(:343-355:TargetPhase, Decision, Comment, ReturnMode?, ReturnTargetUserId?, ...) →TransitionAsync.PurchaseEvaluationWorkflowService.cs:67-75Guard#1: target=TraLai/TuChoi BẮT BUỘCdecision=Reject(gotcha #45 — nếu FE gửi Approve → BE lỡ vào ApproveV2Async = "trả về nhưng vẫn duyệt").:84-89Guard#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.:98EnsureCanRejectV2Async(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) khiPhase != 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-141addPurchaseEvaluationApprovalrow (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(/RemoveRangetrên LevelOpinions toàn backend = 0 match. Opinion key theoApprovalWorkflowLevelId(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-90signedApprovers= distinctevaluation.levelOpinions⇒ dropdown Assignee chỉ hiện người ĐÃ KÝ.:523-598picker CHỈ hiện mode được admin bật (CurrentLevelOptionsDTOPurchaseEvaluationDtos.cs:300, build từ slot Cấp hiện tại của rejecterPurchaseEvaluationFeatures.cs:1023-1039). Không mode nào bật → chỉ còn Drafter.:193-195chỉ gửireturnMode/returnTargetUserIdkhiisTraLaiAction.
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
- OR-of-N cùng Cấp bị reject: Cấp có N approver (N row cùng Order);
ApproveV2Async:763-764chỉ 1 người ký → chỉ 1 opinion row/Cấp; advance khi BẤT KỲ 1 người ký (:927). ⇒signedApproverschỉ 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. - 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ệ.
- 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
levelOpinionskhô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. - 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.
- Phiếu đã DaDuyet (terminal) — LATENT HOLE: DaDuyet xoá pointer (null,null). Reject branch (
:92) chạy khidecision==Rejectbất kể fromPhase — KHÔNG có guardfromPhase==DaDuyet; adjacency-check (:300) bị reject-branch bỏ qua hoàn toàn;EnsureCanRejectV2Asyncearly-return vìPhase!=ChoDuyet(:321). ⇒ 1 request forgeReject+target=TraLaitừ DaDuyet + Drafter-mode sẽ MỞ LẠI phiếu DaDuyet về TraLai. FE không gửi (không render nút khicurrentApproval==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
- 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. - 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.
- 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).
- 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.
- Giới hạn reject-loop (EDGE-4) + chặn reject phiếu DaDuyet (EDGE-5): có cần thêm guard
fromPhase==DaDuyet → throwvà/hoặc cap số lần trả lại không? (bảo mật/toàn vẹn).