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
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 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 - อนุญาตให้ส่ง "อนุมัติเพิ่มเติม" ได้ ไม่จำกัดจำนวนรอบ ต่อปีงบ/หน่วยงาน/พื้นที่เดียว (รอบ 2, 3, 4, ... ได้เรื่อยๆ)
- Migration apply ขึ้น production อัตโนมัติตอน backend เริ่มทำงาน (ดูหัวข้อ "Migration" ด้านล่าง) — ไม่ต้องรันคำสั่งแยกเอง
แก้ไขจากดราฟต์แรก: ตอนแรกคิดว่า approve/reject เดิม (
ApproveState.cs:132-169,298-343) ไม่ต้องแก้เลย เพราะคิดว่า queryis_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:
- โหลด
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แล้ว save - เรียก
ApproveStateService.getApproveState(...)(Modules/Setting/Services/ApproveState.cs:26-106) — ต้องเพิ่มพารามิเตอร์int roundNo = 1ให้ method นี้ (default 1 ไม่กระทบ caller เดิมคือRequestExpenseService.NewEntity) โดย resubmit endpoint เรียกด้วยroundNo: currentMaxRound + 1เพื่อ clonerequest_approve_type_states(config ล่าสุดจากแอดมิน หน้า approve-menu) เป็นt_approve_stateชุดใหม่ติด round_no ใหม่ (แถวเก่าของรอบก่อนหน้าไม่ถูกแก้ ยังอยู่ครบเป็นประวัติ) แถวแรก (row_order==1) ของรอบใหม่ setis_state=true - บันทึกและ return record ที่อัปเดตแล้ว (เหมือน approve/reject เดิม)
เหตุผลที่เลือกใช้ config ล่าสุดจากแอดมินเสมอ (ไม่ clone จากรอบก่อนหน้า): ถ้าแอดมินเคยแก้สายอนุมัติหลังจากรอบแรกอนุมัติไปแล้ว รอบใหม่ควรใช้สายอนุมัติที่ถูกต้องล่าสุด ไม่ใช่สายอนุมัติเก่าที่อาจไม่ถูกต้องแล้ว (เช่น คนย้ายตำแหน่ง)
remark_historys ใหม่ — stamp round_no ฝั่ง frontend ไม่ต้องแก้ backend
ตรวจสอบแล้ว PUT t_request_expense (BaseUidController<t_request_expense,v_request_expense>.UpdateEntityId → EntityUidService.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
RequestExpenseService: เพิ่มresubmit(id)เรียกGET .../approve_state/resubmit/{id}(มิเรอร์approve()/reject()ที่มีอยู่แล้ว)currentRound(): helper ใหม่ในrequest-budget-list.component.ts—Math.max(1, ...(approve_states?.map(s => s.round_no ?? 1) ?? [1]))hasActiveStep()/approveStatus()ต้องกรองตามรอบปัจจุบันด้วย: ทั้งสอง method ที่มีอยู่แล้วต้องกรองapprove_statesด้วย(s.round_no ?? 1) === this.currentRound()ก่อนค้นหา ไม่งั้นรอบเก่าจะมาปนกับรอบใหม่ (approveStatus()โดยเฉพาะใช้.find()ซึ่งจะเจอ entry ของรอบเก่าก่อนเสมอถ้าไม่กรอง)- ปุ่มใหม่ "ส่งอนุมัติเพิ่มเติม": โชว์เมื่อ
approve_button?.is_approve === true(คนละเงื่อนไขกับปุ่ม "ส่งอนุมัติ" เดิมที่ใช้hasActiveStep(1)) ใช้ confirm dialog + โชว์ชื่อผู้อนุมัติคนถัดไปแบบเดียวกับปุ่ม "ส่งอนุมัติ" เดิมที่เพิ่งทำ (nextApproverLabel()ใน container ใช้ต่อได้เลย เพราะหาแถวที่is_state===trueเหมือนกัน) - Badge บอกรอบ: ต่อท้ายข้อความ "สถานะ" ที่มีอยู่แล้ว (
request-budget-list.component.html:28-29) เช่น "สถานะ : อนุมัติแล้ว (รอบปกติ)" / "สถานะ : รออนุมัติ (รอบเพิ่มเติมที่ 2)" — รอบ 1 = "รอบปกติ", รอบ ≥2 = "รอบเพิ่มเติมที่ N" (ใช้currentRound()จากข้อ 2) - กรอง
remark_historysตามรอบปัจจุบัน: การ์ด "รายละเอียดสำหรับการส่งแก้ไข" (แก้เงื่อนไข visibility ไปแล้วในรอบก่อนหน้าให้โชว์เมื่อมี remark) ต้องกรองเฉพาะremark_historysที่(round_no ?? 1) === currentRound()ก่อนเช็ค.length > 0และก่อนแสดงในตาราง — กันคอมเมนต์เก่าจากรอบก่อนมาปนกับรอบใหม่ที่เพิ่งเริ่ม resole()stamp round_no: ตอน push remark ใหม่เข้าapprove_button.remark_historysให้ setround_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
- Backend: migration + endpoint ใหม่ ขึ้น production ก่อน (ต้องมี endpoint พร้อมก่อน ไม่งั้นปุ่มใหม่ฝั่ง frontend จะเรียก endpoint ที่ยังไม่มีอยู่)
- Frontend: build + deploy ตามหลัง (สคริปต์
script.shเดิมที่ใช้มาตลอดเซสชันนี้)