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
442 lines
34 KiB
Markdown
442 lines
34 KiB
Markdown
# ระบบอนุมัติ (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`** เสมอ ไม่งั้นจะเจอบั๊กเดียวกับ
|
|
ที่แก้ไปแล้วในเวอร์ชันนี้ (นับรายการที่ยังกดอนุมัติจริงไม่ได้ว่าเป็น "รออนุมัติ")
|