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

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 == ApproverUserIdKHÔ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:

  1. 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.
  2. Có vết audit đầy đủ: PurchaseEvaluationApproval row + LevelOpinion.SignedByUserId=creator phâ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

  1. 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.
  2. 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.
  3. 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.