diff --git a/docs/superpowers/plans/2026-09-07-invest-asset-self-service-approval.md b/docs/superpowers/plans/2026-09-07-invest-asset-self-service-approval.md new file mode 100644 index 0000000..3a72715 --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-invest-asset-self-service-approval.md @@ -0,0 +1,737 @@ +# ครุภัณฑ์ (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.cs` guard, และ 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/`) 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`) — เช็ค + [[project_rmutr_rsync_deploy_race]] ก่อน rsync เสมอ (มี `ng build` ค้างอยู่จาก session อื่นหรือไม่) + +--- + +## Task 1: ยืนยันรหัส `request_approve_type` ว่างจริงกับ production DB + +**Files:** ไม่มีไฟล์โค้ด — ตรวจสอบข้อมูลเท่านั้น + +- [ ] **Step 1: เชื่อมต่อ DB แล้ว 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"` **ไม่อยู่** ในผลลัพธ์ ใช้ `"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: `ApproverInput` class จาก `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 approvers, ClaimsIdentity claimsIdentity)` + — ให้ Task 3 (controller) เรียกใช้ + +- [ ] **Step 1: เพิ่ม using directive** + +เปิด `Modules/Invests/Services/InvestAsset.cs` เพิ่มบรรทัดนี้ต่อท้าย using เดิม (บรรทัด 13): + +```csharp +using rmutr_budget_api.Modules.RequestExpense.Services; +``` + +- [ ] **Step 2: เพิ่มเมธอด `SetCustomApprovers`** + +เพิ่มเมธอดนี้ในคลาส `InvestAssetService` (ต่อจากเมธอด `ListEntityIdApprove` หรือจุดใดก็ได้ในคลาส): + +```csharp +/// +/// ให้ผู้ส่งอนุมัติกำหนดคิวผู้อนุมัติ (ลำดับ 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) และยังไม่เคยเข้าสู่กระบวนการอนุมัติจริงเลย +/// +public async Task<(bool ok, string error)> SetCustomApprovers(Guid? investAssetUid, + List 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()) + .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** + +```bash +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): + +```csharp +using rmutr_budget_api.Modules.RequestExpense.Services; +``` + +- [ ] **Step 2: เพิ่ม endpoint** + +เพิ่มเมธอดนี้ในคลาส `InvestAssetController` (ต่อจาก `SendApproveEntityId`, หลังบรรทัด 253): + +```csharp +/// ผู้ส่งกำหนดผู้อนุมัติเอง (ลำดับ 1, 2) ก่อนกด "ส่งอนุมัติ" — ดู SetCustomApprovers +[Authorize] +[HttpPost("set_approvers/{uid:guid}")] +[ProducesResponseType(StatusCodes.Status200OK)] +[ProducesResponseType(StatusCodes.Status400BadRequest)] +[ProducesResponseType(StatusCodes.Status404NotFound)] +public async Task SetApprovers([FromRoute] Guid uid, [FromBody] List 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` + +**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): + +```csharp + 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;`): + +```csharp + 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** + +```bash +cd rmutr-api && dotnet build +``` + +Expected: Build succeeded + +--- + +## Task 5: Backend — Deploy และ verify ด้วยข้อมูลทดสอบจริง + +**Files:** ไม่มีไฟล์โค้ดเพิ่ม — deploy + verification เท่านั้น + +- [ ] **Step 1: Publish และ deploy** + +```bash +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_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: +```json +[{"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: ลบข้อมูลทดสอบ** + +```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_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 เท่านั้น เนื้อหาเหมือนกันทุกจุด) + +```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-approvers-dialog', + templateUrl: './set-invest-approvers-dialog.component.html', + styleUrls: ['./set-invest-approvers-dialog.component.scss'] +}) +export class SetInvestApproversDialogComponent { + form: FormGroup + agency$: Observable + errorText: string = null + approverLabels = ['หัวหน้าหน่วยงาน', 'รองอธิการบดี'] + + constructor( + private fb: FormBuilder, + private agencySV: AgencyService, + public dialogRef: MatDialogRef, + @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` ให้ตรงบริบทครุภัณฑ์) + +```html +

