Files
rmutr/docs/superpowers/specs/2026-08-19-request-budget-additional-approval-round-design.md
T
Nut.ไปเรื่อย d1297a134c docs: add design spec for request-budget additional approval round
Design for letting users submit a fresh approval round when a
budget-year/faculty/area's request_expense has already been fully
approved but new line items need to go through approval too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNN1mgyALe9skewgUDSMLK
2026-08-19 13:58:56 +07:00

14 KiB

ส่งอนุมัติเพิ่มเติม (Additional Approval Round) — Design

บริบท

หน้า "จัดทำคำของบประมาณแผ่นดิน" (/app/request-budget, request-budget-list.component.ts/html + request-budget.container.ts ใน rmutr-web) ให้ผู้ใช้บันทึกคำขอ (ง.3/ง.5/ง.อื่นๆ) ตามปีงบประมาณ+หน่วยงาน+พื้นที่ แล้วกด "ส่งอนุมัติ" เข้าสายอนุมัติ (t_approve_state, request_approve_type_code = "03") จนครบทุกขั้นถึงกองแผน

ช่องว่างปัจจุบัน: t_request_expense มี 1 record ต่อ (ปีงบ+หน่วยงาน+พื้นที่) เท่านั้นตลอดไป ระบบสร้างให้ครั้งแรกครั้งเดียว (RequestExpenseService.NewEntity, Modules/Requests/Services/RequestExpense.cs:111-156) แล้วดึง record เดิมมาใช้ซ้ำเสมอ เมื่ออนุมัติครบทุกขั้น (request_expense.is_approve = true) ปุ่ม "ส่งอนุมัติ" ฝั่ง frontend จะหายไปถาวร (hasActiveStep(1) เป็น false เพราะไม่มีแถวไหนใน approve_states ที่ is_state==true อีกแล้ว) — ถ้าผู้ใช้ต้องการบันทึกคำขอเพิ่มในปีงบ/หน่วยงาน/พื้นที่เดิมที่อนุมัติไปแล้ว ไม่มีทางส่งรายการใหม่เข้าสายอนุมัติได้เลย รายการใหม่จะถูกรวมเข้าไปในสรุปยอดเงียบๆ โดยไม่มีใครต้องอนุมัติมันเพิ่ม (ตรวจสอบโค้ดแล้วยืนยันว่าไม่มี hook เชื่อมระหว่าง flow "เพิ่มรายการคำขอ" กับ request_expense/approve_state เลย)

งานนี้ต่อเนื่องจากการแก้บั๊ก 3 อย่างก่อนหน้าในเซสชันเดียวกัน (สีตัวหนังสือการ์ด, เงื่อนไขสิทธิ์ปุ่มส่งอนุมัติ, อ่านสถานะจาก approve_states[] แทน field top-level ที่เป็น null เสมอ, ข้อความบอกผู้อนุมัติคนถัดไป) — ใช้ record จริงของ "คณะวิทยาศาสตร์และเทคโนโลยี / ปีงบ 2569 / ศาลายา" เป็นเคสอ้างอิงตลอดการออกแบบ

ขอบเขต

  • อยู่ในขอบเขต: เฉพาะ flow "จัดทำคำของบประมาณแผ่นดิน" (request_approve_type_code = "03") ที่หน้า request-budget เท่านั้น — คือ t_request_expense + t_approve_state (filtered by request_expense_uid) + t_remark_history (filtered by request_expense_uid)
  • ไม่แตะ: invest_asset_request/invest_construct_request ที่มี approve_state ของตัวเอง (InvestAssetRequest.cs:884, InvestConstructRequest.cs:520) — เป็นคนละหน้า คนละ flow ธุรกิจ ถึงจะใช้ตาราง t_approve_state ร่วมกัน (polymorphic) แต่ scope งานนี้จำกัดเฉพาะแถวที่ request_expense_uid != null
  • ไม่แตะ logic การ approve/reject เดิม (ApproveState.cs:132-169, 298-343) — ยืนยันแล้วว่าไม่จำเป็นต้องแก้ เพราะ query is_state==true จะ match แค่แถวของรอบปัจจุบันเสมอ (รอบเก่าอนุมัติครบแล้ว is_state ถูกเคลียร์เป็น null หมดทุกแถว)
  • อนุญาตให้ส่ง "อนุมัติเพิ่มเติม" ได้ ไม่จำกัดจำนวนรอบ ต่อปีงบ/หน่วยงาน/พื้นที่เดียว (รอบ 2, 3, 4, ... ได้เรื่อยๆ)
  • Migration จะ apply ขึ้น production database จริง โดยตรงในเซสชันนี้ (เหมือนที่ deploy frontend ที่ทำมาตลอด) — ต้อง backup ฐานข้อมูลก่อน apply ทุกครั้ง

