Files
rmutr/docs/superpowers/plans/2026-08-20-request-budget-additional-approval-round.md
Nut.ไปเรื่อยandClaude Sonnet 5 4471b224cf docs: add remaining implementation plans
Adds plan docs that were pending in the working tree, matching the
existing docs/superpowers/plans/ pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf6qFtiQoRuSh9zQSPF1wS
2026-09-24 19:32:52 +07:00

50 KiB
Raw Permalink Blame History

ส่งอนุมัติเพิ่มเติม (Additional Approval Round) 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: Let a submitter start a fresh approval round for a (budget_year, faculty, area) request that has already been fully approved, without touching the already-approved round's history, and show users which round (normal / additional) they're looking at.

Architecture: Add a round_no column to t_approve_state and t_remark_history (default 1, so all existing data becomes "round 1" for free). A new backend endpoint resubmit clones the admin's current approval-chain config into a fresh batch of t_approve_state rows tagged with the next round number, reusing the exact cloning code already used when a request is first created. The existing approve/reject endpoints get a small round-scoping fix so they don't accidentally touch an old, already-resolved round's rows once a second round exists. The frontend adds a "ส่งอนุมัติเพิ่มเติม" button, a round badge, and scopes its remark-history card and status checks to the current round.

Tech Stack: Backend: ASP.NET Core 8 / EF Core 8 / PostgreSQL (rmutr-api, DbContext class Db). Frontend: Angular 17 (rmutr-web).

Spec: docs/superpowers/specs/2026-08-19-request-budget-additional-approval-round-design.md

Global Constraints

  • Scope is strictly the request_expense/"03" approval flow at /app/request-budget — do not touch invest_asset/invest_construct approve_state branches.
  • Unlimited additional rounds allowed (no cap in code).
  • Neither repo has working test infrastructure for the files this plan touches (rmutr-api has no test project at all; rmutr-web's .spec.ts files for these components are unconfigured Angular-CLI stubs with no providers for the components' many injected services). Each task's "test cycle" is therefore: build/compile clean, then a manual verification step described in the task — this matches how every other fix in this session was verified, and is called out explicitly per task rather than silently skipped.
  • Every backend task must end with dotnet build rmutr-budget-api.csproj -c Release reporting 0 Error(s) before moving on.
  • Every frontend task must end with node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng build --configuration production --base-href /budget-web/ reporting exit code 0 before moving on.
  • ~/.dotnet/tools is not on PATH in a fresh shell in this environment — invoke dotnet-ef via ~/.dotnet/tools/dotnet-ef or export PATH="$PATH:$HOME/.dotnet/tools" first.
  • Deploy backend (rmutr-api/script.sh) before frontend (rmutr-web/script.sh) — the new frontend button calls an endpoint that must exist first. Both scripts use sshpass/expect with password @Dmin2241 against k8-node-1@172.27.30.135; frontend previously deployed via SSHPASS='@Dmin2241' sshpass -e bash script.sh, backend via plain bash script.sh (uses expect internally).
  • Back up the production database before Task 3's deploy — it's the first deploy that runs a schema migration (Database.Migrate() auto-applies on backend startup, per Startup.cs:444-445).

Task 1: Backend — add round_no column (models + migration)

Files:

  • Modify: Modules/Setting/Databases/Models/approve_state.cs
  • Modify: Modules/Requests/Databases/Models/remark_history.cs
  • Create: Migrations/{timestamp}_AddRoundNoToApproveStateAndRemarkHistory.cs (+ .Designer.cs, generated by EF tooling)
  • Modify: Migrations/DbModelSnapshot.cs (or module-specific snapshot — generated by EF tooling)

Interfaces:

  • Produces: approve_state.round_no (int, default 1) and remark_history.round_no (int, default 1), available on t_approve_state/v_approve_state and t_remark_history/v_remark_history since both inherit from these base classes. Task 2 and the frontend tasks read/write this property.

  • Step 1: Add round_no to the approve_state base class

Edit Modules/Setting/Databases/Models/approve_state.cs. Current tail of the approve_state class:

        public string position_name_th { get; set; }
        // public int? type_id { get; set; }
    }

Change to:

        public string position_name_th { get; set; }
        public int round_no { get; set; } = 1;
        // public int? type_id { get; set; }
    }
  • Step 2: Add round_no to the remark_history base class

Edit Modules/Requests/Databases/Models/remark_history.cs. Current tail of the remark_history class:

        public DateTime? updated_date { get; set; }
        public string updated_name { get; set; }
    }

Change to:

        public DateTime? updated_date { get; set; }
        public string updated_name { get; set; }
        public int round_no { get; set; } = 1;
    }
  • Step 3: Generate the EF Core migration

Run from /Users/nut.looknut/Project/rmutr/rmutr-api:

export PATH="$PATH:$HOME/.dotnet/tools"
dotnet ef migrations add AddRoundNoToApproveStateAndRemarkHistory --context Db

Expected: command prints Done. and creates two new files under Migrations/ named {yyyyMMddHHmmss}_AddRoundNoToApproveStateAndRemarkHistory.cs and the matching .Designer.cs, plus updates the snapshot file(s) EF maintains for the Db context.

  • Step 4: Inspect the generated migration's Up() method

