Mirrors the invest-asset self-service approver pattern for the construction budget investment plan (invest_construct), per user request to make it identical. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153wBt23EjW125ZsADwWVAs
761 lines
43 KiB
Markdown
761 lines
43 KiB
Markdown
# สิ่งก่อสร้าง (invest_construct) — ผู้ส่งเลือกผู้อนุมัติเอง Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** เปลี่ยนกลไกอนุมัติของ "แผนความต้องการงบลงทุนค่าที่ดินและสิ่งก่อสร้าง" (`/app/invest-construct`,
|
|
`invest_construct`) จากการดึงคิวผู้อนุมัติจาก config แอดมิน (`request_approve_type="02"`) เป็นให้ผู้ส่ง
|
|
พิมพ์ชื่อผู้อนุมัติเองก่อนกด "ส่งอนุมัติ" — **mirror งานเดียวกันที่ทำกับครุภัณฑ์ (invest_asset)
|
|
ทุกจุด แบบคำต่อคำ** (ผู้ใช้ขอตรงๆ ว่า "ให้เหมือนครุภัณฑ์") **แทนที่ไปเลย** เหมือนกัน
|
|
|
|
**Architecture:** เหมือนกับ invest_asset ทุกประการ — ยืนยันจากการอ่านโค้ดจริงแล้วว่า
|
|
`InvestConstructService`/`InvestConstructController`/`invest_construct` model มีโครงสร้าง
|
|
byte-for-byte เดียวกับ `InvestAssetService`/`InvestAssetController`/`invest_asset` (มี `is_draft`
|
|
ความหมายเดียวกัน, `send_approve` endpoint โครงเดียวกัน, ผ่าน `ApproveStateController` ตัวเดียวกัน)
|
|
ดู `docs/superpowers/specs/2026-09-07-invest-asset-self-service-approval-design.md` เป็น pattern
|
|
authority ไม่เขียน design doc ใหม่ซ้ำ (bounded change ต่อยอดจาก pattern ที่ผ่าน review มาแล้ว)
|
|
|
|
**ข้อต่างจาก invest_asset ที่ต้องรู้ (ไม่ต้องแก้อะไร แค่บันทึกไว้):** `RejectGetEntity` ของ
|
|
`invest_construct` (ต่างจาก `invest_asset` ที่รีเซ็ตกลับไปลำดับ 1 เสมอ) จะถอยกลับแค่ 1 ลำดับ
|
|
เท่านั้นเมื่อถูกปฏิเสธจากลำดับ 2+ (กลับไปลำดับ 1 เฉพาะตอนถูกปฏิเสธจากลำดับ 1 เอง) — เป็นพฤติกรรม
|
|
เดิมของระบบ ไม่เกี่ยวกับงานนี้ ไม่ต้องแตะ `RejectGetEntity` เลย (เหมือน invest_asset ที่ก็ไม่ได้แตะ)
|
|
|
|
**Tech Stack:** ASP.NET Core 8 / EF Core / Npgsql (backend), Angular (frontend), PostgreSQL
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-09-07-invest-asset-self-service-approval-design.md`
|
|
(ใช้เป็น pattern authority — ทุก decision ที่นั่นใช้กับ invest_construct เหมือนกันทุกจุด ยกเว้น
|
|
ข้อต่าง reject ด้านบนซึ่งไม่กระทบงานนี้)
|
|
|
|
## Global Constraints
|
|
|
|
- รหัส `request_approve_type` ใหม่สำหรับ invest_construct ต้อง**ไม่ชนกับข้อมูลจริงและไม่ชนกับรหัส
|
|
ใหม่ที่ invest_asset เพิ่งใช้ไป** — invest_asset ใช้ `"05"` (คาดว่าว่างจริง แต่ยังไม่ได้ยืนยันกับ DB
|
|
เพราะ VPN ไปเครือข่ายภายในหลุดอยู่ตอนที่วางแผนนี้) แผนนี้ใช้ `"06"` ต่อจากนั้น — Task 1 ต้องเช็คสด
|
|
กับ DB **ทั้งสองรหัส (`"05"` และ `"06"`) พร้อมกัน** ก่อนเริ่ม เพราะทั้งสองแผนพึ่ง DB เดียวกันและอาจ
|
|
ยังไม่ได้ verify มาก่อน ถ้ารหัสใดชนให้เลือกรหัสว่างจริงถัดไปแทนเฉพาะแผนที่ชน
|
|
- ระบบมี `t_approve_state`/`v_approve_state` ตารางเดียวใช้ร่วมกันทั้ง `invest_asset`/`invest_construct`/
|
|
`request_expense` — ห้ามเปลี่ยนพฤติกรรมของ branch อื่นที่ไม่ใช่ `invest_construct` ใน
|
|
`ApproveStateController` (`Modules/Setting/Controllers/ApproveState.cs`) โดยเด็ดขาด (รวมถึง guard
|
|
ที่เพิ่งเพิ่มให้ `invest_asset` code `"05"` เมื่อวานนี้ — ห้ามแก้ไข)
|
|
- คำขอสิ่งก่อสร้างเก่าที่มี `approve_state` แบบ `request_approve_type="02"` ค้างอยู่ระหว่างอนุมัติต้อง
|
|
เดินหน้าอนุมัติ/ปฏิเสธต่อได้ตามปกติ — guard ใหม่ทุกจุดต้องเช็คเงื่อนไข
|
|
`request_approve_type == "<รหัสใหม่>"` เสมอ ไม่ใช่เช็คว่าเป็น `invest_construct` เฉยๆ
|
|
- ไม่ต้องแตะ `RejectGetEntity` เลย (ดูข้อต่างด้านบน) — เหมือน invest_asset ที่ก็ไม่แตะเช่นกัน
|
|
- ห้ามแตะปุ่ม/เมธอดกลุ่มเซ็นภายในหน่วยงาน (`save_header_approve`/`save_chief_approve`/
|
|
`save_dean_approve`/`save_president_approve`, ปุ่ม "เห็นชอบ"/"ส่งแก้ไข" ของ "รองอธิการบดี") และ
|
|
ห้ามแตะปุ่ม/เมธอดของโหมด `state=='add'` (`add_approve()`) — คนละ code path ไม่ผ่าน
|
|
`send_approve`/approve_state เลย เหมือนกับที่ invest_asset ทำไว้
|
|
- โค้ดเบสนี้ไม่มี backend unit test project — verify ด้วย `dotnet build`/`ng build` ก่อน แล้ว verify
|
|
ด้วย `curl` ยิง endpoint จริงด้วย JWT ทดสอบ เทียบกับข้อมูลจริงใน DB — สร้างข้อมูลทดสอบสังเคราะห์
|
|
(`created_by = 'test.verify'`) ผ่าน SQL แล้วลบทิ้งหลังตรวจเสร็จ
|
|
- Session นี้ทำงานตรงบน `master` ของทั้ง `rmutr-api`/`rmutr-web` และ deploy จริงทันทีหลังแต่ละ task
|
|
ผ่าน (ไม่ใช้ branch/PR) — ข้าม `finishing-a-development-branch` ทั้งหมด
|
|
- Deploy จริง: backend = `dotnet publish -c Release -r linux-x64 --self-contained false` แล้ว `scp`
|
|
ไป `k8-node-1` restart `systemctl restart rmutr-budget-api` (`rmutr-api/script.sh`); frontend =
|
|
`ng build --configuration production --base-href /budget-web/` แล้ว `rsync` ไป
|
|
`k8-node-1:/var/www/html/budget-web/` (`rmutr-web/script.sh`) — ทั้งคู่ต้องผ่าน VPN เข้าเครือข่าย
|
|
ภายในก่อน (เช็คว่า VPN เชื่อมอยู่ก่อนเริ่ม Task 5/9)
|
|
|
|
---
|
|
|
|
## Task 1: ยืนยันรหัส `request_approve_type` ว่างจริงกับ production DB (ทั้ง "05" และ "06")
|
|
|
|
**Files:** ไม่มีไฟล์โค้ด — ตรวจสอบข้อมูลเท่านั้น
|
|
|
|
- [ ] **Step 1: เช็ค VPN ก่อน**
|
|
|
|
```bash
|
|
nc -zv -w5 203.158.221.28 31620
|
|
```
|
|
|
|
Expected: `succeeded` — ถ้ายัง timeout อยู่ ให้หยุดรอ VPN ก่อน ไม่ต้องเดาต่อ
|
|
|
|
- [ ] **Step 2: query รหัสที่ใช้อยู่จริง**
|
|
|
|
```bash
|
|
python3 - <<'EOF'
|
|
import psycopg2
|
|
conn = psycopg2.connect(host="203.158.221.28", port=31620,
|
|
dbname="rmutr_budget_050625", user="devadmin",
|
|
password="vtwi,yo0tpkd-okfouh", connect_timeout=8)
|
|
cur = conn.cursor()
|
|
cur.execute('SELECT DISTINCT request_approve_type FROM "BUDGET".approve_state ORDER BY 1')
|
|
print("approve_state distinct codes:", cur.fetchall())
|
|
EOF
|
|
```
|
|
|
|
Expected: รายการรหัสที่ใช้อยู่จริง (คาดว่า `01, 02, 03, 04`) — ถ้า `"05"` ไม่อยู่ในผลลัพธ์
|
|
invest_asset (แผนที่ทำไปแล้วเมื่อวาน, commit `3bdbcd73`/`38b98800`/`b3608fc1`) ใช้ `"05"` ได้ถูกต้อง
|
|
ไม่ต้องแก้อะไร ถ้า `"05"` มีอยู่แล้ว (ชนกับข้อมูลจริง) ต้องกลับไปแก้ 3 จุดในโค้ดของ invest_asset ที่
|
|
deploy ไปแล้ว (`InvestAssetService.SetCustomApprovers`, guard ใน `ApproveState.cs`) เป็นรหัสว่างจริง
|
|
ก่อน แล้วค่อย deploy ใหม่ — นี่เป็นเหตุการณ์นอกเหนือแผนนี้ ให้แจ้งผู้ใช้ก่อนแก้ย้อนหลัง
|
|
|
|
ถ้า `"06"` **ไม่อยู่** ในผลลัพธ์ (และไม่ชนกับ `"05"` ที่ invest_asset ใช้) ใช้ `"06"` ต่อได้เลยตามที่
|
|
แผนนี้เขียนไว้ทุกจุด ถ้า `"06"` มีอยู่แล้ว ให้เลือกรหัส 2 หลักตัวถัดไปที่ไม่อยู่ในผลลัพธ์และไม่ใช่ `"05"`
|
|
(เช่น `"07"`) แล้วแทนที่คำว่า `"06"` ด้วยรหัสนั้นในทุก code block ของ Task 2, 4 ก่อนเริ่มเขียนโค้ดจริง
|
|
|
|
---
|
|
|
|
## Task 2: Backend — เพิ่ม `SetCustomApprovers` ใน `InvestConstructService`
|
|
|
|
**Files:**
|
|
- Modify: `Modules/Invests/Services/InvestConstruct.cs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `ApproverInput` class จาก `rmutr_budget_api.Modules.RequestExpense.Services` (มีอยู่แล้ว),
|
|
`t_approve_state` จาก `rmutr_budget_api.Modules.General.Databases.Models` (import แล้วในไฟล์นี้)
|
|
- Produces: `Task<(bool ok, string error)> SetCustomApprovers(Guid? investConstructUid, List<ApproverInput> approvers, ClaimsIdentity claimsIdentity)`
|
|
— ให้ Task 3 (controller) เรียกใช้
|
|
|
|
- [ ] **Step 1: เพิ่ม using directive**
|
|
|
|
เปิด `Modules/Invests/Services/InvestConstruct.cs` เพิ่มบรรทัดนี้ต่อท้าย using เดิม (บรรทัด 13):
|
|
|
|
```csharp
|
|
using rmutr_budget_api.Modules.RequestExpense.Services;
|
|
```
|
|
|
|
- [ ] **Step 2: เพิ่มเมธอด `SetCustomApprovers`**
|
|
|
|
เพิ่มเมธอดนี้ในคลาส `InvestConstructService` (ต่อจากเมธอด `ListEntityIdApprove` หรือจุดใดก็ได้ในคลาส
|
|
— คลาสนี้มี field `_context` อยู่แล้วชื่อเดียวกับ `InvestAssetService`):
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// ให้ผู้ส่งอนุมัติกำหนดคิวผู้อนุมัติ (ลำดับ 1, 2) เองก่อนกด "ส่งอนุมัติ" แทนที่จะดึงจาก config
|
|
/// "จัดการเมนูการอนุมัติ" ที่แอดมินต้องตั้งไว้ล่วงหน้า (getApproveState("02", ...)) — mirror
|
|
/// InvestAssetService.SetCustomApprovers (code "05") ทุกจุด ดู
|
|
/// docs/superpowers/specs/2026-09-07-invest-asset-self-service-approval-design.md ข้อ 3.1(a)
|
|
/// ใช้ได้เฉพาะรายการที่ยังเป็นฉบับร่าง (is_draft==true) และยังไม่เคยเข้าสู่กระบวนการอนุมัติจริงเลย
|
|
/// </summary>
|
|
public async Task<(bool ok, string error)> SetCustomApprovers(Guid? investConstructUid,
|
|
List<ApproverInput> approvers, ClaimsIdentity claimsIdentity)
|
|
{
|
|
var entity = await _context.t_invest_construct
|
|
.FirstOrDefaultAsync(c => c.invest_construct_uid == investConstructUid);
|
|
if (entity == null) return (false, "ไม่พบรายการ");
|
|
if (entity.is_draft != true)
|
|
return (false, "รายการนี้ต้องอยู่ในสถานะฉบับร่างก่อนจึงจะกำหนดผู้อนุมัติได้");
|
|
|
|
var existing = await _context.t_approve_state
|
|
.Where(c => c.invest_construct_uid == investConstructUid).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 คน");
|
|
|
|
if (existing.Any())
|
|
{
|
|
_context.t_approve_state.RemoveRange(existing);
|
|
await _context.SaveChangesAsync(claimsIdentity);
|
|
}
|
|
|
|
var rows = approvers.Select((a, idx) => new t_approve_state
|
|
{
|
|
approve_state_uid = Guid.NewGuid(),
|
|
invest_construct_uid = investConstructUid,
|
|
personnel_uid = a.personnel_uid,
|
|
display_name_th = a.display_name_th,
|
|
row_order = idx + 1,
|
|
is_state = idx == 0,
|
|
request_approve_type = "06",
|
|
}).ToList();
|
|
await _context.t_approve_state.AddRangeAsync(rows);
|
|
entity.current_approve_name = rows.First().display_name_th;
|
|
_context.t_invest_construct.Update(entity);
|
|
await _context.SaveChangesAsync(claimsIdentity);
|
|
return (true, null);
|
|
}
|
|
```
|
|
|
|
(แทนที่ `"06"` ด้วยรหัสจริงจาก Task 1 ถ้าไม่ใช่ `"06"`)
|
|
|
|
- [ ] **Step 3: Build ตรวจ compile**
|
|
|
|
```bash
|
|
cd rmutr-api && dotnet build
|
|
```
|
|
|
|
Expected: Build succeeded, ไม่มี error (ถ้า build ค้างนาน เช็ค `ps aux | grep -i "dotnet build\|VBCSCompiler"`
|
|
ก่อนคิดว่า hang จริง — เครื่องนี้มีปัญหา VBCSCompiler bloat เป็นระยะ ให้รอจนกว่า log ใน output ขยับ
|
|
ก่อนสรุปว่าค้าง)
|
|
|
|
---
|
|
|
|
## Task 3: Backend — เพิ่ม endpoint `set_approvers` ใน `InvestConstructController`
|
|
|
|
**Files:**
|
|
- Modify: `Modules/Invests/Controllers/InvestConstruct.cs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `InvestConstructService.SetCustomApprovers` จาก Task 2
|
|
- Produces: `POST /api/invest/invest_construct/set_approvers/{uid}` — ให้ Task 7 (frontend service)
|
|
เรียกใช้
|
|
|
|
- [ ] **Step 1: เพิ่ม using directive**
|
|
|
|
เปิด `Modules/Invests/Controllers/InvestConstruct.cs` เพิ่มต่อท้าย using เดิม (บรรทัด 14):
|
|
|
|
```csharp
|
|
using rmutr_budget_api.Modules.RequestExpense.Services;
|
|
```
|
|
|
|
- [ ] **Step 2: เพิ่ม endpoint**
|
|
|
|
เพิ่มเมธอดนี้ในคลาส `InvestConstructController` (ต่อจาก `SendApproveEntityId`, หลังบรรทัด 243):
|
|
|
|
```csharp
|
|
/// <summary>ผู้ส่งกำหนดผู้อนุมัติเอง (ลำดับ 1, 2) ก่อนกด "ส่งอนุมัติ" — ดู SetCustomApprovers</summary>
|
|
[Authorize]
|
|
[HttpPost("set_approvers/{uid:guid}")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<ActionResult> SetApprovers([FromRoute] Guid uid, [FromBody] List<ApproverInput> approvers)
|
|
{
|
|
var result = await _entityUidService.SetCustomApprovers(uid, approvers, User.Identity as ClaimsIdentity);
|
|
if (!result.ok)
|
|
{
|
|
return BadRequest(new { description = result.error });
|
|
}
|
|
return Ok();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Build ตรวจ compile**
|
|
|
|
```bash
|
|
cd rmutr-api && dotnet build
|
|
```
|
|
|
|
Expected: Build succeeded
|
|
|
|
---
|
|
|
|
## Task 4: Backend — ปิดช่องโหว่อนุมัติก่อนส่งใน `ApproveGetEntity` (branch invest_construct)
|
|
|
|
**Files:**
|
|
- Modify: `Modules/Setting/Controllers/ApproveState.cs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `t_approve_state.request_approve_type` (รหัสใหม่จาก Task 1/2), `t_invest_construct.is_draft`
|
|
- Produces: ไม่มี (แก้ behavior ของ endpoint ที่มีอยู่แล้ว `GET api/setting/approve_state/approve/{uid}`)
|
|
|
|
- [ ] **Step 1: เพิ่ม guard ใน invest_construct branch ของ `ApproveGetEntity`**
|
|
|
|
หาบล็อกนี้ (บรรทัด ~106-113 — **ระวัง: นี่คือ branch `invest_construct`, คนละบล็อกกับ branch
|
|
`invest_asset` ที่เพิ่มไปแล้วเมื่อวาน ห้ามแก้ผิดบล็อก**):
|
|
|
|
```csharp
|
|
if (invest_construct != null)
|
|
{
|
|
getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
|
|
c.invest_construct_uid == uid && c.is_state == true);
|
|
if (getResult != null)
|
|
{
|
|
getResult.is_state = false;
|
|
```
|
|
|
|
แทนที่ด้วย (เพิ่ม guard block ก่อน `getResult.is_state = false;`):
|
|
|
|
```csharp
|
|
if (invest_construct != null)
|
|
{
|
|
getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
|
|
c.invest_construct_uid == uid && c.is_state == true);
|
|
if (getResult != null)
|
|
{
|
|
// สิ่งก่อสร้างแบบผู้ส่งเลือกผู้อนุมัติเอง (code "06"): SetCustomApprovers สร้างแถว
|
|
// is_state=true ไว้ล่วงหน้าตั้งแต่ก่อนกด "ส่งอนุมัติ" (invest_construct.is_draft ยังเป็น
|
|
// true อยู่) ต้องกันไม่ให้ใครก็ได้กด approve ทะลุขั้นตอนไปก่อน + ต้องเป็นคนที่ถูกระบุชื่อ
|
|
// ไว้ในคิวลำดับ 1 เท่านั้นเป็นผู้กด (เหมือน invest_asset code "05")
|
|
if (getResult.row_order == 1 && getResult.request_approve_type == "06")
|
|
{
|
|
if (invest_construct.is_draft == 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(StatusCodes.Status403Forbidden,
|
|
new { description = "ไม่มีสิทธิ์อนุมัติ (ต้องเป็นผู้ที่ถูกกำหนดเป็นผู้อนุมัติลำดับ 1 เท่านั้น)" });
|
|
}
|
|
}
|
|
getResult.is_state = false;
|
|
```
|
|
|
|
(แทนที่ `"06"` ด้วยรหัสจริงจาก Task 1 ถ้าไม่ใช่ `"06"` — ต้องตรงกับค่าที่ใช้ใน Task 2 เป๊ะ)
|
|
|
|
- [ ] **Step 2: Build ตรวจ compile**
|
|
|
|
```bash
|
|
cd rmutr-api && dotnet build
|
|
```
|
|
|
|
Expected: Build succeeded
|
|
|
|
- [ ] **Step 3: ยืนยันด้วย git diff ว่าแตะเฉพาะ branch `invest_construct` เท่านั้น**
|
|
|
|
```bash
|
|
git diff Modules/Setting/Controllers/ApproveState.cs
|
|
```
|
|
|
|
Expected: เห็นการเพิ่มเฉพาะในบล็อก `if (invest_construct != null)` เท่านั้น บล็อก `invest_asset`/
|
|
`request_expense` ต้องไม่มีการเปลี่ยนแปลงใดๆ เลย (รวมถึง guard ของ invest_asset code "05" ที่เพิ่ม
|
|
ไปเมื่อวาน ต้องอยู่ตรงเดิมไม่ถูกแตะ)
|
|
|
|
---
|
|
|
|
## Task 5: Backend — Deploy และ verify ด้วยข้อมูลทดสอบจริง
|
|
|
|
**Files:** ไม่มีไฟล์โค้ดเพิ่ม — deploy + verification เท่านั้น
|
|
|
|
- [ ] **Step 1: เช็ค VPN แล้ว publish/deploy**
|
|
|
|
```bash
|
|
nc -zv -w5 172.27.30.135 22 && cd rmutr-api && ./script.sh
|
|
```
|
|
|
|
Expected: สคริปต์รันจบไม่ error, service `rmutr-budget-api` restart สำเร็จ
|
|
|
|
- [ ] **Step 2: สร้างข้อมูลทดสอบสังเคราะห์**
|
|
|
|
```bash
|
|
python3 - <<'EOF'
|
|
import psycopg2, uuid
|
|
conn = psycopg2.connect(host="203.158.221.28", port=31620,
|
|
dbname="rmutr_budget_050625", user="devadmin",
|
|
password="vtwi,yo0tpkd-okfouh")
|
|
cur = conn.cursor()
|
|
test_uid = str(uuid.uuid4())
|
|
cur.execute('''
|
|
INSERT INTO "BUDGET".invest_construct
|
|
(invest_construct_uid, is_draft, created_by, invest_construct_name_th, status_id)
|
|
VALUES (%s, true, 'test.verify', 'ทดสอบระบุผู้อนุมัติเอง', 1)
|
|
''', (test_uid,))
|
|
conn.commit()
|
|
print("test invest_construct_uid:", test_uid)
|
|
EOF
|
|
```
|
|
|
|
(ปรับคอลัมน์ตาม NOT NULL constraint จริงของตาราง `invest_construct` ถ้า insert error ให้เช็ค schema
|
|
ก่อนแล้วเติมคอลัมน์ที่ขาดให้ครบ — เทียบเคียงกับที่ทำสำเร็จตอน invest_asset)
|
|
|
|
- [ ] **Step 3: ทดสอบ `set_approvers` ก่อนส่งอนุมัติ — ต้องสำเร็จ**
|
|
|
|
ยิง `POST /api/invest/invest_construct/set_approvers/{test_uid}` ด้วย JWT ทดสอบ body:
|
|
```json
|
|
[{"display_name_th": "ทดสอบ หัวหน้าหน่วยงาน"}, {"display_name_th": "ทดสอบ รองอธิการบดี"}]
|
|
```
|
|
Expected: 200 OK ตรวจด้วย SQL ว่ามี `t_approve_state` 2 แถวผูกกับ `test_uid`,
|
|
`request_approve_type='06'`, row_order 1/2, row_order=1 มี `is_state=true`
|
|
|
|
- [ ] **Step 4: ทดสอบ approve ก่อนกด "ส่งอนุมัติ" — ต้องถูกบล็อก**
|
|
|
|
ยิง `GET /api/setting/approve_state/approve/{test_uid}` (ตอนที่ invest_construct ยัง `is_draft=true`)
|
|
Expected: 400 BadRequest, message "ยังไม่ได้ส่งอนุมัติ กรุณากด \"ส่งอนุมัติ\" ก่อน"
|
|
|
|
- [ ] **Step 5: กด "ส่งอนุมัติ" แล้วทดสอบ approve ด้วยคนละชื่อ — ต้องถูกบล็อก 403**
|
|
|
|
ยิง `GET /api/invest/invest_construct/send_approve/{test_uid}` ก่อน (ตรวจว่า `is_draft` เปลี่ยนเป็น
|
|
`null`) แล้วยิง `GET /api/setting/approve_state/approve/{test_uid}` ด้วย JWT ที่ `display_name`
|
|
**ไม่ตรง**กับ "ทดสอบ หัวหน้าหน่วยงาน"
|
|
Expected: 403 Forbidden
|
|
|
|
- [ ] **Step 6: approve ด้วยชื่อที่ถูกต้อง — ต้องสำเร็จและเลื่อนคิว**
|
|
|
|
ยิง `GET /api/setting/approve_state/approve/{test_uid}` ด้วย JWT ที่ `display_name` = "ทดสอบ
|
|
หัวหน้าหน่วยงาน" Expected: 200 OK, ตรวจ SQL ว่า row_order=1 มี `is_approve=true`, row_order=2 มี
|
|
`is_state=true`
|
|
|
|
- [ ] **Step 7: ลบข้อมูลทดสอบ**
|
|
|
|
```bash
|
|
python3 - <<'EOF'
|
|
import psycopg2
|
|
conn = psycopg2.connect(host="203.158.221.28", port=31620,
|
|
dbname="rmutr_budget_050625", user="devadmin",
|
|
password="vtwi,yo0tpkd-okfouh")
|
|
cur = conn.cursor()
|
|
cur.execute('DELETE FROM "BUDGET".approve_state WHERE invest_construct_uid = %s', (test_uid,))
|
|
cur.execute('DELETE FROM "BUDGET".invest_construct WHERE invest_construct_uid = %s', (test_uid,))
|
|
conn.commit()
|
|
EOF
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Frontend — สร้าง dialog "ระบุผู้อนุมัติ" สำหรับสิ่งก่อสร้าง
|
|
|
|
**Files:**
|
|
- Create: `rmutr-web/src/app/feature/budget-request/investment-budget/invest-construct/presenter/popup/set-invest-construct-approvers-dialog/set-invest-construct-approvers-dialog.component.ts`
|
|
- Create: `.../set-invest-construct-approvers-dialog/set-invest-construct-approvers-dialog.component.html`
|
|
- Create: `.../set-invest-construct-approvers-dialog/set-invest-construct-approvers-dialog.component.scss`
|
|
- Modify: `rmutr-web/src/app/feature/budget-request/investment-budget/invest-construct/invest-construct.module.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `AgencyService.getHr()` (`src/app/core/service/general/agency.service`, มีอยู่แล้ว),
|
|
`SearchAutoPipe` (มาจาก `SharedModule` ที่ import อยู่แล้วในโมดูลนี้)
|
|
- Produces: `SetInvestConstructApproversDialogComponent` — ให้ Task 8 เรียกผ่าน `MatDialog.open()`,
|
|
`dialogRef.afterClosed()` คืนค่า `{ personnel_uid, display_name_th }[]` หรือ `null` ถ้ากดยกเลิก
|
|
|
|
- [ ] **Step 1: สร้าง component ts** (เนื้อหาเหมือน
|
|
`feature/budget-request/investment-budget/durable-articles/presenter/popup/set-invest-approvers-dialog/set-invest-approvers-dialog.component.ts`
|
|
ทุกจุด เปลี่ยนแค่ชื่อคลาส/selector)
|
|
|
|
```typescript
|
|
import { Component, Inject } from '@angular/core';
|
|
import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
|
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
|
import { Observable } from 'rxjs';
|
|
import { AgencyService } from 'src/app/core/service/general/agency.service';
|
|
|
|
@Component({
|
|
selector: 'app-set-invest-construct-approvers-dialog',
|
|
templateUrl: './set-invest-construct-approvers-dialog.component.html',
|
|
styleUrls: ['./set-invest-construct-approvers-dialog.component.scss']
|
|
})
|
|
export class SetInvestConstructApproversDialogComponent {
|
|
form: FormGroup
|
|
agency$: Observable<any>
|
|
errorText: string = null
|
|
approverLabels = ['หัวหน้าหน่วยงาน', 'รองอธิการบดี']
|
|
|
|
constructor(
|
|
private fb: FormBuilder,
|
|
private agencySV: AgencyService,
|
|
public dialogRef: MatDialogRef<SetInvestConstructApproversDialogComponent>,
|
|
@Inject(MAT_DIALOG_DATA) public data: any,
|
|
) {
|
|
this.agency$ = this.agencySV.getHr()
|
|
const existing = (this.data?.approvers || []) as any[]
|
|
this.form = this.fb.group({
|
|
approvers: this.fb.array([
|
|
this.approverForm(existing[0]?.personnel_uid, existing[0]?.display_name_th, true),
|
|
this.approverForm(existing[1]?.personnel_uid, existing[1]?.display_name_th, false),
|
|
])
|
|
})
|
|
}
|
|
|
|
get approvers(): FormArray {
|
|
return this.form.get('approvers') as FormArray
|
|
}
|
|
|
|
approverForm(personnel_uid: any = null, display_name_th: string = '', required: boolean = false): FormGroup {
|
|
return this.fb.group({
|
|
personnel_uid: [personnel_uid],
|
|
display_name_th: [display_name_th, required ? [Validators.required] : []],
|
|
})
|
|
}
|
|
|
|
click_person(item: any, group: FormGroup) {
|
|
group.get('personnel_uid').setValue(item.personnel_uid)
|
|
group.get('display_name_th').setValue(item.display_name_th)
|
|
}
|
|
|
|
save() {
|
|
this.errorText = null
|
|
this.approvers.markAllAsTouched()
|
|
if (!this.approvers.at(0).value.display_name_th) {
|
|
this.errorText = 'กรุณาระบุหัวหน้าหน่วยงาน'
|
|
return
|
|
}
|
|
const value = (this.form.value.approvers || []).filter((a: any) => !!a.display_name_th)
|
|
this.dialogRef.close(value)
|
|
}
|
|
|
|
cancel() {
|
|
this.dialogRef.close(null)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: สร้าง component html**
|
|
|
|
```html
|
|
<h2 mat-dialog-title>กำหนดผู้อนุมัติ</h2>
|
|
<div mat-dialog-content [formGroup]="form">
|
|
<p class="dialog-hint">ระบุผู้อนุมัติคำขอสิ่งก่อสร้าง (หัวหน้าหน่วยงานจะได้รับก่อน เมื่ออนุมัติแล้วจะส่งต่อไปยังรองอธิการบดีอัตโนมัติ) — บันทึกไว้ก่อน แล้วค่อยกด "ส่งอนุมัติ" ด้านนอกอีกครั้งเพื่อส่งจริง</p>
|
|
|
|
<div formArrayName="approvers">
|
|
<div class="approver-row" *ngFor="let group of approvers.controls; let i = index" [formGroupName]="i">
|
|
<span class="row-order-badge">{{ approverLabels[i] }} <span class="required-asterisk" *ngIf="i === 0">*</span></span>
|
|
<mat-form-field appearance="outline" class="approver-input">
|
|
<input autocomplete="off" type="text" matInput placeholder="ค้นหาชื่อผู้อนุมัติ"
|
|
formControlName="display_name_th" [matAutocomplete]="autoApprover">
|
|
<mat-icon matSuffix>search</mat-icon>
|
|
<mat-autocomplete #autoApprover="matAutocomplete">
|
|
<mat-option (click)="click_person(item, group)"
|
|
*ngFor="let item of agency$ | async | searchAuto : group.value.display_name_th : 'display_name_th'"
|
|
[value]="item.display_name_th">
|
|
{{ item.display_name_th }}
|
|
</mat-option>
|
|
</mat-autocomplete>
|
|
<mat-error *ngIf="group.get('display_name_th').hasError('required')">กรุณาระบุ{{ approverLabels[i] }}</mat-error>
|
|
</mat-form-field>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="error-text" *ngIf="errorText">{{ errorText }}</p>
|
|
</div>
|
|
<div mat-dialog-actions align="end">
|
|
<button mat-button (click)="cancel()">ยกเลิก</button>
|
|
<button mat-raised-button color="primary" (click)="save()">บันทึก</button>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 3: สร้าง component scss**
|
|
|
|
```scss
|
|
.dialog-hint {
|
|
color: #666;
|
|
font-size: 0.85rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.approver-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.row-order-badge {
|
|
min-width: 130px;
|
|
font-size: 0.85rem;
|
|
color: #444;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.required-asterisk {
|
|
color: #c62828;
|
|
}
|
|
|
|
.approver-input {
|
|
flex: 1;
|
|
}
|
|
|
|
.error-text {
|
|
color: #c62828;
|
|
font-size: 0.85rem;
|
|
margin-top: 0.75rem;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: ประกาศ component ใน module**
|
|
|
|
เปิด `invest-construct.module.ts` เพิ่ม import (ต่อจากบรรทัด 45):
|
|
|
|
```typescript
|
|
import { SetInvestConstructApproversDialogComponent } from './presenter/popup/set-invest-construct-approvers-dialog/set-invest-construct-approvers-dialog.component';
|
|
```
|
|
|
|
เพิ่มเข้า `declarations` array ต่อจาก `ListApprove5Component,` (บรรทัด 93):
|
|
|
|
```typescript
|
|
SetInvestConstructApproversDialogComponent,
|
|
```
|
|
|
|
- [ ] **Step 5: Build ตรวจ compile**
|
|
|
|
```bash
|
|
cd rmutr-web && ng build
|
|
```
|
|
|
|
Expected: Build ผ่านไม่มี error
|
|
|
|
---
|
|
|
|
## Task 7: Frontend — เพิ่ม `setApprovers` ใน `InvestConstructService` (Angular)
|
|
|
|
**Files:**
|
|
- Modify: `rmutr-web/src/app/core/service/invest/invest-construct.service.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `POST /api/invest/invest_construct/set_approvers/{uid}` จาก Task 3
|
|
- Produces: `setApprovers(id, approvers)` — ให้ Task 8 เรียกใช้
|
|
|
|
- [ ] **Step 1: เพิ่มเมธอด**
|
|
|
|
เปิดไฟล์ เพิ่มเมธอดนี้ต่อจาก `reject(id)` (บรรทัด 21):
|
|
|
|
```typescript
|
|
setApprovers(id, approvers: { personnel_uid: string, display_name_th: string }[]) {
|
|
return this.http.post(`${environment.rmutrApi}/api/invest/invest_construct/set_approvers/${id}`, approvers)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Build ตรวจ compile**
|
|
|
|
```bash
|
|
cd rmutr-web && ng build
|
|
```
|
|
|
|
Expected: Build ผ่านไม่มี error
|
|
|
|
---
|
|
|
|
## Task 8: Frontend — เสียบปุ่ม "ระบุผู้อนุมัติ" เข้าฟอร์มสิ่งก่อสร้าง
|
|
|
|
**Files:**
|
|
- Modify: `rmutr-web/src/app/feature/budget-request/investment-budget/invest-construct/presenter/form/invest-construct-form/invest-construct-form.component.ts`
|
|
- Modify: `.../invest-construct-form.component.html`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `SetInvestConstructApproversDialogComponent` (Task 6), `InvestConstructService.setApprovers` (Task 7)
|
|
|
|
- [ ] **Step 1: import dialog component**
|
|
|
|
เปิด `invest-construct-form.component.ts` เพิ่ม import ต่อจากบรรทัด 26:
|
|
|
|
```typescript
|
|
import { SetInvestConstructApproversDialogComponent } from '../../popup/set-invest-construct-approvers-dialog/set-invest-construct-approvers-dialog.component';
|
|
```
|
|
|
|
- [ ] **Step 2: เพิ่ม field เก็บ approve_states ปัจจุบัน**
|
|
|
|
เพิ่ม field นี้ต่อจาก `canSendApprove = false` (บรรทัด 51):
|
|
|
|
```typescript
|
|
/** approve_states ปัจจุบันของ invest_construct นี้ (เซ็ตใน setUpform) ใช้เช็คว่ากำหนดคิวผู้อนุมัติแล้วหรือยัง */
|
|
currentApproveStates: any[] = []
|
|
```
|
|
|
|
- [ ] **Step 3: เพิ่ม `canDefineApprovers()`/`hasApproveQueue()`**
|
|
|
|
เพิ่มเมธอดนี้ต่อจาก `canApprove()` (หลังบรรทัด 59):
|
|
|
|
```typescript
|
|
/** แสดงปุ่ม "ระบุผู้อนุมัติ" เฉพาะรายการที่ยังไม่เคยเข้าสู่กระบวนการอนุมัติจริงเลย */
|
|
canDefineApprovers(): boolean {
|
|
if (this.form.value.is_draft !== true) return false
|
|
if (this.form.value.is_approve === true) return false
|
|
return !(this.currentApproveStates || []).some((s: any) => s.is_approve === true)
|
|
}
|
|
/** มีคิวผู้อนุมัติที่กำหนดไว้แล้ว — ใช้บังคับให้ต้อง "ระบุผู้อนุมัติ" ก่อนจึงจะกด "ส่งอนุมัติ" ได้ */
|
|
hasApproveQueue(): boolean {
|
|
return (this.currentApproveStates || []).some((s: any) => s.row_order === 1)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: เก็บ `approve_states` ตอนโหลดฟอร์ม**
|
|
|
|
เปิด `setUpform(res)` (บรรทัด 161) เพิ่มบรรทัดแรกในเมธอด:
|
|
|
|
```typescript
|
|
setUpform(res) {
|
|
this.currentApproveStates = res.approve_states || []
|
|
this.form.patchValue(res)
|
|
```
|
|
|
|
- [ ] **Step 5: เพิ่มเมธอด `onSetApprovers()`**
|
|
|
|
เพิ่มเมธอดนี้ต่อจาก `save_approve()` (หลังบรรทัด 781, ก่อน `add_approve()`):
|
|
|
|
```typescript
|
|
/** ให้ผู้ส่งอนุมัติเลือกเองว่าจะส่งให้ใครอนุมัติลำดับ 1, 2 แทนการดึงจาก "จัดการเมนูการอนุมัติ" ที่แอดมินตั้งค่าล่วงหน้า */
|
|
onSetApprovers() {
|
|
const existingApprovers = (this.currentApproveStates || [])
|
|
.slice()
|
|
.sort((a: any, b: any) => (a.row_order ?? 0) - (b.row_order ?? 0))
|
|
.map((s: any) => ({ personnel_uid: s.personnel_uid, display_name_th: s.display_name_th }))
|
|
const dialogRef = this.dialog.open(SetInvestConstructApproversDialogComponent, {
|
|
width: '600px',
|
|
disableClose: true,
|
|
data: { approvers: existingApprovers },
|
|
})
|
|
dialogRef.afterClosed().subscribe((approvers) => {
|
|
if (!approvers) return
|
|
this.InvestConstructSV.setApprovers(this.form.getRawValue().invest_construct_uid, approvers).pipe(
|
|
catchError(err => {
|
|
Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error')
|
|
return throwError(err)
|
|
}),
|
|
).subscribe(() => {
|
|
Swal.fire({
|
|
title: 'บันทึกผู้อนุมัติสำเร็จ',
|
|
text: 'กด "ส่งอนุมัติ" อีกครั้งเพื่อส่งเข้าสู่กระบวนการอนุมัติ',
|
|
icon: 'success',
|
|
confirmButtonText: 'ตกลง'
|
|
}).then(() => {
|
|
this.InvestConstructSV.get(this.uniquekey).subscribe((res: any) => {
|
|
this.setUpform(res)
|
|
this.cdRef.detectChanges()
|
|
})
|
|
})
|
|
})
|
|
})
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: เพิ่มปุ่มและแก้เงื่อนไขปุ่ม "ส่งอนุมัติ" เดิม (โหมด edit เท่านั้น)**
|
|
|
|
เปิด `invest-construct-form.component.html` หาบรรทัด (บรรทัด 368):
|
|
|
|
```html
|
|
<button mat-raised-button class="buttonColorApprove" *ngIf="!viewMode && state == 'edit' && form.value.is_draft == true && canApprove()" (click)="save_approve()"
|
|
color="primary" style="margin-right: 10px;">ส่งอนุมัติ</button>
|
|
```
|
|
|
|
แทนที่ด้วย (เพิ่มปุ่ม "ระบุผู้อนุมัติ" ก่อนหน้า + เพิ่ม `hasApproveQueue()` เป็นเงื่อนไขของปุ่ม
|
|
"ส่งอนุมัติ" เดิม):
|
|
|
|
```html
|
|
<button mat-raised-button color="primary"
|
|
*ngIf="!viewMode && state == 'edit' && form.value.is_draft == true && canApprove() && canDefineApprovers()"
|
|
(click)="onSetApprovers()" style="margin-right: 10px;">
|
|
<mat-icon>playlist_add_check</mat-icon>
|
|
ระบุผู้อนุมัติ
|
|
</button>
|
|
<button mat-raised-button class="buttonColorApprove" *ngIf="!viewMode && state == 'edit' && form.value.is_draft == true && canApprove() && hasApproveQueue()" (click)="save_approve()"
|
|
color="primary" style="margin-right: 10px;">ส่งอนุมัติ</button>
|
|
```
|
|
|
|
**หมายเหตุสำคัญ:** ปุ่ม "ส่งอนุมัติ" ของโหมด `add` (บรรทัด 366, `add_approve()`) และปุ่ม
|
|
"เห็นชอบ"/"ส่งแก้ไข" ของ "รองอธิการบดี" (บรรทัด 349, 355 — มีเงื่อนไข `isLastApprover` พิเศษที่
|
|
invest_asset ไม่มี) **ห้ามแตะทั้งคู่** — คนละ code path ไม่เกี่ยวกับงานนี้
|
|
|
|
- [ ] **Step 7: Build ตรวจ compile**
|
|
|
|
```bash
|
|
cd rmutr-web && ng build
|
|
```
|
|
|
|
Expected: Build ผ่านไม่มี error
|
|
|
|
---
|
|
|
|
## Task 9: ทดสอบ end-to-end บนเบราว์เซอร์จริงและ deploy
|
|
|
|
**Files:** ไม่มีไฟล์โค้ดเพิ่ม — verification เท่านั้น
|
|
|
|
- [ ] **Step 1: เช็ค VPN แล้ว deploy frontend**
|
|
|
|
```bash
|
|
nc -zv -w5 172.27.30.135 22 && cd rmutr-web && ./script.sh
|
|
```
|
|
|
|
Expected: build+rsync สำเร็จ (เช็คก่อนว่าไม่มี `ng build` ค้างจาก session อื่น
|
|
[[project_rmutr_rsync_deploy_race]])
|
|
|
|
- [ ] **Step 2: เดินตามหน้าเว็บจริงด้วยบัญชีทดสอบ**
|
|
|
|
เปิด `https://rmis.rmutr.ac.th/budget-web/app/invest-construct/add` → กรอกฟอร์มขั้นต่ำ →
|
|
"บันทึกฉบับร่าง" → เปิดแก้ไขรายการที่เพิ่งสร้าง (โหมด edit) → ตรวจว่าเห็นปุ่ม "ระบุผู้อนุมัติ" และ
|
|
ปุ่ม "ส่งอนุมัติ" **ยังไม่ขึ้น** จนกว่าจะกำหนดผู้อนุมัติก่อน → กด "ระบุผู้อนุมัติ" กรอกชื่อ 2 ลำดับ →
|
|
บันทึก → ตรวจว่าปุ่ม "ส่งอนุมัติ" ขึ้นมาแล้ว → กด "ส่งอนุมัติ" → ตรวจสถานะเปลี่ยนถูกต้อง
|
|
|
|
- [ ] **Step 3: ตรวจว่าคำขอสิ่งก่อสร้างเก่า (code "02") ไม่ได้รับผลกระทบ**
|
|
|
|
หาคำขอสิ่งก่อสร้างเก่าที่กำลังอยู่ระหว่างอนุมัติจริง (query `approve_state` ที่
|
|
`request_approve_type='02'` และ `is_approve is null`) เปิดดูในหน้าเว็บ ตรวจว่าปุ่มอนุมัติ/ปฏิเสธของ
|
|
ผู้อนุมัติเดิมยังทำงานปกติ ไม่มีปุ่ม "ระบุผู้อนุมัติ" ปนมาให้เห็น
|
|
|
|
- [ ] **Step 4: ตรวจว่าครุภัณฑ์ (invest_asset, code "05") ไม่ได้รับผลกระทบจากงานนี้**
|
|
|
|
หาคำขอครุภัณฑ์ที่ยังทดสอบ/ใช้งานอยู่ (ถ้ามี) เปิดดูในหน้าเว็บ ตรวจว่าปุ่ม "ระบุผู้อนุมัติ"/
|
|
"ส่งอนุมัติ" ของครุภัณฑ์ยังทำงานปกติเหมือนก่อนเริ่มงานนี้ (regression check ข้ามแผน เพราะทั้งคู่แชร์
|
|
`ApproveStateController` ตัวเดียวกัน)
|