Data Model

เพิ่มคอลัมน์เดียวกัน 2 ตาราง (EF Core migration, dotnet ef migrations add):

ตาราง คอลัมน์ ชนิด default
t_approve_state round_no int not null 1
t_remark_history round_no int not null 1

Record เดิมทั้งหมดได้ round_no=1 อัตโนมัติจาก default value — ไม่กระทบข้อมูลเก่า ไม่ต้อง backfill เพิ่ม

ไม่เพิ่มคอลัมน์ใหม่ที่ t_request_expense — คำนวณ "รอบปัจจุบัน" จาก MAX(round_no) ของ approve_states ที่ query มาแทน เพื่อเลี่ยงปัญหาค่าไม่ sync กันระหว่าง denormalized field กับข้อมูลจริง

Backend (rmutr-api)

Endpoint ใหม่

GET /api/setting/approve_state/resubmit/{request_expense_uid} (ตั้งชื่อ/ตำแหน่งตามแบบ approve/reject เดิมใน ApproveStateController/ApproveStateService)

Logic:

  1. โหลด t_request_expense ด้วย uid — ถ้าไม่พบ 404
  2. เช็ค request_expense.is_approve == true (อนุมัติครบทุกขั้นของรอบล่าสุดแล้วจริง) — ถ้าไม่ใช่ ตอบ 400 พร้อมข้อความ error ชัดเจน (เช่น "คำขอนี้ยังไม่ได้รับการอนุมัติครบทุกขั้น ไม่สามารถส่งอนุมัติเพิ่มเติมได้")
  3. หา currentMaxRound = MAX(round_no) จาก t_approve_state where request_expense_uid == uid (ถ้าไม่มีแถวเลยถือเป็น 0 กันพลาด แม้ในทางปฏิบัติต้องมีอยู่แล้วจาก NewEntity เดิม)
  4. reset request_expense.is_approve = null, updated_by/updated_datetime
  5. เรียกใช้ logic เดิมที่ clone request_approve_type_states (config ล่าสุดจากแอดมิน หน้า approve-menu) เป็น t_approve_state ชุดใหม่ — reuse ApproveStateService.getApproveState(...) (Modules/Setting/Services/ApproveState.cs:26-106) โดยพารามิเตอร์เพิ่ม roundNo = currentMaxRound + 1 ให้ทุกแถวที่ clone ใหม่ set round_no ตามนี้ (แถวเก่าของรอบก่อนหน้าไม่ถูกแก้ ยังอยู่ครบเป็นประวัติ) แถวแรก (row_order==1) ของรอบใหม่ set is_state=true
  6. บันทึกและ return record ที่อัปเดตแล้ว (เหมือน approve/reject เดิม)

เหตุผลที่เลือกใช้ config ล่าสุดจากแอดมินเสมอ (ไม่ clone จากรอบก่อนหน้า): ถ้าแอดมินเคยแก้สายอนุมัติหลังจากรอบแรกอนุมัติไปแล้ว รอบใหม่ควรใช้สายอนุมัติที่ถูกต้องล่าสุด ไม่ใช่สายอนุมัติเก่าที่อาจไม่ถูกต้องแล้ว (เช่น คนย้ายตำแหน่ง)

PUT request_expense (บันทึกคอมเมนต์ใหม่ตอนตีกลับ)

จุดที่ resole()/reject() ฝั่ง frontend push remark_historys ใหม่แล้ว PUT ทั้งก้อน — ฝั่ง backend (RequestExpenseService PUT handler) ต้อง stamp round_no = currentMaxRound (รอบปัจจุบัน ไม่ใช่รอบใหม่ เพราะ remark เกิดขึ้นระหว่างรอบที่กำลังดำเนินอยู่) ให้ทุก t_remark_history แถวใหม่ที่ยังไม่มี remark_history_uid (แถวเก่าที่มีอยู่แล้วไม่แตะ)

