Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153wBt23EjW125ZsADwWVAs
40 KiB
ครุภัณฑ์ (invest_asset) — ผู้ส่งเลือกผู้อนุมัติเอง 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/durable-articles,
invest_asset) จากการดึงคิวผู้อนุมัติจาก config แอดมิน ("จัดการเมนูการอนุมัติ", request_approve_type="01")
เป็นให้ผู้ส่งพิมพ์ชื่อผู้อนุมัติเองก่อนกด "ส่งอนุมัติ" — mirror กลไกเดียวกับ "สรุปคำของบประมาณรายได้"
(request_expense, code "04") ทุกจุด แทนที่ไปเลย (ไม่เก็บเป็นทางเลือกคู่ขนาน)
Architecture: ไม่เพิ่มฟิลด์ is_sent ใหม่ — invest_asset.is_draft (true=ยังไม่ส่ง,
null=ส่งแล้ว) ทำหน้าที่เดียวกันอยู่แล้ว เพิ่มเมธอด SetCustomApprovers ใน InvestAssetService
(mirror RequestExpenseService.SetCustomApprovers) + endpoint ใหม่ 1 ตัว, เพิ่ม guard เช็ค
is_draft+ชื่อผู้กดใน ApproveGetEntity (endpoint กลางที่ invest_asset/invest_construct/
request_expense ใช้ร่วมกัน) เฉพาะเมื่อ request_approve_type เป็นรหัสใหม่เท่านั้น ไม่กระทบ
"01"/"02"/"03"/"04" เดิม send_approve (SendApproveEntityId) ไม่ต้องแก้เลย เพราะเช็คอยู่แล้วว่า
มี approve_state แถวไหนอยู่ก่อนหรือไม่ ถ้ามี (จาก SetCustomApprovers) จะข้ามการดึง config แอดมินให้เอง
Frontend เพิ่ม dialog "ระบุผู้อนุมัติ" (copy จาก set-approvers-dialog ของ income-report, ป้าย 2 ลำดับ
เดียวกัน: หัวหน้าหน่วยงาน/รองอธิการบดี) เสียบเข้าหน้า durable-articles-form เฉพาะโหมด edit
(โหมด add/add_approve() เป็นคนละ code path ที่ไม่ผ่าน send_approve/approve_state เลย ไม่แตะ)
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
(อ้างอิงเพิ่ม: docs/superpowers/specs/2026-09-07-approve-state-workflow-reference.md)
Global Constraints
- รหัส
request_approve_typeใหม่ต้องไม่ชนกับข้อมูลจริง — Task 1 เช็คสดกับ production DB ก่อน ทุกโค้ดถัดไปในแผนนี้เขียนโดยสมมติว่าผลเช็คคือ"05"(คาดว่าว่างจริงจากการอ่านโค้ด แต่ยังไม่ได้ยืนยันกับ DB เพราะ connection timeout ตอนออกแบบ) — ถ้า Task 1 พบว่า"05"ถูกใช้แล้ว ให้แทนที่ด้วยรหัสว่างจริงที่ เจอ ในทุกจุด ที่ระบุไว้ในแผนนี้ (มี 3 จุด:InvestAssetService.SetCustomApprovers,ApproveState.csguard, และ comment อ้างอิงใน design spec) ก่อนดำเนินการ Task ถัดไป - ระบบมี
t_approve_state/v_approve_stateตารางเดียวใช้ร่วมกันทั้งinvest_asset/invest_construct/request_expenseผ่าน FK คนละคอลัมน์ — ห้ามเปลี่ยนพฤติกรรมของ branch อื่นที่ไม่ใช่invest_assetในApproveStateController(Modules/Setting/Controllers/ApproveState.cs) โดยเด็ดขาด - คำขอครุภัณฑ์เก่าที่มี
approve_stateแบบrequest_approve_type="01"ค้างอยู่ระหว่างอนุมัติต้องเดินหน้า อนุมัติ/ปฏิเสธต่อได้ตามปกติ — guard ใหม่ทุกจุดต้องเช็คเงื่อนไขrequest_approve_type == "<รหัสใหม่>"เสมอ ไม่ใช่เช็คว่าเป็นinvest_assetเฉยๆ - โค้ดเบสนี้ไม่มี backend unit test project และ frontend spec ที่มีอยู่เป็น boilerplate ที่ไม่เคยรันจริง —
ทุก task verify ด้วย
dotnet build/ng buildให้ผ่านไม่มี error ก่อน แล้ว verify ด้วยcurlยิง endpoint จริงด้วย JWT ทดสอบ เทียบกับข้อมูลจริงใน DB (query ตรงด้วยpsycopg2) — สร้างข้อมูลทดสอบสังเคราะห์ (created_by = 'test.verify') ผ่าน SQL แล้วลบทิ้งหลังตรวจเสร็จ ไม่แตะข้อมูลจริงของผู้ใช้ - Session นี้ทำงานตรงบน
masterของทั้งrmutr-api/rmutr-webและ deploy จริงทันทีหลังแต่ละ task ผ่าน (ไม่ใช้ branch/PR) — ข้ามfinishing-a-development-branchทั้งหมด, จบแผนด้วยการยืนยันผลบน production โดยตรง - Deploy จริง: backend =
dotnet publish -c Release -r linux-x64 --self-contained falseแล้วscpไปk8-node-1(/var/www/rmutr-budget-api/) restartsystemctl 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) — เช็ค project_rmutr_rsync_deploy_race ก่อน rsync เสมอ (มีng buildค้างอยู่จาก session อื่นหรือไม่)
Task 1: ยืนยันรหัส request_approve_type ว่างจริงกับ production DB
Files: ไม่มีไฟล์โค้ด — ตรวจสอบข้อมูลเท่านั้น
- Step 1: เชื่อมต่อ DB แล้ว query รหัสที่ใช้อยู่จริง
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" ไม่อยู่ ในผลลัพธ์ ใช้ "05"
ต่อได้เลยตามที่แผนนี้เขียนไว้ทุกจุด ถ้า "05" มีอยู่แล้ว ให้เลือกรหัส 2 หลักตัวถัดไปที่ไม่อยู่ในผลลัพธ์
(เช่น "06") แล้วแทนที่คำว่า "05" ด้วยรหัสนั้นในทุก code block ของ Task 2, 4 ก่อนเริ่มเขียนโค้ดจริง
- Step 2: บันทึกผลลัพธ์
จดรหัสที่ยืนยันแล้วไว้ใช้อ้างอิงตลอดแผนที่เหลือ (ไม่ต้อง commit ที่ไหน แค่เป็น context สำหรับ ผู้ implement)
Task 2: Backend — เพิ่ม SetCustomApprovers ใน InvestAssetService
Files:
- Modify:
Modules/Invests/Services/InvestAsset.cs
Interfaces:
-
Consumes:
ApproverInputclass จากrmutr_budget_api.Modules.RequestExpense.Services(มีอยู่แล้ว, fields:Guid? personnel_uid,string display_name_th),t_approve_stateจากrmutr_budget_api.Modules.General.Databases.Models(import แล้วในไฟล์นี้) -
Produces:
Task<(bool ok, string error)> SetCustomApprovers(Guid? investAssetUid, List<ApproverInput> approvers, ClaimsIdentity claimsIdentity)— ให้ Task 3 (controller) เรียกใช้ -
Step 1: เพิ่ม using directive
เปิด Modules/Invests/Services/InvestAsset.cs เพิ่มบรรทัดนี้ต่อท้าย using เดิม (บรรทัด 13):
using rmutr_budget_api.Modules.RequestExpense.Services;
- Step 2: เพิ่มเมธอด
SetCustomApprovers
เพิ่มเมธอดนี้ในคลาส InvestAssetService (ต่อจากเมธอด ListEntityIdApprove หรือจุดใดก็ได้ในคลาส):
/// <summary>
/// ให้ผู้ส่งอนุมัติกำหนดคิวผู้อนุมัติ (ลำดับ 1, 2) เองก่อนกด "ส่งอนุมัติ" แทนที่จะดึงจาก config
/// "จัดการเมนูการอนุมัติ" ที่แอดมินต้องตั้งไว้ล่วงหน้า (getApproveState("01", ...)) — mirror
/// RequestExpenseService.SetCustomApprovers (code "04") ทุกจุด ดู
/// 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? investAssetUid,
List<ApproverInput> approvers, ClaimsIdentity claimsIdentity)
{
var entity = await _context.t_invest_asset
.FirstOrDefaultAsync(c => c.invest_asset_uid == investAssetUid);
if (entity == null) return (false, "ไม่พบรายการ");
if (entity.is_draft != true)
return (false, "รายการนี้ต้องอยู่ในสถานะฉบับร่างก่อนจึงจะกำหนดผู้อนุมัติได้");
var existing = await _context.t_approve_state
.Where(c => c.invest_asset_uid == investAssetUid).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_asset_uid = investAssetUid,
personnel_uid = a.personnel_uid,
display_name_th = a.display_name_th,
row_order = idx + 1,
is_state = idx == 0,
request_approve_type = "05",
}).ToList();
await _context.t_approve_state.AddRangeAsync(rows);
entity.current_approve_name = rows.First().display_name_th;
_context.t_invest_asset.Update(entity);
await _context.SaveChangesAsync(claimsIdentity);
return (true, null);
}
(แทนที่ "05" ด้วยรหัสจริงจาก Task 1 ถ้าไม่ใช่ "05")
- Step 3: Build ตรวจ compile
cd rmutr-api && dotnet build
Expected: Build succeeded, ไม่มี error
Task 3: Backend — เพิ่ม endpoint set_approvers ใน InvestAssetController
Files:
- Modify:
Modules/Invests/Controllers/InvestAsset.cs
Interfaces:
-
Consumes:
InvestAssetService.SetCustomApproversจาก Task 2 -
Produces:
POST /api/invest/invest_asset/set_approvers/{uid}— ให้ Task 7 (frontend service) เรียกใช้ -
Step 1: เพิ่ม using directive
เปิด Modules/Invests/Controllers/InvestAsset.cs เพิ่มต่อท้าย using เดิม (บรรทัด 16):
using rmutr_budget_api.Modules.RequestExpense.Services;
- Step 2: เพิ่ม endpoint
เพิ่มเมธอดนี้ในคลาส InvestAssetController (ต่อจาก SendApproveEntityId, หลังบรรทัด 253):
/// <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
cd rmutr-api && dotnet build
Expected: Build succeeded
Task 4: Backend — ปิดช่องโหว่อนุมัติก่อนส่งใน ApproveGetEntity
Files:
- Modify:
Modules/Setting/Controllers/ApproveState.cs
Interfaces:
-
Consumes:
t_approve_state.request_approve_type(รหัสใหม่จาก Task 1/2),t_invest_asset.is_draft -
Produces: ไม่มี (แก้ behavior ของ endpoint ที่มีอยู่แล้ว
GET api/setting/approve_state/approve/{uid}) -
Step 1: เพิ่ม guard ใน invest_asset branch ของ
ApproveGetEntity
หาบล็อกนี้ (บรรทัด ~50-59):
if (invest_asset != null)
{
getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
c.invest_asset_uid == uid && c.is_state == true);
if (getResult == null)
{
// ยังไม่เคยกด "ส่งอนุมัติ" จึงไม่มี approve_state ที่ active ให้เห็นชอบ
return BadRequest(new { description = "รายการนี้ยังไม่ได้ถูกส่งเข้าสู่กระบวนการอนุมัติ กรุณากด \"ส่งอนุมัติ\" ก่อน" });
}
if (getResult != null)
{
getResult.is_state = false;
แทนที่ด้วย (เพิ่ม guard block ก่อน getResult.is_state = false;):
if (invest_asset != null)
{
getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
c.invest_asset_uid == uid && c.is_state == true);
if (getResult == null)
{
// ยังไม่เคยกด "ส่งอนุมัติ" จึงไม่มี approve_state ที่ active ให้เห็นชอบ
return BadRequest(new { description = "รายการนี้ยังไม่ได้ถูกส่งเข้าสู่กระบวนการอนุมัติ กรุณากด \"ส่งอนุมัติ\" ก่อน" });
}
if (getResult != null)
{
// ครุภัณฑ์แบบผู้ส่งเลือกผู้อนุมัติเอง (code "05"): SetCustomApprovers สร้างแถว
// is_state=true ไว้ล่วงหน้าตั้งแต่ก่อนกด "ส่งอนุมัติ" (invest_asset.is_draft ยังเป็น
// true อยู่) ต้องกันไม่ให้ใครก็ได้กด approve ทะลุขั้นตอนไปก่อน + ต้องเป็นคนที่ถูกระบุชื่อ
// ไว้ในคิวลำดับ 1 เท่านั้นเป็นผู้กด (เหมือน request_expense code "04")
if (getResult.row_order == 1 && getResult.request_approve_type == "05")
{
if (invest_asset.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;
(แทนที่ "05" ด้วยรหัสจริงจาก Task 1 ถ้าไม่ใช่ "05" — ต้องตรงกับค่าที่ใช้ใน Task 2 เป๊ะ)
- Step 2: Build ตรวจ compile
cd rmutr-api && dotnet build
Expected: Build succeeded
Task 5: Backend — Deploy และ verify ด้วยข้อมูลทดสอบจริง
Files: ไม่มีไฟล์โค้ดเพิ่ม — deploy + verification เท่านั้น
- Step 1: Publish และ deploy
cd rmutr-api && ./script.sh
Expected: สคริปต์รันจบไม่ error, service rmutr-budget-api restart สำเร็จ
- Step 2: สร้างข้อมูลทดสอบสังเคราะห์
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_asset
(invest_asset_uid, is_draft, created_by, invest_asset_name_th, status_id)
VALUES (%s, true, 'test.verify', 'ทดสอบระบุผู้อนุมัติเอง', 1)
''', (test_uid,))
conn.commit()
print("test invest_asset_uid:", test_uid)
EOF
(ปรับคอลัมน์ตาม NOT NULL constraint จริงของตาราง ถ้า insert error ให้เช็ค schema ก่อนแล้วเติมคอลัมน์ที่ ขาดให้ครบ)
- Step 3: ทดสอบ
set_approversก่อนส่งอนุมัติ — ต้องสำเร็จ
ยิง POST /api/invest/invest_asset/set_approvers/{test_uid} ด้วย JWT ทดสอบ (login_name/display_name/
agency_uid ตาม pattern ที่ใช้ตลอด session นี้) body:
[{"display_name_th": "ทดสอบ หัวหน้าหน่วยงาน"}, {"display_name_th": "ทดสอบ รองอธิการบดี"}]
Expected: 200 OK ตรวจด้วย SQL ว่ามี t_approve_state 2 แถวผูกกับ test_uid, request_approve_type='05',
row_order 1/2, row_order=1 มี is_state=true
- Step 4: ทดสอบ approve ก่อนกด "ส่งอนุมัติ" — ต้องถูกบล็อก
ยิง GET /api/setting/approve_state/approve/{test_uid} (ตอนที่ invest_asset ยัง is_draft=true)
Expected: 400 BadRequest, message "ยังไม่ได้ส่งอนุมัติ กรุณากด "ส่งอนุมัติ" ก่อน"
- Step 5: กด "ส่งอนุมัติ" แล้วทดสอบ approve ด้วยคนละชื่อ — ต้องถูกบล็อก 403
ยิง GET /api/invest/invest_asset/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: ลบข้อมูลทดสอบ
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_asset_uid = %s', (test_uid,))
cur.execute('DELETE FROM "BUDGET".invest_asset WHERE invest_asset_uid = %s', (test_uid,))
conn.commit()
EOF
Task 6: Frontend — สร้าง dialog "ระบุผู้อนุมัติ" สำหรับครุภัณฑ์
Files:
- Create:
rmutr-web/src/app/feature/budget-request/investment-budget/durable-articles/presenter/popup/set-invest-approvers-dialog/set-invest-approvers-dialog.component.ts - Create:
.../set-invest-approvers-dialog/set-invest-approvers-dialog.component.html - Create:
.../set-invest-approvers-dialog/set-invest-approvers-dialog.component.scss - Modify:
rmutr-web/src/app/feature/budget-request/investment-budget/durable-articles/durable-articles.module.ts
Interfaces:
-
Consumes:
AgencyService.getHr()(มีอยู่แล้ว,src/app/core/service/general/agency.service),SearchAutoPipe(มาจากSharedModuleที่ import อยู่แล้วในโมดูลนี้) -
Produces:
SetInvestApproversDialogComponent— ให้ Task 8 เรียกผ่านMatDialog.open(),dialogRef.afterClosed()คืนค่า{ personnel_uid, display_name_th }[]หรือnullถ้ากดยกเลิก -
Step 1: สร้าง component ts (copy โครงจาก
feature/income-report/presenter/popup/set-approvers-dialog/set-approvers-dialog.component.tsเปลี่ยนชื่อคลาส/selector เท่านั้น เนื้อหาเหมือนกันทุกจุด)
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-approvers-dialog',
templateUrl: './set-invest-approvers-dialog.component.html',
styleUrls: ['./set-invest-approvers-dialog.component.scss']
})
export class SetInvestApproversDialogComponent {
form: FormGroup
agency$: Observable<any>
errorText: string = null
approverLabels = ['หัวหน้าหน่วยงาน', 'รองอธิการบดี']
constructor(
private fb: FormBuilder,
private agencySV: AgencyService,
public dialogRef: MatDialogRef<SetInvestApproversDialogComponent>,
@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 (copy จาก
set-approvers-dialog.component.htmlเปลี่ยนแค่คำอธิบาย.dialog-hintให้ตรงบริบทครุภัณฑ์)
<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 (copy ตรงจาก
set-approvers-dialog.component.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
เปิด durable-articles.module.ts เพิ่ม import (ต่อจากบรรทัด 45):
import { SetInvestApproversDialogComponent } from './presenter/popup/set-invest-approvers-dialog/set-invest-approvers-dialog.component';
เพิ่มเข้า declarations array (ต่อจาก ListApprove5Component, บรรทัด 99):
SetInvestApproversDialogComponent,
- Step 5: Build ตรวจ compile
cd rmutr-web && ng build
Expected: Build ผ่านไม่มี error
Task 7: Frontend — เพิ่ม setApprovers ใน InvestAssetService (Angular)
Files:
- Modify:
rmutr-web/src/app/core/service/invest/invest-asset.service.ts
Interfaces:
-
Consumes:
POST /api/invest/invest_asset/set_approvers/{uid}จาก Task 3 -
Produces:
setApprovers(id, approvers)— ให้ Task 8 เรียกใช้ -
Step 1: เพิ่มเมธอด
เปิดไฟล์ เพิ่มเมธอดนี้ต่อจาก reject(id) (บรรทัด 21):
setApprovers(id, approvers: { personnel_uid: string, display_name_th: string }[]) {
return this.http.post(`${environment.rmutrApi}/api/invest/invest_asset/set_approvers/${id}`, approvers)
}
- Step 2: Build ตรวจ compile
cd rmutr-web && ng build
Expected: Build ผ่านไม่มี error
Task 8: Frontend — เสียบปุ่ม "ระบุผู้อนุมัติ" เข้าฟอร์มครุภัณฑ์
Files:
- Modify:
rmutr-web/src/app/feature/budget-request/investment-budget/durable-articles/presenter/form/durable-articles-form/durable-articles-form.component.ts - Modify:
.../durable-articles-form.component.html
Interfaces:
-
Consumes:
SetInvestApproversDialogComponent(Task 6),InvestAssetService.setApprovers(Task 7) -
Step 1: import dialog component
เปิด durable-articles-form.component.ts เพิ่ม import ต่อจากบรรทัด 25:
import { SetInvestApproversDialogComponent } from '../../popup/set-invest-approvers-dialog/set-invest-approvers-dialog.component';
- Step 2: เพิ่ม field เก็บ approve_states ปัจจุบัน
เพิ่ม field นี้ต่อจาก canSendApprove = false (บรรทัด 45):
/** approve_states ปัจจุบันของ invest_asset นี้ (เซ็ตใน setUpform) ใช้เช็คว่ากำหนดคิวผู้อนุมัติแล้วหรือยัง */
currentApproveStates: any[] = []
- Step 3: เพิ่ม
canDefineApprovers()/hasApproveQueue()
เพิ่มเมธอดนี้ต่อจาก canApprove() (หลังบรรทัด 53):
/** แสดงปุ่ม "ระบุผู้อนุมัติ" เฉพาะรายการที่ยังไม่เคยเข้าสู่กระบวนการอนุมัติจริงเลย */
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) (บรรทัด 142) เพิ่มบรรทัดแรกในเมธอด:
setUpform(res) {
this.currentApproveStates = res.approve_states || []
this.form.patchValue(res)
- Step 5: เพิ่มเมธอด
onSetApprovers()
เพิ่มเมธอดนี้ต่อจาก save_approve() (หลังบรรทัด 788, ก่อน add_approve()):
/** ให้ผู้ส่งอนุมัติเลือกเองว่าจะส่งให้ใครอนุมัติลำดับ 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(SetInvestApproversDialogComponent, {
width: '600px',
disableClose: true,
data: { approvers: existingApprovers },
})
dialogRef.afterClosed().subscribe((approvers) => {
if (!approvers) return
this.InvestAssetSV.setApprovers(this.form.getRawValue().invest_asset_uid, approvers).pipe(
catchError(err => {
Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error')
return throwError(err)
}),
).subscribe(() => {
Swal.fire({
title: 'บันทึกผู้อนุมัติสำเร็จ',
text: 'กด "ส่งอนุมัติ" อีกครั้งเพื่อส่งเข้าสู่กระบวนการอนุมัติ',
icon: 'success',
confirmButtonText: 'ตกลง'
}).then(() => {
this.InvestAssetSV.get(this.uniquekey).subscribe((res: any) => {
this.setUpform(res)
this.cdRef.detectChanges()
})
})
})
})
}
- Step 6: เพิ่มปุ่มและแก้เงื่อนไขปุ่ม "ส่งอนุมัติ" เดิม (โหมด edit เท่านั้น)
เปิด durable-articles-form.component.html หาบรรทัด (บรรทัด 362):
<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() เป็นเงื่อนไขของปุ่ม
"ส่งอนุมัติ" เดิม บังคับให้ต้องกำหนดผู้อนุมัติก่อนเสมอ):
<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 (บรรทัด 360, add_approve()) ห้ามแตะ —
เป็น code path คนละเส้นทาง (.add() ตรงๆ ไม่ผ่าน send_approve/approve_state) ไม่เกี่ยวกับงานนี้
- Step 7: Build ตรวจ compile
cd rmutr-web && ng build
Expected: Build ผ่านไม่มี error
Task 9: ทดสอบ end-to-end บนเบราว์เซอร์จริงและ deploy
Files: ไม่มีไฟล์โค้ดเพิ่ม — verification เท่านั้น
- Step 1: Deploy frontend
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/durable-articles/add → กรอกฟอร์มขั้นต่ำ →
"บันทึกฉบับร่าง" → เปิดแก้ไขรายการที่เพิ่งสร้าง (โหมด edit) → ตรวจว่าเห็นปุ่ม "ระบุผู้อนุมัติ" และปุ่ม
"ส่งอนุมัติ" ยังไม่ขึ้น จนกว่าจะกำหนดผู้อนุมัติก่อน → กด "ระบุผู้อนุมัติ" กรอกชื่อ 2 ลำดับ → บันทึก →
ตรวจว่าปุ่ม "ส่งอนุมัติ" ขึ้นมาแล้ว → กด "ส่งอนุมัติ" → ตรวจสถานะเปลี่ยนถูกต้อง
- Step 3: ตรวจว่าคำขอครุภัณฑ์เก่า (code "01") ไม่ได้รับผลกระทบ
หาคำขอครุภัณฑ์เก่าที่กำลังอยู่ระหว่างอนุมัติจริง (query approve_state ที่ request_approve_type='01'
และ is_approve is null) เปิดดูในหน้าเว็บ ตรวจว่าปุ่มอนุมัติ/ปฏิเสธของผู้อนุมัติเดิมยังทำงานปกติ ไม่มีปุ่ม
"ระบุผู้อนุมัติ" ปนมาให้เห็น (เพราะ canDefineApprovers() เช็ค is_draft ซึ่งรายการที่ส่งไปแล้วเป็น null)