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

8.3 KiB

HEADLINE: Luồng hiện tại: ApproveV2Async làm UPSERT-opinion + advance-pointer trong CÙNG 1 call ("save = đẩy thẳng"). Điểm CHẶN "creator comment ô của mình rồi save" = ApplyDrafterBypassOnSubmitAsync — lúc SUBMIT nó TỰ ký Cấp-1 của creator bằng comment canned + nhảy pointer qua luôn.

LANE A — Luồng hiện tại (create → sign-off → advance)

Tất cả file:line từ PurchaseEvaluationWorkflowService.cs (viết tắt SVC), PurchaseEvaluationFeatures.cs (FEAT), PurchaseEvaluationsController.cs (CTRL), ApprovalWorkflow.cs (AW).

Sơ đồ luồng (file:line từng bước)

[CREATE] CreatePurchaseEvaluationCommandHandler (FEAT:61-160)
  Phase = DangSoanThao                         FEAT:139
  DrafterUserId = currentUser.UserId           FEAT:147   ← creator == DrafterUserId (dùng làm ID logic)
  ApprovalWorkflowId = request.ApprovalWorkflowId (pin V2)  FEAT:149  (user chọn ở PeWorkspaceCreateView:176-177)
  CurrentWorkflowStepIndex = null, CurrentApprovalLevelOrder = null  (không set)
  → Phiếu Nháp, CHƯA có pointer.

[SUBMIT] POST /transitions (CTRL:130-143) → TransitionAsync, nhánh Drafter trình (SVC:150-265)
  Guard: reject/tuchoi (SVC:67-89) · Section-3 đủ data (SVC:175-229) · require-workflow (SVC:238-241)
  Phase = ChoDuyet                             SVC:243
  CurrentWorkflowStepIndex = 0                  SVC:249
  CurrentApprovalLevelOrder = 1 (vì V2 pin)     SVC:251
  ►► ApplyDrafterBypassOnSubmitAsync(...)  ◄◄   SVC:260-261   ***ĐIỂM CHẶN***

[BYPASS] ApplyDrafterBypassOnSubmitAsync (SVC:571-681) — V2-only, chạy MỌI lần submit
  drafterSlots = firstStep.Levels WHERE ApproverUserId == DrafterUserId   SVC:589
  if drafterSlots rỗng → return (creator NGOÀI bước đầu → flow thường)     SVC:590
  k = max Order của drafter slots                                          SVC:592
  ∀ order 1..k:  + Approval row Decision=AutoApprove                       SVC:603-614
                 + UPSERT LevelOpinion comment CANNED "(duyệt tự động —     SVC:619, 629-641
                   người soạn phiếu là người duyệt cấp này)"
  Advance pointer:  k<max → CurrentApprovalLevelOrder = order kế >k        SVC:647-656
                    còn Bước → (Step 1, Cấp 1)                             SVC:657-666
                    hết → Phase=DaDuyet terminal                          SVC:667-680

[SIGN-OFF] POST /transitions decision=Approve → ApproveV2Async (SVC:686-958)
  currentIdx = CurrentWorkflowStepIndex ?? 0    SVC:711
  currentLevelOrder = CurrentApprovalLevelOrder ?? 1   SVC:715
  pendingLevelGroup = Levels Order==currentLevelOrder (OR-of-N)   SVC:724
  GATE (SVC:728-739): !admin → actor.Id ∈ pendingLevelGroup.ApproverUserId else 403 Forbidden
  + Approval row                                SVC:745-754
  ►► UPSERT LevelOpinion (matchingLevel theo ApproverUserId, comment sync)  SVC:763-790
  (skipToFinal SVC:805-846 · AllowApproverFinalize SVC:867-886 · CCM-deleg SVC:888-924)
  ►► ADVANCE pointer  SVC:927-957: currentLevelOrder<max → ++Cấp; else Bước kế Cấp1 / DaDuyet
  → UPSERT opinion + advance = CÙNG 1 call ApproveV2Async ("save = đẩy thẳng").

1. Comment UPSERT + advance xảy ra thế nào? Cùng call? Gate gì?

CÙNG 1 call ApproveV2Async: UPSERT opinion (SVC:763-790) rồi advance pointer (SVC:927-957) tuần tự trong cùng method, cùng SaveChangesAsync ở caller (SVC:285). Gate duy nhất trước khi ghi = actor phải thuộc pendingLevelGroup (SVC:728-739). Đúng như spec mô tả "save thao-tác duyệt = comment + advance cùng lúc".

2. Ai là approver Cấp-1?

ApprovalWorkflow (AW:21) > Steps OrderBy Order (AW:70-78, Bước=Phòng) > Levels group by Order (AW:83-88, Cấp) > ApproverUserId (AW:88, 1 NV/row, N rows cùng Order = OR-of-N). Cấp-1 Bước-1 = steps[0].Levels WHERE Order==1; approver = tập ApproverUserId đó.

