Files
rmutr/docs/superpowers/specs/2026-09-07-invest-asset-self-service-approval-design.md

15 KiB

แผนความต้องการงบลงทุนค่าครุภัณฑ์ — เปลี่ยนเป็นผู้ส่งเลือกผู้อนุมัติเอง (Design)

สถานะ: ผ่าน design review แล้ว รอเขียน implementation plan อ้างอิง: docs/superpowers/specs/2026-09-07-approve-state-workflow-reference.md (การอนุมัติ 2 ลำดับ + การส่งอนุมัติ ของ code "04")

1. บริบทและปัญหา

หน้า แผนความต้องการงบลงทุน ⟶ ค่าครุภัณฑ์ ที่ใช้งานจริงตอนนี้คือ /app/durable-articles (โฟลเดอร์ feature/budget-request/investment-budget/durable-articles/) ใช้กลไกอนุมัติแบบ แอดมินตั้งคิวล่วงหน้า (request_approve_type = "01") เท่านั้น: ตอนกด "ส่งอนุมัติ" (GET /invest/invest_asset/send_approve/{uid}) ระบบจะเรียก ApproveStateService.getApproveState(..., "01", ...) ซึ่งอ่านคิวจากตาราง config request_approve_type / request_approve_type_state ที่แอดมินต้องตั้งไว้ล่วงหน้าเป็น รายหน่วยงาน/พื้นที่ผ่านหน้า "จัดการเมนูการอนุมัติ" — ถ้าไม่ตั้งไว้ หรือหน่วยงานใหม่ยังไม่ถูก ตั้งค่า จะไม่มีผู้อนุมัติให้เข้าคิวเลย

ผู้ใช้ต้องการเปลี่ยนกลไกนี้ ทั้งหมด ให้เป็นแบบเดียวกับสรุปคำของบประมาณรายได้ (request_expense, code "04"): ผู้ส่งเป็นคนพิมพ์ชื่อผู้อนุมัติเองตอนส่งอนุมัติ แทนที่การพึ่ง config แอดมินล่วงหน้า

2. ของเดิมที่ยังใช้ได้ (ไม่แตะ)

จากการอ่านโค้ดจริงของหน้านี้ พบว่ามีกลไกที่คล้ายกับ is_sent ของ request_expense อยู่แล้ว เพียงแต่ใช้ฟิลด์คนละตัว:

request_expense (code "04") invest_asset (ของเดิม)
is_sent (false/null→ยังไม่ส่ง, true→ส่งแล้ว) is_draft (true→ยังไม่ส่ง/ฉบับร่าง, null→ส่งแล้ว)
SendApprove2() เซ็ต is_sent=true SendApproveEntityId() เซ็ต is_draft=null (มีอยู่แล้ว)
ApproveGetEntity/RejectGetEntity ใช้ t_approve_state ร่วมกับ invest_asset/invest_construct เหมือนกันเป๊ะ — เป็น controller เดียวกัน (ApproveStateController)

สิ่งที่ไม่ต้องแก้เพราะทำงานถูกต้องอยู่แล้ว:

  • SendApproveEntityId (Modules/Invests/Controllers/InvestAsset.cs:206) — เช็คอยู่แล้วว่ามี approve_state แถวที่ผูกกับ invest_asset_uid นี้หรือยัง (check_approve) ถ้ามีแล้ว (เช่น มาจากการเรียก SetCustomApprovers ก่อนหน้า) จะข้ามการดึง config แอดมินให้เอง ไม่ต้องเขียนโค้ดเพิ่ม
  • RejectGetEntity (invest_asset branch) — รีสตาร์ทคิวกลับไป row_order 1 เสมอเมื่อถูกปฏิเสธ ทำงานได้เหมือนกันไม่ว่าคิวจะมาจากแอดมินตั้งหรือผู้ส่งตั้งเอง (อ่านจาก t_approve_state ตรงๆ)
  • save_header_approve / save_chief_approve / save_dean_approve ในฟอร์ม — เป็นการเซ็นภายใน หน่วยงานตัวเอง (fixed role, เซ็ตฟิลด์ is_header_approve/is_chief_approve/is_dean_approve ตรงบน entity) คนละกลไกกับคิวอนุมัติกลาง ไม่เกี่ยวกัน ไม่แตะ

3. ของใหม่ที่ต้องเพิ่ม

3.1 Backend

a) SetCustomApprovers ใหม่ใน InvestAssetService (Modules/Invests/Services/InvestAsset.cs) — mirror RequestExpenseService.SetCustomApprovers ทุกจุด:

