diff --git a/docs/superpowers/specs/2026-09-07-approve-state-workflow-reference.md b/docs/superpowers/specs/2026-09-07-approve-state-workflow-reference.md new file mode 100644 index 0000000..b3eb69d --- /dev/null +++ b/docs/superpowers/specs/2026-09-07-approve-state-workflow-reference.md @@ -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 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()) + .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 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`** เสมอ ไม่งั้นจะเจอบั๊กเดียวกับ + ที่แก้ไปแล้วในเวอร์ชันนี้ (นับรายการที่ยังกดอนุมัติจริงไม่ได้ว่าเป็น "รออนุมัติ")