# evaluate-collect → evaluate-follow: เพิ่มขั้นตอนอนุมัติ 2 ลำดับ + Central Review (Design) **สถานะ:** ผ่าน design review แล้ว รอเขียน implementation plan **อ้างอิง:** - `docs/superpowers/specs/2026-09-07-approve-state-workflow-reference.md` — กลไก approve_state 2 ลำดับ + ส่งอนุมัติ (code "04") ต้นแบบหลัก - `docs/superpowers/specs/2026-09-07-invest-asset-self-service-approval-design.md` — ตัวอย่างการพอร์ต pattern เดียวกันไปยัง entity ใหม่ (invest_asset) ล่าสุด - `RequestBudgetReviewStatusComponent`/`RequestBudgetReviewController` (คำขอเงินรายได้ type_4) — ต้นแบบ central-review stage หลัง 2 ลำดับอนุมัติผ่านครบ ## 1. บริบทและปัญหา หน้า `/app/research-and-evaluation/evaluate-collect` (entity `evaluate_form`) ปัจจุบันเป็น flow ระดับเดียว: กรอกข้อมูล (`onSubmit` → PUT) → "ส่งผล" (`onSend` → PUT แล้วเรียก `GET final_active/{uid}` ทันที ตั้ง `is_active=true`) ไม่มีแนวคิดผู้อนุมัติหรือสถานะรออนุมัติเลย — ใครก็กด "ส่งผล" ให้ finalize ได้ทันที หน้า `/app/research-and-evaluation/evaluate-follow` (list จาก `agency_evaluate/final_active`) ปัจจุบันมีแค่ ปุ่ม "ส่งกลับแก้ไข" (`onSendBack`) ที่เขียนคอมเมนต์ราย indicator ผ่าน `evaluate_follow_comment` แล้วเรียก `GET send_back/{uid}` (ตั้ง `is_active=false`) — ไม่มีปุ่ม "อนุมัติ/ตรวจสอบแล้ว" ที่ปิดจบกระบวนการ ผู้ใช้ต้องการเพิ่ม: 1. ปุ่ม "กำหนดผู้อนุมัติ" ที่ evaluate-collect → popup เลือกผู้อนุมัติ 2 ลำดับ (label เดียวกับคำขอเงินรายได้) 2. ปุ่ม "ส่งอนุมัติ" 3. ผู้อนุมัติแต่ละลำดับ login เข้ามากด "อนุมัติ" หรือ "ส่งแก้ไข" (ต้องกรอกรายละเอียด) 4. เมื่อผู้อนุมัติลำดับสุดท้ายอนุมัติ → เข้า evaluate-follow โดยอัตโนมัติ ที่นั่นเจ้าหน้าที่กด "ส่งแก้ไข" หรือ "ตรวจสอบแล้ว" (จบ process) ## 2. ของเดิมที่ยังใช้ได้ (ไม่แตะ) - `evaluate_form_target_indicator_agency` และกลไกกรอกข้อมูลราย indicator/หน่วยงาน — ไม่เกี่ยวกับงานนี้ - ปุ่ม "ส่งกลับหน่วยงาน (n)" (`onSendBackAgency` → `POST send_back_agency/{uid}`) ที่ evaluate-collect — เป็นการส่งกลับเฉพาะบาง agency ให้กรอกใหม่ **ก่อน** เข้ากระบวนการอนุมัติทั้งฟอร์ม คนละระดับกับงานนี้ ยังใช้ได้ปกติ แต่จะถูก disable ระหว่างฟอร์มอยู่ในสถานะรออนุมัติ (ดูข้อ 6) - ตาราง/endpoint `evaluate_form_active`, `evaluate_form_display` และ CRUD สายที่สอง (`create_form`/ `update_form`) — ไม่แตะ - `GET final_active/{uid}` (ตั้ง `is_active=true, active_date=now`) และ `GET send_back/{uid}` (ตั้ง `is_active=false, active_date=null`) — logic เดิม **ยังใช้อยู่** แต่จะถูกเรียกจากจุดใหม่ (อัตโนมัติตอน อนุมัติผ่านครบ / ตอนกด "ส่งแก้ไข" ที่ evaluate-follow) แทนที่จะเรียกตรงจากปุ่ม "ส่งผล"/"ส่งกลับแก้ไข" เดิม - `evaluate_follow_comment` (ตาราง + service) — ยังอยู่ในระบบ ไม่ลบ แต่จะไม่มี action ใหม่เขียนเข้าไปอีก (ดูข้อ 8 เรื่อง dead code) ## 3. โมเดลข้อมูลใหม่ ### 3.1 `approve_state` / `remark_history` — เพิ่มคอลัมน์ FK ใหม่ ตาม pattern "wide nullable FK ต่อ entity" เดิม (ห้าม reuse `request_expense_uid` เพราะ `evaluate_form` เป็นคนละตาราง): ```csharp // t_approve_state, t_remark_history — เพิ่มคอลัมน์เดียวกันทั้งสองตาราง public Guid? evaluate_form_uid { get; set; } ``` อัปเดต `v_approve_state` (และ view ของ remark_history ถ้ามี) ให้รวมคอลัมน์ใหม่ + nav property `evaluate_form` แบบเดียวกับ `invest_asset`/`invest_construct`/`request_expense` ### 3.2 `evaluate_form` — เพิ่ม 3 ฟิลด์สถานะ ใช้ semantics แบบ `request_expense` code "04"/"08" (pristine = null/null) ไม่ใช้แบบ `invest_asset` (`is_draft` ตรงข้าม) เพราะ `evaluate_form` ไม่มีแนวคิด draft เดิมอยู่แล้ว เพิ่มเองใหม่ทั้งหมดจึงเลือก รูปแบบที่ตรงไปตรงมากว่า: ```csharp public bool? is_sent { get; set; } // true = กด "ส่งอนุมัติ" แล้วจริง public bool? is_approve { get; set; } // true = อนุมัติผ่านครบทุกลำดับ (รอบปัจจุบัน) public string current_approve_name { get; set; } // denormalize ชื่อผู้อนุมัติที่กำลังรอคิว (โชว์ในตาราง) ``` สถานะที่เป็นไปได้ (mirror ตารางในเอกสารอ้างอิงข้อ 2 ทุกช่อง — ไม่มี `is_draft` เพราะไม่ต้องใช้): | is_sent | is_approve | ความหมาย | |---|---|---| | null | null | ยังไม่กด "ส่งอนุมัติ" (ต่อให้ตั้งผู้อนุมัติไว้แล้วก็ตาม) | | true | null | ส่งอนุมัติแล้ว กำลังรอผู้อนุมัติแถวที่ `is_state=true` | | false | false | ถูกส่งแก้ไขจากลำดับ 1 กลับมาหาผู้กรอกข้อมูล — แก้แล้วกด "ส่งอนุมัติ" ใหม่ได้ | | true | false | ถูกส่งแก้ไขจากลำดับ >1 กลับมาลำดับก่อนหน้า — ยังไม่ถึงมือผู้กรอกข้อมูล | | — | true | อนุมัติผ่านครบทุกลำดับแล้ว (จุดที่ trigger `final_active` + สร้าง `evaluate_form_review`) | ### 3.3 ตารางใหม่ `evaluate_form_review` — central review stage (mirror `request_budget_review`) ```csharp public class evaluate_form_review : base_table { [Key] public Guid? evaluate_form_review_uid { get; set; } public Guid? evaluate_form_uid { get; set; } public short review_status_id { get; set; } // 1=รอตรวจสอบ, 2=ถูกส่งแก้ไข, 3=ตรวจสอบแล้ว public string remark { get; set; } } ``` Append-only log (เหมือนต้นแบบ) — สถานะปัจจุบันคือแถวล่าสุดตาม `created_datetime` ของ `evaluate_form_uid` นั้น ไม่มีฟิลด์ status เดี่ยวบน `evaluate_form` เอง Migration: SQL ใหม่ `Migrations/add_evaluate_form_review.sql` (ตาราง + view) ตามสไตล์ migration อื่นๆ ใน `Modules/Evaluate/` ## 4. Flow 1 — กำหนดผู้อนุมัติ (`SetCustomApprovers` สำหรับ evaluate_form) Mirror `RequestExpenseService.SetCustomApprovers` (เอกสารอ้างอิงข้อ 3) ทุกจุด เปลี่ยนแค่ entity/FK: - โหลด `t_evaluate_form` ตาม uid, ไม่พบ → error "ไม่พบรายการ" - Guard: `entity.is_sent != null` **หรือ** `entity.is_approve != null` → error "รายการนี้เข้าสู่ กระบวนการอนุมัติไปแล้ว ไม่สามารถกำหนดผู้อนุมัติใหม่ได้" (pristine check — null/null เท่านั้น) - Guard: มีแถว `t_approve_state` ที่ `evaluate_form_uid==uid && is_approve==true` อยู่แล้ว → error เดียวกัน - กรองแถวว่างทิ้ง, ไม่มีเหลือ → error "กรุณาระบุผู้อนุมัติอย่างน้อย 1 คน" - ลบคิวเก่า (ถ้ามี, เผื่อกำหนดซ้ำก่อนส่ง) → สร้างใหม่ทั้งหมด `row_order = idx+1`, `is_state = idx==0`, `request_approve_type = "<รหัสใหม่ — ดูข้อ 9>"`, `evaluate_form_uid = uid` - เซ็ต `entity.current_approve_name` เป็นชื่อคนแรก, save **Endpoint:** `POST /api/evaluate/evaluate_form/set_approvers/{uid}` Body: `[{ personnel_uid, display_name_th }, ...]` **Frontend:** `evaluate-collect-send.component` เพิ่มปุ่ม "กำหนดผู้อนุมัติ" เปิด `SetApproversDialogComponent` เดิม (reuse ตรงๆ ไม่ต้อง copy component ใหม่ — component นี้ generic อยู่แล้ว รับ/คืนแค่ `{personnel_uid, display_name_th}[]`) `approverLabels = ['หัวหน้าหน่วยงาน', 'รองอธิการบดี']` ลำดับ 1 required, ลำดับ 2 optional (ตามต้นแบบ) ## 5. Flow 2 — ส่งอนุมัติ (`SendApprove` สำหรับ evaluate_form) Mirror `SendApprove2` (เอกสารอ้างอิงข้อ 4): - โหลด entity, ไม่พบ → error - `is_approve == true` → error "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว" - ส่งได้ 2 กรณีเท่านั้น: pristine (`is_sent==null && is_approve==null`) หรือเพิ่งถูกส่งแก้ไขกลับมาถึงผู้กรอก ข้อมูล (`is_sent==false && is_approve==false`) — อย่างอื่น block - ต้องมีคิว `row_order==1` ตั้งไว้แล้ว (`is_state==true`) ไม่งั้น error "ยังไม่พร้อมส่งอนุมัติ กรุณา กำหนดผู้อนุมัติก่อน" - เซ็ต `is_sent=true, is_approve=null`, save **Endpoint:** `POST /api/evaluate/evaluate_form/send_approve/{uid}` (body ว่าง, ใครก็เรียกได้) **Frontend:** ปุ่ม "ส่งอนุมัติ" แทนที่ปุ่ม "ส่งผล" เดิม (ปุ่มเดิมที่เรียก `final_active` ตรงๆ ถูกลบออก — การ finalize จริงย้ายไปเกิดอัตโนมัติตอนอนุมัติผ่านครบทุกลำดับ ดูข้อ 6) ## 6. Flow 3 — อนุมัติ 2 ลำดับ (ขยาย `ApproveGetEntity` ใน `ApproveState.cs`) เพิ่ม branch ใหม่ในคอนโทรลเลอร์กลาง `Modules/Setting/Controllers/ApproveState.cs` (`GET /api/setting/approve_state/approve/{uid}`) คู่ขนานกับ branch `request_expense`/`invest_asset`/ `invest_construct` เดิม โดย: ```csharp if (evaluate_form != null && getResult != null) { // ต่างจากต้นแบบเดิม (ที่เช็คสิทธิ์เฉพาะ row_order==1): เช็คตัวตนผู้อนุมัติทุก row_order // — ใช้ fix ล่าสุดที่ระบบทำกับ request_expense (commit c12b93bd) เป็นค่าเริ่มต้นตั้งแต่วันแรก // ไม่ต้องรอเจอบั๊กเดียวกันซ้ำ if (getResult.row_order == 1 && getResult.request_approve_type == "<รหัสใหม่>" && evaluate_form.is_sent != true) { return BadRequest(new { description = "ยังไม่ได้ส่งอนุมัติ กรุณากด \"ส่งอนุมัติ\" ก่อน" }); } var currentDisplayName = (User.Identity as ClaimsIdentity)?.FindFirst("display_name")?.Value; if (string.IsNullOrEmpty(currentDisplayName) || currentDisplayName != getResult.display_name_th) return StatusCode(403, new { description = "ไม่มีสิทธิ์อนุมัติ (ต้องเป็นผู้ที่ถูกกำหนดเป็นผู้อนุมัติลำดับนี้เท่านั้น)" }); // ปิดคิวปัจจุบัน = อนุมัติผ่าน getResult.is_state = null; getResult.is_approve = true; evaluate_form.current_approve_name = null; // ... save ... var remaining = /* แถวที่ evaluate_form_uid==uid && round_no==currentRound && is_approve!=true */; if (!remaining.Any()) { evaluate_form.is_approve = true; evaluate_form.is_active = true; // = logic เดิมของ final_active evaluate_form.active_date = DateTime.Now; // สร้าง evaluate_form_review ใหม่ review_status_id=1 (รอตรวจสอบ) } else { // เดินหน้า is_state=true ไปแถว row_order+1, เซ็ต current_approve_name } } ``` **Endpoint:** ใช้ตัวเดิม `GET /api/setting/approve_state/approve/{uid}` (endpoint กลาง ไม่เพิ่มใหม่) **Frontend:** `evaluate-collect-send.component` เพิ่มปุ่ม "อนุมัติ" / "ส่งแก้ไข" แสดงเฉพาะเมื่อ `current_approve_name` ตรงกับชื่อผู้ login อยู่ (เทียบแบบ `request-expense-approval.ts`, port มาเป็นไฟล์ util คู่ขนานสำหรับ `evaluate_form`) — ปุ่ม "อนุมัติ" เรียก endpoint approve ตรงๆ, ปุ่ม "ส่งแก้ไข" เปิด Swal textarea บังคับกรอก แล้วบันทึก `t_remark_history` (`evaluate_form_uid`, `round_no` ปัจจุบัน) ก่อนเรียก reject (ข้อ 7) — mirror `onApprove4`/`onReject4` ทุกจุด รวมตาราง remark history ใต้ปุ่มด้วย ## 7. Flow 4 — ส่งแก้ไขระหว่างอนุมัติ (ขยาย `RejectGetEntity`) ตามที่เลือกไว้ ("ย้อนไปทีละลำดับ") — mirror `RejectGetEntity` branch `request_expense` เป๊ะ (เอกสารอ้างอิงข้อ 6): - หา index ของแถว `is_state==true` ปัจจุบันในคิว (เรียงตาม `row_order`) - ถ้า `index > 0`: ปิดแถวปัจจุบัน (`is_state=null, is_approve=null`), เปิดแถวก่อนหน้า (`is_state=true, is_approve=null`) — ยังไม่ถึงมือผู้กรอกข้อมูล - ถ้า `index == 0` (ลำดับ 1 ปฏิเสธเอง): **ไม่แตะ** `approve_state` (ตาม pattern เดิม ทิ้ง `is_state` ของแถวลำดับ 1 ค้างไว้เป็น true — โค้ดฝั่งอ่านสถานะต้องเช็ค `is_sent` ควบคู่เสมอ ตามคำเตือนในเอกสาร อ้างอิงข้อ 2/6/7) - เซ็ต `evaluate_form.is_approve=false`; ถ้า `index==0` เพิ่ม `evaluate_form.is_sent=false` ด้วย (คืนสถานะ "ยังไม่ได้ส่งอนุมัติ" ให้ส่งใหม่ได้หลังแก้ไข) ถ้า `index>0` ไม่แตะ `is_sent` (ยังเป็น true) - อัปเดต `current_approve_name` จากแถวที่ `is_state==true` ใหม่ (ถ้ามี) **Endpoint:** ใช้ตัวเดิม `GET /api/setting/approve_state/reject/{uid}` ## 8. Flow 5 — Central Review Stage (evaluate-follow) — mirror `request_budget_review` **Controller/Service ใหม่** `Modules/Evaluate/Controllers/EvaluateFormReview.cs` + `Modules/Evaluate/Services/EvaluateFormReview.cs`, route `api/evaluate/evaluate_form_review`: | Endpoint | พฤติกรรม | |---|---| | `GET status/{evaluate_form_uid}` | derive `not_ready`/`pending`/`returned`/`reviewed` จาก `evaluate_form.is_approve` + แถวล่าสุดใน `evaluate_form_review`; คืน `history[]` จาก `remark_history` ที่ `evaluate_form_uid` นี้ (แบบ `by`=`updated_name`, `detail`=`remark_history_name_th`) | | `GET dashboard` | group รายการที่ `is_approve==true` เป็น `pending`/`returned`/`reviewed` ตาม `evaluate_form_review` ล่าสุด | | `POST return_for_edit/{uid}` (body `{remark}`) | **"ส่งแก้ไข"**: require `is_approve==true` && remark ไม่ว่าง → เปิด `is_state=true, is_approve=null` ที่แถว **`row_order` สูงสุด** (ผู้อนุมัติคนสุดท้าย — ไม่ย้อนกลับไปลำดับ 1) → เซ็ต `evaluate_form.is_approve=false, current_approve_name=<ชื่อแถวนั้น>` (ไม่แตะ `is_sent`, ยังเป็น true) → insert `t_remark_history` (comment) + `t_evaluate_form_review` (`review_status_id=2`) | | `POST mark_reviewed/{uid}` | **"ตรวจสอบแล้ว"**: require `is_approve==true` && สถานะล่าสุดเป็น pending (หรือยังไม่มี log เลย สำหรับ record เก่า) → insert `t_evaluate_form_review` (`review_status_id=3`) — จบ process | **Frontend:** สร้าง `EvaluateFormReviewService` (มิเรอร์ `RequestBudgetReviewService` 4 endpoint) และ คอมโพเนนต์ใหม่มิเรอร์ `RequestBudgetReviewStatusComponent` ฝังใน `evaluate-follow-send.component` — **แทนที่** ปุ่ม "ส่งกลับแก้ไข" เดิมทั้งหมด (ลบการเรียก `evaluate_follow_comment.add` + `send_back` ออกจาก ปุ่มนี้) ด้วย: - ปุ่ม "ส่งแก้ไข" (แสดงเมื่อ status `pending`) → Swal textarea บังคับกรอก → `returnForEdit` - ปุ่ม "ตรวจสอบแล้ว" (แสดงเมื่อ status `pending`) → confirm "ยืนยันว่าตรวจสอบแล้วใช่หรือไม่" → `markReviewed` - ตาราง history ยุบ/ขยายได้ (mirror `toggleHistory()`) `evaluate-follow-index.component` เพิ่ม bucket/tab สถานะ (รอตรวจสอบ/ถูกส่งแก้ไข/ตรวจสอบแล้ว) มิเรอร์ `RequestBudgetReviewDashboardComponent` — list source ยังคงอ่านจาก `agency_evaluate/final_active` เดิม (รายการเข้ามาที่นี่เมื่อไรก็ต่อเมื่อ `evaluate_form.is_approve=true` ซึ่งตอนนี้ trigger `is_active=true` ให้อัตโนมัติจาก flow อนุมัติแล้ว — ไม่ต้องเปลี่ยน query เดิม) ## 9. รหัส `request_approve_type` ใหม่ — ต้องยืนยันสดก่อนเขียนโค้ด โค้ดที่ใช้แล้ว ณ ตอนเขียนเอกสารนี้: `"01"`-`"08"` (ดูตารางในเอกสารอ้างอิง + type_4 คำขอแผ่นดิน) คาดว่า รหัสถัดไปคือ `"09"` แต่**ต้องเช็คแบบสดกับฐานข้อมูล production เป็น task แรกของ implementation plan** (`SELECT DISTINCT request_approve_type FROM approve_state` + ตาราง `request_approve_type` ถ้ามี) ก่อน เขียนโค้ดจริง เหมือนที่ทำกับ invest-asset self-service (ข้อ 4 ของสเปกนั้น) ## 10. ความเสี่ยง/จุดต้องระวัง (สืบทอดจากเอกสารอ้างอิง) 1. **`display_name_th` string matching แบบ exact** — ระบบเดิม match ตัวตนผู้อนุมัติด้วยการเทียบ string ล้วนๆ (เจอเคสจริงที่ชื่อเดียวกันมี/ไม่มีวรรคแล้ว match ไม่ติด) เวอร์ชันใหม่นี้จะ**ตามระบบเดิมไปก่อน** เพื่อความสอดคล้อง ไม่เปลี่ยนกลไกนี้เฉพาะจุด — ถ้าเจอปัญหาจริงหลัง deploy ค่อยพิจารณาย้ายไป match ด้วย `personnel_uid` ทั้งระบบแยกเป็นงานอื่น (นอกขอบเขต) 2. **เช็คสิทธิ์ทุก `row_order` ตั้งแต่วันแรก** (ไม่ใช่แค่ row_order==1 แบบต้นแบบเดิมก่อนแก้) — ตามที่ระบุ ในข้อ 6 ป้องกันบั๊กเดิมที่เพิ่งแก้ไปกับ `request_expense` 3. **ปุ่ม "ส่งกลับหน่วยงาน" ที่ evaluate-collect ต้อง disable ระหว่างรออนุมัติ** (`is_sent==true && is_approve!=true`) — ไม่งั้นข้อมูลจะถูกแก้ระหว่างที่กำลังอยู่ในคิวอนุมัติ ทำให้ข้อมูลที่อนุมัติไปแล้ว กับข้อมูลจริงไม่ตรงกัน 4. **`evaluate_follow_comment` กลายเป็น dead write-path** จากงานนี้ (ยังอ่านแสดงได้ ถ้ามีข้อมูลเก่า แต่ไม่มี action ใหม่เขียนเพิ่ม) — ไม่ลบทิ้งในรอบนี้ ตามที่ตกลงไว้ นอกขอบเขต cleanup 5. **ต้องเพิ่ม guard `is_sent` ในจุดเช็คของ `ApproveGetEntity`** ให้ครอบคลุม code ใหม่ตั้งแต่แรก — ถ้าลืม ระบบจะข้ามการเช็ค "ส่งอนุมัติหรือยัง" ไปเลย เพราะ `SetCustomApprovers` seed `is_state=true` ที่แถวแรก ตั้งแต่ก่อนกด "ส่งอนุมัติ" ด้วยซ้ำ 6. **ห้ามไว้ใจฟิลด์ denormalize ชื่อหน่วยงาน/ปีงบบน `evaluate_form` เอง** ถ้ามี — join จากตารางต้นทางเสมอ เวลาทำ dashboard (บทเรียนเดียวกับ `request_expense.faculty_name_th`) ## 11. Testing Methodology เดียวกับที่ใช้ทำ income-budget-review-stage/request-budget-approve-workflow ใน session ก่อน หน้า: สร้างข้อมูลทดสอบสังเคราะห์ผ่าน SQL (evaluate_form + agency data ที่ครบเงื่อนไข), ยิง endpoint จริง ด้วย JWT ทดสอบของผู้ใช้ 3 บทบาท (ผู้กรอกข้อมูล, ผู้อนุมัติลำดับ 1, ผู้อนุมัติลำดับ 2/เจ้าหน้าที่ follow), ตรวจผลผ่าน SQL หลังแต่ละ step (`approve_state`, `evaluate_form.is_sent/is_approve/is_active`, `evaluate_form_review`, `remark_history`), ลบข้อมูลทดสอบทิ้งหลังตรวจเสร็จ — ไม่มี automated test suite ในโปรเจกต์นี้ Path ที่ต้องครอบคลุม: (1) happy path ครบ 2 ลำดับ → evaluate-follow → ตรวจสอบแล้ว, (2) ลำดับ 2 ส่งแก้ไข → กลับลำดับ 1, (3) ลำดับ 1 ส่งแก้ไข (ปฏิเสธตัวเอง) → กลับผู้กรอกข้อมูล → ส่งอนุมัติใหม่, (4) evaluate-follow ส่งแก้ไข → เปิดคิวลำดับสุดท้ายใหม่ → อนุมัติอีกรอบ → กลับเข้า evaluate-follow อีกครั้ง, (5) guard: กด approve/reject โดยไม่ใช่ชื่อที่ตรงคิว → ต้อง 403, (6) กด approve ก่อนกด "ส่งอนุมัติ" → ต้อง 400