75e1f3706e
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
88 lines
18 KiB
Markdown
88 lines
18 KiB
Markdown
# ส่งอนุมัติเพิ่มเติม (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>.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`
|
|
|
|
1. **`RequestExpenseService`**: เพิ่ม `resubmit(id)` เรียก `GET .../approve_state/resubmit/{id}` (มิเรอร์ `approve()`/`reject()` ที่มีอยู่แล้ว)
|
|
2. **`currentRound()`**: helper ใหม่ใน `request-budget-list.component.ts` — `Math.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` เดิมที่ใช้มาตลอดเซสชันนี้)
|