Task<(bool ok, string error)> SetCustomApprovers(
    Guid? investAssetUid, List<ApproverInput> approvers, ClaimsIdentity claimsIdentity)
  • โหลด t_invest_asset ตาม uid, ไม่พบ → error "ไม่พบรายการ"
  • Guard: entity.is_draft != true → error "รายการนี้ต้องอยู่ในสถานะฉบับร่างก่อนจึงจะกำหนด ผู้อนุมัติได้" (invest_asset ใช้ is_draft==true แทนความหมาย "ยังไม่ส่ง" ตรงข้ามกับ request_expense ที่ใช้ null/null — ต้องเทียบ semantics ให้ถูก ไม่ใช่ copy เงื่อนไขตรงตัว)
  • Guard: มีแถว t_approve_state ที่ invest_asset_uid==uid && is_approve==true อยู่แล้ว → error "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว"
  • รับ List<ApproverInput> (มี personnel_uid, display_name_th), กรองแถวว่างทิ้ง, ถ้าไม่มี เหลือเลย → error "กรุณาระบุผู้อนุมัติอย่างน้อย 1 คน"
  • ลบแถว t_approve_state เดิมของ invest_asset นี้ทั้งหมด (ถ้ามี, เผื่อกดกำหนดซ้ำก่อนส่ง), สร้างแถวใหม่ row_order 1..N, is_state = (row_order==1), request_approve_type = <รหัสใหม่> (ดูข้อ 4)
  • เซ็ต entity.current_approve_name เป็นชื่อผู้อนุมัติคนแรก, save

หมายเหตุ: โค้ดต้นแบบ (RequestExpenseService.SetCustomApprovers) มี docstring บอกว่า "ห้ามระบุตัวเองเป็นผู้อนุมัติ" แต่จากการอ่านโค้ดจริงไม่มีการเช็คนี้ในฝั่ง backend (ปล่อยให้ UI เป็นคนกรองตอนเลือกจากรายชื่อ) — เวอร์ชันใหม่นี้จะ mirror พฤติกรรมจริงที่มีอยู่ คือไม่เช็คใน backend เช่นกัน เพื่อความสอดคล้องกัน ไม่ใช่ bug ใหม่ที่เพิ่มเข้ามา

b) Endpoint ใหม่ POST /api/invest/invest_asset/{uid}/set_approvers ใน InvestAssetController เรียก SetCustomApprovers ข้างบน, คืน 200 + error message ถ้าไม่ผ่าน guard (mirror ทรงเดียวกับ endpoint ของ request_expense)

c) ปิดช่องโหว่ใน ApproveGetEntity (approve endpoint ที่ใช้ร่วมกัน)

ที่ Modules/Setting/Controllers/ApproveState.cs, invest_asset branch (บรรทัด ~50-86) ปัจจุบันไม่มีการเช็คว่าใครเป็นคนกด และไม่เช็คว่า "ส่งอนุมัติ" แล้วหรือยัง — เพราะของเดิม approve_state จะถูกสร้างพร้อมกับตอนกด "ส่งอนุมัติ" เท่านั้น (ไม่มีช่วงเวลาที่แถวมีอยู่ก่อน ส่งจริง) แต่พอเพิ่ม SetCustomApprovers ที่สร้างแถว (พร้อม is_state=true ที่แถวแรก) ก่อน กด "ส่งอนุมัติ" จะเกิดช่องโหว่ทันที: ใครก็เรียก GET approve/{uid} อนุมัติแทนได้เลยตั้งแต่ ยังไม่กด "ส่งอนุมัติ" ด้วยซ้ำ

ต้องเพิ่ม guard แบบเดียวกับที่ request_expense code "04" มี (บรรทัด 133-150 เดิม) แต่สลับให้ ตรงกับ semantics ของ is_draft:

if (invest_asset != null && getResult != null)
{
    if (getResult.row_order == 1 && getResult.request_approve_type == "<รหัสใหม่>")
    {
        if (invest_asset.is_draft == true)
        {
            return BadRequest(new { description = "ยังไม่ได้ส่งอนุมัติ กรุณากด \"ส่งอนุมัติ\" ก่อน" });
        }
        var currentDisplayName = (User.Identity as ClaimsIdentity)?.FindFirst("display_name")?.Value;
        if (string.IsNullOrEmpty(currentDisplayName) || currentDisplayName != getResult.display_name_th)
        {
            return StatusCode(StatusCodes.Status403Forbidden,
                new { description = "ไม่มีสิทธิ์อนุมัติ (ต้องเป็นผู้ที่ถูกกำหนดเป็นผู้อนุมัติลำดับ 1 เท่านั้น)" });
        }
    }
    // ... โค้ดเดิมที่เหลือ (getResult.is_state=false, is_approve=true, ฯลฯ) ไม่เปลี่ยน
}