Open the new {timestamp}_AddRoundNoToApproveStateAndRemarkHistory.cs file and confirm Up() contains exactly two migrationBuilder.AddColumn<int> calls — one for table t_approve_state (or whatever the mapped table name is, check against AddColumn calls for position_name_th in earlier migrations for the exact table-name casing this project uses) column round_no, and one for t_remark_history column round_no, both with defaultValue: 1. If EF generated anything else (e.g. it picked up unrelated pending model changes), stop and investigate before proceeding — do not hand-edit the migration to remove unrelated changes without understanding why they appeared.

  • Step 5: Build to confirm everything compiles
dotnet build rmutr-budget-api.csproj -c Release

Expected: 0 Error(s) (warning count may match the pre-existing baseline of 244 warnings from before this plan — new warnings related to round_no would indicate a mistake).

  • Step 6: Commit
git add Modules/Setting/Databases/Models/approve_state.cs Modules/Requests/Databases/Models/remark_history.cs Migrations/
git commit -m "feat: add round_no column to t_approve_state and t_remark_history

Enables tracking multiple approval rounds per request_expense — existing
rows default to round_no=1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNN1mgyALe9skewgUDSMLK"

Task 2: Backend — round-aware getApproveState, round-scoped approve/reject, new resubmit endpoint

Files:

  • Modify: Modules/Setting/Services/ApproveState.cs
  • Modify: Modules/Setting/Controllers/ApproveState.cs

Interfaces:

  • Consumes: t_approve_state.round_no (from Task 1).

  • Produces: ApproveStateService.getApproveState(Guid? reference_uid, Guid? faculty_uid, Guid? budget_location_uid, string code, ClaimsIdentity claimsIdentity, int roundNo = 1) (new optional 6th parameter — existing callers unaffected). New route GET /api/setting/approve_state/resubmit/{uid:guid} returning ActionResult<v_approve_state>, consumed by frontend Task 4.

  • Step 1: Add the roundNo parameter to getApproveState and stamp it on cloned rows

In Modules/Setting/Services/ApproveState.cs, change the method signature (currently):

        public async Task<string> getApproveState(Guid? reference_uid, Guid? faculty_uid, Guid? budget_location_uid,
            string code, ClaimsIdentity claimsIdentity)
        {

to:

        public async Task<string> getApproveState(Guid? reference_uid, Guid? faculty_uid, Guid? budget_location_uid,
            string code, ClaimsIdentity claimsIdentity, int roundNo = 1)
        {

Then inside the method, in the .Select(c => { var result = new t_approve_state() { ... } ...}) block, add round_no = roundNo, to the object initializer (after position_name_th = c.position_name_th,):

                        var result = new t_approve_state()
                        {
                            approve_state_uid = Guid.NewGuid(),
                            personnel_uid = c.personnel_uid,
                            display_name_th = c.display_name_th,
                            row_order = c.row_order,
                            is_state = false,
                            request_approve_type = request_approve_type.request_approve_type_code,
                            position_name_th = c.position_name_th,
                            round_no = roundNo,
                        };
  • Step 2: Add round-scoping to ApproveGetEntity's request_expense branch

In Modules/Setting/Controllers/ApproveState.cs, the if (request_expense != null) block inside ApproveGetEntity currently reads (after the earlier area_uid permission fix):

            if (request_expense != null)
            {
                getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
                    c.request_expense_uid == uid && c.is_state == true);
                if (getResult != null)
                {
                    // ขั้นแรก (หน่วยงานส่ง row_order==1): ไม่บังคับสิทธิ์ที่แอดมินตั้งไว้แล้ว
                    // แต่บังคับว่าผู้ส่งต้องอยู่ "พื้นที่" เดียวกับคำขอนี้ (area_uid ของผู้ใช้ ==
                    // budget_location_uid ของคำขอ) เพราะเจ้าหน้าที่ส่วนกลาง (เช่น กองนโยบายและแผน)
                    // คีย์/ส่งคำขอแทนหลายคณะในพื้นที่เดียวกันได้ ไม่ได้จำกัดแค่คณะต้นสังกัดตัวเอง
                    if (getResult.row_order == 1)
                    {
                        var userAreaUidRaw = (User.Identity as ClaimsIdentity)?.FindFirst("area_uid")?.Value;
                        var isOwnArea = !string.IsNullOrEmpty(userAreaUidRaw)
                            && Guid.TryParse(userAreaUidRaw, out var userAreaUid)
                            && request_expense.budget_location_uid == userAreaUid;
                        if (!isOwnArea)
                        {
                            return StatusCode(StatusCodes.Status403Forbidden,
                                "ไม่มีสิทธิ์ส่งอนุมัติ (พื้นที่ของผู้ใช้ไม่ตรงกับคำขอนี้)");
                        }
                    }
                    getResult.is_state = null;
                    getResult.is_approve = true;
                    request_expense.current_approve_name = null;
                    _db.t_approve_state.Update(getResult);
                    _db.t_request_expense.Update(request_expense);
                    await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
                    var check_all_state = await _db.t_approve_state
                        .Where(c => c.request_expense_uid == uid && c.is_approve != true).ToListAsync();
                    if (!check_all_state.Any())
                    {
                        request_expense.is_approve = true;
                        _db.t_request_expense.Update(request_expense);
                        await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
                    }

                    var check_getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
                        c.request_expense_uid == uid && c.row_order == getResult.row_order + 1);
                    if (check_getResult != null)
                    {
                        check_getResult.is_state = true;
                        request_expense.current_approve_name = check_getResult.display_name_th;
                        _db.t_request_expense.Update(request_expense);
                        _db.t_approve_state.Update(check_getResult);
                        await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
                    }
                }
            }

