# ส่งอนุมัติเพิ่มเติม (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` เดิมที่ใช้มาตลอดเซสชันนี้)