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
This commit is contained in:
Nut.ไปเรื่อย
2026-09-07 16:46:10 +07:00
co-authored by Claude Sonnet 5
parent 75e1f3706e
commit efbc4618c2
@@ -0,0 +1,441 @@
# ระบบอนุมัติ (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. โมเดลข้อมูล
```csharp
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; // รอบการอนุมัติ (เผื่อมี "ส่งอนุมัติเพิ่มเติม" รอบใหม่)
}
```
```csharp
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`)
ผู้ส่งอนุมัติ (ใครก็ได้ที่มีสิทธิ์เข้าถึงเรื่องนี้) เลือกคิวผู้อนุมัติเอง **ก่อน** กด "ส่งอนุมัติ"
```csharp
// 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)
```csharp
// 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`:
```csharp
// 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 หรือคนสร้างตรงๆ
```csharp
// 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`)
```csharp
// 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`** เสมอ ไม่งั้นจะเจอบั๊กเดียวกับ
ที่แก้ไปแล้วในเวอร์ชันนี้ (นับรายการที่ยังกดอนุมัติจริงไม่ได้ว่าเป็น "รออนุมัติ")