Replace the whole block with (adds currentRound lookup, filters the three t_approve_state queries by it):

            if (request_expense != null)
            {
                var currentRound = await _db.t_approve_state
                    .Where(c => c.request_expense_uid == uid)
                    .Select(c => (int?)c.round_no)
                    .MaxAsync() ?? 1;
                getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
                    c.request_expense_uid == uid && c.round_no == currentRound && c.is_state == true);
                if (getResult != null)
                {
                    // ขั้นแรก (หน่วยงานส่ง row_order==1): ไม่บังคับสิทธิ์ที่แอดมินตั้งไว้แล้ว
                    // แต่บังคับว่าผู้ส่งต้องอยู่ "พื้นที่" เดียวกับคำขอนี้ (area_uid ของผู้ใช้ ==
                    // budget_location_uid ของคำขอ) เพราะเจ้าหน้าที่ส่วนกลาง (เช่น กองนโยบายและแผน)
                    // คีย์/ส่งคำขอแทนหลายคณะในพื้นที่เดียวกันได้ ไม่ได้จำกัดแค่คณะต้นสังกัดตัวเอง
                    if (getResult.row_order == 1)
                    {
                        var userAreaUidRaw = (User.Identity as ClaimsIdentity)?.FindFirst("area_uid")?.Value;
                        var isOwnArea = !string.IsNullOrEmpty(userAreaUidRaw)
                            && Guid.TryParse(userAreaUidRaw, out var userAreaUid)
                            && request_expense.budget_location_uid == userAreaUid;
                        if (!isOwnArea)
                        {
                            return StatusCode(StatusCodes.Status403Forbidden,
                                "ไม่มีสิทธิ์ส่งอนุมัติ (พื้นที่ของผู้ใช้ไม่ตรงกับคำขอนี้)");
                        }
                    }
                    getResult.is_state = null;
                    getResult.is_approve = true;
                    request_expense.current_approve_name = null;
                    _db.t_approve_state.Update(getResult);
                    _db.t_request_expense.Update(request_expense);
                    await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
                    var check_all_state = await _db.t_approve_state
                        .Where(c => c.request_expense_uid == uid && c.round_no == currentRound && c.is_approve != true)
                        .ToListAsync();
                    if (!check_all_state.Any())
                    {
                        request_expense.is_approve = true;
                        _db.t_request_expense.Update(request_expense);
                        await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
                    }

                    var check_getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
                        c.request_expense_uid == uid && c.round_no == currentRound
                        && c.row_order == getResult.row_order + 1);
                    if (check_getResult != null)
                    {
                        check_getResult.is_state = true;
                        request_expense.current_approve_name = check_getResult.display_name_th;
                        _db.t_request_expense.Update(request_expense);
                        _db.t_approve_state.Update(check_getResult);
                        await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
                    }
                }
            }
  • Step 3: Add round-scoping to RejectGetEntity's request_expense branch

In the same file, the if (request_expense_approve != null) block inside RejectGetEntity currently reads:

            if (request_expense_approve != null)
            {
                getResult = request_expense_approve;
                var result_data_lists = await _db.t_approve_state
                    .Where(c => c.request_expense_uid == uid)
                    .OrderBy(o => o.row_order).ToListAsync();
                var index_current = result_data_lists.FindIndex(c => c.is_state == true);
                if (index_current > 0)
                {
                    result_data_lists[index_current].is_state = null;
                    result_data_lists[index_current].is_approve = null;
                    result_data_lists[index_current - 1].is_state = true;
                    result_data_lists[index_current - 1].is_approve = null;
                }

                // var result_data = result_data_lists.Select(c =>
                // {
                //   c.is_state = null;
                //   c.is_approve = null;
                //   if (c.row_order == 1)
                //   {
                //     c.is_state = true;
                //   }
                //   return c;
                // }).ToList();
                _db.t_approve_state.UpdateRange(result_data_lists);
                await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
                var request_expense =
                    await _db.t_request_expense.FirstOrDefaultAsync(c => c.request_expense_uid == uid);
                if (request_expense != null)
                {
                    request_expense.current_approve_name = null;
                    var approve = await _db.t_approve_state
                        .Where(c => c.request_expense_uid == uid && c.is_state == true).OrderBy(c => c.row_order)
                        .ToListAsync();
                    if (approve.Count > 0)
                    {
                        request_expense.current_approve_name = approve.First().display_name_th;
                    }

                    request_expense.is_draft = true;
                    request_expense.is_approve = false;
                    _db.t_request_expense.Update(request_expense);
                    await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
                }
            }

