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
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 byrequest_expense_uid) +t_remark_history(filtered byrequest_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) — ยืนยันแล้วว่าไม่จำเป็นต้องแก้ เพราะ queryis_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:
- โหลด
t_request_expenseด้วย uid — ถ้าไม่พบ 404 - เช็ค
request_expense.is_approve == true(อนุมัติครบทุกขั้นของรอบล่าสุดแล้วจริง) — ถ้าไม่ใช่ ตอบ 400 พร้อมข้อความ error ชัดเจน (เช่น "คำขอนี้ยังไม่ได้รับการอนุมัติครบทุกขั้น ไม่สามารถส่งอนุมัติเพิ่มเติมได้") - หา
currentMaxRound = MAX(round_no)จากt_approve_state where request_expense_uid == uid(ถ้าไม่มีแถวเลยถือเป็น 0 กันพลาด แม้ในทางปฏิบัติต้องมีอยู่แล้วจาก NewEntity เดิม) - reset
request_expense.is_approve = null,updated_by/updated_datetime - เรียกใช้ logic เดิมที่ clone
request_approve_type_states(config ล่าสุดจากแอดมิน หน้า approve-menu) เป็นt_approve_stateชุดใหม่ — reuseApproveStateService.getApproveState(...)(Modules/Setting/Services/ApproveState.cs:26-106) โดยพารามิเตอร์เพิ่มroundNo = currentMaxRound + 1ให้ทุกแถวที่ clone ใหม่ setround_noตามนี้ (แถวเก่าของรอบก่อนหน้าไม่ถูกแก้ ยังอยู่ครบเป็นประวัติ) แถวแรก (row_order==1) ของรอบใหม่ setis_state=true - บันทึกและ 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
RequestExpenseService: เพิ่มresubmit(id)เรียกGET .../approve_state/resubmit/{id}(มิเรอร์approve()/reject()ที่มีอยู่แล้ว)- ปุ่มใหม่ "ส่งอนุมัติเพิ่มเติม": โชว์เมื่อ
approve_button?.is_approve === true(คนละเงื่อนไขกับปุ่ม "ส่งอนุมัติ" เดิมที่ใช้hasActiveStep(1)) ใช้ confirm dialog + โชว์ชื่อผู้อนุมัติคนถัดไปแบบเดียวกับปุ่ม "ส่งอนุมัติ" เดิมที่เพิ่งทำ (nextApproverLabel()ใน container ใช้ต่อได้เลย เพราะหาแถวที่is_state===trueเหมือนกัน) - Badge บอกรอบ: ต่อท้ายข้อความ "สถานะ" ที่มีอยู่แล้ว (
request-budget-list.component.html:28-29) เช่น "สถานะ : อนุมัติแล้ว (รอบปกติ)" / "สถานะ : รออนุมัติ (รอบเพิ่มเติมที่ 2)" — คำนวณจากMath.max(...approve_states.map(s => s.round_no)): รอบ 1 = "รอบปกติ", รอบ ≥2 = "รอบเพิ่มเติมที่ N" - กรอง
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
- Backend: migration + endpoint ใหม่ ขึ้น production ก่อน (ต้องมี endpoint พร้อมก่อน ไม่งั้นปุ่มใหม่ฝั่ง frontend จะเรียก endpoint ที่ยังไม่มีอยู่)
- Frontend: build + deploy ตามหลัง (สคริปต์
script.shเดิมที่ใช้มาตลอดเซสชันนี้)