Files
rmutr/docs/superpowers/specs/2026-09-07-approve-state-workflow-reference.md
Nut.ไปเรื่อยandClaude Sonnet 5 efbc4618c2 docs: add approve_state workflow reference for คำขอแผ่นดิน reuse
Documents the full "ระบุผู้อนุมัติ → ส่งอนุมัติ → อนุมัติ 2 ลำดับ → ส่งแก้ไข" mechanism
as implemented for request_expense/type_id=2 (request_approve_type "04") — data model,
state table, each flow's actual code with comments, and a checklist of gotchas
(display_name_th exact-match matching, denormalized name fields being null,
the is_sent gate) to watch for when adding an equivalent approval flow for
คำขอแผ่นดิน on a new entity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153wBt23EjW125ZsADwWVAs
2026-09-07 16:46:10 +07:00

34 KiB

ระบบอนุมัติ (approve_state) — เอกสารอ้างอิงสำหรับนำไปใช้กับ "คำขอแผ่นดิน"

วันที่: 2026-09-07 ขอบเขต: สรุปกลไก "ระบุผู้อนุมัติ → ส่งอนุมัติ → อนุมัติ 2 ลำดับ → ส่งแก้ไข" ที่ใช้อยู่จริงในหน้า "ส่งอนุมัติคำขอเงินรายได้" (request_expense, type_id=2, request_approve_type="04") พร้อมโค้ดจริง คอมเมนต์กำกับ และจุดที่ต้องระวังถ้าจะนำแพทเทิร์นเดียวกันไปทำกับ คำขอแผ่นดิน (entity ใหม่)


1. ภาพรวม: ตารางเดียว ใช้ร่วมกันหลาย entity

approve_state เป็นตารางกลาง ใช้ร่วมกัน สำหรับกระบวนการอนุมัติของหลาย entity ในระบบ โดยแยกกันด้วย FK คนละคอลัมน์ (ไม่ใช่ FK เดียวที่ใช้ polymorphic reference):

entity FK คอลัมน์ใน approve_state request_approve_type code ที่มาของคิวผู้อนุมัติ
invest_asset invest_asset_uid "01" config "จัดการเมนูการอนุมัติ" (ตั้งล่วงหน้าตามคณะ+พื้นที่)
invest_construct invest_construct_uid "02" config "จัดการเมนูการอนุมัติ"
request_expense request_expense_uid "03" config "จัดการเมนูการอนุมัติ" (type_id=1 เดิม)
request_expense request_expense_uid "04" ผู้ส่งอนุมัติกำหนดเอง ผ่าน SetCustomApprovers (type_id=2 — หน้าที่เราคุยกันอยู่)

สิ่งที่ต้องรู้ก่อนเริ่ม: code "04" ถูกสร้างขึ้นทีหลัง เพื่อเลิกพึ่งพา config ที่แอดมินต้องตั้งไว้ ล่วงหน้าทีละคณะ/พื้นที่ (แบบ "01"/"02"/"03") — ผู้ส่งอนุมัติ (ใครก็ได้) เลือกผู้อนุมัติเองตอนจะส่งเรื่อง ถ้าจะทำ "คำขอแผ่นดิน" ควรพิจารณาแบบ "04" เป็นค่าเริ่มต้น เว้นแต่มีเหตุผลเฉพาะที่ต้องบังคับคิว ผู้อนุมัติตายตัวต่อคณะ/พื้นที่แบบ config เดิม


2. โมเดลข้อมูล

public class approve_state : base_table
{
    [Key] public Guid? approve_state_uid { get; set; }

    // FK แยกตาม entity — เพิ่ม FK ใหม่ตรงนี้เวลาจะรองรับ entity ใหม่ (เช่น request_budget_uid)
    public Guid? invest_asset_uid { get; set; }
    public Guid? invest_construct_uid { get; set; }
    public Guid? request_expense_uid { get; set; }

