Files
rmutr/docs/superpowers/plans/2026-09-08-invest-construct-self-service-approval.md
T
Nut.ไปเรื่อยandClaude Sonnet 5 24e25f8c9d docs: add implementation plan for invest-construct self-service approval
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
2026-09-08 07:08:04 +07:00

43 KiB

สิ่งก่อสร้าง (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 ก่อน
nc -zv -w5 203.158.221.28 31620

Expected: succeeded — ถ้ายัง timeout อยู่ ให้หยุดรอ VPN ก่อน ไม่ต้องเดาต่อ

  • Step 2: 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" ไม่อยู่ในผลลัพธ์ 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):

using rmutr_budget_api.Modules.RequestExpense.Services;
  • Step 2: เพิ่มเมธอด SetCustomApprovers

เพิ่มเมธอดนี้ในคลาส InvestConstructService (ต่อจากเมธอด ListEntityIdApprove หรือจุดใดก็ได้ในคลาส — คลาสนี้มี field _context อยู่แล้วชื่อเดียวกับ InvestAssetService):

/// <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
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):

using rmutr_budget_api.Modules.RequestExpense.Services;
  • Step 2: เพิ่ม endpoint

เพิ่มเมธอดนี้ในคลาส InvestConstructController (ต่อจาก SendApproveEntityId, หลังบรรทัด 243):

/// <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 (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 ที่เพิ่มไปแล้วเมื่อวาน ห้ามแก้ผิดบล็อก):

            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;):

            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
cd rmutr-api && dotnet build

Expected: Build succeeded

  • Step 3: ยืนยันด้วย git diff ว่าแตะเฉพาะ branch invest_construct เท่านั้น
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
nc -zv -w5 172.27.30.135 22 && 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_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:

[{"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: ลบข้อมูลทดสอบ
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)

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
<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
.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):

import { SetInvestConstructApproversDialogComponent } from './presenter/popup/set-invest-construct-approvers-dialog/set-invest-construct-approvers-dialog.component';

เพิ่มเข้า declarations array ต่อจาก ListApprove5Component, (บรรทัด 93):

    SetInvestConstructApproversDialogComponent,
  • Step 5: Build ตรวจ compile
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):

  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
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:

import { SetInvestConstructApproversDialogComponent } from '../../popup/set-invest-construct-approvers-dialog/set-invest-construct-approvers-dialog.component';
  • Step 2: เพิ่ม field เก็บ approve_states ปัจจุบัน

เพิ่ม field นี้ต่อจาก canSendApprove = false (บรรทัด 51):

  /** approve_states ปัจจุบันของ invest_construct นี้ (เซ็ตใน setUpform) ใช้เช็คว่ากำหนดคิวผู้อนุมัติแล้วหรือยัง */
  currentApproveStates: any[] = []
  • Step 3: เพิ่ม canDefineApprovers()/hasApproveQueue()

เพิ่มเมธอดนี้ต่อจาก canApprove() (หลังบรรทัด 59):

  /** แสดงปุ่ม "ระบุผู้อนุมัติ" เฉพาะรายการที่ยังไม่เคยเข้าสู่กระบวนการอนุมัติจริงเลย */
  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) เพิ่มบรรทัดแรกในเมธอด:

  setUpform(res) {
    this.currentApproveStates = res.approve_states || []
    this.form.patchValue(res)
  • Step 5: เพิ่มเมธอด onSetApprovers()

เพิ่มเมธอดนี้ต่อจาก save_approve() (หลังบรรทัด 781, ก่อน 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(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):

        <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 (บรรทัด 366, add_approve()) และปุ่ม "เห็นชอบ"/"ส่งแก้ไข" ของ "รองอธิการบดี" (บรรทัด 349, 355 — มีเงื่อนไข isLastApprover พิเศษที่ invest_asset ไม่มี) ห้ามแตะทั้งคู่ — คนละ code path ไม่เกี่ยวกับงานนี้

  • Step 7: Build ตรวจ compile
cd rmutr-web && ng build

Expected: Build ผ่านไม่มี error


Task 9: ทดสอบ end-to-end บนเบราว์เซอร์จริงและ deploy

Files: ไม่มีไฟล์โค้ดเพิ่ม — verification เท่านั้น

  • Step 1: เช็ค VPN แล้ว deploy frontend
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 ตัวเดียวกัน)