Files
rmutr/docs/superpowers/plans/2026-08-20-request-budget-additional-approval-round.md
T
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

1000 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ส่งอนุมัติเพิ่มเติม (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:
```csharp
public string position_name_th { get; set; }
// public int? type_id { get; set; }
}
```
Change to:
```csharp
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:
```csharp
public DateTime? updated_date { get; set; }
public string updated_name { get; set; }
}
```
Change to:
```csharp
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`:
```bash
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**
```bash
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**
```bash
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):
```csharp
public async Task<string> getApproveState(Guid? reference_uid, Guid? faculty_uid, Guid? budget_location_uid,
string code, ClaimsIdentity claimsIdentity)
{
```
to:
```csharp
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,`):
```csharp
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):
```csharp
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):
```csharp
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:
```csharp
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):
```csharp
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:
```csharp
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):
```csharp
[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**
```bash
cd /Users/nut.looknut/Project/rmutr/rmutr-api
dotnet build rmutr-budget-api.csproj -c Release
```
Expected: `0 Error(s)`.
- [ ] **Step 6: Commit**
```bash
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):
```bash
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**
```bash
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**
```bash
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:
```bash
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:
```ts
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`:
```ts
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:
```ts
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:
```ts
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()`:
```ts
} 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`:
```ts
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**
```bash
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**
```bash
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:
```html
<div class="no-data-detail" *ngIf="keyUrl != 2">
<span *ngIf="approveStatus() == true">สถานะ : อนุมัติแล้ว</span>
<span *ngIf="approveStatus() == false">สถานะ : ส่งแก้ไข</span>
</div>
```
Change to:
```html
<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):
```html
<div class="rmutr_card" *ngIf="(keyUrl == 1 && approve_button?.remark_historys?.length > 0) || (keyUrl == 3 && hasActiveStep(2))" >
```
and further down:
```html
<ng-container >
<tr *ngFor="let detail of approve_button.remark_historys;let detailIndex = index">
```
Change the card's `*ngIf` to:
```html
<div class="rmutr_card" *ngIf="(keyUrl == 1 && currentRoundRemarkHistorys().length > 0) || (keyUrl == 3 && hasActiveStep(2))" >
```
and the `*ngFor` to:
```html
<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:
```ts
@Output() onapprove = new EventEmitter();
@Output() onapprove2 = new EventEmitter();
@Output() onreject = new EventEmitter();
```
Change to:
```ts
@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:
```ts
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:
```ts
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:
```html
<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):
```html
<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:
```html
(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:
```ts
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()`):
```ts
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**
```bash
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**
```bash
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**
```bash
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**
```bash
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.