    public int? row_order { get; set; }         // ลำดับผู้อนุมัติ 1, 2, 3, ...
    public bool? is_approve { get; set; }        // true = แถวนี้ (ลำดับนี้) อนุมัติผ่านแล้ว
    public bool? is_state { get; set; }          // true = "คิวปัจจุบัน" กำลังรอผู้อนุมัติแถวนี้อยู่
    public DateTime? approve_date { get; set; }
    public Guid? personnel_uid { get; set; }
    public string display_name_th { get; set; }  // ⚠️ ใช้ match ตัวตนผู้อนุมัติแบบ exact string เท่านั้น
    public string request_approve_type { get; set; } // "01".."04" ตามตารางข้างบน
    public string position_name_th { get; set; }
    public int round_no { get; set; } = 1;        // รอบการอนุมัติ (เผื่อมี "ส่งอนุมัติเพิ่มเติม" รอบใหม่)
}
public class request_expense : base_table
{
    [Key] public Guid? request_expense_uid { get; set; }
    public bool? is_approve { get; set; }   // true = อนุมัติผ่านครบทุกลำดับแล้ว (ทั้งรอบ)
    public bool? is_draft { get; set; }     // true = ถูกส่งแก้ไข (ตีกลับ) อยู่ ต้องแก้แล้วส่งใหม่/อนุมัติใหม่
    public bool? is_sent { get; set; }      // true = มีคนกด "ส่งอนุมัติ" แล้วจริง (เกตสำคัญ — ดูข้อ 4)
    public string current_approve_name { get; set; } // ชื่อผู้อนุมัติที่กำลังรอคิวอยู่ตอนนี้ (denormalize ไว้โชว์ในตาราง)
    [NotMapped] public bool? is_state { get; set; }   // ไม่ได้ map กับ DB — ใช้ชั่วคราวตอนคำนวณผลใน controller เท่านั้น
    [NotMapped] public int? row_order { get; set; }
}

สถานะที่เป็นไปได้ของ request_expense (ผสมกันได้ตามตาราง):

is_sent is_draft is_approve ความหมาย
null null null ยังไม่เคยกด "ส่งอนุมัติ" เลย (แม้จะตั้งผู้อนุมัติไว้แล้วก็ตาม)
true null null ส่งอนุมัติแล้ว กำลังรอผู้อนุมัติแถวที่ is_state=true อยู่ (ลำดับใดก็ได้)
true true false ถูกตีกลับ (ปฏิเสธ) จากลำดับ > 1 กลับมาลำดับก่อนหน้า — ยังไม่ถึงมือผู้สร้าง
false true false ถูกตีกลับจากลำดับ 1 กลับมาหาผู้สร้าง — ต้องแก้ไขแล้วกด "ส่งอนุมัติ" ใหม่
— — true อนุมัติผ่านครบทุกลำดับของรอบนี้แล้ว

⚠️ กับดักที่เจอจริง: ตอน "ระบุผู้อนุมัติ" (SetCustomApprovers) ระบบจะ seed แถว row_order=1 ให้ is_state=true ทันที ก่อน ที่จะมีใครกด "ส่งอนุมัติ" เลย ถ้า query ฝั่งอ่านไปเช็คแค่ approve_state.is_state==true โดยไม่เช็ค request_expense.is_sent==true ควบคู่ จะนับรายการที่ยัง กดอนุมัติจริงไม่ได้ (เพราะ endpoint อนุมัติเองก็ block ด้วย error "ยังไม่ได้ส่งอนุมัติ") ว่าเป็น "รออนุมัติ" ไปด้วย — เจอบั๊กนี้จริงตอนทำ dashboard (ดูข้อ 6)


3. Flow 1 — การระบุผู้อนุมัติ (SetCustomApprovers)

ผู้ส่งอนุมัติ (ใครก็ได้ที่มีสิทธิ์เข้าถึงเรื่องนี้) เลือกคิวผู้อนุมัติเอง ก่อน กด "ส่งอนุมัติ"