Replace with (adds currentRound lookup, filters the Where(request_expense_uid == uid) list-fetch and the later approve re-check by it — the request_expense_approve variable that gated entry into this branch also needs the same round filter, see Step 3b below):

            if (request_expense_approve != null)
            {
                getResult = request_expense_approve;
                var currentRound = request_expense_approve.round_no;
                var result_data_lists = await _db.t_approve_state
                    .Where(c => c.request_expense_uid == uid && c.round_no == currentRound)
                    .OrderBy(o => o.row_order).ToListAsync();
                var index_current = result_data_lists.FindIndex(c => c.is_state == true);
                if (index_current > 0)
                {
                    result_data_lists[index_current].is_state = null;
                    result_data_lists[index_current].is_approve = null;
                    result_data_lists[index_current - 1].is_state = true;
                    result_data_lists[index_current - 1].is_approve = null;
                }

                _db.t_approve_state.UpdateRange(result_data_lists);
                await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
                var request_expense =
                    await _db.t_request_expense.FirstOrDefaultAsync(c => c.request_expense_uid == uid);
                if (request_expense != null)
                {
                    request_expense.current_approve_name = null;
                    var approve = await _db.t_approve_state
                        .Where(c => c.request_expense_uid == uid && c.round_no == currentRound && c.is_state == true)
                        .OrderBy(c => c.row_order)
                        .ToListAsync();
                    if (approve.Count > 0)
                    {
                        request_expense.current_approve_name = approve.First().display_name_th;
                    }

                    request_expense.is_draft = true;
                    request_expense.is_approve = false;
                    _db.t_request_expense.Update(request_expense);
                    await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
                }
            }

(Dropped the already-commented-out dead code block during this edit — it was inert and unrelated to the round fix.)

  • Step 3b: Round-scope the query that finds request_expense_approve itself

A few lines earlier in RejectGetEntity, the initial lookup reads:

            var request_expense_approve =
                await _db.t_approve_state.FirstOrDefaultAsync(c => c.request_expense_uid == uid && c.is_state == true);

This one does not need a round filter — is_state == true is already unique per request_expense_uid across all rounds combined (only the live round ever has a row with is_state == true; every completed round's rows are left with is_state == null). Leave this line unchanged. (Documented here so the round_no on request_expense_approve, used as currentRound in Step 3, is trustworthy — it always belongs to the one truly-active round.)

  • Step 4: Add the resubmit endpoint

In Modules/Setting/Controllers/ApproveState.cs, add a new action inside ApproveStateController, directly after RejectGetEntity (before the closing } of the class):


        [Authorize]
        [HttpGet("resubmit/{uid:guid}")]
        [ProducesResponseType(StatusCodes.Status200OK)]
        [ProducesResponseType(StatusCodes.Status400BadRequest)]
        [ProducesResponseType(StatusCodes.Status404NotFound)]
        public async Task<ActionResult<v_approve_state>> ResubmitGetEntity([FromRoute] Guid uid)
        {
            var request_expense = await _db.t_request_expense.FirstOrDefaultAsync(c => c.request_expense_uid == uid);
            if (request_expense == null)
            {
                return NotFound();
            }

            if (request_expense.is_approve != true)
            {
                return BadRequest("คำขอนี้ยังไม่ได้รับการอนุมัติครบทุกขั้น ไม่สามารถส่งอนุมัติเพิ่มเติมได้");
            }

            var currentMaxRound = await _db.t_approve_state
                .Where(c => c.request_expense_uid == uid)
                .Select(c => (int?)c.round_no)
                .MaxAsync() ?? 0;
            var nextRound = currentMaxRound + 1;

            request_expense.is_approve = null;
            request_expense.current_approve_name = await _entityUidService.getApproveState(
                uid, request_expense.faculty_uid, request_expense.budget_location_uid, "03",
                User.Identity as ClaimsIdentity, nextRound);
            _db.t_request_expense.Update(request_expense);
            await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);

            var getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
                c.request_expense_uid == uid && c.round_no == nextRound && c.row_order == 1);
            return Ok(getResult);
        }
  • Step 5: Build to confirm everything compiles
cd /Users/nut.looknut/Project/rmutr/rmutr-api
dotnet build rmutr-budget-api.csproj -c Release

Expected: 0 Error(s).

  • Step 6: Commit
git add Modules/Setting/Services/ApproveState.cs Modules/Setting/Controllers/ApproveState.cs
git commit -m "feat: round-scope approve/reject and add resubmit endpoint

approve/reject's request_expense branch now filters t_approve_state
queries by the current round_no, so a second approval round's rows
can never collide with the first round's already-resolved rows.
Added GET approve_state/resubmit/{uid} to start a fresh round once
the current one is fully approved, reusing getApproveState's existing
clone-from-admin-config logic with a new roundNo parameter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNN1mgyALe9skewgUDSMLK"

Task 3: Backend — deploy to production

Files: none (deploy only)

Interfaces:

  • Consumes: Task 1 + Task 2's committed backend changes.

  • Produces: live GET /api/setting/approve_state/resubmit/{uid} endpoint and round-scoped approve/reject on production, with the round_no column added to the production database (auto-applied by Database.Migrate() on service start).

  • Step 1: Back up the production database

SSH to the server and take a pg_dump before restarting the service (the restart auto-applies the migration):

SSHPASS='@Dmin2241' sshpass -e ssh -o StrictHostKeyChecking=accept-new k8-node-1@172.27.30.135 \
  "pg_dump -Fc \$(grep -oP '(?<=Database=)[^;]+' /var/www/rmutr-budget-api/appsettings.json 2>/dev/null || echo rmutr_budget) > /tmp/rmutr_budget_pre_round_no_$(date +%Y%m%d_%H%M%S).dump 2>&1 || echo BACKUP_FAILED"

