9.8 KiB
HEADLINE: Giữ "save = advance" (1 nút) — sửa đúng 1 chỗ BE: gỡ/gate ApplyDrafterBypassOnSubmitAsync để creator ĐÁP xuống ô Cấp-1 của mình tự comment+ký thay vì bị auto-bypass ký hộ; FE gần như 0 đổi. Self-approval = bước ĐỀ-XUẤT hợp lệ, CHỈ rủi ro nếu slot creator bật AllowApproverFinalize/SkipToFinal.
Kết luận cốt lõi
Yêu cầu "creator comment vào ô duyệt của chính họ → save → advance" KHÔNG cần đổi save-semantics và KHÔNG cần tách 2 nút. Cơ chế "comment-in-own-box → save → advance" đã tồn tại nguyên vẹn trong dialog Duyệt (Textarea comment → "Xác nhận" → ApproveV2Async UPSERT LevelOpinion + advance). Khiếm khuyết THẬT không nằm ở save-semantics mà ở auto-bypass im lặng: khi drafter (=PRO=Cấp-1) gửi duyệt, ApplyDrafterBypassOnSubmitAsync (service:571-681, gọi tại :260-261) tự ký hộ ô Cấp-1 với comment đóng hộp "(duyệt tự động — người soạn phiếu là người duyệt cấp này)" (:619) rồi advance qua luôn → creator KHÔNG bao giờ đáp xuống ô mình để gõ ghi-chú ngân-sách thật (đúng cái screenshot Cấp1 Bình muốn).
Sơ đồ luồng — AS-IS vs TO-BE
AS-IS (auto-bypass nuốt Cấp-1 của creator):
Nháp --gửi duyệt--> ChoDuyet, pointer=(Step0,Cấp1)
└─ ApplyDrafterBypassOnSubmit: creator là Cấp1 → UPSERT opinion CANNED
"(duyệt tự động…)" + Approval row AutoApprove + advance → Cấp2 (Trà)
=> creator MẤT ô comment của mình. Screenshot "Bình comment ngân-sách" KHÔNG đạt.
TO-BE (creator tự ký ô mình):
Nháp --gửi duyệt--> ChoDuyet, pointer ĐỨNG tại (Step0, Cấp thấp nhất của creator)
=> currentApproval populated → banner "✓ Đến lượt bạn duyệt" + nút "✓ Duyệt"
=> creator mở dialog, gõ ghi-chú NS thật, "Xác nhận"
→ ApproveV2Async UPSERT opinion (comment THẬT, SignedByUserId=creator) + advance → Trà
=> Trà ký → Bước2 Bình lại ký → Chương "ok" … (đúng screenshot)
Khuyến nghị save-semantics: GIỮ 1 NÚT ("save = advance")
| Tiêu chí | 1 nút (save=advance) — KHUYẾN NGHỊ | 2 nút (lưu nháp ý-kiến + submit) |
|---|---|---|
| Khớp yêu-cầu | ✅ dialog gõ comment → Xác nhận = "comment→save→advance" y hệt | Thừa — user không đòi giai-đoạn nháp |
| Chi phí | 1 method BE (gate bypass), FE ~0 | Schema mới (opinion cần state Draft / SignedAt nullable), endpoint mới, nút+handler FE mới, DTO field mới |
| Audit | Chữ-ký atomic (Approval row + opinion cùng lúc) | Nửa-trạng-thái "opinion chưa advance" làm rối reject/return + terminal |
| Sửa-lại-comment | Đã có: return→UPSERT overwrite (latest-write-wins, entity doc:17-19) | Không lợi thêm |
"Lưu nháp rồi mới đẩy" đã có sẵn xuyên chu-kỳ Trả-lại: opinion tìm theo (PEId, ApprovalWorkflowLevelId) và bị OVERWRITE mỗi lần ký lại (service:766-790) → không cần draft-state riêng.
Bảng thay đổi BE
| # | File:line | Method/field | Đổi gì |
|---|---|---|---|
| B1 (chính) | PurchaseEvaluationWorkflowService.cs:571-681 |
ApplyDrafterBypassOnSubmitAsync |
NGỪNG tự ký ô CHÍNH CHỦ của creator. Phương án A1 (khuyến nghị, giữ S60 skip NV cấp dưới): đổi k=drafterSlots.Max (:592) + bypassedOrders = order<=k (:595) → chỉ bypass order < drafterSlots.Min (thuần NV dưới creator); set pointer ĐỨNG tại minOwnOrder (thay :647-680 advance qua k). Nếu creator là Cấp-1 (phổ biến/screenshot) → subordinate rỗng → pointer giữ (0,1), 0 auto-sign. Phương án A2 (đơn giản nhất): xóa call :260-261 → creator luôn đáp (0,1) tự ký mọi cấp, NHƯNG mất skip-NV-cấp-dưới của S60. |
| B2 (governance guard — nên thêm) | ApproveV2Async ~:805 (skipToFinal) + ~:867 (AllowApproverFinalize) |
thêm điều kiện actorUserId == evaluation.DrafterUserId |
Chặn creator TỰ kết-thúc/vượt-cấp phiếu mình: nếu actor là người soạn mà slot bật finalize/skip → throw Forbidden("Người soạn không được tự kết thúc/duyệt vượt cấp phiếu của mình — phải để cấp trên duyệt."). Đây là kiểm-soát self-approval THẬT (xem đánh giá dưới). |
| B3 (test — BẮT BUỘC, spec-change) | tests/…/Services/PeSubmitGuardAndBypassTests.cs:28 |
test bypass cũ | Cập-nhật kỳ-vọng: creator=Cấp1 sau submit → pointer ĐỨNG tại Cấp1 (không advance, không opinion canned); creator=Cấp2+NV-dưới → NV-dưới bypass, pointer=Cấp2 (rule §7 spec-change = update test cùng commit). |
Match-approver KHÔNG chặn creator: ApproveV2Async:728-739 yêu-cầu actorUserId ∈ pendingLevelGroup.ApproverUserId. Creator LÀ ApproverUserId của Cấp-1 mình → pass. Không cần đụng.
Bảng thay đổi FE (2 app byte-identical — diff exit 0, 903 dòng — sửa gì PHẢI mirror cả 2)
| # | File:line | Component/handler | Đổi gì |
|---|---|---|---|
| F0 | — | — | Happy-path: 0 đổi chức-năng. Banner isV2Pending+actorInV2Level (:98-103,:407-426) + nút Duyệt (:441,:449-472) + dialog comment→Xác nhận→transition.mutate (:153-207) đã chạy đúng khi pointer đứng ở ô creator. Nút Trả-lại/Từ-chối vốn ĐÃ ẩn cho creator (:446 drafterUserId===currentUser.id); Duyệt KHÔNG ẩn. |
| F1 (polish, tùy chọn) | PeWorkflowPanel.tsx:414-424 + dialog title :492-496 |
banner/label | Khi drafterUserId===currentUser.id && actorInV2Level: đổi chữ thành "Ý kiến của bạn (người soạn — Cấp {order})" để creator hiểu đang ký ô đề-xuất của mình. Thuần cosmetic. |
SignedByUserId vs ApproverUserId — ảnh hưởng
ApprovalWorkflowLevel.ApproverUserId(design-time, entity:88) = NV được cấu hình.LevelOpinion.SignedByUserId(runtime, entity:31) = người ký thật.- Creator ký ô mình →
SignedByUserId == ApproverUserId→ KHÔNG hiện banner "Admin duyệt thay" (chỉ hiện khi lệch, service:762). Sạch, không side-effect. - TO-BE cho opinion THẬT:
SignedByUserId=creator, Comment=ghi-chú-NS-thật— chữ-ký audit GENUINE, tốt hơn canned comment của auto-bypass. Đây là cải-thiện governance, không phải nới lỏng.
Đánh giá SELF-APPROVAL
Là bước ĐỀ-XUẤT hợp lệ, KHÔNG phải lỗ-hổng kiểm-soát — với 2 điều kiện:
- Cấp-1 của creator = chữ-ký ĐỀ-XUẤT/chuẩn-bị (PRO trình phương-án + ngân-sách); thẩm-quyền DUYỆT thật nằm ở cấp trên (CCM Cấp-2, BGĐ/CEO Bước cuối). Creator không thể tự đẩy tới
DaDuyet(cấp mình không phải terminal) → phân-tách nhiệm-vụ giữ nguyên. - Có vết audit đầy đủ:
PurchaseEvaluationApprovalrow +LevelOpinion.SignedByUserId=creatorphân-biệt rõ chữ-ký.
THÀNH lỗ-hổng CHỈ KHI slot Cấp-1 của creator bật AllowApproverFinalize (F5, entity:142) hoặc AllowApproverSkipToFinal (F2, entity:131) → creator một mình duyệt phiếu mình lên DaDuyet, bỏ CEO. Hiện KHÔNG có guard chặn việc này (finalize/skip chỉ gate theo admin-config slot, không xét actor==drafter). → Đề-xuất guard B2.
Ghi nhận tích-cực: chuyển từ auto-bypass-im-lặng (approve hộ không ai xác-nhận) sang ký-tay-tường-minh (bắt creator chủ-động nêu lý-do + tạo chữ-ký) là nâng-cấp minh-bạch, không giảm.
Yêu cầu (3) Reject/Trả-lại về đúng người → re-comment → forward lại: ĐÃ ĐỦ, 0 code mới
Multi-mode Trả-lại (service ApplyReturnModeAsync:348-548) OneLevel/OneStep/Assignee giữ Phase=ChoDuyet + lùi pointer về đúng Cấp/người; Assignee (:485-510) pick từ NV đã ký (gồm creator). Người nhận lại → banner "Đến lượt bạn" → Duyệt → UPSERT overwrite comment + advance. Chỉ cần admin bật AllowReturnToAssignee (entity:110) trên slot cấp trên — config, không phải code.
Edge cases
- Creator xuất-hiện nhiều cấp (screenshot: Bình ở Bước1-Cấp1 VÀ Bước2-Cấp1): A1/A2 xử đúng — creator tự ký từng cấp khi pointer chạm; không auto-sign chéo.
- Notify: creator không nhận chuông "cần bạn duyệt" phiếu mình (service:1164-1169 S86) — giữ nguyên, thấy ở "Phiếu của tôi". Không double-notify.
- Submit-guard Section-3 (:175-229) chạy TRƯỚC bypass → không bị ảnh hưởng.
- A2 đảo ngược ý S60 "TP tạo phiếu vẫn bắt NV cấp dưới duyệt lại" → dùng A1 nếu muốn giữ skip-NV.
Token cost estimate: ~62K
Open Questions
- A1 hay A2? A1 = giữ skip-NV-cấp-dưới của S60 (creator là TP → NV dưới vẫn auto-qua, creator tự ký ô mình) — phức tạp hơn ~15 dòng. A2 = xóa hẳn bypass, creator tự ký MỌI cấp mình chiếm (kể cả có NV dưới) — đơn giản nhất nhưng bỏ tính-năng S60. Owner chốt còn muốn skip-NV-cấp-dưới không.
- Guard B2 self-finalize: có chặn creator-slot bật AllowApproverFinalize/SkipToFinal không? Nếu Designer cho phép cố-ý (vd phiếu giá-trị nhỏ creator được tự-chốt) thì B2 phải thành cảnh-báo mềm thay vì Forbidden cứng.
- Xác nhận KHÔNG cần 2-nút (lưu-nháp-ý-kiến riêng): nếu owner thực-sự muốn creator "lưu comment, để đó, lát mới đẩy" (không advance ngay) thì mới cần Option B (schema+endpoint+DTO). Cần owner xác-nhận yêu-cầu chỉ là "tự ký ô mình" (1 nút đủ) hay có nhu-cầu draft-rời-advance thật.