// Modules/Requests/Services/RequestExpense.cs
public async Task<(bool ok, string error)> SetCustomApprovers(Guid? requestExpenseUid,
    List<ApproverInput> approvers, ClaimsIdentity claimsIdentity)
{
    var entity = await _context.t_request_expense
        .FirstOrDefaultAsync(c => c.request_expense_uid == requestExpenseUid);
    if (entity == null) return (false, "ไม่พบรายการ");

    // กันเขียนทับคิวที่เข้ากระบวนการอนุมัติไปแล้ว (ทั้งอนุมัติผ่าน/ถูกส่งแก้ไข/ถูกส่งอนุมัติแล้ว)
    if (entity.is_draft != null || entity.is_approve != null)
        return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว ไม่สามารถกำหนดผู้อนุมัติใหม่ได้");
    if (entity.is_sent == true)
        return (false, "รายการนี้ถูกส่งอนุมัติไปแล้ว ไม่สามารถกำหนดผู้อนุมัติใหม่ได้");

    var existing = await _context.t_approve_state
        .Where(c => c.request_expense_uid == requestExpenseUid).ToListAsync();
    if (existing.Any(s => s.is_approve == true))
        return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว ไม่สามารถกำหนดผู้อนุมัติใหม่ได้");

    approvers = (approvers ?? new List<ApproverInput>())
        .Where(a => !string.IsNullOrWhiteSpace(a?.display_name_th)).ToList();
    if (!approvers.Any()) return (false, "กรุณาระบุผู้อนุมัติอย่างน้อย 1 คน");

    // ล้างคิวเก่า (ถ้ามี) แล้วสร้างใหม่ทั้งหมด — ไม่ merge ทีละคน
    if (existing.Any())
    {
        _context.t_approve_state.RemoveRange(existing);
        await _context.SaveChangesAsync(claimsIdentity);
    }

    // สร้างคิวใหม่ตามลำดับที่ frontend ส่งมา (idx 0 = row_order 1 = ลำดับแรก)
    var rows = approvers.Select((a, idx) => new t_approve_state
    {
        approve_state_uid = Guid.NewGuid(),
        request_expense_uid = requestExpenseUid,
        personnel_uid = a.personnel_uid,
        display_name_th = a.display_name_th,   // ⚠️ ต้องตรงกับ claim "display_name" ของผู้อนุมัติ
                                                //    แบบตัวอักษรต่อตัวอักษร รวมช่องว่าง — ดูข้อ 7
        row_order = idx + 1,
        is_state = idx == 0,     // เฉพาะลำดับแรกที่ตั้ง is_state=true ตั้งแต่ตอนนี้ (ก่อนส่งอนุมัติด้วยซ้ำ!)
        request_approve_type = "04",
    }).ToList();
    await _context.t_approve_state.AddRangeAsync(rows);
    entity.current_approve_name = rows.First().display_name_th;
    _context.t_request_expense.Update(entity);
    await _context.SaveChangesAsync(claimsIdentity);
    return (true, null);
}

Frontend เรียกผ่าน: POST /api/request/request_expense/type_2/set_approvers/{uid} Body: [{ personnel_uid, display_name_th }, ...] เรียงตามลำดับที่จะให้อนุมัติ


4. Flow 2 — การส่งอนุมัติ (SendApprove2)

หลักการสำคัญ: "ส่งอนุมัติ" กับ "อนุมัติจริง (ลำดับ 1)" เป็นคนละการกระทำ คนละคนกันได้ — ใครก็ได้ (ผู้รับผิดชอบกรอกข้อมูล) กดส่งอนุมัติได้ แต่การ "อนุมัติ" ต้องเป็นคนที่ระบุชื่อไว้ในคิวลำดับ 1 จริงๆ เท่านั้นถึงจะกดผ่าน (ดูเงื่อนไขเช็คสิทธิ์ในข้อ 5)

// Modules/Requests/Services/RequestExpense.cs
public async Task<(bool ok, string error)> SendApprove2(Guid? requestExpenseUid, ClaimsIdentity claimsIdentity)
{
    var entity = await _context.t_request_expense
        .FirstOrDefaultAsync(c => c.request_expense_uid == requestExpenseUid);
    if (entity == null) return (false, "ไม่พบรายการ");
    if (entity.is_approve == true) return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว");

    // สถานะที่ส่งได้ 2 แบบเท่านั้น:
    //  (1) ยังไม่เคยแตะต้องเลย (pristine — is_draft/is_approve เป็น null ทั้งคู่)
    //  (2) เพิ่งถูกส่งแก้ไขกลับมาถึงมือผู้สร้าง (is_draft=true, is_approve=false)
    //      ต้องยอมให้ส่งใหม่ได้เสมอ แม้ is_sent จะยังค้างเป็น true จากรอบก่อน
    var isPristine = entity.is_draft == null && entity.is_approve == null;
    var isRejectedReadyToResend = entity.is_draft == true && entity.is_approve == false;
    if (!isPristine && !isRejectedReadyToResend)
        return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว");
    if (entity.is_sent == true && !isRejectedReadyToResend)
        return (false, "รายการนี้ถูกส่งอนุมัติไปแล้ว");

    // ต้องมีคิวผู้อนุมัติลำดับ 1 ตั้งไว้แล้ว (ผ่าน SetCustomApprovers) ก่อนถึงจะส่งได้
    var pending = await _context.t_approve_state.FirstOrDefaultAsync(c =>
        c.request_expense_uid == requestExpenseUid && c.is_state == true);
    if (pending == null || pending.row_order != 1)
        return (false, "ยังไม่พร้อมส่งอนุมัติ กรุณากำหนดผู้อนุมัติก่อน");

    entity.is_sent = true;
    entity.is_draft = null;
    entity.is_approve = null;
    _context.t_request_expense.Update(entity);
    await _context.SaveChangesAsync(claimsIdentity);
    return (true, null);
}