If this prints BACKUP_FAILED or an error, stop and manually determine the correct database name/credentials (check /var/www/rmutr-budget-api/appsettings.json's databases:postgres connection string on the server) before proceeding — do not deploy a migration without a confirmed-good backup.

  • Step 2: Deploy
cd /Users/nut.looknut/Project/rmutr/rmutr-api
bash script.sh

Expected: script completes printing ==> Done (uploads rmutr-budget-api.dll, restarts the rmutr-budget-api systemd service). Note from this session's experience: the scp step can appear to stall for a long time under poor network conditions without actually failing — if the command seems stuck, verify via a separate terminal/session whether the remote file timestamp and systemctl show rmutr-budget-api --property=ActiveEnterTimestamp have already moved forward before concluding it's actually hung.

  • Step 3: Verify the service restarted healthy and the migration applied
SSHPASS='@Dmin2241' sshpass -e ssh -o StrictHostKeyChecking=accept-new k8-node-1@172.27.30.135 \
  "systemctl status rmutr-budget-api --no-pager | head -10"

Expected: Active: active (running) with a recent ActiveEnterTimestamp. Then confirm the column exists:

SSHPASS='@Dmin2241' sshpass -e ssh -o StrictHostKeyChecking=accept-new k8-node-1@172.27.30.135 \
  "journalctl -u rmutr-budget-api --since '5 minutes ago' --no-pager | grep -i 'migrat\|round_no\|error\|exception' | tail -30"

Expected: no exception/error lines related to migration; absence of output here is fine (it just means nothing noteworthy was logged, which is normal for a clean migration).


Task 4: Frontend — service method + round-scoped component logic

Files:

  • Modify: src/app/core/service/request/request-expense.service.ts
  • Modify: src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.ts

Interfaces:

  • Consumes: GET /api/setting/approve_state/resubmit/{id} (from Task 3, now live in production).

  • Produces: RequestExpenseService.resubmit(id); on RequestBudgetListComponent — currentRound(): number, currentRoundApproveStates(): any[], isFullyApproved(): boolean, roundLabel(): string, currentRoundRemarkHistorys(): any[], updated hasActiveStep(rowOrder) and approveStatus() (both now round-scoped), updated resole() (stamps round_no on new remarks). Task 5's template and new button consume all of these.

  • Step 1: Add resubmit() to RequestExpenseService

Edit src/app/core/service/request/request-expense.service.ts — current full file:

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { BaseService } from '../../base/base-service';
import { environment } from 'src/environments/environment';

@Injectable({
  providedIn: 'root'
})
export class RequestExpenseService extends BaseService {

  constructor(
    public http: HttpClient
  ) {
    super('/request/request_expense',http);
   }
   approve(id) {
    return this.http.get(`${environment.rmutrApi}/api/setting/approve_state/approve/${id}`)
  }
  reject(id) {
    return this.http.get(`${environment.rmutrApi}/api/setting/approve_state/reject/${id}`)
  }
}

Change the closing of the class to add resubmit:

   approve(id) {
    return this.http.get(`${environment.rmutrApi}/api/setting/approve_state/approve/${id}`)
  }
  reject(id) {
    return this.http.get(`${environment.rmutrApi}/api/setting/approve_state/reject/${id}`)
  }
  resubmit(id) {
    return this.http.get(`${environment.rmutrApi}/api/setting/approve_state/resubmit/${id}`)
  }
}
  • Step 2: Add currentRound() and round-scope hasActiveStep()/approveStatus()

Edit src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.ts. Current relevant block:

  approveStatus(){
    let data  = this.approve_button?.approve_states.find(x=>x.row_order == 1).is_approve
    return data
  }

  /**
   * สถานะจริงของขั้นตอนอนุมัติ (is_state/row_order) backend เก็บไว้ใน approve_states[]
   * เท่านั้น — field is_state/row_order ที่ระดับบนสุดของ approve_button เป็น null เสมอ
   * (ไม่เคย sync ค่า) ห้ามใช้ approve_button?.is_state ตรงๆ ให้ใช้ hasActiveStep() แทน
   */
  hasActiveStep(rowOrder: number): boolean {
    return !!this.approve_button?.approve_states?.some(s => s.row_order === rowOrder && s.is_state === true)
  }

