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
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):
SetCustomApproversสร้างแถวrow_order=1 (is_state=true),row_order=2 (is_state=false)- ใครก็ได้กด "ส่งอนุมัติ" →
is_sent=true - คนที่ระบุไว้ที่
row_order=1กด "อนุมัติ" → แถว 1 ปิด (is_state=null, is_approve=true), แถว 2 เปิด (is_state=true),request_expense.current_approve_nameเปลี่ยนเป็นชื่อคนที่ 2 - คนที่
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 → กางลิสต์ใต้การ์ด (คณะ/พื้นที่/ปีงบ/วันที่)
- กดแถวในลิสต์ → เซ็ตค่าฟอร์มค้นหา (
presetQueryinput บน search component) แล้วเรียกonSearch()ทันที (หน้านี้แสดงทีละ 1 เรื่องต่อ ปีงบ+คณะ+พื้นที่ ไม่มีตารางรายการแยกต่างหาก จึงต้อง pre-fill+ค้นหาแทนการลิงก์ไปหน้าอื่น)
8. เช็คลิสต์ถ้าจะนำไปใช้กับ "คำขอแผ่นดิน"
-
เพิ่มคอลัมน์ FK ใหม่ใน
approve_stateเช่นrequest_budget_uid(ตาม pattern เดียวกับinvest_asset_uid/invest_construct_uid/request_expense_uid) — อย่า reuserequest_expense_uidถ้า entity คำขอแผ่นดินเป็นคนละตารางกับrequest_expense -
กำหนด
request_approve_typecode ใหม่ (ตอนนี้ใช้"01"-"04"ไปแล้ว เช็คตาราง configv_request_approve_typeก่อนว่ามี"05"ใช้อยู่แล้วหรือยัง) แล้วเพิ่มเงื่อนไขเกตis_sentในจุดเดิม (ApproveState.csบรรทัดที่เช็คrequest_approve_type == "04") ให้ครอบคลุม code ใหม่ด้วย — ไม่งั้น ระบบใหม่จะข้ามการเช็ค "ส่งอนุมัติหรือยัง" ไปเลย -
เขียนฟังก์ชันคู่ขนานของ
SetCustomApprovers/SendApprove2สำหรับ entity ใหม่ (โครงเดียวกันได้เลย เปลี่ยนแค่ชื่อ FK คอลัมน์และชื่อตาราง) หรือถ้าอยากได้แบบ config-ตายตัวต่อคณะ/พื้นที่แทน ให้ใช้getApproveState()(Modules/Setting/Services/ApproveState.cs) แบบเดียวกับ code"01"-"03" -
เพิ่ม branch ใหม่ใน
ApproveGetEntity/RejectGetEntity(ApproveState.cs) สำหรับ FK ใหม่ — โครงสร้าง if-block เหมือนกับinvest_asset/invest_construct/request_expenseเป๊ะ ก็อปแล้วเปลี่ยน ชื่อ field ได้เลย แต่อย่าลืมใส่เกตis_sent+เช็คสิทธิ์display_nameแบบเดียวกับrequest_expenseถ้าอยากได้พฤติกรรม "ใครก็ได้ส่งได้ แต่อนุมัติต้องเป็นตัวจริง" แบบเดียวกัน -
⚠️ ระวังเรื่อง
display_name_thstring matching — ทั้งระบบ match ตัวตนผู้อนุมัติด้วยการเทียบ stringdisplay_name_th(ตอนตั้งคิว) กับ claimdisplay_name(ตอนล็อกอิน) แบบ exact match เจอจริงในข้อมูล ว่าชื่อเดียวกันมีทั้ง"นางสาวนภาพร สูนาสวน"(ไม่มีวรรค) และ"นางสาว นภาพร สูนาสวน"(มีวรรค) ทำให้ match ไม่ติดถ้าฟอร์แมตไม่ตรงกันเป๊ะ — ถ้าออกแบบใหม่ มีโอกาสควรพิจารณา match ด้วยpersonnel_uidแทน (มีอยู่แล้วในตาราง แต่โค้ดปัจจุบันไม่ได้ใช้จริงจัง) -
ห้ามไว้ใจฟิลด์
faculty_name_th/ชื่ออื่นที่ denormalize ไว้บน entity ตัวเอง — เจอจริงว่าrequest_expense.faculty_name_thเป็น null เกือบทั้งหมด แม้faculty_uidจะมีค่า ให้ join จากตาราง ต้นทาง (faculty,budget_year,budget_location) เอาเองเสมอเวลาจะโชว์ชื่อ -
ทำ dashboard คู่ขนาน — ก๊อปโครง
GetMyDashboardได้เลย เปลี่ยนแค่type_id/FK คอลัมน์/entity — จำไว้ว่าapprover_pending/approver_returnedต้องมีis_sent == trueเสมอ ไม่งั้นจะเจอบั๊กเดียวกับ ที่แก้ไปแล้วในเวอร์ชันนี้ (นับรายการที่ยังกดอนุมัติจริงไม่ได้ว่าเป็น "รออนุมัติ")