Frontend เรียกผ่าน: POST /api/request/request_expense/type_2/send_approve/{uid} (body ว่าง)


5. Flow 3 — การอนุมัติ (2 ลำดับขึ้นไป, ApproveGetEntity)

Endpoint นี้ ใช้ร่วมกันทั้ง 3 entity (invest_asset, invest_construct, request_expense) — โค้ดข้างล่าง ตัดมาเฉพาะ branch ของ request_expense:

// Modules/Setting/Controllers/ApproveState.cs — GET /api/setting/approve_state/approve/{uid}
if (request_expense != null)
{
    // รองรับหลายรอบ (round_no) เผื่อมี "ส่งอนุมัติเพิ่มเติม" หลังอนุมัติผ่านรอบแรกไปแล้ว
    var currentRound = await _db.t_approve_state
        .Where(c => c.request_expense_uid == uid)
        .Select(c => (int?)c.round_no).MaxAsync() ?? 1;

    getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
        c.request_expense_uid == uid && c.round_no == currentRound && c.is_state == true);

    if (getResult != null)
    {
        // ── เกตพิเศษเฉพาะ "04" ลำดับ 1: ต้องส่งอนุมัติแล้วจริง (is_sent) และต้องเป็นตัวจริงในคิวเท่านั้น ──
        if (getResult.row_order == 1 && getResult.request_approve_type == "04")
        {
            if (request_expense.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 = "ไม่มีสิทธิ์อนุมัติ (ต้องเป็นผู้ที่ถูกกำหนดเป็นผู้อนุมัติลำดับ 1 เท่านั้น)" });
        }

        // ปิดคิวลำดับปัจจุบัน = อนุมัติผ่าน
        getResult.is_state = null;
        getResult.is_approve = true;
        request_expense.current_approve_name = null;
        _db.t_approve_state.Update(getResult);
        _db.t_request_expense.Update(request_expense);
        await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);

        // ถ้าไม่เหลือลำดับไหนใน "รอบนี้" ที่ยัง is_approve!=true เลย = ผ่านครบทุกลำดับ
        var check_all_state = await _db.t_approve_state
            .Where(c => c.request_expense_uid == uid && c.round_no == currentRound && c.is_approve != true)
            .ToListAsync();
        if (!check_all_state.Any())
        {
            request_expense.is_approve = true;
            _db.t_request_expense.Update(request_expense);
            await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
        }

        // เดินหน้าไปลำดับถัดไป (row_order + 1) ถ้ามี — ถ้าไม่มี = ลำดับนี้คือลำดับสุดท้าย
        var check_getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
            c.request_expense_uid == uid && c.round_no == currentRound
            && c.row_order == getResult.row_order + 1);
        if (check_getResult != null)
        {
            check_getResult.is_state = true;
            request_expense.current_approve_name = check_getResult.display_name_th;
            _db.t_request_expense.Update(request_expense);
            _db.t_approve_state.Update(check_getResult);
            await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
        }
    }
}

ตัวอย่างการไล่ 2 ลำดับ (row_order 1 → 2):

  1. SetCustomApprovers สร้างแถว row_order=1 (is_state=true), row_order=2 (is_state=false)
  2. ใครก็ได้กด "ส่งอนุมัติ" → is_sent=true
  3. คนที่ระบุไว้ที่ row_order=1 กด "อนุมัติ" → แถว 1 ปิด (is_state=null, is_approve=true), แถว 2 เปิด (is_state=true), request_expense.current_approve_name เปลี่ยนเป็นชื่อคนที่ 2
  4. คนที่ row_order=2 กด "อนุมัติ" → แถว 2 ปิด, ไม่มีแถวถัดไป → request_expense.is_approve=true (จบกระบวนการ)

