2-level self-service approval (mirroring request-budget type_4) plus a central-review stage on evaluate-follow, following the existing approve_state/remark_history reuse pattern. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mf6qFtiQoRuSh9zQSPF1wS
27 KiB
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) — ไม่มีปุ่ม "อนุมัติ/ตรวจสอบแล้ว" ที่ปิดจบกระบวนการ
ผู้ใช้ต้องการเพิ่ม:
- ปุ่ม "กำหนดผู้อนุมัติ" ที่ evaluate-collect → popup เลือกผู้อนุมัติ 2 ลำดับ (label เดียวกับคำขอเงินรายได้)
- ปุ่ม "ส่งอนุมัติ"
- ผู้อนุมัติแต่ละลำดับ login เข้ามากด "อนุมัติ" หรือ "ส่งแก้ไข" (ต้องกรอกรายละเอียด)
- เมื่อผู้อนุมัติลำดับสุดท้ายอนุมัติ → เข้า 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
เป็นคนละตาราง):
// 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 เดิมอยู่แล้ว เพิ่มเองใหม่ทั้งหมดจึงเลือก
รูปแบบที่ตรงไปตรงมากว่า:
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)
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 เดิม โดย:
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. ความเสี่ยง/จุดต้องระวัง (สืบทอดจากเอกสารอ้างอิง)
display_name_thstring matching แบบ exact — ระบบเดิม match ตัวตนผู้อนุมัติด้วยการเทียบ string ล้วนๆ (เจอเคสจริงที่ชื่อเดียวกันมี/ไม่มีวรรคแล้ว match ไม่ติด) เวอร์ชันใหม่นี้จะตามระบบเดิมไปก่อน เพื่อความสอดคล้อง ไม่เปลี่ยนกลไกนี้เฉพาะจุด — ถ้าเจอปัญหาจริงหลัง deploy ค่อยพิจารณาย้ายไป match ด้วยpersonnel_uidทั้งระบบแยกเป็นงานอื่น (นอกขอบเขต)- เช็คสิทธิ์ทุก
row_orderตั้งแต่วันแรก (ไม่ใช่แค่ row_order==1 แบบต้นแบบเดิมก่อนแก้) — ตามที่ระบุ ในข้อ 6 ป้องกันบั๊กเดิมที่เพิ่งแก้ไปกับrequest_expense - ปุ่ม "ส่งกลับหน่วยงาน" ที่ evaluate-collect ต้อง disable ระหว่างรออนุมัติ (
is_sent==true && is_approve!=true) — ไม่งั้นข้อมูลจะถูกแก้ระหว่างที่กำลังอยู่ในคิวอนุมัติ ทำให้ข้อมูลที่อนุมัติไปแล้ว กับข้อมูลจริงไม่ตรงกัน evaluate_follow_commentกลายเป็น dead write-path จากงานนี้ (ยังอ่านแสดงได้ ถ้ามีข้อมูลเก่า แต่ไม่มี action ใหม่เขียนเพิ่ม) — ไม่ลบทิ้งในรอบนี้ ตามที่ตกลงไว้ นอกขอบเขต cleanup- ต้องเพิ่ม guard
is_sentในจุดเช็คของApproveGetEntityให้ครอบคลุม code ใหม่ตั้งแต่แรก — ถ้าลืม ระบบจะข้ามการเช็ค "ส่งอนุมัติหรือยัง" ไปเลย เพราะSetCustomApproversseedis_state=trueที่แถวแรก ตั้งแต่ก่อนกด "ส่งอนุมัติ" ด้วยซ้ำ - ห้ามไว้ใจฟิลด์ 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