Replace with:

  approveStatus(){
    let data  = this.currentRoundApproveStates().find(x=>x.row_order == 1)?.is_approve
    return data
  }

  /**
   * เลขรอบอนุมัติปัจจุบัน (1 = รอบปกติ, 2+ = รอบเพิ่มเติม) — คำนวณจาก round_no สูงสุดที่มีอยู่ใน
   * approve_states[] แทนการเก็บ field แยกใน request_expense เอง กัน field ไม่ sync กับข้อมูลจริง
   */
  currentRound(): number {
    const rounds = this.approve_button?.approve_states?.map(s => s.round_no ?? 1) ?? [1]
    return Math.max(1, ...rounds)
  }

  /** approve_states[] เฉพาะของรอบปัจจุบัน — กันรอบเก่าที่อนุมัติไปแล้วมาปนกับรอบใหม่ */
  currentRoundApproveStates(): any[] {
    const round = this.currentRound()
    return this.approve_button?.approve_states?.filter(s => (s.round_no ?? 1) === round) ?? []
  }

  /**
   * สถานะจริงของขั้นตอนอนุมัติ (is_state/row_order) backend เก็บไว้ใน approve_states[]
   * เท่านั้น — field is_state/row_order ที่ระดับบนสุดของ approve_button เป็น null เสมอ
   * (ไม่เคย sync ค่า) ห้ามใช้ approve_button?.is_state ตรงๆ ให้ใช้ hasActiveStep() แทน
   */
  hasActiveStep(rowOrder: number): boolean {
    return this.currentRoundApproveStates().some(s => s.row_order === rowOrder && s.is_state === true)
  }

  /** true เมื่ออนุมัติครบทุกขั้นของรอบปัจจุบันแล้ว — ใช้คุมปุ่ม "ส่งอนุมัติเพิ่มเติม" */
  isFullyApproved(): boolean {
    return this.approve_button?.is_approve === true
  }

  /** ป้ายบอกรอบ ต่อท้ายข้อความ "สถานะ" — "รอบปกติ" (รอบ 1) หรือ "รอบเพิ่มเติมที่ N" (รอบ ≥2) */
  roundLabel(): string {
    const round = this.currentRound()
    return round <= 1 ? 'รอบปกติ' : `รอบเพิ่มเติมที่ ${round}`
  }

  /** remark_historys[] เฉพาะของรอบปัจจุบัน — กันคอมเมนต์เก่าจากรอบก่อนมาปนกับรอบใหม่ที่เพิ่งเริ่ม */
  currentRoundRemarkHistorys(): any[] {
    const round = this.currentRound()
    return this.approve_button?.remark_historys?.filter(r => (r.round_no ?? 1) === round) ?? []
  }
  • Step 3: Stamp round_no on new remarks and round-filter the remark list in resole()/del_array_dec()

Current resole() (relevant "else" branch that adds a brand-new remark) and del_array_dec():

        } else {
          const { value: text } = await Swal.fire({
            input: 'textarea',
            inputValue: ``,
            inputLabel: 'รายละเอียดที่ส่งแก้ไข',
            inputPlaceholder: 'กรอกรายละเอียดที่ส่งแก้ไข...',
            width: 1200,
            inputAttributes: {
              'aria-label': 'กรอกรายละเอียดที่ส่งแก้ไข'
            },
            showCancelButton: true
          })
    
          if (text) {
            Swal.fire('เพิ่มข้อมูลสำเร็จ')
            console.log(text)
            let data = {
              remark_history_name_th: text,
              updated_name: this.odic.user.profile.display_name,
              updated_date: new Date(),
            }
            this.approve_button.remark_historys.push(data)
            this.cdRef.detectChanges()
    
          }
        }
    
      }
      del_array_dec(i) {
        // let form_array = this.form.get('remark_historys') as FormArray
        // form_array.removeAt(i)
        this.approve_button.remark_historys.splice(i,1)
      }

Change the let data = {...} object literal to include round_no:

          if (text) {
            Swal.fire('เพิ่มข้อมูลสำเร็จ')
            console.log(text)
            let data = {
              remark_history_name_th: text,
              updated_name: this.odic.user.profile.display_name,
              updated_date: new Date(),
              round_no: this.currentRound(),
            }
            this.approve_button.remark_historys.push(data)
            this.cdRef.detectChanges()
    
          }