Frontend เรียกผ่าน: GET /api/setting/approve_state/approve/{uid}


6. Flow 4 — การส่งแก้ไข (RejectGetEntity)

หลักการ: ปฏิเสธ = ถอยกลับ ทีละ 1 ลำดับ เสมอ ไม่ใช่กระโดดกลับไปลำดับ 1 หรือคนสร้างตรงๆ

// Modules/Setting/Controllers/ApproveState.cs — GET /api/setting/approve_state/reject/{uid}
if (request_expense_approve != null)
{
    getResult = request_expense_approve;
    var currentRound = request_expense_approve.round_no;
    var result_data_lists = await _db.t_approve_state
        .Where(c => c.request_expense_uid == uid && c.round_no == currentRound)
        .OrderBy(o => o.row_order).ToListAsync();

    var index_current = result_data_lists.FindIndex(c => c.is_state == true); // ตำแหน่ง (0-based) ของคิวปัจจุบัน

    if (index_current > 0)
    {
        // ยังไม่ใช่ลำดับแรก — ถอยกลับไปลำดับก่อนหน้า (index_current - 1)
        result_data_lists[index_current].is_state = null;
        result_data_lists[index_current].is_approve = null;
        result_data_lists[index_current - 1].is_state = true;
        result_data_lists[index_current - 1].is_approve = null;
    }
    // ⚠️ ถ้า index_current == 0 (ลำดับ 1 ปฏิเสธเอง) — โค้ด "ไม่ทำอะไรกับ approve_state เลย"
    //    is_state ของแถวลำดับ 1 จึงยังค้างเป็น true อยู่ (ไม่ถูกเคลียร์!) แม้ว่าลูกบอลจะไปอยู่ที่ผู้สร้างแล้ว
    //    ก็ตาม — นี่คือกับดักที่ทำให้ query ฝั่งอ่านต้องเช็ค is_sent ควบคู่เสมอ ไม่งั้นจะเข้าใจผิดว่า
    //    "ยังรอผู้อนุมัติลำดับ 1 อยู่" ทั้งที่จริงๆ คนละสถานะ (ดูข้อ 2 ตารางสถานะ, ข้อ 7)

    _db.t_approve_state.UpdateRange(result_data_lists);
    await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);

    var request_expense = await _db.t_request_expense.FirstOrDefaultAsync(c => c.request_expense_uid == uid);
    request_expense.current_approve_name = null;
    var approve = await _db.t_approve_state
        .Where(c => c.request_expense_uid == uid && c.round_no == currentRound && c.is_state == true)
        .OrderBy(c => c.row_order).ToListAsync();
    if (approve.Count > 0)
        request_expense.current_approve_name = approve.First().display_name_th;

    request_expense.is_draft = true;
    request_expense.is_approve = false;
    if (index_current == 0)
    {
        // ลำดับ 1 ปฏิเสธเอง = ไม่มีลำดับก่อนหน้าให้ตีกลับ → คืนสถานะ "ยังไม่ได้ส่งอนุมัติ"
        // ให้ผู้กดส่งอนุมัติ (ใครก็ได้) กลับมาส่งใหม่ได้อีกครั้งหลังแก้ไขข้อมูล
        request_expense.is_sent = false;
    }
    _db.t_request_expense.Update(request_expense);
    await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
}

Frontend เรียกผ่าน: GET /api/setting/approve_state/reject/{uid} (หน้า income-report เรียกผ่าน this.RequestExpenseSV.put(...) เพื่ออัปเดตข้อมูลฟอร์มก่อน แล้วค่อยเรียก reject())


7. Dashboard สรุปสถานะรายบุคคล (my_dashboard)

