Files
solution-erp/.claude/workflows/runs/2026-07-12-S112-pe-signoff-save-advance/sub-invest-A.md
2026-07-12 14:19:19 +07:00

9.3 KiB
Raw Blame History

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}/transitionsTransitionPeBody (: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 curLevel1 (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ý; N1 đồ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).