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
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
# ส่งอนุมัติเพิ่มเติม (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` เดิมที่ใช้มาตลอดเซสชันนี้)
|
||||||
Reference in New Issue
Block a user