กำหนดผู้อนุมัติ

+
+

ระบุผู้อนุมัติคำขอครุภัณฑ์ (หัวหน้าหน่วยงานจะได้รับก่อน เมื่ออนุมัติแล้วจะส่งต่อไปยังรองอธิการบดีอัตโนมัติ) — บันทึกไว้ก่อน แล้วค่อยกด "ส่งอนุมัติ" ด้านนอกอีกครั้งเพื่อส่งจริง

+ +
+
+ {{ approverLabels[i] }} * + + + search + + + {{ item.display_name_th }} + + + กรุณาระบุ{{ approverLabels[i] }} + +
+
+ +

{{ errorText }}

+
+
+ + +
+``` + +- [ ] **Step 3: สร้าง component scss** (copy ตรงจาก `set-approvers-dialog.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** + +เปิด `durable-articles.module.ts` เพิ่ม import (ต่อจากบรรทัด 45): + +```typescript +import { SetInvestApproversDialogComponent } from './presenter/popup/set-invest-approvers-dialog/set-invest-approvers-dialog.component'; +``` + +เพิ่มเข้า `declarations` array (ต่อจาก `ListApprove5Component,` บรรทัด 99): + +```typescript + SetInvestApproversDialogComponent, +``` + +- [ ] **Step 5: Build ตรวจ compile** + +```bash +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): + +```typescript + 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** + +```bash +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: + +```typescript +import { SetInvestApproversDialogComponent } from '../../popup/set-invest-approvers-dialog/set-invest-approvers-dialog.component'; +``` + +- [ ] **Step 2: เพิ่ม field เก็บ approve_states ปัจจุบัน** + +เพิ่ม field นี้ต่อจาก `canSendApprove = false` (บรรทัด 45): + +```typescript + /** approve_states ปัจจุบันของ invest_asset นี้ (เซ็ตใน setUpform) ใช้เช็คว่ากำหนดคิวผู้อนุมัติแล้วหรือยัง */ + currentApproveStates: any[] = [] +``` + +- [ ] **Step 3: เพิ่ม `canDefineApprovers()`/`hasApproveQueue()`** + +เพิ่มเมธอดนี้ต่อจาก `canApprove()` (หลังบรรทัด 53): + +```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)` (บรรทัด 142) เพิ่มบรรทัดแรกในเมธอด: + +```typescript + setUpform(res) { + this.currentApproveStates = res.approve_states || [] + this.form.patchValue(res) +``` + +- [ ] **Step 5: เพิ่มเมธอด `onSetApprovers()`** + +เพิ่มเมธอดนี้ต่อจาก `save_approve()` (หลังบรรทัด 788, ก่อน `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(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): + +```html + +``` + +แทนที่ด้วย (เพิ่มปุ่ม "ระบุผู้อนุมัติ" ก่อนหน้า + เพิ่ม `hasApproveQueue()` เป็นเงื่อนไขของปุ่ม +"ส่งอนุมัติ" เดิม บังคับให้ต้องกำหนดผู้อนุมัติก่อนเสมอ): + +```html + + +``` + +**หมายเหตุสำคัญ:** ปุ่ม "ส่งอนุมัติ" ของโหมด `add` (บรรทัด 360, `add_approve()`) **ห้ามแตะ** — +เป็น code path คนละเส้นทาง (`.add()` ตรงๆ ไม่ผ่าน `send_approve`/approve_state) ไม่เกี่ยวกับงานนี้ + +- [ ] **Step 7: Build ตรวจ compile** + +```bash +cd rmutr-web && ng build +``` + +Expected: Build ผ่านไม่มี error + +--- + +## Task 9: ทดสอบ end-to-end บนเบราว์เซอร์จริงและ deploy + +**Files:** ไม่มีไฟล์โค้ดเพิ่ม — verification เท่านั้น + +- [ ] **Step 1: Deploy frontend** + +```bash +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`)