3. Creator (PE.CreatedBy) = approver Cấp-1 được không? Có gate actor.Id==ApproverUserId chặn không?

  • DrafterUserId == creator == CreatedBy (set chung lúc create FEAT:147). Service dùng DrafterUserId (KHÔNG dùng CreatedBy) cho mọi logic identity.
  • ĐƯỢC — không có luật nào cấm creator làm approver. Ngược lại hệ thống có nhánh RIÊNG cho đúng case này: ApplyDrafterBypassOnSubmitAsync.
  • Gate actor.Id==ApproverUserId tại SVC:728-739 là gate THUẬN (chỉ approver của Cấp mới được duyệt) — nó KHÔNG chặn creator; nếu pointer đang ở Cấp của creator thì gate này CHO PHÉP. Đây KHÔNG phải điểm chặn.

4. Sau create/submit phiếu ở Phase nào + CurrentApprovalLevelOrder nào (đã nhảy qua Cấp-1 chưa?)

Mốc Phase Pointer (Step, Cấp)
Sau CREATE DangSoanThao (null, null)
Sau SUBMIT, creator KHÔNG phải Cấp-1 approver ChoDuyet (0, 1) — chờ Cấp-1
Sau SUBMIT, creator LÀ Cấp-1 approver (case spec) ChoDuyet (hoặc DaDuyet nếu 1-bước) (0, k+1) hoặc (1,1)ĐÃ NHẢY QUA Cấp-1

Trong case mục tiêu, pointer đã vượt Cấp-1 ngay tại submit, và ô C;ấp-1 của creator đã bị auto-fill comment canned (SVC:619).

5. ĐIỂM CHẶN (chốt)

ApplyDrafterBypassOnSubmitAsync (SVC:571-681, gọi tại SVC:260-261). Vì lúc SUBMIT nó (a) auto-ký Cấp-1 của creator bằng comment CANNED thay vì comment thật (SVC:619), và (b) advance pointer qua Cấp-1 (SVC:647-680), nên creator:

  • KHÔNG bao giờ gõ được ý kiến của mình vào ô của mình (ô đã auto-fill);
  • KHÔNG còn thao tác được ở Cấp-1 — pointer đã dời. FE actorInV2Level = false → blockedByV2Level (PeWorkflowPanel.tsx:98-103); BE gate SVC:728-739 cũng 403 vì pointer không ở Cấp của họ.

Nguồn gốc: đây là feature UAT S60 (anh Kiệt trước đó: "Trưởng phòng tạo thì bypass — không cần NV duyệt lại", comment SVC:550-570). Nay yêu cầu mới NGƯỢC lại.

Quan trọng — sửa 1 chỗ là đủ: nếu vô hiệu/không chạy bypass (để pointer giữ (0,1) sau submit cho creator-approver), luồng MONG MUỐN chạy được NGAY với ZERO thay đổi trong ApproveV2Async — logic "comment-in-own-box → save → advance" ĐÃ có sẵn (UPSERT SVC:763-790 + advance SVC:927). ApproveV2Async vốn hỗ trợ đúng hành vi spec cần.

FE xác nhận: currentApproval.approvers = approver tại pointer HIỆN TẠI (resolve giống ResolveV2InboxIdsAsync FEAT:786-821, match l.Order==CurrentApprovalLevelOrder && ApproverUserId==userId). Sau bypass đó là Cấp-2 → FE ẩn nút Duyệt của creator.

6. Reject/Trả lại → trả về đúng người đó comment lại rồi forward (spec item 3)

Primitive ĐÃ tồn tại qua 4 mode ApplyReturnModeAsync (SVC:348-548). 3 mode OneLevel/OneStep/Assignee giữ Phase=ChoDuyet + lùi pointer về Cấp trước → người đó duyệt lại (UPSERT ghi đè opinion cũ SVC:784-790) rồi forward tiếp. Mode Assignee (SVC:485-510) target bất kỳ NV đã ký. Nhưng lưu ý: Cấp-1 của creator một khi bị bypass mang comment auto, không phải comment thật.

Open Questions

  1. Xung đột trực tiếp với UAT S60: bypass drafter (anh Kiệt cũ: "Trưởng phòng tạo thì bypass") NGƯỢC với yêu cầu mới (creator/PRO comment ô của mình rồi save). Owner chốt: (a) bỏ hẳn bypass, (b) chuyển bypass thành opt-in/opt-out per-workflow hoặc per-level, hay (c) giữ bypass cho drafter-không-phải-Cấp-1 nhưng để creator-Cấp-1 comment tay. Đây là đảo ngược hành vi S60 — cần sign-off rõ.
  2. Khi creator LÀ Cấp-1: submit nên để pointer ở (0,1) rồi creator bấm Duyệt riêng (2 click), hay gộp 1 action "submit + duyệt-Cấp-mình"? (1 click).
  3. Data cũ: các phiếu đã submit trước đây có Approval row Decision=AutoApprove + LevelOpinion comment canned do bypass ghi — cần migrate/cleanup hay chỉ áp cho phiếu mới?
  4. Nếu bỏ bypass, các quy trình mà TP/Trưởng phòng tự tạo sẽ phải tự bấm duyệt Cấp mình — xác nhận đây là UX chấp nhận được (đúng ý spec "comment vào ô duyệt CỦA CHÍNH HỌ").