// Modules/Requests/Services/RequestExpense.cs
public async Task<request_expense_dashboard> GetMyDashboard(string loginName, string displayName)
{
    var result = new request_expense_dashboard();
    if (string.IsNullOrEmpty(loginName) && string.IsNullOrEmpty(displayName)) return result;

    var years = await _context.v_budget_year.AsNoTracking()
        .ToDictionaryAsync(c => c.budget_year_uid, c => c.budget_year_name_th);
    var locations = await _context.v_budget_location.AsNoTracking()
        .ToDictionaryAsync(c => c.budget_location_uid, c => c.budget_location_name_th);
    // request_expense.faculty_name_th มักเป็น null แม้ faculty_uid จะมีค่า (พบ 89/91 แถวในข้อมูลจริง) —
    // ห้ามไว้ใจคอลัมน์ denormalize แบบนี้ ต้อง join จากตาราง faculty เอาเองเสมอ
    var faculties = await _context.v_faculty.AsNoTracking()
        .ToDictionaryAsync(c => c.faculty_uid, c => c.faculty_name_th);

    request_expense_dashboard_item ToItem(v_request_expense e) => new()
    {
        request_expense_uid = e.request_expense_uid,
        budget_year_uid = e.budget_year_uid,
        budget_year_name_th = e.budget_year_uid.HasValue && years.TryGetValue(e.budget_year_uid, out var y) ? y : null,
        faculty_uid = e.faculty_uid,
        faculty_name_th = e.faculty_uid.HasValue && faculties.TryGetValue(e.faculty_uid, out var f) ? f : e.faculty_name_th,
        budget_location_uid = e.budget_location_uid,
        budget_location_name_th = e.budget_location_uid.HasValue && locations.TryGetValue(e.budget_location_uid, out var l) ? l : null,
        updated_datetime = e.updated_datetime,
    };

    // ── ในฐานะ "ผู้ส่ง" (สร้างเรื่อง) — match ด้วย created_by == login_name ──
    if (!string.IsNullOrEmpty(loginName))
    {
        var myRequests = await _context.v_request_expense.AsNoTracking()
            .Include(e => e.approve_states)
            .Where(e => e.type_id == 2 && e.created_by == loginName)
            .ToListAsync();

        result.sender_pending = myRequests
            .Where(e => e.is_sent == true && e.is_approve != true && e.is_draft != true)
            .Select(ToItem).ToList();
        result.sender_returned = myRequests
            .Where(e => e.is_draft == true && e.is_approve == false && e.is_sent != true)
            .Select(ToItem).ToList();
    }

    // ── ในฐานะ "ผู้อนุมัติ" — match ด้วย approve_state.display_name_th == display_name ──
    if (!string.IsNullOrEmpty(displayName))
    {
        var myApproverRequests = await _context.v_request_expense.AsNoTracking()
            .Include(e => e.approve_states)
            .Where(e => e.type_id == 2 && e.approve_states.Any(a => a.display_name_th == displayName))
            .ToListAsync();

        // is_sent==true คือเงื่อนไขบังคับว่า "กดส่งอนุมัติแล้วจริง" — ไม่งั้นจะรวมกรณี approve_state
        // แถวลำดับ 1 ที่ถูก seed ไว้ตอนสร้างเรื่อง (is_state=true) แต่ยังไม่มีใครกด "ส่งอนุมัติ" เลย
        // และกรณีลำดับ 1 ปฏิเสธของตัวเองกลับไปหาผู้สร้าง (is_state ของแถวตัวเองไม่ถูกเคลียร์ แต่ลูกบอล
        // ไปอยู่ที่ผู้สร้างแล้ว ไม่ใช่ผู้อนุมัติอีกต่อไป — ดูข้อ 6)
        result.approver_pending = myApproverRequests
            .Where(e => e.is_sent == true && e.is_draft != true &&
                        e.approve_states.Any(a => a.display_name_th == displayName && a.is_state == true))
            .Select(ToItem).ToList();
        result.approver_returned = myApproverRequests
            .Where(e => e.is_sent == true && e.is_draft == true && e.is_approve == false &&
                        e.approve_states.Any(a => a.display_name_th == displayName && a.is_state == true))
            .Select(ToItem).ToList();
        result.approver_approved = myApproverRequests
            .Where(e => e.approve_states.Any(a => a.display_name_th == displayName && a.is_approve == true))
            .Select(ToItem).ToList();
    }

    return result;
}

Endpoint: GET /api/request/request_expense/type_2/my_dashboard (ต้อง [Authorize], ไม่รับ query parameter ใดๆ — อ่าน login_name/display_name จาก JWT claim ของผู้ที่ล็อกอินอยู่เอง)

