Files
rmutr/docs/superpowers/specs/2026-09-24-evaluate-collect-approve-workflow-design.md
T
Nut.ไปเรื่อยandClaude Sonnet 5 4aa4f0449e docs: add design spec for evaluate-collect approve workflow
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
2026-09-24 20:15:12 +07:00

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) — ไม่มีปุ่ม "อนุมัติ/ตรวจสอบแล้ว" ที่ปิดจบกระบวนการ

ผู้ใช้ต้องการเพิ่ม:

  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 เป็นคนละตาราง):

// 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. ความเสี่ยง/จุดต้องระวัง (สืบทอดจากเอกสารอ้างอิง)

  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