เช็คนี้ทำเฉพาะ row_order==1 เหมือน "04" เป๊ะ (ลำดับถัดไปไม่เช็ค ตาม pattern เดิม ที่มีอยู่แล้วในระบบ — ไม่ใช่ scope ของงานนี้ที่จะไปแก้ pattern เดิม)

send_approve (SendApproveEntityId) ไม่ต้องแก้ ตามที่อธิบายในข้อ 2

3.2 Frontend

a) Dialog ใหม่ "ระบุผู้อนุมัติ" สำหรับครุภัณฑ์

Copy โครงจาก feature/income-report/presenter/popup/set-approvers-dialog/ มาสร้างใน feature/budget-request/investment-budget/durable-articles/presenter/popup/set-approvers-dialog/ (หรือ path เทียบเท่าในโมดูลเดียวกับฟอร์มครุภัณฑ์) ปรับ:

  • แหล่งข้อมูลบุคลากรให้ตรงกับที่ใช้ในหน้าครุภัณฑ์ (เดิม AgencyService.getHr()) — คงไว้ ใช้ตัวเดิม
  • 2 ลำดับตายตัวเหมือน request_expense: approverLabels = ['หัวหน้าหน่วยงาน', 'รองอธิการบดี'] (ยืนยันกับผู้ใช้แล้วว่าใช้ชุดเดียวกัน ไม่ต้องออกแบบ label ใหม่) — ลำดับแรก required, ลำดับสอง optional เหมือนต้นแบบ (this.approverForm(..., required=true) / (..., required=false))

b) แก้ durable-articles-form.component.ts

save_approve() (บรรทัด 734) ปัจจุบันเรียก .put() แล้วต่อด้วย send_approve ตรงๆ — เปลี่ยน เป็น: เปิด dialog ระบุผู้อนุมัติก่อน (ถ้ายังไม่เคยกำหนด) → เรียก set_approvers endpoint ใหม่ → ต่อด้วย send_approve เดิม (concatMap ต่อกัน คล้ายที่ income-report ทำ)

c) ไม่แตะ: save_header_approve/save_chief_approve/save_dean_approve/save_president_approve, save_draft, save_draft_as, add_approve — เป็นคนละกลไก/คนละปุ่ม

4. รหัส request_approve_type ใหม่

โค้ด "01"/"02"/"03"/"04" ถูกใช้แล้ว (invest_asset เดิม / invest_construct / request_expense เดิม / request_expense self-service) ต้องเลือกรหัสใหม่ที่ไม่ชนกับข้อมูล จริงในตาราง approve_state/request_approve_type — ขณะออกแบบนี้ต่อฐานข้อมูล production ไม่ได้ชั่วคราว (connection timeout ที่ port 31620) จะเช็คแบบสดตอนเริ่ม task แรกของแผน implementation ก่อนเขียนโค้ดจริง (SELECT DISTINCT request_approve_type FROM approve_state

  • ดูตาราง request_approve_type) แล้วเลือกรหัสว่างจริง (คาดว่าจะเป็น "05" แต่ต้องยืนยันก่อน)

5. Backward compatibility

  • คำขอครุภัณฑ์เก่าที่มี approve_state แบบ request_approve_type="01" ค้างอยู่ (กำลังอยู่ ระหว่างอนุมัติ) ไม่ถูกแตะ — เดินหน้าอนุมัติ/ปฏิเสธต่อได้ตามปกติ เพราะ guard ใหม่ในข้อ 3.1(c) เช็คเฉพาะรหัสใหม่เท่านั้น
  • โค้ด getApproveState(...,"01",...) และตาราง config เดิมยังอยู่ในระบบ (ไม่ลบ) แต่จะไม่มีใคร ตั้งค่าใหม่อีกต่อไปเพราะ frontend บังคับให้ต้องกำหนดผู้อนุมัติเองก่อนส่งเสมอ — ตรงตามที่ผู้ใช้ ต้องการ ("แทนที่ไปเลย")

6. Testing

ตาม methodology เดียวกับที่ใช้ทำ income-budget-review-stage ใน session นี้: สร้างข้อมูลทดสอบ สังเคราะห์ (test.verify) ผ่าน SQL, ยิง endpoint จริงด้วย JWT ทดสอบ, ตรวจผลผ่าน SQL, ลบข้อมูลทดสอบทิ้งหลังตรวจเสร็จ — ไม่มี automated test suite ในโปรเจกต์นี้