Frontend:

  • income-report-approve-dashboard.component.ts — เรียก endpoint ตอน ngOnInit, แสดงเป็น chip กดได้
  • กด chip → กางลิสต์ใต้การ์ด (คณะ/พื้นที่/ปีงบ/วันที่)
  • กดแถวในลิสต์ → เซ็ตค่าฟอร์มค้นหา (presetQuery input บน search component) แล้วเรียก onSearch() ทันที (หน้านี้แสดงทีละ 1 เรื่องต่อ ปีงบ+คณะ+พื้นที่ ไม่มีตารางรายการแยกต่างหาก จึงต้อง pre-fill+ค้นหาแทนการลิงก์ไปหน้าอื่น)

8. เช็คลิสต์ถ้าจะนำไปใช้กับ "คำขอแผ่นดิน"

  1. เพิ่มคอลัมน์ FK ใหม่ใน approve_state เช่น request_budget_uid (ตาม pattern เดียวกับ invest_asset_uid/invest_construct_uid/request_expense_uid) — อย่า reuse request_expense_uid ถ้า entity คำขอแผ่นดินเป็นคนละตารางกับ request_expense

  2. กำหนด request_approve_type code ใหม่ (ตอนนี้ใช้ "01"-"04" ไปแล้ว เช็คตาราง config v_request_approve_type ก่อนว่ามี "05" ใช้อยู่แล้วหรือยัง) แล้วเพิ่มเงื่อนไขเกต is_sent ในจุดเดิม (ApproveState.cs บรรทัดที่เช็ค request_approve_type == "04") ให้ครอบคลุม code ใหม่ด้วย — ไม่งั้น ระบบใหม่จะข้ามการเช็ค "ส่งอนุมัติหรือยัง" ไปเลย

  3. เขียนฟังก์ชันคู่ขนานของ SetCustomApprovers/SendApprove2 สำหรับ entity ใหม่ (โครงเดียวกันได้เลย เปลี่ยนแค่ชื่อ FK คอลัมน์และชื่อตาราง) หรือถ้าอยากได้แบบ config-ตายตัวต่อคณะ/พื้นที่แทน ให้ใช้ getApproveState() (Modules/Setting/Services/ApproveState.cs) แบบเดียวกับ code "01"-"03"

  4. เพิ่ม branch ใหม่ใน ApproveGetEntity/RejectGetEntity (ApproveState.cs) สำหรับ FK ใหม่ — โครงสร้าง if-block เหมือนกับ invest_asset/invest_construct/request_expense เป๊ะ ก็อปแล้วเปลี่ยน ชื่อ field ได้เลย แต่อย่าลืมใส่เกต is_sent+เช็คสิทธิ์ display_name แบบเดียวกับ request_expense ถ้าอยากได้พฤติกรรม "ใครก็ได้ส่งได้ แต่อนุมัติต้องเป็นตัวจริง" แบบเดียวกัน

  5. ⚠️ ระวังเรื่อง display_name_th string matching — ทั้งระบบ match ตัวตนผู้อนุมัติด้วยการเทียบ string display_name_th (ตอนตั้งคิว) กับ claim display_name (ตอนล็อกอิน) แบบ exact match เจอจริงในข้อมูล ว่าชื่อเดียวกันมีทั้ง "นางสาวนภาพร สูนาสวน" (ไม่มีวรรค) และ "นางสาว นภาพร สูนาสวน" (มีวรรค) ทำให้ match ไม่ติดถ้าฟอร์แมตไม่ตรงกันเป๊ะ — ถ้าออกแบบใหม่ มีโอกาสควรพิจารณา match ด้วย personnel_uid แทน (มีอยู่แล้วในตาราง แต่โค้ดปัจจุบันไม่ได้ใช้จริงจัง)

  6. ห้ามไว้ใจฟิลด์ faculty_name_th/ชื่ออื่นที่ denormalize ไว้บน entity ตัวเอง — เจอจริงว่า request_expense.faculty_name_th เป็น null เกือบทั้งหมด แม้ faculty_uid จะมีค่า ให้ join จากตาราง ต้นทาง (faculty, budget_year, budget_location) เอาเองเสมอเวลาจะโชว์ชื่อ

  7. ทำ dashboard คู่ขนาน — ก๊อปโครง GetMyDashboard ได้เลย เปลี่ยนแค่ type_id/FK คอลัมน์/entity — จำไว้ว่า approver_pending/approver_returned ต้องมี is_sent == true เสมอ ไม่งั้นจะเจอบั๊กเดียวกับ ที่แก้ไปแล้วในเวอร์ชันนี้ (นับรายการที่ยังกดอนุมัติจริงไม่ได้ว่าเป็น "รออนุมัติ")