diff --git a/docs/superpowers/specs/2026-08-19-request-budget-additional-approval-round-design.md b/docs/superpowers/specs/2026-08-19-request-budget-additional-approval-round-design.md index 03b053b..b6bcb41 100644 --- a/docs/superpowers/specs/2026-08-19-request-budget-additional-approval-round-design.md +++ b/docs/superpowers/specs/2026-08-19-request-budget-additional-approval-round-design.md @@ -6,15 +6,16 @@ **ช่องว่างปัจจุบัน**: `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 / ศาลายา" เป็นเคสอ้างอิงตลอดการออกแบบ +งานนี้ต่อเนื่องจากการแก้บั๊กหลายอย่างก่อนหน้าในเซสชันเดียวกัน (สีตัวหนังสือการ์ด, เงื่อนไขสิทธิ์ปุ่มส่งอนุมัติ, อ่านสถานะจาก `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` -- **ไม่แตะ** 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 ทุกครั้ง +- 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 @@ -31,32 +32,43 @@ Record เดิมทั้งหมดได้ `round_no=1` อัตโน ## Backend (`rmutr-api`) -### Endpoint ใหม่ +### แก้ query ของ approve/reject เดิมให้ round-aware (`Modules/Setting/Controllers/ApproveState.cs`) -`GET /api/setting/approve_state/resubmit/{request_expense_uid}` (ตั้งชื่อ/ตำแหน่งตามแบบ `approve`/`reject` เดิมใน `ApproveStateController`/`ApproveStateService`) +เฉพาะ 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`, `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` +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 จากรอบก่อนหน้า): ถ้าแอดมินเคยแก้สายอนุมัติหลังจากรอบแรกอนุมัติไปแล้ว รอบใหม่ควรใช้สายอนุมัติที่ถูกต้องล่าสุด ไม่ใช่สายอนุมัติเก่าที่อาจไม่ถูกต้องแล้ว (เช่น คนย้ายตำแหน่ง) -### PUT request_expense (บันทึกคอมเมนต์ใหม่ตอนตีกลับ) +### `remark_historys` ใหม่ — stamp round_no ฝั่ง frontend ไม่ต้องแก้ backend -จุดที่ `resole()`/`reject()` ฝั่ง frontend push `remark_historys` ใหม่แล้ว PUT ทั้งก้อน — ฝั่ง backend (`RequestExpenseService` PUT handler) ต้อง stamp `round_no = currentMaxRound` (รอบปัจจุบัน ไม่ใช่รอบใหม่ เพราะ remark เกิดขึ้นระหว่างรอบที่กำลังดำเนินอยู่) ให้ทุก `t_remark_history` แถวใหม่ที่ยังไม่มี `remark_history_uid` (แถวเก่าที่มีอยู่แล้วไม่แตะ) +ตรวจสอบแล้ว PUT `t_request_expense` (`BaseUidController.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. **ปุ่มใหม่ "ส่งอนุมัติเพิ่มเติม"**: โชว์เมื่อ `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` และก่อนแสดงในตาราง — กันคอมเมนต์เก่าจากรอบก่อนมาปนกับรอบใหม่ที่เพิ่งเริ่ม +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