(del_array_dec needs no change — it deletes by array index, and the template in Task 5 will iterate the round-filtered list, so indexes stay consistent within that filtered view. See Task 5 Step 2 for how the template's *ngFor switches to a filtered getter.)

  • Step 4: Build to confirm everything compiles
cd /Users/nut.looknut/Project/rmutr/rmutr-web
node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng build --configuration production --base-href /budget-web/

Expected: exit code 0, same warning baseline as before (exceljs/oidc-client/Syncfusion CJS + selector warnings only — no new errors).

  • Step 5: Commit
git add src/app/core/service/request/request-expense.service.ts src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.ts
git commit -m "feat: round-scoped approval state helpers + resubmit service call

hasActiveStep()/approveStatus() now read only the current round's
approve_states entries (find() would otherwise match a stale round-1
entry once round 2 exists). Added currentRound()/roundLabel()/
isFullyApproved()/currentRoundRemarkHistorys() and RequestExpenseService.resubmit().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNN1mgyALe9skewgUDSMLK"

Task 5: Frontend — template (badge, remark filter, new button) + container wiring

Files:

  • Modify: src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.html
  • Modify: src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.ts
  • Modify: src/app/feature/budget-request/request-budget/container/request-budget/request-budget.container.ts
  • Modify: src/app/feature/budget-request/request-budget/container/request-budget/request-budget.container.html

Interfaces:

  • Consumes: currentRound(), roundLabel(), isFullyApproved(), currentRoundRemarkHistorys() (from Task 4). nextApproverLabel() and the Swal.fire confirm/success pattern already in on_approve() (existing, from earlier in this session).

  • Produces: new @Output() onresubmit event on RequestBudgetListComponent, new on_resubmit() handler on RequestBudgetContainer, wired end to end.

  • Step 1: Add the round badge next to the existing status text

Edit request-budget-list.component.html. Current:

        <div  class="no-data-detail" *ngIf="keyUrl != 2">
            
            <span *ngIf="approveStatus() == true">สถานะ : อนุมัติแล้ว</span>
            <span *ngIf="approveStatus() == false">สถานะ : ส่งแก้ไข</span>
         
      
      
        </div>

Change to:

        <div  class="no-data-detail" *ngIf="keyUrl != 2">
            
            <span *ngIf="approveStatus() == true">สถานะ : อนุมัติแล้ว ({{roundLabel()}})</span>
            <span *ngIf="approveStatus() == false">สถานะ : ส่งแก้ไข ({{roundLabel()}})</span>
         
      
      
        </div>
  • Step 2: Switch the remark-history table to the round-filtered list

Current (the card's *ngIf was already fixed earlier this session to require non-empty remarks; this step only changes what list the table iterates and re-derives that *ngIf from the same filtered list for consistency):

        <div class="rmutr_card" *ngIf="(keyUrl == 1 && approve_button?.remark_historys?.length > 0) || (keyUrl == 3 && hasActiveStep(2))" >

and further down:

                        <ng-container >
                            <tr *ngFor="let detail of approve_button.remark_historys;let detailIndex = index">

Change the card's *ngIf to:

        <div class="rmutr_card" *ngIf="(keyUrl == 1 && currentRoundRemarkHistorys().length > 0) || (keyUrl == 3 && hasActiveStep(2))" >

and the *ngFor to:

                        <ng-container >
                            <tr *ngFor="let detail of currentRoundRemarkHistorys();let detailIndex = index">

del_array_dec(detailIndex) inside that loop keeps calling this.approve_button.remark_historys.splice(i,1) (unchanged, from Task 4 Step 3's note) — this is safe as long as remark_historys only ever contains one round's worth of unsaved entries at a time in practice for this UI, which holds here since the card is only ever shown/editable for the currently active round. No further change needed here.

  • Step 3: Add the "ส่งอนุมัติเพิ่มเติม" button and its output event

In request-budget-list.component.ts, add a new @Output next to the existing ones. Current:

  @Output() onapprove = new EventEmitter();
  @Output() onapprove2 = new EventEmitter();
  @Output() onreject = new EventEmitter();

Change to:

  @Output() onapprove = new EventEmitter();
  @Output() onapprove2 = new EventEmitter();
  @Output() onreject = new EventEmitter();
  @Output() onresubmit = new EventEmitter();

Then add a resubmit() method next to the existing approve() method. Current:

  approve(){

    if (!this.query.budget_year_uid) return this.swSV.errText("โปรดเลือกปีงบประมาณ")
    if (!this.query.faculty_uid) return this.swSV.errText("โปรดเลือกหน่วยงาน/คณะ")

    this.onapprove.emit(1)
  }

Add directly after it:

  resubmit(){
    if (!this.query.budget_year_uid) return this.swSV.errText("โปรดเลือกปีงบประมาณ")
    if (!this.query.faculty_uid) return this.swSV.errText("โปรดเลือกหน่วยงาน/คณะ")

    this.onresubmit.emit(1)
  }

In request-budget-list.component.html, the keyUrl == 1 button block currently reads:

        <div class="flex justify-start	w-full mt-3 "  *ngIf="keyUrl == 1" >
            <button mat-raised-button class="buttonColorApprove" *ngIf="hasActiveStep(1) && isOwnArea()" style="color: white;margin-right: 10px;width:120px" (click)="approve()" [disabled]="isPeriodClosed()">ส่งอนุมัติ</button>
        </div>

Change to add the new button alongside (shown only once the current round is fully approved, mirroring the existing button's style/permission/period-closed guard):

        <div class="flex justify-start	w-full mt-3 "  *ngIf="keyUrl == 1" >
            <button mat-raised-button class="buttonColorApprove" *ngIf="hasActiveStep(1) && isOwnArea()" style="color: white;margin-right: 10px;width:120px" (click)="approve()" [disabled]="isPeriodClosed()">ส่งอนุมัติ</button>
            <button mat-raised-button class="buttonColorApprove" *ngIf="isFullyApproved() && isOwnArea()" style="color: white;margin-right: 10px;width:160px" (click)="resubmit()" [disabled]="isPeriodClosed()">ส่งอนุมัติเพิ่มเติม</button>
        </div>
  • Step 4: Wire the new output through the container template

In src/app/feature/budget-request/request-budget/container/request-budget/request-budget.container.html, find the existing (onapprove)="on_approve($event)" binding on <app-request-budget-list> and add a sibling binding directly after it:

(onapprove)="on_approve($event)"
(onresubmit)="on_resubmit($event)"
  • Step 5: Add on_resubmit() to the container

In request-budget.container.ts, on_approve() currently reads:

  on_approve(el){
    const nextApprover = this.nextApproverLabel()
    Swal.fire({
      icon: 'warning',
      title: 'ต้องการส่งอนุมัติหรือไม่',
      text: nextApprover ? `ระบบจะส่งคำขอนี้ไปให้ ${nextApprover} เป็นผู้ดำเนินการขั้นถัดไป` : undefined,
      showCancelButton: true,
      confirmButtonText: `บันทึก`,
      cancelButtonText: `ยกเลิก`,
    }).then((result) => {
      if (result.isConfirmed) {

        this.RequestExpenseSV.approve(`${this.approve_button.request_expense_uid}`).pipe(
          catchError(err => {
            Swal.fire(err.error.description, '', 'error')
            return throwError(err)
          })).subscribe(
            (res) => {
              Swal.fire({
                title: 'บันทึกสำเร็จ',
                text: nextApprover ? `ส่งไปให้ ${nextApprover} เรียบร้อยแล้ว` : undefined,
                icon: 'success',
                confirmButtonText: 'ตกลง'
              }).then(

                (result) => {
                  if (result.isConfirmed) {
                    // this.back()
                    this.onClickSearch(this.searchInput)
                  }
                }
              )
            });
      }
    });

Add a new method directly after on_approve()'s closing (before whatever comes next in the file — the commented-out dead code block that already follows on_approve()):


  on_resubmit(el){
    Swal.fire({
      icon: 'warning',
      title: 'ต้องการส่งอนุมัติเพิ่มเติมหรือไม่',
      text: 'คำขอนี้อนุมัติครบทุกขั้นไปแล้ว การส่งเพิ่มเติมจะเริ่มรอบอนุมัติใหม่ทั้งสาย (ผู้อนุมัติทุกคนต้องพิจารณาใหม่ รวมรายการเดิมที่เคยอนุมัติแล้วด้วย)',
      showCancelButton: true,
      confirmButtonText: `บันทึก`,
      cancelButtonText: `ยกเลิก`,
    }).then((result) => {
      if (result.isConfirmed) {
        this.RequestExpenseSV.resubmit(`${this.approve_button.request_expense_uid}`).pipe(
          catchError(err => {
            Swal.fire(err.error.description, '', 'error')
            return throwError(err)
          })).subscribe(
            (res) => {
              Swal.fire({
                title: 'ส่งอนุมัติเพิ่มเติมสำเร็จ',
                icon: 'success',
                confirmButtonText: 'ตกลง'
              }).then(
                (result) => {
                  if (result.isConfirmed) {
                    this.onClickSearch(this.searchInput)
                  }
                }
              )
            });
      }
    });
  }
  • Step 6: Build to confirm everything compiles
cd /Users/nut.looknut/Project/rmutr/rmutr-web
node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng build --configuration production --base-href /budget-web/

Expected: exit code 0, same warning baseline as before.

  • Step 7: Commit
git add src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.html src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.ts src/app/feature/budget-request/request-budget/container/request-budget/request-budget.container.ts src/app/feature/budget-request/request-budget/container/request-budget/request-budget.container.html
git commit -m "feat: add ส่งอนุมัติเพิ่มเติม button, round badge, round-filtered remark card

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNN1mgyALe9skewgUDSMLK"

Task 6: Frontend — deploy to production

Files: none (deploy only)

Interfaces:

  • Consumes: Task 4 + Task 5's committed frontend changes, and Task 3's already-live backend endpoint.

  • Step 1: Deploy

cd /Users/nut.looknut/Project/rmutr/rmutr-web
SSHPASS='@Dmin2241' sshpass -e bash script.sh

Expected: script completes printing Deployment complete!.

  • Step 2: Verify the deployed index.html timestamp matches
SSHPASS='@Dmin2241' sshpass -e ssh -o StrictHostKeyChecking=accept-new k8-node-1@172.27.30.135 \
  "ls -la /var/www/html/budget-web/index.html; date"

Expected: index.html's timestamp is within a minute or two of the current time printed by date.


Task 7: End-to-end smoke test on production

Files: none (manual verification only — this repo has no automated test harness for this flow, see Global Constraints)

Interfaces:

  • Consumes: everything from Tasks 1–6.

  • Step 1: Confirm existing (round 1) data still renders correctly

Have a user with the right area (e.g. คุณศุภาภรณ์, or the original ศาลายา/คณะวิทยาศาสตร์และเทคโนโลยี/ปีงบ 2569 record referenced throughout this session) load /app/request-budget with those filters. Expected: status line now reads สถานะ : ... (รอบปกติ), and behavior otherwise matches what was already verified working before this plan (card + buttons as established in this session).

  • Step 2: Drive that record through full approval if not already fully approved

Using accounts with the right area (row 1) and SEND-APPROVE permission (row 2), click through "ส่งอนุมัติ" until approve_button.is_approve === true for that record. Expected: at each step, the existing behavior (confirm dialog naming the next approver, success dialog, badge showing "รอบปกติ") continues to work exactly as before — this is the regression check that Task 2's round-scoping didn't break the existing single-round path.

  • Step 3: Trigger an additional round

Once fully approved, reload the page for that same record. Expected: the original "ส่งอนุมัติ" button is gone (as before), a new "ส่งอนุมัติเพิ่มเติม" button appears. Click it, confirm the dialog. Expected: success dialog, page refreshes, status line now reads ... (รอบเพิ่มเติมที่ 2), and the "รายละเอียดสำหรับการส่งแก้ไข" card is hidden again (no remarks yet in round 2, even though round 1 may have had some).

  • Step 4: Drive round 2 through to completion and confirm round 1 history is untouched

Approve round 2 through to full completion the same way as Step 2. At any point during this, use the Network tab (same technique used earlier in this session) to inspect the raw request_expense API response and confirm approve_states contains both round 1's rows (untouched, is_approve: true throughout) and round 2's rows with their own round_no — proving history was preserved rather than overwritten.

  • Step 5: Report results

Summarize pass/fail for each step back to the user, including a screenshot or the raw JSON snippet from Step 4 if anything looks off.