Frontend (rmutr-web)

ไฟล์หลัก: request-budget-list.component.ts/html, request-budget.container.ts, request-expense.service.ts

  1. RequestExpenseService: เพิ่ม resubmit(id) เรียก GET .../approve_state/resubmit/{id} (มิเรอร์ approve()/reject() ที่มีอยู่แล้ว)
  2. ปุ่มใหม่ "ส่งอนุมัติเพิ่มเติม": โชว์เมื่อ approve_button?.is_approve === true (คนละเงื่อนไขกับปุ่ม "ส่งอนุมัติ" เดิมที่ใช้ hasActiveStep(1)) ใช้ confirm dialog + โชว์ชื่อผู้อนุมัติคนถัดไปแบบเดียวกับปุ่ม "ส่งอนุมัติ" เดิมที่เพิ่งทำ (nextApproverLabel() ใน container ใช้ต่อได้เลย เพราะหาแถวที่ is_state===true เหมือนกัน)
  3. Badge บอกรอบ: ต่อท้ายข้อความ "สถานะ" ที่มีอยู่แล้ว (request-budget-list.component.html:28-29) เช่น "สถานะ : อนุมัติแล้ว (รอบปกติ)" / "สถานะ : รออนุมัติ (รอบเพิ่มเติมที่ 2)" — คำนวณจาก Math.max(...approve_states.map(s => s.round_no)): รอบ 1 = "รอบปกติ", รอบ ≥2 = "รอบเพิ่มเติมที่ N"
  4. กรอง remark_historys ตามรอบปัจจุบัน: การ์ด "รายละเอียดสำหรับการส่งแก้ไข" (แก้เงื่อนไข visibility ไปแล้วในรอบก่อนหน้าให้โชว์เมื่อมี remark) ต้องกรองเฉพาะ remark_historys ที่ round_no === currentRound ก่อนเช็ค .length > 0 และก่อนแสดงในตาราง — กันคอมเมนต์เก่าจากรอบก่อนมาปนกับรอบใหม่ที่เพิ่งเริ่ม

Error Handling

  • กด "ส่งอนุมัติเพิ่มเติม" ตอนยังไม่ครบรอบปัจจุบัน (เผื่อ race condition/refresh ช้า) → backend ตอบ 400 → frontend โชว์ error message เดิมที่มี pattern อยู่แล้ว (catchError + Swal.fire(err.error.description, '', 'error'))
  • Migration: backup DB (pg_dump/เทียบเท่าตาม engine จริง) ก่อน apply ทุกครั้ง โดยเฉพาะรอบนี้ที่ apply ขึ้น production ตรงๆ

Testing Plan

  • Backend: unit test endpoint resubmit ทั้ง 2 กรณี (ยังไม่ครบรอบ → 400, ครบแล้ว → สร้างรอบใหม่ถูกต้อง + round_no เพิ่มขึ้นถูกต้อง), integration test approve/reject เดิมว่ายังทำงานถูกต้องหลัง migration (regression)
  • Frontend: build ตรวจ compile ผ่านตามปกติ (ไม่มี test suite ของหน้านี้อยู่ก่อนแล้ว)
  • Smoke test บน production: ใช้ record จริงของคณะวิทยาศาสตร์และเทคโนโลยี/ปีงบ 2569/ศาลายา ที่ใช้อ้างอิงมาตลอดเซสชันนี้ — เดินสายอนุมัติให้ครบรอบ 1 (ถ้ายังไม่ครบ) แล้วลองกด "ส่งอนุมัติเพิ่มเติม" เช็ค badge/การ์ด/ปุ่มเปลี่ยนถูกต้อง

ลำดับการ Deploy

  1. Backend: migration + endpoint ใหม่ ขึ้น production ก่อน (ต้องมี endpoint พร้อมก่อน ไม่งั้นปุ่มใหม่ฝั่ง frontend จะเรียก endpoint ที่ยังไม่มีอยู่)
  2. Frontend: build + deploy ตามหลัง (สคริปต์ script.sh เดิมที่ใช้มาตลอดเซสชันนี้)