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==ApproverUserIdtạ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
- 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õ.
- 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).
- 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?
- 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Ọ").