Files
rmutr/docs/superpowers/specs/2026-08-19-request-budget-additional-approval-round-design.md
T
Nut.ไปเรื่อย 75e1f3706e docs: correct spec after implementation research
Approve/reject DO need round_no-aware query filtering (the row_order+1
lookup and the reject index-walk have no is_state guard and would
collide across rounds) - the initial draft's "no changes needed" claim
was wrong. Also: migration auto-applies via Database.Migrate() on
startup, remark_historys round_no can be stamped client-side (no PUT
handler changes needed), and frontend's hasActiveStep()/approveStatus()
need round-scoping too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNN1mgyALe9skewgUDSMLK
2026-08-20 10:06:09 +07:00

18 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 เลย)

งานนี้ต่อเนื่องจากการแก้บั๊กหลายอย่างก่อนหน้าในเซสชันเดียวกัน (สีตัวหนังสือการ์ด, เงื่อนไขสิทธิ์ปุ่มส่งอนุมัติ, อ่านสถานะจาก approve_states[] แทน field top-level ที่เป็น null เสมอ, ข้อความบอกผู้อนุมัติคนถัดไป, ล่าสุดคือแก้ backend ApproveState.cs ให้เช็ค area_uid แทนสิทธิ์ SEND-APPROVE — deploy ไปแล้วก่อนเริ่มงานนี้) — ใช้ 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
  • อนุญาตให้ส่ง "อนุมัติเพิ่มเติม" ได้ ไม่จำกัดจำนวนรอบ ต่อปีงบ/หน่วยงาน/พื้นที่เดียว (รอบ 2, 3, 4, ... ได้เรื่อยๆ)
  • Migration apply ขึ้น production อัตโนมัติตอน backend เริ่มทำงาน (ดูหัวข้อ "Migration" ด้านล่าง) — ไม่ต้องรันคำสั่งแยกเอง

แก้ไขจากดราฟต์แรก: ตอนแรกคิดว่า approve/reject เดิม (ApproveState.cs:132-169, 298-343) ไม่ต้องแก้เลย เพราะคิดว่า query is_state==true จะ match แค่แถวของรอบปัจจุบันเสมอ — ข้อสรุปนี้ผิด เมื่อตรวจโค้ดละเอียดพบว่าอีก query หนึ่งในทั้งสอง method (c.row_order == getResult.row_order + 1 ตอน approve, และ OrderBy(row_order) + FindIndex ตอน reject) ไม่ได้กรองด้วย is_state เลย — พอมี 2 รอบที่มี row_order ซ้ำกัน (เช่นรอบ 1 กับรอบ 2 ต่างก็มี row_order 1,2) จะเจอแถวปนกันได้ (FirstOrDefaultAsync/OrderBy ไม่มี tie-breaker ระหว่างรอบ) เสี่ยงไปแก้แถวรอบเก่าที่อนุมัติไปแล้วซ้ำ ดังนั้น approve/reject ต้องแก้ให้กรองด้วย round_no ปัจจุบันด้วย (รายละเอียดในหัวข้อ Backend ด้านล่าง)

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)

แก้ query ของ approve/reject เดิมให้ round-aware (Modules/Setting/Controllers/ApproveState.cs)

เฉพาะ branch request_expense != null ใน ApproveGetEntity (บรรทัด 132-169) และ RejectGetEntity (บรรทัด 298-343) — เพิ่มการหา currentRound = MAX(round_no) ของ request_expense_uid นั้นก่อน แล้วกรองทุก query ที่แตะ t_approve_state ในสอง branch นี้ด้วย round_no == currentRound เพิ่มจากเดิม (ทั้ง FirstOrDefaultAsync(is_state==true), Where(is_approve != true), FirstOrDefaultAsync(row_order == getResult.row_order + 1) ฝั่ง approve; และ Where(...).OrderBy(row_order) ที่ดึงมาทำ FindIndex ฝั่ง reject) branch invest_asset/invest_construct ไม่ต้องแตะ (อยู่นอกขอบเขต, round_no จะเป็น 1 เสมอสำหรับสองอันนี้)

Endpoint ใหม่: resubmit

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

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 แล้ว save
  5. เรียก ApproveStateService.getApproveState(...) (Modules/Setting/Services/ApproveState.cs:26-106) — ต้องเพิ่มพารามิเตอร์ int roundNo = 1 ให้ method นี้ (default 1 ไม่กระทบ caller เดิมคือ RequestExpenseService.NewEntity) โดย resubmit endpoint เรียกด้วย roundNo: currentMaxRound + 1 เพื่อ clone request_approve_type_states (config ล่าสุดจากแอดมิน หน้า approve-menu) เป็น t_approve_state ชุดใหม่ติด round_no ใหม่ (แถวเก่าของรอบก่อนหน้าไม่ถูกแก้ ยังอยู่ครบเป็นประวัติ) แถวแรก (row_order==1) ของรอบใหม่ set is_state=true
  6. บันทึกและ return record ที่อัปเดตแล้ว (เหมือน approve/reject เดิม)

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

remark_historys ใหม่ — stamp round_no ฝั่ง frontend ไม่ต้องแก้ backend

ตรวจสอบแล้ว PUT t_request_expense (BaseUidController<t_request_expense,v_request_expense>.UpdateEntityIdEntityUidService.UpdateEntity, ไม่ได้ override ใน RequestExpenseController) cascade-insert แถวใหม่ใน remark_historys ให้อัตโนมัติอยู่แล้วจาก payload ที่ส่งมา (EF Core Update() เดา insert/update จาก key ที่เป็นค่า default หรือไม่) — ไม่ต้องแก้ backend ส่วนนี้เลย แค่ให้ frontend set round_no บน object ที่ push เข้า remark_historys array ก่อน (ดูหัวข้อ Frontend ข้อ 5)

Migration

เพิ่ม round_no int not null default 1 ที่ approve_state และ remark_history model classes (Modules/Setting/Databases/Models/approve_state.cs, Modules/Requests/Databases/Models/remark_history.cs) แล้วรัน dotnet ef migrations add AddRoundNoToApproveStateAndRemarkHistory — backend project นี้ apply migration อัตโนมัติตอน startup ผ่าน dbContext.Database.Migrate() (Startup.cs:444-445) ดังนั้นไม่ต้องรันคำสั่ง dotnet ef database update แยกเอง แค่ deploy binary ใหม่ (script.sh) แล้ว service restart จะ apply migration ให้เอง — ต้อง backup database ก่อน deploy รอบนี้เสมอ เพราะเป็นการ apply schema change ขึ้น production โดยอัตโนมัติทันทีที่ restart

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. currentRound(): helper ใหม่ใน request-budget-list.component.tsMath.max(1, ...(approve_states?.map(s => s.round_no ?? 1) ?? [1]))
  3. hasActiveStep()/approveStatus() ต้องกรองตามรอบปัจจุบันด้วย: ทั้งสอง method ที่มีอยู่แล้วต้องกรอง approve_states ด้วย (s.round_no ?? 1) === this.currentRound() ก่อนค้นหา ไม่งั้นรอบเก่าจะมาปนกับรอบใหม่ (approveStatus() โดยเฉพาะใช้ .find() ซึ่งจะเจอ entry ของรอบเก่าก่อนเสมอถ้าไม่กรอง)
  4. ปุ่มใหม่ "ส่งอนุมัติเพิ่มเติม": โชว์เมื่อ approve_button?.is_approve === true (คนละเงื่อนไขกับปุ่ม "ส่งอนุมัติ" เดิมที่ใช้ hasActiveStep(1)) ใช้ confirm dialog + โชว์ชื่อผู้อนุมัติคนถัดไปแบบเดียวกับปุ่ม "ส่งอนุมัติ" เดิมที่เพิ่งทำ (nextApproverLabel() ใน container ใช้ต่อได้เลย เพราะหาแถวที่ is_state===true เหมือนกัน)
  5. Badge บอกรอบ: ต่อท้ายข้อความ "สถานะ" ที่มีอยู่แล้ว (request-budget-list.component.html:28-29) เช่น "สถานะ : อนุมัติแล้ว (รอบปกติ)" / "สถานะ : รออนุมัติ (รอบเพิ่มเติมที่ 2)" — รอบ 1 = "รอบปกติ", รอบ ≥2 = "รอบเพิ่มเติมที่ N" (ใช้ currentRound() จากข้อ 2)
  6. กรอง remark_historys ตามรอบปัจจุบัน: การ์ด "รายละเอียดสำหรับการส่งแก้ไข" (แก้เงื่อนไข visibility ไปแล้วในรอบก่อนหน้าให้โชว์เมื่อมี remark) ต้องกรองเฉพาะ remark_historys ที่ (round_no ?? 1) === currentRound() ก่อนเช็ค .length > 0 และก่อนแสดงในตาราง — กันคอมเมนต์เก่าจากรอบก่อนมาปนกับรอบใหม่ที่เพิ่งเริ่ม
  7. resole() stamp round_no: ตอน push remark ใหม่เข้า approve_button.remark_historys ให้ set round_no: this.currentRound() บน object ใหม่ด้วย ไม่งั้นค่าจะเป็น undefined จนกว่าจะโหลดข้อมูลใหม่จาก backend (ซึ่ง backend จะไม่ได้ stamp เพิ่มเพราะไม่แตะ endpoint นี้ — ต้องพึ่ง frontend ส่งค่ามาให้ถูกตั้งแต่แรก)

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 เดิมที่ใช้มาตลอดเซสชันนี้)