diff --git a/docs/superpowers/plans/2026-07-06-between-year-draft-send.md b/docs/superpowers/plans/2026-07-06-between-year-draft-send.md new file mode 100644 index 0000000..324fcfb --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-between-year-draft-send.md @@ -0,0 +1,965 @@ +# Between-Year Draft → Send-to-Planning 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:** Add a draft/send-to-planning workflow to the "คำขอระหว่างปี" (between-year budget request) menu — save as draft, then explicitly select rows and click "ส่งกองนโยบายและแผน" to send them to the planning office, locking them from further edits. + +**Architecture:** No new tables/endpoints. Everything hinges on the existing `request_budget.status_id` column (0 = draft, 1 = sent) on the SAME record. The list already shows both states (once we remove a stale query filter); a new checkbox + bulk-send button flips `status_id` 0→1 via the existing generic `RequestBudgetService.updateMany()`. The list-level UI lives in `list-between-year.component.ts/html`; the record-level form lives in a large shared component (`RequestOtherExpensesFormComponent`) reused by many other project-proposal types, so every change there is gated behind `this.typeUrl == 21` (the between-year route's type) to avoid touching other flows. + +**Tech Stack:** Angular 17, Angular Material (`mat-table`, `mat-checkbox`), Angular CDK `SelectionModel`, RxJS, SweetAlert2 (via `SweetalertService`). + +## Global Constraints + +- Scope is strictly the "คำขอระหว่างปี" menu (`typeUrl == 21` / `is_between_year`). Do not change behavior for any other `typeUrl` value in shared files (`request-budget-statistics.container.ts`, `request-budget-statistics-list.component.ts/html`, `request-other-expenses-form.component.ts/html`) — every new branch must be explicitly guarded by `typeUrl == 21`. +- This repo has no meaningful unit-test culture for these Angular components (only CLI-boilerplate `.spec.ts` files exist project-wide; none for the files touched here). Per-task verification therefore uses `ng build --configuration=production` (from `/Users/nut.looknut/Project/rmutr/rmutr-web`) to catch compile/template errors, plus a manual browser check via `npm start` (dev server with HMR) — this matches how this project has actually been verified throughout this session (Playwright-driven manual checks), not fabricated unit tests. +- Follow existing code conventions exactly: raw `Swal.fire(...)` in `request-other-expenses-form.component.ts` (it does NOT use `SweetalertService`), vs. `this.swSV.confirmSave()/.updateSuccess()/.errText()` in `request-budget-statistics.container.ts` (it DOES use `SweetalertService`). Don't introduce a new pattern in either file. +- `BaseService.updateMany(data)` (PUT `{fullUrl}/s`) is the existing generic bulk-update method — reuse it, don't add a new API method. + +--- + +### Task 1: Fix stale `status_id=1` filter blocking drafts from the list + +**Files:** +- Modify: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/container/request-other-expenses/request-budget-statistics.container.ts:199` + +**Interfaces:** +- Consumes: nothing new +- Produces: `dataSourceBetweenYear$` now returns both draft (`status_id=0`) and sent (`status_id=1`) rows — later tasks (checkbox/status badge in Task 4) rely on both being present in the same list. + +- [ ] **Step 1: Read the current line to confirm context** + +The `getAll()` method currently has (line 199): +```ts + this.dataSourceBetweenYear$ = this.requestBudgetSV.queryString(`?is_between_year=true&status_id=1`) +``` +This hardcodes `status_id=1`, meaning newly-created drafts (`status_id=0`, added by Task 2) would never show up in the list at all. The search path (`onsearch()`, same file, `typeUrl == 21` branch) already has no such filter — this line is inconsistent with it and must be fixed. + +- [ ] **Step 2: Edit the line** + +Change: +```ts + this.dataSourceBetweenYear$ = this.requestBudgetSV.queryString(`?is_between_year=true&status_id=1`) +``` +to: +```ts + this.dataSourceBetweenYear$ = this.requestBudgetSV.queryString(`?is_between_year=true`) +``` + +- [ ] **Step 3: Build to confirm no errors** + +Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && ng build --configuration=production` +Expected: build succeeds with no new errors. + +- [ ] **Step 4: Commit** + +```bash +git add rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/container/request-other-expenses/request-budget-statistics.container.ts +git commit -m "fix: stop filtering out draft between-year requests from the list" +``` + +--- + +### Task 2: Set `status_id=0` on create + lock the form once sent (shared form component) + +**Files:** +- Modify: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/feature/budget-request/request/request-other-expenses/presenter/form/request-other-expenses-form/request-other-expenses-form.component.ts` + +**Interfaces:** +- Consumes: `this.typeUrl` (already set at line 391 from route data; between-year route resolves this to `21`, confirmed live via browser console), `this.state` (`'add'`/`'edit'`, set by `BaseFormV2`) +- Produces: `isLocked: boolean` class field — Task 3 (template) reads this to hide the save button and show a locked banner. + +- [ ] **Step 1: Add the `isLocked` field** + +Find (around line 199): +```ts + is_plan_log: boolean = false +``` +Change to: +```ts + is_plan_log: boolean = false + isLocked: boolean = false +``` + +- [ ] **Step 2: Set `status_id = 0` on create, scoped to `typeUrl == 21`** + +Find, at the top of `save()` (around line 4455-4458): +```ts + save() { + console.log('ททท',this.request_budget_type_url) + console.log(this.typeUrl) + + if(this.typeUrl == 2001){ +``` +Change to: +```ts + save() { + console.log('ททท',this.request_budget_type_url) + console.log(this.typeUrl) + + if (this.typeUrl == 21 && this.state === 'add') { + this.form.get('status_id').setValue(0) + } + + if(this.typeUrl == 2001){ +``` +This only ever fires for the between-year route (`typeUrl == 21`) on first creation, leaving every other `typeUrl` branch (including the `typeUrl == 2001` review flow right below, which manages its own `status_id` independently) untouched. On `'edit'` saves of an existing draft, `status_id` is left as whatever was loaded (already `0`), so this is a no-op there too. + +- [ ] **Step 3: Lock the form when an already-sent between-year record loads** + +Find, in `setUpform(res)` (around line 975-990): +```ts + setUpform(res) { + console.log(res) + this.change_year(res.budget_year_uid) + if (res.budget_strategy_uid) this.change_budget_strategy_edit(res.budget_strategy_uid) + if (res.budget_strategy_target_uid) this.change_budget_strategy_target_edit(res.budget_strategy_target_uid) + if (res.budget_reform_uid) this.change_budget_reform_edit(res.budget_reform_uid) + if (res.budget_reform_target_uid) this.change_budget_reform_target_edit(res.budget_reform_target_uid) + if (res.budget_economic_uid) this.change_budget_economic_edit(res.budget_economic_uid) + if (res.budget_economic_target_uid) this.change_budget_economic_target_edit(res.budget_economic_target_uid) + if (res.budget_strategy_faculty_uid) this.change_budget_strategy_faculty_strategic_edit(res.budget_strategy_faculty_uid) + if (res.budget_unit_uid) this.change_budget_unit_edit(res.budget_unit_uid) + if (res.service_insurance_level_uid) this.on_search_Insurance_fac(res.service_insurance_level_uid) + if (res.service_insurance_faculty_uid) this.on_search_Insurance(res.service_insurance_faculty_uid) + this.click_faculty_edit(res) + this.form.patchValue(res) +``` +Change the last line (`this.form.patchValue(res)`) to add the lock check right after it: +```ts + this.click_faculty_edit(res) + this.form.patchValue(res) + + if (this.typeUrl == 21) { + this.isLocked = res.status_id !== 0 + if (this.isLocked) { + this.form.disable() + } + } +``` +`setUpform` is shared by every project type, so this must stay guarded by `typeUrl == 21` — for any other type, `isLocked` stays `false` and `form.disable()` never runs, preserving current behavior exactly. + +- [ ] **Step 4: Build to confirm no errors** + +Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && ng build --configuration=production` +Expected: build succeeds with no new errors. + +- [ ] **Step 5: Commit** + +```bash +git add rmutr-web/src/app/feature/budget-request/request/request-other-expenses/presenter/form/request-other-expenses-form/request-other-expenses-form.component.ts +git commit -m "feat: save between-year requests as draft (status_id=0) and lock the form once sent" +``` + +--- + +### Task 3: Split the save button into "บันทึก" / "บันทึกฉบับร่าง" and show a locked banner + +**Files:** +- Modify: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/feature/budget-request/request/request-other-expenses/presenter/form/request-other-expenses-form/request-other-expenses-form.component.html:876-877` + +**Interfaces:** +- Consumes: `typeUrl`, `isLocked` (from Task 2), `save()` (unchanged method — same call for both buttons) +- Produces: nothing new for later tasks (this is the leaf-most template change) + +- [ ] **Step 1: Edit the button block** + +Find (lines 876-877): +```html +     + +``` +Change to: +```html + ส่งกองนโยบายและแผนแล้ว — ไม่สามารถแก้ไขได้ +     + + +``` +The original button's `*ngIf` now excludes `typeUrl == 21` (so between-year never shows the generic "บันทึก" label), and a new button shows "บันทึกฉบับร่าง" only for between-year and only while unlocked (draft). When locked, neither save button renders — only the banner and "ยกเลิก". + +- [ ] **Step 2: Build to confirm no errors** + +Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && ng build --configuration=production` +Expected: build succeeds with no new errors. + +- [ ] **Step 3: Manual verification** + +Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && npm start`, then in a browser: +- Open the "คำขอระหว่างปี" menu → "เพิ่มคำขอระหว่างปี" (add). Confirm the button reads "บันทึกฉบับร่าง" (not "บันทึก"). +- Save it, then edit that same record. Confirm "บันทึกฉบับร่าง" still shows (not locked yet — Task 5 is what flips it to sent). + +- [ ] **Step 4: Commit** + +```bash +git add rmutr-web/src/app/feature/budget-request/request/request-other-expenses/presenter/form/request-other-expenses-form/request-other-expenses-form.component.html +git commit -m "feat: label between-year save button as draft and show locked banner when sent" +``` + +--- + +### Task 4: Checkbox column, status badge, and locked delete icon in the list + +**Files:** +- Modify: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/list/request-budget-statistics-list/list-between-year/list-between-year.component.ts` +- Modify: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/list/request-budget-statistics-list/list-between-year/list-between-year.component.html` +- Modify: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/list/request-budget-statistics-list/list-between-year/list-between-year.component.scss` + +**Interfaces:** +- Consumes: `dataSource` input rows now include both `status_id: 0` (draft) and `status_id: 1` (sent) rows (Task 1), each row has `is_ok`/`is_modify`/`status_id` fields already returned by the API. +- Produces: `@Output() onsend = new EventEmitter()` — emits the array of selected draft rows when "ส่งกองนโยบายและแผน" is clicked. Task 5 wires this output up through the presenter list and container. + +- [ ] **Step 1: Add `SelectionModel` and the send output to the component class** + +Find the full current file: +```ts +import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Input, Output, OnChanges, SimpleChanges } from '@angular/core'; +import { PageEvent } from '@angular/material/paginator'; +import { Router } from '@angular/router'; +import { BaseList } from 'src/app/core/base/base-list'; +import { RequestPlanUidService } from 'src/app/core/service/request-budget/request-plan-uid.service'; + +@Component({ + selector: 'app-list-between-year', + templateUrl: './list-between-year.component.html', + styleUrls: ['./list-between-year.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ListBetweenYearComponent extends BaseList implements OnInit, OnChanges { + + @Input() dataSource: any = []; + @Output() onchange = new EventEmitter(); + @Output() onedit = new EventEmitter(); + @Output() ondelete = new EventEmitter(); + @Output() onexcel = new EventEmitter(); + data + + constructor( + private router: Router, + private RequestPlanUidSV: RequestPlanUidService, + ) { + super(); + } + + ngOnChanges(changes: SimpleChanges): void { + if ('dataSource' in changes && changes?.dataSource?.currentValue) { + this.data = this.dataSource; + this.dataSource = this.updateMatTable(this.dataSource ? this.dataSource : []); + } + } + + ngOnInit(): void { + } + + add() { + this.RequestPlanUidSV.is_between_year$.next(true); + this.RequestPlanUidSV.request_budget_type$.next('ae9afcdb-a8d6-4be6-b440-931e7a27fa73'); + this.router.navigate(['app/request-budget-between-year/add-between-year/add']); + } + + edit(val) { + this.router.navigate(['app/request-budget-between-year/add-between-year/edit', val.request_budget_uid]); + } + + delete(val) { + this.ondelete.emit(val); + } + + change(event: PageEvent) { + const page: number = event.pageIndex + 1; + const table: any = { page, size: event.pageSize }; + this.onchange.emit(table); + } + + excel(val) { + this.onexcel.emit(val); + } + +} +``` + +Replace it entirely with: +```ts +import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Input, Output, OnChanges, SimpleChanges } from '@angular/core'; +import { SelectionModel } from '@angular/cdk/collections'; +import { PageEvent } from '@angular/material/paginator'; +import { Router } from '@angular/router'; +import { BaseList } from 'src/app/core/base/base-list'; +import { RequestPlanUidService } from 'src/app/core/service/request-budget/request-plan-uid.service'; + +@Component({ + selector: 'app-list-between-year', + templateUrl: './list-between-year.component.html', + styleUrls: ['./list-between-year.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ListBetweenYearComponent extends BaseList implements OnInit, OnChanges { + + @Input() dataSource: any = []; + @Output() onchange = new EventEmitter(); + @Output() onedit = new EventEmitter(); + @Output() ondelete = new EventEmitter(); + @Output() onexcel = new EventEmitter(); + @Output() onsend = new EventEmitter(); + selection = new SelectionModel(true, []); + data + + constructor( + private router: Router, + private RequestPlanUidSV: RequestPlanUidService, + ) { + super(); + } + + ngOnChanges(changes: SimpleChanges): void { + if ('dataSource' in changes && changes?.dataSource?.currentValue) { + this.data = this.dataSource; + this.dataSource = this.updateMatTable(this.dataSource ? this.dataSource : []); + this.selection.clear(); + } + } + + ngOnInit(): void { + } + + add() { + this.RequestPlanUidSV.is_between_year$.next(true); + this.RequestPlanUidSV.request_budget_type$.next('ae9afcdb-a8d6-4be6-b440-931e7a27fa73'); + this.router.navigate(['app/request-budget-between-year/add-between-year/add']); + } + + edit(val) { + this.router.navigate(['app/request-budget-between-year/add-between-year/edit', val.request_budget_uid]); + } + + delete(val) { + this.ondelete.emit(val); + } + + change(event: PageEvent) { + const page: number = event.pageIndex + 1; + const table: any = { page, size: event.pageSize }; + this.onchange.emit(table); + } + + excel(val) { + this.onexcel.emit(val); + } + + private draftRows(): any[] { + return (this.data || []).filter((x: any) => x.status_id === 0); + } + + isAllSelected(): boolean { + const draft = this.draftRows(); + return draft.length > 0 && this.selection.selected.length === draft.length; + } + + masterToggle(): void { + if (this.isAllSelected()) { + this.selection.clear(); + return; + } + this.selection.select(...this.draftRows()); + } + + checkboxLabel(row?: any): string { + if (!row) { + return `${this.isAllSelected() ? 'deselect' : 'select'} all`; + } + return `${this.selection.isSelected(row) ? 'deselect' : 'select'} row`; + } + + sendToStrategic(): void { + this.onsend.emit(this.selection.selected); + } + +} +``` +Selection is cleared on every `dataSource` change (Angular gives new row object instances on every refresh, so stale selected references would otherwise linger); only draft (`status_id === 0`) rows are selectable, matching the same "already-sent rows can't be selected" pattern used by `list6`/`list24` elsewhere in this codebase. + +- [ ] **Step 2: Add the checkbox column, fix the status column, lock the delete icon, add the send button** + +Find the full current file: +```html +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ edit + delete + ปีงบฯ + {{x.budget_year_name_th}} + พื้นที่ + {{x.budget_location_name_th}} + หน่วยงาน/คณะ + {{x.responsible_faculty_name_th}} + ชื่อโครงการ + {{x.project_name_th}} + ผลผลิต + {{x.budget_project_name_th}} + ด้าน + {{x.budget_topic_name_th}} + ประเภทคำขอ + คำขอระหว่างปี + วงเงิน (บาท) + {{x.total_budget | number:'1.2-2'}} +
+
+ + +
+``` + +Replace it entirely with: +```html +
+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + edit + delete + delete + ปีงบฯ + {{x.budget_year_name_th}} + พื้นที่ + {{x.budget_location_name_th}} + หน่วยงาน/คณะ + {{x.responsible_faculty_name_th}} + ชื่อโครงการ + {{x.project_name_th}} + ผลผลิต + {{x.budget_project_name_th}} + ด้าน + {{x.budget_topic_name_th}} + ประเภทคำขอ + คำขอระหว่างปี + สถานะ + ตรวจสอบแล้ว + ส่งแก้ไข + ส่งงานแผน + ร่าง + วงเงิน (บาท) + {{x.total_budget | number:'1.2-2'}} +
+
+ + +
+``` + +- [ ] **Step 3: Add the checkbox column width and locked-icon styles** + +Find, in the `.scss` file: +```scss +.action-icon { + font-size: 17px !important; + width: 17px !important; + height: 17px !important; + color: #f8a300; + cursor: pointer; + display: inline-block; + margin: 0 4px; + transition: opacity 0.15s; + vertical-align: middle; + + &:hover { opacity: 0.7; } + + &--delete { + color: #ef4444; + } +} +``` +Change to: +```scss +.action-icon { + font-size: 17px !important; + width: 17px !important; + height: 17px !important; + color: #f8a300; + cursor: pointer; + display: inline-block; + margin: 0 4px; + transition: opacity 0.15s; + vertical-align: middle; + + &:hover { opacity: 0.7; } + + &--delete { + color: #ef4444; + } + + &--disabled { + color: #d1d5db; + cursor: not-allowed; + } +} + +.col-checkbox { + width: 44px !important; + min-width: 44px !important; + max-width: 44px !important; + text-align: center !important; +} +``` +Also find, near the top of the file: +```scss +.btn-add { + display: flex; + align-items: center; + gap: 4px; + font-size: 13px; + font-weight: 600; +} +``` +Change to: +```scss +.btn-add { + display: flex; + align-items: center; + gap: 4px; + font-size: 13px; + font-weight: 600; +} + +.btn-send { + display: flex; + align-items: center; + gap: 4px; + font-size: 13px; + font-weight: 600; +} +``` + +- [ ] **Step 4: Build to confirm no errors** + +Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && ng build --configuration=production` +Expected: build succeeds with no new errors. + +- [ ] **Step 5: Commit** + +```bash +git add rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/list/request-budget-statistics-list/list-between-year/ +git commit -m "feat: add checkbox selection, status badge, and locked delete icon to between-year list" +``` + +--- + +### Task 5: Wire the send action end-to-end and implement the bulk status update + +**Files:** +- Modify: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/list/request-budget-statistics-list/request-budget-statistics-list.component.ts` +- Modify: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/list/request-budget-statistics-list/request-budget-statistics-list.component.html` +- Modify: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/container/request-other-expenses/request-budget-statistics.container.html` +- Modify: `/Users/nut.looknut/Project/rmutr/rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/container/request-other-expenses/request-budget-statistics.container.ts` + +**Interfaces:** +- Consumes: `list-between-year`'s `@Output() onsend` (Task 4) — emits `any[]` of selected draft rows. +- Produces: nothing further downstream — this is the final task; the sent rows automatically appear in the existing "ตรวจสอบคำเสนอขอโครงการ" menu (`check-project-proposal`) because that menu already queries `status_id=1` on the same table (`request_budget_and_strategic`), unchanged by this plan. + +- [ ] **Step 1: Add the output + forwarding method to the presenter list component** + +Find, in `request-budget-statistics-list.component.ts`: +```ts + @Output() onsave22 = new EventEmitter(); + @Output() onsendPlan22 = new EventEmitter(); +``` +Change to: +```ts + @Output() onsave22 = new EventEmitter(); + @Output() onsendPlan22 = new EventEmitter(); + @Output() onsendBetweenYear = new EventEmitter(); +``` + +Find: +```ts + save22(val){ + this.onsave22.emit(val) + } + + sendPlan22(val){ + this.onsendPlan22.emit(val) + } +``` +Change to: +```ts + save22(val){ + this.onsave22.emit(val) + } + + sendPlan22(val){ + this.onsendPlan22.emit(val) + } + + sendBetweenYear(val){ + this.onsendBetweenYear.emit(val) + } +``` + +- [ ] **Step 2: Wire the child `(onsend)` into the new method in the presenter list's template** + +Find, in `request-budget-statistics-list.component.html`: +```html + + + +``` +Change to: +```html + + + +``` + +- [ ] **Step 3: Wire the presenter list's `(onsendBetweenYear)` into the container's template** + +Find, in `request-budget-statistics.container.html`: +```html +(onedit)="onedit($event)" +(onedit_request_budget)="onedit_request_budget($event)" +(ondelete)="ondelete($event)" +(onchange)="onchange($event)" +``` +Change to: +```html +(onedit)="onedit($event)" +(onedit_request_budget)="onedit_request_budget($event)" +(ondelete)="ondelete($event)" +(onchange)="onchange($event)" +(onsendBetweenYear)="sendBetweenYearToStrategic($event)" +``` + +- [ ] **Step 4: Implement the bulk-send handler in the container** + +Find, in `request-budget-statistics.container.ts`, the `ondelete(value)` method: +```ts + ondelete(value){ + // console.log(value) + this.swSV.confirm().pipe( + filter(x => x.isConfirmed), + concatMap(() => { + const delete$ = (this.typeUrl == 7 || this.typeUrl == 22 || this.typeUrl == 9 || this.typeUrl == 11) + ? this.requestBudgetAnalysis5SV.deleteData(value.request_budget_analysis_5_uid) + : this.requestBudgetSV.deleteData(value.request_budget_uid) + return delete$.pipe( + tap(() => this.swSV.removeSuccess()), + tap(() => this.ngOnInit()), + tap(() => this.cdRef.detectChanges()) + ) + }) + ).subscribe() + } +``` +Add a new method directly after it: +```ts + ondelete(value){ + // console.log(value) + this.swSV.confirm().pipe( + filter(x => x.isConfirmed), + concatMap(() => { + const delete$ = (this.typeUrl == 7 || this.typeUrl == 22 || this.typeUrl == 9 || this.typeUrl == 11) + ? this.requestBudgetAnalysis5SV.deleteData(value.request_budget_analysis_5_uid) + : this.requestBudgetSV.deleteData(value.request_budget_uid) + return delete$.pipe( + tap(() => this.swSV.removeSuccess()), + tap(() => this.ngOnInit()), + tap(() => this.cdRef.detectChanges()) + ) + }) + ).subscribe() + } + + sendBetweenYearToStrategic(value: any[]){ + const selected = (value || []).filter(v => v?.status_id === 0) + if (selected.length === 0) { + this.swSV.errText('กรุณาเลือกรายการที่ต้องการส่ง') + return + } + this.swSV.confirmSave('ต้องการส่งรายการที่เลือกไปกองนโยบายและแผนหรือไม่?').pipe( + filter(x => x.isConfirmed), + concatMap(() => { + const putData = selected.map(v => ({ ...v, status_id: 1, is_examine: true })) + return this.requestBudgetSV.updateMany(putData).pipe( + catchError(err => { + this.swSV.errText(err?.error?.description || 'ส่งไม่สำเร็จ') + return throwError(err) + }) + ) + }), + tap(() => this.swSV.updateSuccess()), + tap(() => this.getAll()), + tap(() => this.cdRef.detectChanges()) + ).subscribe() + } +``` +`filter`, `concatMap`, `tap`, `catchError`, and `throwError` are already imported at the top of this file (used throughout the other handlers above), so no new imports are needed. + +- [ ] **Step 5: Build to confirm no errors** + +Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && ng build --configuration=production` +Expected: build succeeds with no new errors. + +- [ ] **Step 6: Commit** + +```bash +git add rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/list/request-budget-statistics-list/request-budget-statistics-list.component.ts rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/presenter/list/request-budget-statistics-list/request-budget-statistics-list.component.html rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/container/request-other-expenses/request-budget-statistics.container.html rmutr-web/src/app/feature/budget-request/request/request-budget-statistics/container/request-other-expenses/request-budget-statistics.container.ts +git commit -m "feat: wire checkbox send action to bulk-update between-year requests to sent status" +``` + +--- + +### Task 6: End-to-end manual verification + +**Files:** none (verification only) + +**Interfaces:** none + +- [ ] **Step 1: Start the dev server** + +Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && npm start` + +- [ ] **Step 2: Draft creation** + +In the browser, open "แผนงานและโครงการ > คำขอระหว่างปี" → "เพิ่มคำขอระหว่างปี" → fill required fields → click "บันทึกฉบับร่าง". +Expected: record appears in the list with badge "ร่าง", checkbox enabled, both "แก้ไข" and "ลบ" icons active. + +- [ ] **Step 3: Edit a draft** + +Click "แก้ไข" on the drafted row. +Expected: form loads fully editable, button still reads "บันทึกฉบับร่าง", no locked banner. + +- [ ] **Step 4: Send to planning** + +Back in the list, tick the checkbox on 1-2 draft rows → click "ส่งกองนโยบายและแผน" → confirm the dialog. +Expected: success toast, rows now show badge "ส่งงานแผน", checkbox greyed out and unchecked, "ลบ" icon greyed out/disabled, "แก้ไข" icon still clickable. + +- [ ] **Step 5: View a sent (locked) record** + +Click "แก้ไข" on a now-sent row. +Expected: form loads with all fields disabled, red banner "ส่งกองนโยบายและแผนแล้ว — ไม่สามารถแก้ไขได้" visible, no save button, only "ยกเลิก". + +- [ ] **Step 6: Confirm it reached the planning menu** + +Open "แผนงานและโครงการ > ตรวจสอบคำเสนอขอโครงการ" → tab "จัดการคำเสนอขอ". +Expected: the just-sent project(s) appear there (this is existing, unmodified behavior — confirms `status_id=1` propagation works end-to-end). + +- [ ] **Step 7: Regression check on an unrelated menu** + +Open "แผนงานและโครงการ > ต้นฉบับเสนอโครงการ ง.5" (typeUrl 1, uses the same shared form component) → add or edit a record. +Expected: behaves exactly as before — plain "บันทึก" button (not "บันทึกฉบับร่าง"), no locked banner, saves normally. This confirms the `typeUrl == 21` guards in Task 2/3 didn't leak into other project types. diff --git a/docs/superpowers/plans/2026-08-05-income-budget-report-native-group-a-remediation.md b/docs/superpowers/plans/2026-08-05-income-budget-report-native-group-a-remediation.md new file mode 100644 index 0000000..d0b24c2 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-income-budget-report-native-group-a-remediation.md @@ -0,0 +1,1272 @@ +# Income Budget Report Native (Group A) — Remediation 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:** Fix the Critical/Important defects found by the final whole-branch review of `docs/superpowers/plans/2026-08-05-income-budget-report-native-group-a.md` (both the backend and frontend halves), and close the scope gaps the human explicitly approved fixing now rather than deferring: (1) seed *every* editable amount field so nothing is silently dropped, (2) source ร.4/ร.5 (`invest_asset_request_information`/`invest_construct_request_information`) into the native tree so the "ค่าครุภัณฑ์"/"ค่าที่ดินและสิ่งก่อสร้าง" leaf-tables aren't permanently empty, (3) generate a real government-form-styled `.xlsx` export instead of a flat 2-column dump, and (4) dual-write type-1 drafts into `revenue_draft_committee` so type-2's picker and the report list keep working end to end, with legacy pre-existing rows still reachable. + +**Architecture:** Continues directly on the two existing worktrees/branches from the Group A plan (do not create new worktrees — this is a fix-forward continuation of `worktree-income-budget-report-api` in both `rmutr-api` and `rmutr-web`). No new tables. Extends `income_budget_report` with one nullable link column, extends `IncomeBudgetReportService.CreateDraft` to seed every schema-defined line (not just personnel ones) and to mirror a `revenue_draft_committee` row, replaces the flat xlsx exporter with a structured one, and extends the frontend list/edit flow to union native + legacy data sources. + +**Tech Stack:** ASP.NET Core 8 / EF Core 8 / Npgsql (manual SQL migrations only — no `dotnet ef migrations add`), EPPlus 6.2.4, Angular / RxJS, PostgreSQL (`"BUDGET"` schema). + +## Global Constraints + +- Manual SQL migrations only, in `rmutr-api/Migrations/*.sql`, applied directly to production Postgres (`rmutr_budget_050625` at `203.158.221.28:31620`) via `psql`. `dotnet ef migrations add` is forbidden in this codebase (pre-existing unrelated EF migration-history drift). +- No automated test framework exists in either repo. Every task's verification step is a concrete manual check (`dotnet build`, `psql` query, `curl`, or browser check) — run it and read the actual output before moving on. +- All monetary fields end in `_amount` or `_price` so the project-wide `OnModelCreating` convention (`rmutr-api/Databases/Features/Db.cs:91-99`) automatically applies `numeric(18,4)` precision — do not set precision manually. `income_budget_report_line.amount`/`original_amount` are already plain `numeric` columns per the approved Task-3 deviation from the original plan — do not change their SQL type. +- **EF Core `t_`/`v_` FK gotcha:** when adding a relationship between two entities that have `t_`/`v_` write/read-view variants, `HasOne/WithMany/HasForeignKey` must be configured on BOTH the `t_` and `v_` sides in `Db.cs`, or EF creates a broken shadow FK causing a runtime 500. This plan avoids the gotcha entirely by adding only plain scalar `Guid?` columns (no navigation properties) — do not add navigation properties for `income_budget_report.revenue_draft_committee_uid`. +- DI registration for every new service must go in a `Modules//Configures/.cs` file using the `public static class XxxCollectionExtension { public static IServiceCollection AddXxxServices(this IServiceCollection services) { ... return services; } }` pattern, invoked from `Startup.cs`'s service-registration block (around lines 155-183). This is how every other module in the codebase registers its services — `IncomeBudgetReportService` currently has no such file, which is Task 1 of this plan. +- Newtonsoft.Json via `AddNewtonsoftJson()` with no camelCase policy — new DTO properties consumed by the frontend need explicit `[JsonProperty("camelCaseName")]` attributes, exactly like `Modules/IncomeBudgetReport/Config/ReportTreeNode.cs` already does. +- Frontend error handling convention for HTTP calls in this component: `catchError(err => { Swal.fire(err.error.description, '', 'error'); return throwError(err); })`, matching `saveComplete()` in `income-budget-report.component.ts`. Use this pattern for every new `.subscribe()` this plan adds to that file. +- `Helper.GetDataFilter(identity, new List{"agency_uid"})` (external `SeventyOneDev.Utilities` package, no local source) returns the caller's agency uid(s) as `List`; empty list = unrestricted. Existing convention: `.Where(a => a != Guid.Empty).ToList()` before using it as a filter. +- Types 2-10 must remain byte-for-byte behaviorally unchanged by this plan — every task that touches shared files (`income-budget-report.component.ts`, `income-budget-report-list.component.ts`) must be additive/branching only, never modifying the existing type 2-10 code paths. + +--- + +## File Map + +**Backend (`rmutr-api/.claude/worktrees/income-budget-report-api`):** +- Create: `Modules/IncomeBudgetReport/Configures/IncomeBudgetReport.cs` — DI registration (Task 1) +- Create: `Migrations/add_income_budget_report_remediation.sql` — unique index, link column (Task 2) +- Modify: `Modules/IncomeBudgetReport/Databases/Models/income_budget_report/income_budget_report.cs` — add `revenue_draft_committee_uid` scalar (Task 2) +- Modify: `Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs` — seed all lines, owner_agency_uid stamping, dual-write (Tasks 3, 4, 5, 8) +- Modify: `Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs` — leaf-amount key list + output-label matcher (Tasks 3, 4) +- Modify: `Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs` — ownership guard on `PUT lines`, new exporter (Tasks 6, 7) +- Create: `Modules/IncomeBudgetReport/Services/IncomeBudgetReportXlsxExporter.cs` — structured export (Task 7) + +**Frontend (`rmutr-web/.claude/worktrees/income-budget-report-api`):** +- Modify: `src/app/feature/income/draft/income-budget-report1/income-budget-report1-list/income-budget-report-list.component.ts` — union legacy rows (Task 9) +- Modify: `src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.ts` — legacy-edit branch, error/loading handling, pendingChanges reset (Task 10) +- Modify: `src/app/shared/components/budget-report-tree/budget-report-tree-node.component.ts`/`.html`/`.scss` — decimal-input fix, indentation fix (Task 10) + +--- + +### Task 1: Register `IncomeBudgetReportService` in DI + +**Files:** +- Create: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Configures/IncomeBudgetReport.cs` +- Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Startup.cs` + +**Why this is Critical:** `IncomeBudgetReportController`'s constructor takes `IncomeBudgetReportService` by concrete type. No `Configures/*.cs` file exists for this module and `Startup.cs` never calls an `AddIncomeBudgetReportServices()`. Every request to any endpoint on this controller — including the inherited `BaseUidController` CRUD/list routes the frontend list page depends on — throws `InvalidOperationException: Unable to resolve service for type 'IncomeBudgetReportService'` and 500s. This was never caught because the plan had no DI-registration verification step and Task 8's swagger-based check only enumerates routes, never activates the controller. + +**Interfaces:** +- Consumes: `IncomeBudgetReportService` (existing, `Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs`) +- Produces: nothing new consumed by later tasks — this just makes the existing controller constructible. + +- [ ] **Step 1: Create the Configures file** + +```csharp +using Microsoft.Extensions.DependencyInjection; +using rmutr_budget_api.Modules.IncomeBudgetReport.Services; + +namespace rmutr_budget_api.Modules.IncomeBudgetReport.Configures +{ + public static class IncomeBudgetReportCollectionExtension + { + public static IServiceCollection AddIncomeBudgetReportServices(this IServiceCollection services) + { + services.AddScoped(); + return services; + } + } +} +``` + +- [ ] **Step 2: Register it in Startup.cs** + +Open `Startup.cs`, find the block of `services.AddXxxServices();` calls (around line 155-183, ends with `services.AddPlanResultServices();`). Add a new line immediately after it: + +```csharp +services.AddPlanResultServices(); +services.AddIncomeBudgetReportServices(); +``` + +Add the using statement at the top of `Startup.cs` alongside the other `Modules.*.Configures` usings: + +```csharp +using rmutr_budget_api.Modules.IncomeBudgetReport.Configures; +``` + +- [ ] **Step 3: Build and verify by actually calling the endpoint, not swagger** + +```bash +cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api +dotnet build +``` +Expected: `Build succeeded. 0 Error(s)`. + +Then start the API locally (or use whatever run command the rest of this worktree's task reports used — check `.superpowers/sdd/2026-08-05-income-budget-report-native-group-a/task-8-report.md` for the exact local-run command) and hit the endpoint directly: +```bash +curl -s -o /dev/null -w "%{http_code}\n" http://localhost:/api/setting/income_budget_report/agency?type_id=1 +``` +Expected: `401` (Unauthorized — no token supplied) or `200`, **not** `500`. A `500` here means DI is still broken; a `401` proves the controller was constructed successfully (DI resolved) and only the `[Authorize]` check rejected the anonymous request, which is correct. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api +git add Modules/IncomeBudgetReport/Configures/IncomeBudgetReport.cs Startup.cs +git commit -m "fix: register IncomeBudgetReportService in DI (every endpoint was 500ing)" +``` + +--- + +### Task 2: Migration — duplicate-draft unique index + dual-write link column + +**Files:** +- Create: `rmutr-api/.claude/worktrees/income-budget-report-api/Migrations/add_income_budget_report_remediation.sql` +- Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Databases/Models/income_budget_report/income_budget_report.cs` + +**Why:** (1) `CreateDraft`'s existing check-then-insert duplicate-draft guard has no backing constraint — a double-click still creates two drafts (this is the exact bug that triggered a Task-5 reopen during the original plan; a partial unique index closes it for good and also gives the lookup an index it currently lacks). (2) Task 8 (dual-write) needs a place to record which `revenue_draft_committee` row mirrors a given `income_budget_report` row. + +**Interfaces:** +- Produces: `income_budget_report.revenue_draft_committee_uid : Guid?`, consumed by Task 8 (dual-write) and Task 9 (frontend list union, to dedupe legacy rows). + +- [ ] **Step 1: Write the migration** + +```sql +-- Migration: Remediation for income_budget_report (Group A native report). +-- Date: 2026-08-05 +-- Description: (1) Partial unique index preventing duplicate type-1 drafts per personnel_statement_uid +-- (closes a known TOCTOU race in CreateDraft's check-then-insert guard). +-- (2) revenue_draft_committee_uid link column for the dual-write mirror +-- (see IncomeBudgetReportService.CreateDraft / SyncMirror). +-- Idempotent: safe to run again. + +CREATE UNIQUE INDEX IF NOT EXISTS "UX_income_budget_report_personnel_statement_uid_type1" + ON "BUDGET".income_budget_report (personnel_statement_uid) + WHERE type_id = 1; + +ALTER TABLE "BUDGET".income_budget_report + ADD COLUMN IF NOT EXISTS revenue_draft_committee_uid uuid; + +CREATE OR REPLACE VIEW "BUDGET".v_income_budget_report AS +SELECT * FROM "BUDGET".income_budget_report; +``` + +- [ ] **Step 2: Apply it to production** + +```bash +PGPASSWORD='vtwi,yo0tpkd-okfouh' psql -h 203.158.221.28 -p 31620 -U devadmin -d rmutr_budget_050625 \ + -f /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api/Migrations/add_income_budget_report_remediation.sql +``` +If the VPN to `203.158.221.28` is down, ask the human to reconnect before running this (established pattern from the rest of this session). + +Verify: +```bash +PGPASSWORD='vtwi,yo0tpkd-okfouh' psql -h 203.158.221.28 -p 31620 -U devadmin -d rmutr_budget_050625 \ + -c "\d \"BUDGET\".income_budget_report" +``` +Expected: `revenue_draft_committee_uid` listed as a column, and `UX_income_budget_report_personnel_statement_uid_type1` listed as an index. + +- [ ] **Step 3: Add the matching C# property** + +In `income_budget_report.cs`, add the field to the base class (so both `t_income_budget_report` and `v_income_budget_report` inherit it — no navigation property, plain scalar, no FK gotcha): + +```csharp + public Guid? parent_uid { get; set; } + public Guid? personnel_statement_uid { get; set; } + public Guid? revenue_draft_committee_uid { get; set; } +``` +(insert the new line directly after the existing `personnel_statement_uid` line) + +- [ ] **Step 4: Build** + +```bash +cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api +dotnet build +``` +Expected: `Build succeeded. 0 Error(s)`. + +- [ ] **Step 5: Commit** + +```bash +git add Migrations/add_income_budget_report_remediation.sql Modules/IncomeBudgetReport/Databases/Models/income_budget_report/income_budget_report.cs +git commit -m "feat: add duplicate-draft unique index and revenue_draft_committee link column" +``` + +--- + +### Task 3: Seed every leaf-amount line (fixes the silent data-loss bug) + +**Files:** +- Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs` +- Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs` + +**Why this is Critical:** `CreateDraft` only ever creates lines for `node_key = "pb-temp-old"`/`"pb-temp-new"` (personnel sources). The other 13 `leaf-amount` nodes in the schema (`pb-cola-old`, `pb-cola-new`, `op-comp`, and `{out1..out5}-grant-proj`/`{out1..out5}-other`) have no backing line at all. `BuildNode` renders them as editable (`Locked = false`, `LineUid = null`) because there's no line to lock, but every edit collides on the same `null` key client-side and the backend silently skips a `null`-keyed line-uid update. **Not a single one of these 13 fields can currently be saved.** The design spec (`docs/superpowers/specs/2026-08-05-income-budget-report-native-design.md:77`) explicitly describes this case — "รายการที่ `source_uid = null`... แก้ไขได้ทั้งชื่อและจำนวนเงิน" (source_uid=null items are editable) — meaning these rows were always meant to exist with `source_uid = null`, `source_type = "manual"`; `CreateDraft` simply never created them. + +**Interfaces:** +- Produces: `IncomeReportTreeSchema.CollectLeafAmountKeys() : List<(string NodeKey, string Label)>`, consumed by `CreateDraft` in this task. + +- [ ] **Step 1: Add a leaf-amount key collector to the schema** + +In `IncomeReportTreeSchema.cs`, add this method to the `IncomeReportTreeSchema` static class (after `ForType1()`): + +```csharp + /// Every "leaf-amount" node key + label in the type-1 tree, in document order. + /// Used by CreateDraft to seed a zero-amount manual line for each one, so every + /// editable field has a real income_budget_report_line row to save against. + public static List<(string NodeKey, string Label)> CollectLeafAmountKeys() + { + var result = new List<(string, string)>(); + CollectLeafAmountKeysRecursive(ForType1(), result); + return result; + } + + private static void CollectLeafAmountKeysRecursive(ReportTreeNodeDef def, List<(string, string)> result) + { + if (def.Kind == "leaf-amount") + { + result.Add((def.NodeKey, def.Label)); + return; + } + foreach (var child in def.Children) + { + CollectLeafAmountKeysRecursive(child, result); + } + } +``` + +- [ ] **Step 2: Seed a manual line for every leaf-amount key in `CreateDraft`** + +In `IncomeBudgetReportService.cs`, inside `CreateDraft`, after the existing `foreach (var d in statement.personnel_statement_details_2 ...)` block and before `await _context.t_income_budget_report.AddAsync(report);`, add: + +```csharp + foreach (var (nodeKey, label) in IncomeReportTreeSchema.CollectLeafAmountKeys()) + { + lines.Add(new t_income_budget_report_line + { + income_budget_report_line_uid = Guid.NewGuid(), + income_budget_report_uid = reportUid, + node_key = nodeKey, + source_type = "manual", + source_uid = null, + label_th = label, + qualification_th = null, + qty = null, + amount = 0, + original_amount = 0, + sequence_no = seq++, + }); + } +``` + +This must run unconditionally (not gated on `statement != null`) — the leaf-amount fields aren't personnel-sourced, so a report should always get them. + +- [ ] **Step 3: Build** + +```bash +cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api +dotnet build +``` +Expected: `Build succeeded. 0 Error(s)`. + +- [ ] **Step 4: Verify against a real draft** + +Pick a real `personnel_statement_uid` from production (or ask the human which one is safe to use for testing), call: +```bash +curl -s -X POST "http://localhost:/api/setting/income_budget_report/create_draft?personnel_statement_uid=" \ + -H "Authorization: Bearer " | jq '.income_budget_report_lines | length, ([.[] | select(.source_type == "manual")] | length)' +``` +Expected: the manual-line count is exactly `13`. Then `GET .../{uid}/tree` and confirm `pb-cola-old`, `pb-cola-new`, `op-comp`, and every `out{N}-grant-proj`/`out{N}-other` node has `"locked": false` and a non-null `"lineUid"`. + +- [ ] **Step 5: Commit** + +```bash +git add Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs +git commit -m "fix: seed manual lines for every leaf-amount node (13 fields could not be saved before)" +``` + +--- + +### Task 4: Wire ร.4/ร.5 (invest_asset/invest_construct) into the leaf-table nodes + +**Files:** +- Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs` +- Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs` + +**Why:** The type-1 schema has 10 `leaf-table` nodes (`{out1..out5}-invest-equip`/`{out1..out5}-invest-land`) meant to hold ร.4 (`invest_asset_request_information`)/ร.5 (`invest_construct_request_information`) rows for that output ("ผลผลิต"). `CreateDraft` never queries either table, so these always render `"ยังไม่มีรายการ"`. The human explicitly approved wiring this in now rather than deferring it. + +**Grouping approach:** `invest_asset_request_information`/`invest_construct_request_information` don't carry a `node_key` — they're matched to an output node by their `budget_project_name_th` (the "ผลผลิต" name), compared against the schema's own `out1`..`out5` group labels (which are the real ผลผลิต names, verified against production — see `IncomeReportTreeSchema.cs:43-51`). A row whose `budget_project_name_th` doesn't match any of the 5 output labels is skipped (logged, not silently discarded — see Step 3) rather than guessed at, since there is no existing convention in this codebase for approval-status filtering on these two tables (confirmed: `BudgetProgressService.GetSummaryExpense` doesn't filter on `is_approve` either) and inventing one here would be a new, undiscussed policy. + +**Interfaces:** +- Consumes: `invest_asset_request_information`/`invest_construct_request_information` — read via `Db` directly (`_context.v_invest_asset_request_information`, `_context.v_invest_construct_request_information`), filtered `budget_year_uid == statement.budget_year_uid` (the report's own budget year — there is no `faculty_uid` on `personnel_statement` to filter by, matching the already-approved Task-5 precedent of not having a faculty filter for this report; filtering by `budget_year_uid` alone mirrors `BudgetProgressService.GetSummaryExpense`'s scoping intent as closely as this report's available data allows). +- Produces: `IncomeReportTreeSchema.OutputPrefixByLabel() : Dictionary`, consumed by `CreateDraft`. + +- [ ] **Step 1: Add an output-label-to-prefix lookup to the schema** + +In `IncomeReportTreeSchema.cs`, add (after `CollectLeafAmountKeys`): + +```csharp + /// Maps each "ผลผลิต" (output) label to its node-key prefix ("out1".."out5"), so + /// CreateDraft can match invest_asset/invest_construct rows (which carry a + /// budget_project_name_th, not a node_key) to the correct leaf-table node. + public static Dictionary OutputPrefixByLabel() + { + var result = new Dictionary(); + CollectOutputPrefixesRecursive(ForType1(), result); + return result; + } + + private static void CollectOutputPrefixesRecursive(ReportTreeNodeDef def, Dictionary result) + { + if (def.Kind == "group" && def.NodeKey.StartsWith("out") && def.NodeKey.Length <= 4) + { + result[def.Label.Trim()] = def.NodeKey; + } + foreach (var child in def.Children) + { + CollectOutputPrefixesRecursive(child, result); + } + } +``` + +(`def.NodeKey.Length <= 4` excludes `out1-grant`/`out1-invest` etc., matching only the top-level `out1`..`out5` group nodes themselves.) + +- [ ] **Step 2: Query and map invest rows in `CreateDraft`** + +In `IncomeBudgetReportService.cs`, `CreateDraft`, immediately after the leaf-amount seeding loop added in Task 3 and still before `await _context.t_income_budget_report.AddAsync(report);`, add: + +```csharp + var outputPrefixByLabel = IncomeReportTreeSchema.OutputPrefixByLabel(); + var unmatchedInvestLabels = new List(); + + if (statement?.budget_year_uid != null) + { + var assetRows = await _context.v_invest_asset_request_information + .Where(c => c.budget_year_uid == statement.budget_year_uid) + .ToListAsync(); + foreach (var a in assetRows) + { + var label = a.budget_project_name_th?.Trim(); + if (label == null || !outputPrefixByLabel.TryGetValue(label, out var prefix)) + { + if (label != null) unmatchedInvestLabels.Add($"invest_asset:{label}"); + continue; + } + lines.Add(new t_income_budget_report_line + { + income_budget_report_line_uid = Guid.NewGuid(), + income_budget_report_uid = reportUid, + node_key = $"{prefix}-invest-equip", + source_type = "invest_asset", + source_uid = a.invest_asset_request_information_uid, + label_th = a.invest_asset_request_information_name_th, + qualification_th = null, + qty = a.amount_unit, + amount = a.total_unit_price ?? 0, + original_amount = a.total_unit_price ?? 0, + sequence_no = seq++, + }); + } + + var constructRows = await _context.v_invest_construct_request_information + .Where(c => c.budget_year_uid == statement.budget_year_uid) + .ToListAsync(); + foreach (var c in constructRows) + { + var label = c.budget_project_name_th?.Trim(); + if (label == null || !outputPrefixByLabel.TryGetValue(label, out var prefix)) + { + if (label != null) unmatchedInvestLabels.Add($"invest_construct:{label}"); + continue; + } + lines.Add(new t_income_budget_report_line + { + income_budget_report_line_uid = Guid.NewGuid(), + income_budget_report_uid = reportUid, + node_key = $"{prefix}-invest-land", + source_type = "invest_construct", + source_uid = c.invest_construct_request_information_uid, + label_th = c.invest_construct_request_information_name_th, + qualification_th = null, + qty = null, + amount = c.budget ?? 0, + original_amount = c.budget ?? 0, + sequence_no = seq++, + }); + } + } +``` + +- [ ] **Step 3: Surface unmatched rows instead of silently dropping them** + +Still in `CreateDraft`, after building `report` but before `return await GetEntity(reportUid);`, log unmatched labels so a mismatch (e.g. a ผลผลิต name that drifted since the schema was verified against production) is visible in logs rather than invisible: + +```csharp + if (unmatchedInvestLabels.Any()) + { + Console.WriteLine($"[IncomeBudgetReportService.CreateDraft] {unmatchedInvestLabels.Count} invest row(s) did not match any output label and were skipped: {string.Join(", ", unmatchedInvestLabels.Distinct())}"); + } +``` + +- [ ] **Step 4: Build** + +```bash +cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api +dotnet build +``` +Expected: `Build succeeded. 0 Error(s)`. If `v_invest_asset_request_information`/`v_invest_construct_request_information` aren't already `DbSet`-exposed on `Db`, this step will fail with a compile error naming the missing property — if that happens, find the exact `DbSet` / `DbSet` property names already declared in `Modules/InvestInformations/Databases/Db.cs` (or wherever they're registered) and use those exact names/casing. + +- [ ] **Step 5: Verify against a real budget year with known invest rows** + +```bash +curl -s -X POST "http://localhost:/api/setting/income_budget_report/create_draft?personnel_statement_uid=" \ + -H "Authorization: Bearer " > /tmp/draft.json +curl -s "http://localhost:/api/setting/income_budget_report/$(jq -r .income_budget_report_uid /tmp/draft.json)/tree" \ + -H "Authorization: Bearer " | jq '.. | objects | select(.nodeKey? | test("invest")) | {nodeKey, rows: (.rows | length)}' +``` +Expected: at least one `*-invest-equip`/`*-invest-land` node has `rows > 0` for a faculty/year known to have ร.4/ร.5 data (ask the human which `budget_year_uid` has real data if unsure, or check `check-pa-status.md`/similar files from earlier investigation in the outer repo for a known-good example). + +- [ ] **Step 6: Commit** + +```bash +git add Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs +git commit -m "feat: source invest_asset/invest_construct (ร.4/ร.5) into the leaf-table nodes" +``` + +--- + +### Task 5: Agency-ownership guard on `PUT lines`, and reject writes to locked lines + +**Files:** +- Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs` +- Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs` + +**Why:** `PUT .../lines` currently looks up each line by bare uid and overwrites it with no check that the line's report belongs to the caller's agency, and no check on `source_uid` — the UI-only `locked` flag can be bypassed entirely via direct API call, letting any authenticated user rewrite any faculty's report, including authoritative ร.2/ร.4/ร.5-sourced figures. There is no existing precedent in this codebase for a write-time ownership guard (confirmed — every `Helper.GetDataFilter` usage is either a read-list filter or a create-time stamp), so this task also has to make `CreateDraft`/`CloneNextRound` actually stamp `owner_agency_uid` (currently unset on both), since the guard needs something real to check. + +**Interfaces:** +- Consumes: `Helper.GetDataFilter(ClaimsIdentity, List)` (external package, existing convention). +- Produces: `UpdateLineAmounts` now returns `400 BadRequest` with a description when a line is rejected, instead of silently succeeding. + +- [ ] **Step 1: Stamp `owner_agency_uid` in `CreateDraft` and `CloneNextRound`** + +In `IncomeBudgetReportService.cs`, `CreateDraft`, add to the `report` object initializer (right after `status_id = 0,`): + +```csharp + status_id = 0, + owner_agency_uid = Helper.GetDataFilter(identity, new List { "agency_uid" }).Where(a => a != Guid.Empty).FirstOrDefault(), +``` + +Do the same in `CloneNextRound`'s `clone` object initializer: + +```csharp + status_id = 0, + owner_agency_uid = Helper.GetDataFilter(identity, new List { "agency_uid" }).Where(a => a != Guid.Empty).FirstOrDefault(), +``` + +(`Helper` is already in scope via `using SeventyOneDev.Utilities;` at the top of the file.) + +- [ ] **Step 2: Add a guarded update method to the service** + +In `IncomeBudgetReportService.cs`, add a new public method (after `CloneNextRound`, before `BuildTree`): + +```csharp + public async Task> UpdateLineAmounts(List<(Guid LineUid, decimal Amount)> updates, ClaimsIdentity identity) + { + var agencies = Helper.GetDataFilter(identity, new List { "agency_uid" }).Where(a => a != Guid.Empty).ToList(); + var rejected = new List(); + + foreach (var (lineUid, amount) in updates) + { + var line = await _context.t_income_budget_report_line.FindAsync(lineUid); + if (line == null) + { + rejected.Add($"{lineUid}: ไม่พบรายการ"); + continue; + } + if (line.source_uid != null) + { + rejected.Add($"{lineUid}: รายการนี้ล็อกไว้ แก้ไขไม่ได้"); + continue; + } + var report = await _context.t_income_budget_report.FindAsync(line.income_budget_report_uid); + if (report == null || (agencies.Any() && !agencies.Contains(report.owner_agency_uid))) + { + rejected.Add($"{lineUid}: ไม่มีสิทธิ์แก้ไขรายงานนี้"); + continue; + } + line.amount = amount; + } + + await _context.SaveChangesAsync(identity); + return rejected; + } +``` + +- [ ] **Step 3: Use it from the controller instead of the inline `_db` loop** + +In `IncomeBudgetReportController.cs`, replace the entire `UpdateLineAmounts` action body: + +```csharp + [Authorize] + [HttpPut("lines")] + public async Task UpdateLineAmounts([FromBody] List updates) + { + var rejected = await _service.UpdateLineAmounts( + updates.Select(u => (u.income_budget_report_line_uid, u.amount)).ToList(), + User.Identity as ClaimsIdentity); + if (rejected.Any()) + { + return BadRequest(new { description = string.Join("; ", rejected) }); + } + return Ok(); + } +``` + +Add `using System.Linq;` to the top of the controller file if not already present (it is not, per the file read earlier). + +The `_db` field and its constructor parameter are no longer used by this action, but leave them in place — `Db db` may still be needed if other actions in this controller are added later, and removing it isn't required by this fix. + +- [ ] **Step 4: Build** + +```bash +cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api +dotnet build +``` +Expected: `Build succeeded. 0 Error(s)`. + +- [ ] **Step 5: Verify the guard rejects a locked-line write** + +```bash +curl -s -X PUT "http://localhost:/api/setting/income_budget_report/lines" \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -d '[{"income_budget_report_line_uid":"","amount":999999}]' +``` +Expected: `400` with a description containing "ล็อกไว้". Then repeat with a real unlocked manual line's uid (from Task 3's seeded lines) and confirm `200 OK` and that the amount actually changed on a follow-up `GET .../tree`. + +- [ ] **Step 6: Commit** + +```bash +git add Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs +git commit -m "fix: guard PUT lines against cross-agency writes and locked-line edits" +``` + +--- + +### Task 6: Structured xlsx export matching the official form's shape + +**Files:** +- Create: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportXlsxExporter.cs` +- Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs` + +**Why:** The current export (`WriteNode`) is a flat 2-column `label`/`amount` dump with space-indentation — nothing like the real government form. **No literal `.xlsx` template file exists anywhere reachable from this codebase** (confirmed: the real form is rendered by a separate paid FastReport microservice, `rmutr_report`, using `.frx` templates that cannot be loaded by EPPlus; the production blank-template URL the old SyncFusion UI used is currently returning `503`). Per the human's explicit decision, this task builds a **new, EPPlus-constructed workbook that replicates the government form's structure** (title block, indented tree with real merged/bordered cells, right-aligned currency, signature block) using the schema's own hierarchy and labels — not a pixel-identical reproduction of the FastReport output. This must be reviewed against the real form by a human before this feature is considered fully production-ready for actual government submission (flag this explicitly in the task's completion report). + +**Interfaces:** +- Consumes: `ReportTreeNode` (existing, `Modules/IncomeBudgetReport/Config/ReportTreeNode.cs`), `v_income_budget_report` (for header fields: `sector_name_th`, `budget_year_uid` — resolve the year's display name via `_context.v_budget_year`). +- Produces: `IncomeBudgetReportXlsxExporter.Build(v_income_budget_report report, ReportTreeNode tree, string budgetYearNameTh, string personnelStatementDisplayName) : byte[]`, consumed by the controller (this task) and by Task 8 (dual-write, server-side generation). + +- [ ] **Step 1: Write the exporter** + +```csharp +using System.Linq; +using OfficeOpenXml; +using OfficeOpenXml.Style; +using rmutr_budget_api.Modules.IncomeBudgetReport.Config; +using rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models; + +namespace rmutr_budget_api.Modules.IncomeBudgetReport.Services +{ + public static class IncomeBudgetReportXlsxExporter + { + public static byte[] Build(v_income_budget_report report, ReportTreeNode tree, string budgetYearNameTh, string personnelStatementDisplayName) + { + ExcelPackage.LicenseContext = LicenseContext.NonCommercial; + using var package = new ExcelPackage(); + var ws = package.Workbook.Worksheets.Add("Page1"); + + ws.Column(1).Width = 55; + ws.Column(2).Width = 20; + ws.Column(3).Width = 12; + ws.Column(4).Width = 18; + + ws.Cells["A1:D1"].Merge = true; + ws.Cells["A1"].Value = "ร่างเงินรายได้ เพื่อประชุมคณะกรรมการร่างเงินรายได้"; + ws.Cells["A1"].Style.Font.Bold = true; + ws.Cells["A1"].Style.Font.Size = 14; + ws.Cells["A1"].Style.HorizontalAlignment = ExcelHorizontalAlignment.Center; + + ws.Cells["A2:D2"].Merge = true; + ws.Cells["A2"].Value = $"หน่วยงาน: {personnelStatementDisplayName} ปีงบประมาณ: {budgetYearNameTh}"; + ws.Cells["A2"].Style.HorizontalAlignment = ExcelHorizontalAlignment.Center; + + int headerRow = 4; + ws.Cells[headerRow, 1].Value = "รายการ"; + ws.Cells[headerRow, 2].Value = "อัตรา/วุฒิ"; + ws.Cells[headerRow, 3].Value = "จำนวน"; + ws.Cells[headerRow, 4].Value = "จำนวนเงิน (บาท)"; + using (var headerRange = ws.Cells[headerRow, 1, headerRow, 4]) + { + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.PatternType = ExcelFillStyle.Solid; + headerRange.Style.Fill.BackgroundColor.SetColor(System.Drawing.Color.LightGray); + headerRange.Style.Border.BorderAround(ExcelBorderStyle.Thin); + } + + int row = headerRow + 1; + WriteNode(tree, ws, ref row); + + using (var dataRange = ws.Cells[headerRow, 1, row - 1, 4]) + { + dataRange.Style.Border.Top.Style = ExcelBorderStyle.Thin; + dataRange.Style.Border.Bottom.Style = ExcelBorderStyle.Thin; + dataRange.Style.Border.Left.Style = ExcelBorderStyle.Thin; + dataRange.Style.Border.Right.Style = ExcelBorderStyle.Thin; + } + + row += 3; + ws.Cells[row, 1].Value = "ลงชื่อ .............................................. ผู้จัดทำ"; + row += 2; + ws.Cells[row, 1].Value = "ลงชื่อ .............................................. ประธานคณะกรรมการร่างเงินรายได้"; + row += 2; + ws.Cells[row, 1].Value = "วันที่ .............../.............../..............."; + + ws.View.FreezePanes(headerRow + 1, 1); + + return package.GetAsByteArray(); + } + + private static void WriteNode(ReportTreeNode node, ExcelWorksheet ws, ref int row) + { + var cell = ws.Cells[row, 1]; + cell.Value = node.Label; + cell.Style.Indent = node.Depth; + if (node.Kind == "group") + { + cell.Style.Font.Bold = true; + } + var amountCell = ws.Cells[row, 4]; + amountCell.Value = node.Amount; + amountCell.Style.Numberformat.Format = "#,##0.00"; + amountCell.Style.HorizontalAlignment = ExcelHorizontalAlignment.Right; + row++; + + if (node.Kind == "leaf-table") + { + foreach (var r in node.Rows) + { + var rowCell = ws.Cells[row, 1]; + rowCell.Value = "- " + r.Label; + rowCell.Style.Indent = node.Depth + 1; + ws.Cells[row, 2].Value = r.Qualification; + ws.Cells[row, 3].Value = r.Qty; + var rowAmountCell = ws.Cells[row, 4]; + rowAmountCell.Value = r.Amount; + rowAmountCell.Style.Numberformat.Format = "#,##0.00"; + rowAmountCell.Style.HorizontalAlignment = ExcelHorizontalAlignment.Right; + row++; + } + } + + foreach (var child in node.Children) + { + WriteNode(child, ws, ref row); + } + } + } +} +``` + +- [ ] **Step 2: Use it from the controller** + +In `IncomeBudgetReportController.cs`, replace the `ExportXlsx` action and delete the old private `WriteNode` method entirely: + +```csharp + [Authorize] + [HttpGet("{uid:guid}/export/xlsx")] + public async Task ExportXlsx([FromRoute] Guid uid) + { + var report = await _service.GetEntity(uid); + if (report == null) return NotFound(); + var tree = _service.BuildTree(report); + var bytes = await _service.BuildXlsx(report, tree); + var fileName = $"income_budget_report_{uid}.xlsx"; + return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName); + } +``` + +- [ ] **Step 3: Add the wrapper method the controller now calls** + +In `IncomeBudgetReportService.cs`, add (after `BuildTree`/`BuildNode`, this resolves the display strings the exporter needs): + +```csharp + public async Task BuildXlsx(v_income_budget_report report, ReportTreeNode tree) + { + var budgetYear = report.budget_year_uid != null + ? await _context.v_budget_year.FirstOrDefaultAsync(y => y.budget_year_uid == report.budget_year_uid) + : null; + var personnel = report.personnel_statement_uid != null + ? await _context.v_personnel_statement.FirstOrDefaultAsync(p => p.personnel_statement_uid == report.personnel_statement_uid) + : null; + var displayName = personnel?.display_name ?? personnel?.agency_name_th ?? report.sector_name_th ?? ""; + return IncomeBudgetReportXlsxExporter.Build(report, tree, budgetYear?.budget_year_name_th ?? "", displayName); + } +``` +If `v_budget_year`/`v_personnel_statement` aren't the exact `DbSet` property names on `Db`, grep for the actual names (`grep -rn "DbSet\|DbSet"`) and use those. + +- [ ] **Step 4: Build** + +```bash +cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api +dotnet build +``` +Expected: `Build succeeded. 0 Error(s)`. + +- [ ] **Step 5: Verify by downloading and opening the file** + +```bash +curl -s "http://localhost:/api/setting/income_budget_report//export/xlsx" \ + -H "Authorization: Bearer " -o /tmp/export-test.xlsx +``` +Open `/tmp/export-test.xlsx` and confirm: title block at top, indented tree rows (not space-prefixed text — actual cell indent), bordered table, currency right-aligned with thousands separators, signature block at the bottom. + +- [ ] **Step 6: Flag for human review** + +In the task completion report, state explicitly: *"This export is a structural approximation built from the tree schema, not a reproduction of the real FastReport-generated government form (no template file was obtainable — see remediation plan Task 6 rationale). Before this is used for an actual government submission, compare it side-by-side against a real production `.xlsx` (e.g. once `rmutr.71dev.com/budget-api/...` is back up) and confirm with the human whether further layout changes are needed."* + +- [ ] **Step 7: Commit** + +```bash +git add Modules/IncomeBudgetReport/Services/IncomeBudgetReportXlsxExporter.cs Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs +git commit -m "feat: replace flat xlsx export with a structured, form-styled workbook" +``` + +--- + +### Task 7: Dual-write — mirror type-1 drafts into `revenue_draft_committee` + +**Files:** +- Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs` +- Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs` + +**Why:** Per the human's explicit "เปิดใช้เต็มรูปแบบเลย ทำ dual-write/union ข้อมูล" decision: type-2's picker (`income-budget-report.component.ts:132`, `RevenueDraftCommitteeSV.queryWithAgency('?type_id=1')`) and `change()` (reads `res.revenue_draft_committee_file`) still only read from the old `revenue_draft_committee` table. Rather than rewire type-2 (out of scope — it's still the untouched SyncFusion flow), `CreateDraft` and `UpdateLineAmounts` mirror a `revenue_draft_committee` row for every type-1 report, keeping its `revenue_draft_committee_file` populated with a freshly generated xlsx (via Task 6's exporter) using the exact same on-disk storage convention `DocumentUploadController` already uses (`Modules/Utilities/Controller/Upload.cs:23-60`) — writing directly to `application_setting.document_path` server-side, no client round-trip needed. + +**Interfaces:** +- Consumes: `IncomeBudgetReportXlsxExporter.Build` (Task 6), `application_setting` (`Modules/Utilities/Models/application_setting.cs`, injected via `IOptions`), `t_revenue_draft_committee` (existing `DbSet` on `Db`, confirmed at `Modules/Setting/Databases/Db.cs:71`). +- Produces: `income_budget_report.revenue_draft_committee_uid` is now always populated for type-1 reports after `CreateDraft`. + +- [ ] **Step 1: Inject `IOptions` into the service** + +In `IncomeBudgetReportService.cs`, update the constructor. This mirrors `DocumentUploadController`'s exact pattern (`Modules/Utilities/Controller/Upload.cs:16-21`), which injects `IOptions` and stores `.Value`: + +```csharp + private readonly Db _context; + private readonly rmutp_mooc_api.Modules.Utilities.Models.application_setting _appSetting; + + public IncomeBudgetReportService(Db context, Microsoft.Extensions.Options.IOptions appSetting) : base(context) + { + _context = context; + _appSetting = appSetting.Value; + } +``` + +- [ ] **Step 2: Add the mirror-sync method** + +In `IncomeBudgetReportService.cs`, add (after `BuildXlsx`): + +```csharp + private async Task SyncRevenueDraftCommitteeMirror(Guid reportUid, ClaimsIdentity identity) + { + var report = await GetEntity(reportUid); + if (report == null || report.type_id != 1) return; + + var tree = BuildTree(report); + var bytes = await BuildXlsx(report, tree); + + if (!Directory.Exists(_appSetting.document_path)) + { + Directory.CreateDirectory(_appSetting.document_path); + } + var fileName = Guid.NewGuid() + ".xlsx"; + await System.IO.File.WriteAllBytesAsync(Path.Combine(_appSetting.document_path, fileName), bytes); + var fileUrl = _appSetting.document_url + fileName; + + var personnel = report.personnel_statement_uid != null + ? await _context.v_personnel_statement.FirstOrDefaultAsync(p => p.personnel_statement_uid == report.personnel_statement_uid) + : null; + var displayName = personnel?.display_name ?? personnel?.agency_name_th ?? report.sector_name_th ?? ""; + + var mirror = report.revenue_draft_committee_uid != null + ? await _context.t_revenue_draft_committee.FindAsync(report.revenue_draft_committee_uid) + : null; + + if (mirror == null) + { + mirror = new t_revenue_draft_committee + { + revenue_draft_committee_uid = Guid.NewGuid(), + type_id = 1, + personnel_statement_uid = report.personnel_statement_uid, + budget_year_uid = report.budget_year_uid, + status_id = 1, + owner_agency_uid = report.owner_agency_uid, + }; + await _context.t_revenue_draft_committee.AddAsync(mirror); + var trackedReport = await _context.t_income_budget_report.FindAsync(reportUid); + trackedReport.revenue_draft_committee_uid = mirror.revenue_draft_committee_uid; + } + + mirror.revenue_draft_committee_name_th = displayName; + mirror.revenue_draft_committee_file = fileUrl; + + await _context.SaveChangesAsync(identity); + } +``` + +Add `using System.IO;` to the top of the file if not already present. + +- [ ] **Step 3: Call it from `CreateDraft` and after line updates** + +In `CreateDraft`, change the final two lines from: +```csharp + return await GetEntity(reportUid); +``` +to: +```csharp + await SyncRevenueDraftCommitteeMirror(reportUid, identity); + return await GetEntity(reportUid); +``` + +In `UpdateLineAmounts` (Task 5), change the end of the method — replace: +```csharp + await _context.SaveChangesAsync(identity); + return rejected; +``` +with: +```csharp + await _context.SaveChangesAsync(identity); + + var reportUids = updates.Select(u => u.LineUid).Distinct().ToList(); + var affectedReportUids = await _context.t_income_budget_report_line + .Where(l => reportUids.Contains(l.income_budget_report_line_uid.Value)) + .Select(l => l.income_budget_report_uid.Value) + .Distinct() + .ToListAsync(); + foreach (var reportUid in affectedReportUids) + { + await SyncRevenueDraftCommitteeMirror(reportUid, identity); + } + + return rejected; +``` + +- [ ] **Step 4: Build** + +```bash +cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api +dotnet build +``` +Expected: `Build succeeded. 0 Error(s)`. Fix any namespace mismatches against what `Upload.cs` actually uses (the exact `application_setting` namespace/using was copied from the earlier read of that file — verify it still matches). + +- [ ] **Step 5: Verify end to end** + +1. `POST create_draft` for a type-1 personnel_statement → confirm the response's `revenue_draft_committee_uid` is non-null. +2. `GET /api/setting/revenue_draft_committee/` (or however the existing controller exposes single-record GET) → confirm `revenue_draft_committee_file` is a real, non-null URL and `type_id == 1`. +3. Download that URL and confirm it opens as a valid `.xlsx` (same file Task 6 verified). +4. `PUT lines` an amount change on that report → repeat step 2 and confirm `revenue_draft_committee_file` changed to a **new** URL (proving the mirror actually regenerates, not just points at a stale file). +5. `GET /api/setting/revenue_draft_committee/agency?type_id=1` (the exact call type-2's picker makes) → confirm the new draft appears in the list. + +- [ ] **Step 6: Commit** + +```bash +git add Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs +git commit -m "feat: dual-write type-1 drafts into revenue_draft_committee so type-2's picker keeps working" +``` + +--- + +### Task 8: Frontend — union legacy `revenue_draft_committee` rows into the type-1 list + +**Files:** +- Modify: `rmutr-web/.claude/worktrees/income-budget-report-api/src/app/feature/income/draft/income-budget-report1/income-budget-report1-list/income-budget-report-list.component.ts` + +**Why:** After Task 7, every *new* type-1 draft is mirrored into `revenue_draft_committee`, so it would actually show up fine if the list just queried `revenue_draft_committee` directly (like it did before the original Group A plan). But **historical rows that predate this whole feature** only exist in `revenue_draft_committee` and have no matching `income_budget_report` row — those must still be reachable (view/export via the old download, since they have no tree/lines to edit natively), and must not be shown twice once a legacy row eventually gets superseded. Union both sources, dedupe by the link column from Task 2, and flag legacy-only rows so Task 9 can route their edit action correctly. + +**Interfaces:** +- Consumes: `income_budget_report.revenue_draft_committee_uid` (Task 2), the already-dual-written mirror rows (Task 7). +- Produces: each row in `summaryData` now carries `isLegacy: boolean`, consumed by Task 9's `onEdit`/`ondelete`. + +- [ ] **Step 1: Extend `mapType1Rows` to also carry the link field, and add a legacy-union helper** + +Replace the existing `mapType1Rows` method with: + +```typescript + /** type_id 1 drafts live in the new income_budget_report table (v_income_budget_report shape), + * but this list's template renders revenue_draft_committee_* fields. Alias the display fields + * onto the row so the existing template works unmodified, without stripping the real fields. */ + private mapType1Rows(rows: any[], personnelList: any[], budgetYearList: any[]): any[] { + return (rows || []).map((row: any) => { + const personnel = (personnelList || []).find( + (p: any) => p.personnel_statement_uid == row.personnel_statement_uid + ) + const budgetYear = (budgetYearList || []).find( + (b: any) => b.budget_year_uid == row.budget_year_uid + ) + const displayName = personnel?.display_name || personnel?.agency_name_th || '' + return { + ...row, + revenue_draft_committee_uid: row.income_budget_report_uid, + revenue_draft_committee_name_th: displayName, + budget_year_name_th: budgetYear?.budget_year_name_th, + isLegacy: false, + } + }) + } + + /** Historical type-1 rows that predate the native report table — they only exist in + * revenue_draft_committee and have no income_budget_report counterpart. Included so + * they stay reachable; excluded once a native row's dual-write has mirrored them + * (matched via income_budget_report.revenue_draft_committee_uid, aliased to + * revenue_draft_committee_uid on the mapped native rows above). */ + private unionLegacyRows(nativeRows: any[], legacyRows: any[]): any[] { + const mirroredUids = new Set((nativeRows || []).map((r: any) => r.revenue_draft_committee_uid).filter(Boolean)) + const legacyOnly = (legacyRows || []) + .filter((r: any) => !mirroredUids.has(r.revenue_draft_committee_uid)) + .map((r: any) => ({ ...r, isLegacy: true })) + return [...nativeRows, ...legacyOnly] + } +``` + +- [ ] **Step 2: Fetch both sources and union them in `ngOnInit`** + +Replace the `concatMap` branch for `x.type == 1`: + +```typescript + concatMap(x=>{ + if (x.type == 1) { + return combineLatest([ + this.IncomeBudgetReportSV.queryWithAgency('?type_id=1'), + this.RevenueDraftCommitteeSV.queryWithAgency('?type_id=1'), + this.personnelStatement$, + this.budgetYear$, + ]).pipe( + map(([nativeRows, legacyRows, personnelList, budgetYearList]: [any[], any[], any[], any[]]) => + this.unionLegacyRows(this.mapType1Rows(nativeRows, personnelList, budgetYearList), legacyRows) + ) + ) + } + return this.RevenueDraftCommitteeSV.queryWithAgency(`?type_id=${x.type}`) + }), +``` + +- [ ] **Step 3: Same union in `search()`** + +Replace the `typeUrl == 1` branch of `source$`: + +```typescript + const source$ = this.typeUrl == 1 + ? combineLatest([ + this.IncomeBudgetReportSV.queryWithAgency(`?${queryStr}`), + this.RevenueDraftCommitteeSV.queryWithAgency(`?${queryStr}`), + this.personnelStatement$, + this.budgetYear$, + ]).pipe( + map(([nativeRows, legacyRows, personnelList, budgetYearList]: [any[], any[], any[], any[]]) => + this.unionLegacyRows(this.mapType1Rows(nativeRows, personnelList, budgetYearList), legacyRows) + ) + ) + : this.RevenueDraftCommitteeSV.queryWithAgency(`?${queryStr}`) +``` + +- [ ] **Step 4: Build** + +```bash +cd /Users/nut.looknut/Project/rmutr/rmutr-web/.claude/worktrees/income-budget-report-api +npx tsc --noEmit +ng build --configuration production +``` +Expected: zero new errors (ignore the pre-existing unrelated `form8.1.component.spec.ts` errors already noted in the Group A ledger). + +- [ ] **Step 5: Verify in the browser** + +Load `/app/income-budget-report-income1`. Confirm: rows created before this remediation (legacy, `revenue_draft_committee`-only) still appear; a freshly created draft (via `create_draft`, now dual-written) appears exactly once, not twice. + +- [ ] **Step 6: Commit** + +```bash +git add src/app/feature/income/draft/income-budget-report1/income-budget-report1-list/income-budget-report-list.component.ts +git commit -m "feat: union legacy revenue_draft_committee rows into the type-1 list, deduped against dual-written drafts" +``` + +--- + +### Task 9: Frontend — legacy-row edit routing, error/loading handling, decimal-input and indentation fixes + +**Files:** +- Modify: `rmutr-web/.claude/worktrees/income-budget-report-api/src/app/feature/income/draft/income-budget-report1/income-budget-report1-list/income-budget-report-list.component.ts` +- Modify: `rmutr-web/.claude/worktrees/income-budget-report-api/src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.ts` +- Modify: `rmutr-web/.claude/worktrees/income-budget-report-api/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.ts` +- Modify: `rmutr-web/.claude/worktrees/income-budget-report-api/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.html` +- Modify: `rmutr-web/.claude/worktrees/income-budget-report-api/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.scss` + +**Why:** Four Important findings from the final review, all in scope for this remediation: (1) legacy rows (Task 8) have no `income_budget_report_uid` to edit natively — routing them into the tree UI would 404; (2) the whole native tree flow has zero error/loading handling, so any backend failure looks like nothing happened; (3) `pendingChanges`/`tree` aren't cleared when a user switches which personnel_statement they're viewing, so edits can leak across selections; (4) the amount `` bound via `[value]`+`(input)` loses a user's in-progress decimal typing (e.g. typing `"50."` resets to `0`), and the tree's CSS indentation compounds quadratically by depth. + +**Interfaces:** +- Consumes: `row.isLegacy` (Task 8). +- Produces: none consumed by later tasks (this is the last frontend task before verification). + +- [ ] **Step 1: Route legacy rows to the old edit flow via a query param** + +In `income-budget-report-list.component.ts`, replace `onEdit`: + +```typescript + onEdit(row: any){ + const uid = row.revenue_draft_committee_uid + const legacyQuery = row.isLegacy ? { queryParams: { legacy: '1' } } : {} + if(this.typeUrl == 1){ + this.router.navigate(['/app/income-budget-report-income1/edit', uid], legacyQuery) + } + else if(this.typeUrl == 2){ + this.router.navigate(['/app/income-budget-report-income2/edit',uid]) + } + else if(this.typeUrl == 3){ + this.router.navigate(['/app/income-budget-report-income3/edit',uid]) + } + else if(this.typeUrl == 4){ + this.router.navigate(['/app/allocate-income-budget/edit',uid]) + } + else if(this.typeUrl == 5){ + this.router.navigate(['/app/income-expense-estimates/edit',uid]) + } + else if(this.typeUrl == 6){ + this.router.navigate(['/app/income-expense-estimates-02/edit',uid]) + } + else if(this.typeUrl == 10){ + this.router.navigate(['/app/educational-materials/edit',uid]) + } + } +``` +(signature changed from `onEdit(uid)` to `onEdit(row: any)` — update the template call site.) + +In `income-budget-report-list.component.html`, change the edit icon's click handler from `(click)="onEdit(x.revenue_draft_committee_uid)"` to `(click)="onEdit(x)"`. + +Also update `ondelete` to branch on legacy the same way — replace its body: +```typescript + ondelete(value){ + console.log(value) + this.swSV.confirm().pipe( + filter(x => x.isConfirmed), + concatMap(x => { + const delete$ = (this.typeUrl == 1 && !value.isLegacy) + ? this.IncomeBudgetReportSV.deleteData(value.revenue_draft_committee_uid) + : this.RevenueDraftCommitteeSV.deleteData(value.revenue_draft_committee_uid) + return delete$.pipe( + tap(x => this.swSV.removeSuccess()), + tap(x => this.ngOnInit()), + tap(x => this.cdRef.detectChanges()) + ) + }) + ).subscribe() + } +``` +and in the template, change `(click)="ondelete(x.revenue_draft_committee_uid)"` to `(click)="ondelete(x)"`. + +- [ ] **Step 2: Make the edit-mode branch respect the legacy flag** + +In `income-budget-report.component.ts`, `ngOnInit`'s `case 'edit':` block, replace: +```typescript + if (this.typeId == 1) { + this.reportUid = this.uniquekey; + this.loadTree(); + } else { +``` +with: +```typescript + const isLegacyEdit = this.activatedRoute.snapshot.queryParams?.legacy === '1'; + if (this.typeId == 1 && !isLegacyEdit) { + this.reportUid = this.uniquekey; + this.loadTree(); + } else { +``` +Add a field near the other type-1 state fields: +```typescript + /** true when editing a legacy revenue_draft_committee-only row (no income_budget_report + * counterpart) via the ?legacy=1 route — falls back to the old SyncFusion edit UI even + * though typeId === 1. */ + isLegacyEdit = false +``` +and set it alongside the check above: +```typescript + this.isLegacyEdit = this.activatedRoute.snapshot.queryParams?.legacy === '1'; + if (this.typeId == 1 && !this.isLegacyEdit) { +``` +(remove the local `const isLegacyEdit` and use `this.isLegacyEdit` in the `if` instead, to also expose it to the template.) + +In `income-budget-report.component.html`, change the top-level template gate from `*ngIf="typeId === 1; else oldReportUi"` to `*ngIf="typeId === 1 && !isLegacyEdit; else oldReportUi"`. This is the only change to that template's structure — everything inside `#oldReportUi` stays byte-identical, per the Global Constraint. + +- [ ] **Step 3: Add error/loading handling to the four native-flow service calls** + +In `income-budget-report.component.ts`, replace `onPersonnelStatementChange`: +```typescript + onPersonnelStatementChange(personnelStatementUid: string): void { + if (!personnelStatementUid) return; + if (this.state === 'edit') return; + this.pendingChanges.clear(); + this.tree = null; + this.loading(); + this.incomeBudgetReportSV.createDraft(personnelStatementUid).pipe( + tap((report: any) => { + this.reportUid = report.income_budget_report_uid; + this.loadTree(); + }), + catchError(err => { + this.closeLoading(); + Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error'); + return throwError(err); + }), + ).subscribe(); + } +``` + +Replace `loadTree`: +```typescript + loadTree(): void { + if (!this.reportUid) return; + this.incomeBudgetReportSV.getTree(this.reportUid).pipe( + tap((tree) => { this.tree = tree; this.closeLoading(); this.cdRef.detectChanges(); }), + catchError(err => { + this.closeLoading(); + Swal.fire(err.error?.description || 'ไม่พบข้อมูลรายงาน', '', 'error'); + return throwError(err); + }), + ).subscribe(); + } +``` + +Replace `saveTree`: +```typescript + /** Saves the edited line amounts of the native tree report. + * Named apart from save() above, which saves the SyncFusion workbook for types 2..10. */ + saveTree(): void { + if (!this.reportUid || this.pendingChanges.size === 0) return; + const lines = Array.from(this.pendingChanges.entries()).map(([lineUid, amount]) => ({ + income_budget_report_line_uid: lineUid, + amount, + })); + this.loading(); + this.incomeBudgetReportSV.updateLineAmounts(lines).pipe( + tap(() => { + this.pendingChanges.clear(); + this.closeLoading(); + this.AppSV.swaltAlert('บันทึกสำเร็จ'); + this.loadTree(); + }), + catchError(err => { + this.closeLoading(); + Swal.fire(err.error?.description || 'บันทึกไม่สำเร็จ', '', 'error'); + return throwError(err); + }), + ).subscribe(); + } +``` + +Replace `exportXlsx`: +```typescript + exportXlsx(): void { + if (!this.reportUid) return; + const url = this.incomeBudgetReportSV.exportXlsxUrl(this.reportUid); + this.loading(); + this.incomeBudgetReportSV.getExcelReport(url).pipe( + tap((fileUrl: any) => { this.closeLoading(); window.open(fileUrl); }), + catchError(err => { + this.closeLoading(); + Swal.fire(err.error?.description || 'ส่งออกไม่สำเร็จ', '', 'error'); + return throwError(err); + }), + ).subscribe(); + } +``` + +`catchError`, `throwError`, and `Swal` are already imported in this file (confirmed: `catchError` is in the existing `rxjs/operators` import, `throwError` in the `rxjs` import, `Swal` via `import Swal from 'sweetalert2';`). + +- [ ] **Step 4: Fix the decimal-input round-trip bug** + +In `budget-report-tree-node.component.html`, change both amount inputs from `(input)` to `(change)` (matches user intent — commit on blur/enter, not on every keystroke, so a partial `"50."` isn't clobbered mid-type): + +```html + +``` +and +```html + +``` +(`onLeafAmountInput`/`onRowAmountInput` in the `.ts` file don't need to change — they already parse the string value correctly; the bug was purely the `(input)` event firing on every keystroke against a `[value]` binding that immediately re-renders the parsed number.) + +- [ ] **Step 5: Fix the quadratic indentation** + +In `budget-report-tree-node.component.scss`, find the rule using `--depth` for `padding-left` (it's applied per-nesting-level, so nested `.tree-node`s compound it). Replace it so indentation is a flat per-level amount applied only once per node, not inherited from ancestors. Read the current file first to get the exact existing selector, then change the padding calculation from `calc(var(--depth,0) * 20px)` to a fixed `20px` applied unconditionally to `.tree-node .tree-node` (i.e. indent by one level relative to its immediate parent, not by absolute depth): + +```scss +.tree-node { + .tree-node { + padding-left: 20px; + } +} +``` +Remove the `[style.--depth]` binding usage for padding purposes if the `.scss` no longer references `--depth` anywhere after this change — check `budget-report-tree-node.component.html:1`'s `[style.--depth]="node.depth"` binding and leave it in place only if something else in the `.scss` still uses it (e.g. for a `lvl-N` class-based rule); otherwise it's harmless to leave unused. + +- [ ] **Step 6: Build** + +```bash +cd /Users/nut.looknut/Project/rmutr/rmutr-web/.claude/worktrees/income-budget-report-api +npx tsc --noEmit +ng build --configuration production +``` +Expected: zero new errors. + +- [ ] **Step 7: Verify in the browser** + +1. Create a type-1 draft, disconnect network (devtools offline mode), try saving an amount — confirm an error `Swal` appears instead of silence. +2. Reconnect, edit an amount partially (type `"1234."` and pause) — confirm the field doesn't reset to `0` mid-type. +3. Pick personnel A, edit an amount, then pick personnel B without saving — confirm B's tree loads clean with no leftover pending change from A. +4. Visually inspect indentation at the deepest tree level (`out{N}-invest-equip`, depth 5) — confirm it's not visually indented ~300px, just proportionally nested. +5. Click edit on a legacy-flagged row from the list (Task 8) — confirm it opens the old SyncFusion UI (not a blank tree page). + +- [ ] **Step 8: Commit** + +```bash +git add src/app/feature/income/draft/income-budget-report1/income-budget-report1-list/income-budget-report-list.component.ts src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.ts src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.html src/app/shared/components/budget-report-tree/budget-report-tree-node.component.html src/app/shared/components/budget-report-tree/budget-report-tree-node.component.scss +git commit -m "fix: legacy-row edit routing, error/loading handling, decimal input, and indentation" +``` + +--- + +### Task 10: End-to-end verification against real data (both repos) + +**Files:** none (verification only — this is the equivalent of the original Group A plan's Task 13, which was never executed; that gap is why every defect this remediation plan fixes survived 12 clean task-level reviews) + +- [ ] **Step 1: Full round trip with real production-shaped data** + +Using a real (or realistic test) `personnel_statement_uid` with known ร.2 detail rows and, ideally, a `budget_year_uid` that also has ร.4/ร.5 rows: +1. Navigate to `/app/income-budget-report-income1/add`, pick the personnel_statement. +2. Confirm the tree renders with personnel rows (locked) **and** the ค่าตอบแทน/เงินเพิ่มค่าครองชีพ/งบรายจ่ายอื่นๆ fields editable (not showing `0` permanently) **and**, if the test data has ร.4/ร.5 rows, the ค่าครุภัณฑ์/ค่าที่ดินฯ tables populated. +3. Edit an unlocked amount (e.g. ค่าตอบแทน ค่าใช้สอยและค่าวัสดุ). Confirm the parent group totals update live in the browser. +4. Click บันทึก. Confirm a success message appears. +5. **Reload the page** (full browser refresh, not SPA navigation) and re-open the same report via the list's edit action. Confirm the edited amount survived — this is the check that would have caught the original silent-save-loss bug. +6. Click ส่งออก Excel. Confirm the file downloads (not a 401) and opens with the structured layout from Task 6. +7. Go to `/app/income-budget-report-income2/add`, open the picker. Confirm the type-1 report just created/edited appears in the list (proves the Task 7 dual-write worked end to end, not just in isolated `curl` checks). +8. Go back to `/app/income-budget-report-income1`, confirm the report appears exactly once (not duplicated against any legacy row). +9. If a genuine legacy row exists in this environment (predating this feature), click its edit action and confirm it opens the old SyncFusion UI correctly, and that types 2-10's own add/edit flows are visually and functionally unchanged from before this whole feature branch existed. + +- [ ] **Step 2: Record the result in the ledger** + +Append to `/Users/nut.looknut/Project/rmutr/.superpowers/sdd/2026-08-05-income-budget-report-native-group-a/progress.md`: +``` +=== Remediation plan (2026-08-05-income-budget-report-native-group-a-remediation.md) Tasks 1-10 complete. === +Task 10 (end-to-end verification): [PASS/FAIL per step above — fill in actual results, do not mark complete without having actually run every step against a real environment] +``` + +- [ ] **Step 3: Request final whole-branch re-review** + +Once Task 10 passes for real, dispatch the same final whole-branch review process used for the original Group A plan (`superpowers:requesting-code-review`'s template, on the most capable model, `scripts/review-package` diffed from each worktree's original Group A merge-base through the new remediation HEAD) before merge — do not skip this because the individual remediation tasks were reviewed; the whole point of Group A's own final review was that whole-branch issues aren't visible task-by-task, and this remediation branch is now big enough to have the same risk. diff --git a/docs/superpowers/plans/2026-08-06-income-budget-report-r346-autopull.md b/docs/superpowers/plans/2026-08-06-income-budget-report-r346-autopull.md new file mode 100644 index 0000000..085d184 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-income-budget-report-r346-autopull.md @@ -0,0 +1,492 @@ +# Income Budget Report Native (type 1) — Auto-pull ร.3/ร.4/ร.5/ร.6 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The type-1 native report (`docs/superpowers/specs/2026-08-05-income-budget-report-native-design.md`) currently only auto-pulls ร.2 (personnel) data — every other section (`op-comp` = งบดำเนินงาน/ร.3, `{out}-grant-proj`/`{out}-other` = เงินอุดหนุน/งบรายจ่ายอื่นๆ from ร.6, `{out}-invest-equip`/`{out}-invest-land` = ร.4/ร.5) is a `$0` manual placeholder, seeded by an earlier remediation round that explicitly deferred wiring real data into them (see ledger `.superpowers/sdd/2026-08-05-income-budget-report-native-group-a/progress.md` line 53/63). The design spec always intended these to auto-pull (`ขอบเขต`/`สถาปัตยกรรม` sections) — this plan wires the four remaining data sources so `create_draft` produces a report whose numbers match a real downloaded reference report (a full `งบประมาณรายจ่ายจากเงินรายได้` PDF for คณะอุตสาหกรรมและเทคโนโลยี / วิทยาเขตวังไกลกังวล / ปีงบประมาณ 2569, provided by the human — kept at `/Users/nut.looknut/Downloads/download.pdf` at plan-writing time) node for node, not just the personnel section. + +**Architecture:** Continues directly on the existing worktrees/branches (`rmutr-api/.worktrees/income-budget-report-group-a`, `rmutr-web/.worktrees/income-budget-report-group-a`) — do not create new worktrees. No new tables; `income_budget_report_line` already supports arbitrary `node_key`/`source_type`/`source_uid` rows. Extends `IncomeReportTreeSchema.ForType1()` so `op-comp` becomes a group of 4 leaf-tables (was a single leaf-amount) and `{out}-grant-proj`/`{out}-other` become leaf-tables (were leaf-amounts), matching how `{out}-invest-equip`/`{out}-invest-land` already work. Extends `IncomeBudgetReportService.CreateDraft` to query four more EF-mapped views and generate `t_income_budget_report_line` rows for them, keyed the same way the rest of `CreateDraft` already is (by `personnel_statement`'s own fields — see "Matching key" below). + +**Tech Stack:** ASP.NET Core 8 / EF Core 8, Angular / RxJS. No automated tests in either repo — every task's verification step is `dotnet build` + a real `curl`/browser check against production data, comparing to the reference PDF. + +## Matching key (read this before touching any task) + +**⚠️ Corrected 2026-08-06/07 after Task 1 ran against production — the `budget_year_uid` part of this section was wrong. Keeping the wrong version below struck through so later tasks' briefs (already extracted before this correction) can be cross-checked against what actually changed; every task in this plan now uses the corrected key.** + +`personnel_statement` (ร.2, what `CreateDraft` already has in hand) carries **no** `faculty_uid`/`budget_location_uid` GUIDs, and — confirmed by direct query, `SELECT count(*), count(budget_year_uid) FROM "BUDGET".personnel_statement` → `21, 0` — its `budget_year_uid` column is **NULL for every row in production**, so it cannot be used as a join key at all. What it does reliably carry: `academic_year_name_th` (a string year number, e.g. `"2568"`), `parent_agency_name` (e.g. "คณะอุตสาหกรรมและเทคโนโลยี" — a faculty/department-level value, confirmed by direct query against a real record), `agency_name_th` (e.g. "วิทยาเขตวังไกลกังวล" — a campus/location-level value), `sector` (e.g. "ปริญญาตรี ภาคปกติ"). Every other source table's EF *view* (`v_invest_asset_request_information`, `v_invest_construct_request_information`, `v_request_budget_income`, `v_budget_progress`/`v_budget_progress_list`) denormalizes the matching strings as `faculty_name_th`/`budget_location_name_th`/`budget_year_number` (int, not the useless `budget_year_uid`) — `v_budget_progress` additionally carries `sector_name_th`, required for that source specifically (see Task 2). So every new query in this plan filters by, at minimum: + +```csharp +var yearNumber = int.TryParse(statement.academic_year_name_th, out var parsedYear) ? (int?)parsedYear : null; +// yearNumber == null short-circuits to an empty result (logged), never throws — see Task 1's BuildInvestLines for the exact pattern. +... +c.budget_year_number == yearNumber +&& c.faculty_name_th == statement.parent_agency_name +&& c.budget_location_name_th == statement.agency_name_th +// + c.sector_name_th == statement.sector, for v_budget_progress only (Task 2) — confirmed required there +// via two real production rows for the same faculty/location/year/output that differ ONLY by sector +// (6,719,700 for "ปริญญาตรี ภาคปกติ" vs 736,600 for "ภาคปกติ ฤดูร้อน") and would otherwise be +// ambiguous. Not required for invest_asset/invest_construct/request_budget_income — confirmed via +// Task 1's live verification that faculty+location+year alone was sufficient and unambiguous there. +``` + +~~c.budget_year_uid == statement.budget_year_uid~~ ← **do not use this, it is always comparing against NULL and will silently match zero real rows.** + +This is exact-match (not `.Contains()`/fuzzy, unlike the legacy `Personnel.cs` V3 report) — these are picklist-sourced values on both sides, not free text. **Confirmed working end-to-end by Task 1's live verification against production** (zero-data and real-data cases both checked, see the plan's ledger for the exact numbers). + +## Global Constraints + +- Manual SQL migrations only if a migration turns out to be needed (it shouldn't — no schema change, only new queries and a new nullable DTO field). If you find yourself needing one, stop and confirm with the human first — this plan was scoped assuming none are required. +- `dotnet build` after every task; `Build succeeded. 0 Error(s)` is the bar, same as every other plan in this repo. +- Frontend error handling convention: `catchError(err => { Swal.fire(err.error.description, '', 'error'); return throwError(err); })`. +- Every new field added to `ReportTreeNode`/`ReportTreeTableRow` needs `[JsonProperty("camelCaseName")]` (Newtonsoft, no camelCase policy globally). +- Do not touch `CloneNextRound`'s own logic — it clones lines generically by copying every existing `t_income_budget_report_line` row regardless of `node_key`, so it needs no changes for this plan's new node keys to flow through correctly on a round-2/3 clone. Task 4 just verifies this is actually true post-schema-change; it does not modify `CloneNextRound`. +- Types 2–10 (`income-budget-report.component.ts`, the old SyncFusion paths) must remain byte-for-byte unchanged. + +--- + +## File Map + +**Backend (`rmutr-api/.worktrees/income-budget-report-group-a`):** +- Modify: `Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs` — restructure `op-comp`, `{out}-grant-proj`, `{out}-other` (Tasks 1–3) +- Modify: `Modules/IncomeBudgetReport/Config/ReportTreeNode.cs` — add `PricePerUnit` to `ReportTreeTableRow` (Task 1) +- Modify: `Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs` — new query/seed logic in `CreateDraft`, `BuildNode` row-mapping for the new fields (Tasks 1–3) + +**Frontend (`rmutr-web/.worktrees/income-budget-report-group-a`):** +- Modify: `src/app/shared/components/budget-report-tree/budget-report-tree-node.component.ts`/`.html`/`.scss` — render `pricePerUnit` column when present, generalize the leaf-table column headers (Task 5) + +--- + +### Task 1: Wire ร.4/ร.5 (`invest_asset`/`invest_construct`) into the existing leaf-table nodes + +**Files:** +- Modify: `Modules/IncomeBudgetReport/Config/ReportTreeNode.cs` +- Modify: `Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs` + +**Why:** `{out1..out5}-invest-equip`/`{out1..out5}-invest-land` are already `leaf-table` nodes in the schema (`IncomeReportTreeSchema.cs:64-65`), tagged `SourceType = "invest_asset"`/`"invest_construct"`, but `CreateDraft` never queries either table — they always render `"ยังไม่มีรายการ"`. This task is the most mechanical of the four (both tables are already flat, already leaf-tables — no schema restructuring needed, just query + row mapping), so do it first to validate the matching-key approach in "Matching key" above before the harder Tasks 2–3. + +**Interfaces:** +- Consumes: `_context.v_invest_asset_request_information`, `_context.v_invest_construct_request_information` (both already registered `DbSet`s — confirmed via `Modules/InvestInformations/Databases/Models/...`). +- Produces: `IncomeReportTreeSchema.OutputPrefixByLabel() : Dictionary` (maps a ผลผลิต label to its `out1`..`out5` node-key prefix), consumed by this task and Task 3. + +- [ ] **Step 1: Add `PricePerUnit` to `ReportTreeTableRow`** + +In `ReportTreeNode.cs`, add one field so the frontend can render the PDF's full รายการ/จำนวน/หน่วยนับ/ราคาต่อหน่วย/รวมเงิน table for ค่าครุภัณฑ์ rows (construct rows will leave it null): + +```csharp + public class ReportTreeTableRow + { + [JsonProperty("lineUid")] public string LineUid { get; set; } + [JsonProperty("label")] public string Label { get; set; } + [JsonProperty("qualification")] public string Qualification { get; set; } + [JsonProperty("qty")] public decimal? Qty { get; set; } + [JsonProperty("pricePerUnit")] public decimal? PricePerUnit { get; set; } + [JsonProperty("amount")] public decimal Amount { get; set; } + [JsonProperty("locked")] public bool Locked { get; set; } + } +``` + +`t_income_budget_report_line` already has no `price_per_unit` column — check `Modules/IncomeBudgetReport/Databases/Models/income_budget_report_line/income_budget_report_line.cs` for the exact column list. If it's missing, add a plain nullable `decimal? price_per_unit` scalar column via a manual SQL migration (`ALTER TABLE "BUDGET".income_budget_report_line ADD COLUMN IF NOT EXISTS price_per_unit numeric;`) and the matching C# property + `v_income_budget_report_line` mirror — otherwise skip straight to Step 2. (This is the one place this plan might need a migration — everything else reads from existing views without writing new columns.) + +- [ ] **Step 2: Add the output-label lookup to the schema** + +In `IncomeReportTreeSchema.cs`, add (the docstring explains why prefix matching, not node_key, is needed — invest rows carry a `budget_project_name_th`, which is the ผลผลิต's *label*, not any node_key): + +```csharp + /// Maps each "ผลผลิต" (output) label to its node-key prefix ("out1".."out5"), so + /// CreateDraft can match invest_asset/invest_construct/request_budget_income rows + /// (which carry a budget_project_name_th, not a node_key) to the right output node. + public static Dictionary OutputPrefixByLabel() + { + var result = new Dictionary(); + CollectOutputPrefixesRecursive(ForType1(), result); + return result; + } + + private static void CollectOutputPrefixesRecursive(ReportTreeNodeDef def, Dictionary result) + { + if (def.Kind == "group" && def.NodeKey.StartsWith("out") && def.NodeKey.Length <= 4) + { + result[def.Label.Trim()] = def.NodeKey; + } + foreach (var child in def.Children) + { + CollectOutputPrefixesRecursive(child, result); + } + } +``` + +- [ ] **Step 3: Query and seed invest rows in `CreateDraft`** + +In `IncomeBudgetReportService.cs`, inside `CreateDraft`, after the `BuildMissingManualLines`/personnel-line-building block and before `await _context.t_income_budget_report.AddAsync(report);`, add a block that: +1. Queries `v_invest_asset_request_information` / `v_invest_construct_request_information` filtered by the three-field match key from "Matching key" above. +2. For each row, looks up `OutputPrefixByLabel()[row.budget_project_name_th.Trim()]` to get the `out{N}` prefix; rows whose label doesn't match any of the 5 known ผลผลิต labels are **skipped and logged** (`Console.WriteLine`, not silently dropped) — this schema was verified against one real production file, a label drift for a different faculty is possible and must be visible, not invisible. +3. Builds `t_income_budget_report_line` rows: `node_key = "{prefix}-invest-equip"` / `source_type = "invest_asset"` / `source_uid = ` / `label_th = invest_asset_request_information_name_th` / `qualification_th = budget_unit_name_th` (unit label) / `qty = amount_unit` / `amount = original_amount = total_unit_price ?? 0`; and for construct: `node_key = "{prefix}-invest-land"` / `source_type = "invest_construct"` / `label_th = invest_construct_request_information_name_th` / `qty = null` / `qualification_th = null` / `amount = original_amount = budget ?? 0`. +4. `price_per_unit` (if you added the column in Step 1) is set from `a.price_per_unit` for invest_asset rows only. + +- [ ] **Step 4: Map the new row fields in `BuildNode`** + +In `BuildNode`'s `leaf-table` branch, add `PricePerUnit = line.price_per_unit` (or however Step 1 named the column) to the `ReportTreeTableRow` construction — only if Step 1 added the column. + +- [ ] **Step 5: Build** + +```bash +cd /Users/nut.looknut/Project/rmutr/rmutr-api/.worktrees/income-budget-report-group-a +dotnet build +``` +Expected: `Build succeeded. 0 Error(s)`. + +- [ ] **Step 6: Verify against the reference PDF** + +Create (or re-create — `CreateDraft` is idempotent and self-heals missing lines on an existing draft, see its `existing != null` branch) a draft for the personnel_statement matching พื้นที่ = คณะอุตสาหกรรมและเทคโนโลยี / หน่วยงาน = วิทยาเขตวังไกลกังวล / ปี 2569 (ask the human for the exact `personnel_statement_uid` if it's not obvious from `queryWithAgency`), then `GET .../tree` and confirm every `{out}-invest-equip`/`{out}-invest-land` node's row count and amounts match the reference PDF's ค่าครุภัณฑ์/ค่าที่ดินและสิ่งก่อสร้าง tables for that output (the sample PDF shows these as empty/0 for this particular faculty/year — if that's still true, confirm the *query ran and found zero matching rows*, not that it silently errored; check the `unmatchedInvestLabels`-style log line is empty too). + +- [ ] **Step 7: Commit** + +```bash +git add Modules/IncomeBudgetReport/Config/ReportTreeNode.cs Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs +git commit -m "feat: source invest_asset/invest_construct (ร.4/ร.5) into the leaf-table nodes" +``` + +--- + +### Task 2: Wire ร.3 (`v_budget_progress`) into `op-comp` AND a new per-output "ค่าใช้จ่ายดำเนินงาน" section + +**⚠️ Revised after direct production-DB verification (2026-08-06/07), before this task was ever dispatched — the original version of this task only covered the top-level `op-comp` node. Verifying against the real reference agency's data (คณะอุตสาหกรรมและเทคโนโลยี/วิทยาเขตวังไกลกังวล/2569) revealed `v_budget_progress` also backs a SECOND, per-output section the first draft of this plan missed entirely — see "Why" below for the arithmetic proof. This version supersedes the original; there is no other copy to reconcile against.** + +**Files:** +- Modify: `Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs` +- Modify: `Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs` + +**Why:** `op-comp` ("ค่าตอบแทน ค่าใช้สอยและค่าวัสดุ", top-level, sibling of `plan1`/`plan2`/`plan3`) is currently a single `leaf-amount`, manually seeded at `$0`. The reference PDF shows it fully itemized: **ค่าตอบแทน / ค่าใช้สอย / ค่าวัสดุ / ค่าสาธารณูปโภค**, each with named line items and its own subtotal — these 4 category names are `budget_progress_type_name` values on `v_budget_progress`'s nested `budget_progress_types`; each type's line items are its nested `budget_progress_lists` (label = `budget_progress_list_name`, amount = `price`). + +**But** direct production queries against the reference agency/year found **4** `v_budget_progress` rows, not 1: + +| `budget_progress_uid` | `budget_progress_id` | `budget_project_name_th` | `sector_name_th` | `total_price` | +|---|---|---|---|---| +| `831453fb-...` | 1 | ผลผลิต ผู้สำเร็จการศึกษาด้านวิทยาศาสตร์และเทคโนโลยี | ปริญญาตรี ภาคปกติ | 12,758,064 | +| `03a08b8b-...` | **2** | **รายการงบบุคลากร** | ปริญญาตรี ภาคปกติ | **118,700** ← matches the PDF's top-level งบดำเนินงาน exactly | +| `8bfddd85-...` | **2** | **ผลผลิต ผู้สำเร็จการศึกษาด้านวิทยาศาสตร์และเทคโนโลยี** | **ปริญญาตรี ภาคปกติ** | **6,719,700** ← matches the PDF's out1 "ค่าใช้จ่ายดำเนินงาน" exactly | +| `32783f05-...` | 2 | ผลผลิต ผู้สำเร็จการศึกษาด้านวิทยาศาสตร์และเทคโนโลยี | ภาคปกติ ฤดูร้อน | 736,600 | + +This proves three things at once: (1) `budget_progress_id == 2` is confirmed correct (matches the one existing precedent, `BudgetProgressService.GetSummaryExpense`'s `budget_progress_id == 2` filter) — row `831453fb` (`id=1`) is excluded correctly by this filter and its huge total (12.7M) does NOT belong in this report; (2) the top-level `op-comp` section is matched by a **literal constant** `budget_project_name_th == "รายการงบบุคลากร"`, not any output label — do not run it through `OutputPrefixByLabel()`; (3) `v_budget_progress` has a **second, per-output** row (`budget_project_name_th` == the output's own label, e.g. `out1`'s "ผลผลิต ผู้สำเร็จการศึกษาด้านวิทยาศาสตร์และเทคโนโลยี") that must ALSO be matched by `sector_name_th == statement.sector` — without the sector filter, `8bfddd85` (6,719,700, the right one — `sector_name_th = "ปริญญาตรี ภาคปกติ"`, matching the reference `personnel_statement.sector`) is ambiguous against `32783f05` (736,600, wrong one — `sector_name_th = "ภาคปกติ ฤดูร้อน"`, a summer-term row for the same output/faculty/year/plan that does not belong in this report). + +**Arithmetic proof this per-output row is real and its PDF placement is "ค่าใช้จ่ายดำเนินงาน" under "งบเงินอุดหนุน", a SIBLING of "เงินอุดหนุนค่าใช้จ่ายโครงการ" (not its parent or child):** the reference PDF's out1 (ผลผลิต ผู้สำเร็จการศึกษาด้านวิทยาศาสตร์และเทคโนโลยี) totals 6,885,900. Its printed children are: งบเงินอุดหนุน = 6,719,700 (= exactly this `8bfddd85` row's `total_price`, with NO addition for grant projects — proving the projects list is not nested inside it), เงินอุดหนุนค่าใช้จ่ายโครงการ = 166,200, งบลงทุน = 0, งบรายจ่ายอื่นๆ = 0. `6,719,700 + 166,200 + 0 + 0 = 6,885,900` — matches the ผลผลิต total exactly, confirming `เงินอุดหนุนค่าใช้จ่ายโครงการ` (Task 3's `{prefix}-grant-proj`) is a **sibling** of `งบเงินอุดหนุน` at the output level, not a child of it as the original (now superseded) schema draft had it. **This task restructures the schema accordingly** — Task 3, when it runs, must build on this task's shape (`{prefix}-grant` now only contains `{prefix}-grant-opex`; `{prefix}-grant-proj` moves out to be `{prefix}`'s own direct child, alongside `{prefix}-grant`/`{prefix}-invest`/`{prefix}-other`). + +**Interfaces:** +- Consumes: `_context.v_budget_progress` `.Include(c => c.budget_progress_types).ThenInclude(t => t.budget_progress_lists)`. +- Produces: the corrected `BuildOutput` shape (see Step 1) that Task 3's brief already assumes. + +- [ ] **Step 1: Restructure the schema — both the top-level `op-comp` AND `BuildOutput`** + +In `IncomeReportTreeSchema.cs`, replace: +```csharp +ReportTreeNodeDef.LeafAmount("op-comp", "ค่าตอบแทน ค่าใช้สอยและค่าวัสดุ"), +``` +with: +```csharp +BuildCompCategories("op-comp", "ค่าตอบแทน ค่าใช้สอยและค่าวัสดุ"), +``` + +And replace `BuildOutput` entirely: +```csharp +private static ReportTreeNodeDef BuildOutput(string prefix, string label) +{ + return ReportTreeNodeDef.Group(prefix, label, + ReportTreeNodeDef.Group($"{prefix}-grant", "งบเงินอุดหนุน", + BuildCompCategories($"{prefix}-grant-opex", "ค่าใช้จ่ายดำเนินงาน") + ), + ReportTreeNodeDef.LeafAmount($"{prefix}-grant-proj", "เงินอุดหนุนค่าใช้จ่ายโครงการ"), + ReportTreeNodeDef.Group($"{prefix}-invest", "งบลงทุน", + ReportTreeNodeDef.LeafTable($"{prefix}-invest-equip", "ค่าครุภัณฑ์", "invest_asset"), + ReportTreeNodeDef.LeafTable($"{prefix}-invest-land", "ค่าที่ดินและสิ่งก่อสร้าง", "invest_construct") + ), + ReportTreeNodeDef.LeafAmount($"{prefix}-other", "งบรายจ่ายอื่นๆ") + ); +} + +private static ReportTreeNodeDef BuildCompCategories(string prefix, string label) +{ + return ReportTreeNodeDef.Group(prefix, label, + ReportTreeNodeDef.LeafTable($"{prefix}-comp", "ค่าตอบแทน", "budget_progress"), + ReportTreeNodeDef.LeafTable($"{prefix}-serv", "ค่าใช้สอย", "budget_progress"), + ReportTreeNodeDef.LeafTable($"{prefix}-mat", "ค่าวัสดุ", "budget_progress"), + ReportTreeNodeDef.LeafTable($"{prefix}-util", "ค่าสาธารณูปโภค", "budget_progress") + ); +} +``` +(`{prefix}-grant-proj`/`{prefix}-other` stay `LeafAmount` for now — Task 3 converts them to `LeafTable`, don't do that here, it's out of this task's scope.) Both `op-comp` and `{prefix}-grant-opex`'s own amounts now auto-sum from their 4 children for free via `BuildNode`'s existing `group` branch. The old `node_key = "op-comp"` manual `$0` line seeded by an earlier task becomes inert (no longer looked up by any leaf node in the new schema) — expected and harmless, do not attempt to delete/migrate it. + +- [ ] **Step 2: Category names — already confirmed, use verbatim** + +Already confirmed against production (`SELECT DISTINCT budget_progress_type_name FROM "BUDGET".v_budget_progress_type`): exactly `ค่าตอบแทน`, `ค่าใช้สอย`, `ค่าวัสดุ`, `ค่าสาธารณูปโภค` — no further check needed, use these 4 literal strings verbatim in Step 3's mapping. + +- [ ] **Step 3: Query and seed budget_progress rows in `CreateDraft` — two call sites, one shared helper** + +Add a shared private method (same file as `BuildInvestLines` from Task 1 — follow that method's exact structure/placement/error-logging conventions): +```csharp +private List BuildBudgetProgressCategoryLines( + Guid reportUid, string nodePrefix, string budgetProjectNameTh, t_personnel_statement statement, ref int seq) +{ + var categoryNodeKeys = new Dictionary + { + ["ค่าตอบแทน"] = $"{nodePrefix}-comp", + ["ค่าใช้สอย"] = $"{nodePrefix}-serv", + ["ค่าวัสดุ"] = $"{nodePrefix}-mat", + ["ค่าสาธารณูปโภค"] = $"{nodePrefix}-util", + }; + var lines = new List(); + + var yearNumber = int.TryParse(statement.academic_year_name_th, out var parsedYear) ? (int?)parsedYear : null; + if (yearNumber == null) + { + Console.WriteLine($"[IncomeBudgetReportService] academic_year_name_th '{statement.academic_year_name_th}' is not numeric, skipping budget_progress lookup for {nodePrefix}"); + return lines; + } + + var budgetProgress = _context.v_budget_progress + .Include(c => c.budget_progress_types).ThenInclude(t => t.budget_progress_lists) + .Where(c => c.budget_year_number == yearNumber + && c.faculty_name_th == statement.parent_agency_name + && c.budget_location_name_th == statement.agency_name_th + && c.sector_name_th == statement.sector + && c.budget_progress_id == 2 + && c.budget_project_name_th == budgetProjectNameTh) + .ToList(); + + foreach (var bp in budgetProgress) + { + foreach (var type in bp.budget_progress_types ?? new List()) + { + var typeName = type.budget_progress_type_name?.Trim(); + if (typeName == null || !categoryNodeKeys.TryGetValue(typeName, out var nodeKey)) + { + if (typeName != null) Console.WriteLine($"[IncomeBudgetReportService] budget_progress_type_name '{typeName}' did not match any of the 4 known categories for {nodePrefix}, skipped"); + continue; + } + foreach (var list in type.budget_progress_lists ?? new List()) + { + lines.Add(new t_income_budget_report_line + { + income_budget_report_line_uid = Guid.NewGuid(), + income_budget_report_uid = reportUid, + node_key = nodeKey, + source_type = "budget_progress", + source_uid = list.budget_progress_list_uid, + label_th = list.budget_progress_list_name, + qualification_th = null, + qty = null, + amount = list.price ?? 0, + original_amount = list.price ?? 0, + sequence_no = seq++, + }); + } + } + } + return lines; +} +``` +Then in `CreateDraft`, in the same block/placement as Task 1's invest-line seeding (before `AddAsync(report)`), call it twice: +```csharp +lines.AddRange(BuildBudgetProgressCategoryLines(reportUid, "op-comp", "รายการงบบุคลากร", statement, ref seq)); +foreach (var (outputLabel, prefix) in outputPrefixByLabel) // reuse the dictionary from Task 1 if already computed in this method — don't call OutputPrefixByLabel() twice +{ + lines.AddRange(BuildBudgetProgressCategoryLines(reportUid, $"{prefix}-grant-opex", outputLabel, statement, ref seq)); +} +``` +Apply the SAME self-heal treatment Task 1 gave the invest lines: also call this from the `existing != null` branch (deduped by `source_uid`, same pattern Task 1 already established there) so re-running `CreateDraft` on an existing draft backfills these lines too. + +`.Include(...).ThenInclude(...)` navigation property names (`budget_progress_types`/`budget_progress_lists`) were confirmed directly against the model files during plan-writing — if `dotnet build` disagrees, trust the compiler error over this text. + +- [ ] **Step 4: (resolved, no action needed)** `budget_progress_id == 2` and `sector_name_th` are both now confirmed-required filters, applied in Step 3's query. No further investigation needed here. + +- [ ] **Step 5: Build** + +```bash +cd /Users/nut.looknut/Project/rmutr/rmutr-api/.worktrees/income-budget-report-group-a +dotnet build +``` +Expected: `Build succeeded. 0 Error(s)`. + +- [ ] **Step 6: Verify against the reference PDF** + +`GET .../tree` for a draft on the reference agency (คณะอุตสาหกรรมและเทคโนโลยี/วิทยาเขตวังไกลกังวล/2569): +- `op-comp` (top-level): total must be exactly **118,700**, with `op-comp-comp` = 96,000 (rows: เงินสมทบ... none — this category's real rows are 0 for this agency per the PDF, all its named items are 0), `op-comp-serv` = 22,700 (rows: เงินสมทบกองทุนประกันสังคม = 21,800, เงินสมทบกองทุนเงินทดแทน = 900), `op-comp-mat`/`op-comp-util` = 0. +- `out1-grant-opex` (out1 = ผลผลิต ผู้สำเร็จการศึกษาด้านวิทยาศาสตร์และเทคโนโลยี): total must be exactly **6,719,700**, matching the PDF's "ค่าใช้จ่ายดำเนินงาน" under out1's งบเงินอุดหนุน (ค่าตอบแทน 3,595,000 / ค่าใช้สอย 636,800 / ค่าวัสดุ 2,475,900 / ค่าสาธารณูปโภค 12,000 — check each category's own subtotal too, not just the 6,719,700 grand total, since a mismatched category split with a coincidentally-correct total would still be wrong). +- Confirm `out2`–`out5`'s `-grant-opex` nodes also run cleanly (whether they find real data or legitimately zero — check for skip/error logs either way, same standard as Task 1's verification). + +- [ ] **Step 7: Commit** + +```bash +git add Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs +git commit -m "feat: source v_budget_progress (ร.3) into op-comp and per-output ค่าใช้จ่ายดำเนินงาน" +``` + +--- + +### Task 3: Wire ร.6 (`v_request_budget_income`) into `{out}-grant-proj`/`{out}-other`, restructured as leaf-tables + +**⚠️ Depends on Task 2's schema restructuring — read Task 2 in full before starting, especially its "Why" section's arithmetic proof.** Task 2 already moved `{prefix}-grant-proj` OUT of `{prefix}-grant` (which now only contains `{prefix}-grant-opex`) to be `{prefix}`'s own direct sibling child, confirmed correct via `6,719,700 (grant-opex) + 166,200 (grant-proj) + 0 (invest) + 0 (other) = 6,885,900` matching the PDF's output-level total exactly. This task does NOT redo that move — it only converts `{prefix}-grant-proj`/`{prefix}-other` from `LeafAmount` to `LeafTable` in place, and wires the query. Also uses Task 1's confirmed `budget_year_number` matching-key fix (`budget_year_uid` is NULL for every `personnel_statement` row in production — do not use it). + +**Files:** +- Modify: `Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs` +- Modify: `Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs` + +**Why:** `{out1..out5}-grant-proj`/`{out1..out5}-other` are currently `leaf-amount` nodes seeded at `$0`. The reference PDF shows เงินอุดหนุนค่าใช้จ่ายโครงการ as a **numbered list of named projects** (e.g. "โครงการเรียนรู้บทบาทของผู้ประกอบอาชีพด้านอุตสาหกรรมและเทคโนโลยีในรูปแบบ Pre-Course experience" = 25,500), grouped under one more level in the PDF — "1 เงินอุดหนุน ค่าใช้จ่ายโครงการ" then "1 ด้านการพัฒนาคุณภาพการศึกษา" then the numbered project list — **verify in Step 1a whether this "ด้านการพัฒนาคุณภาพการศึกษา" grouping is always a single fixed value or can vary** (check `budget_topic_name_th`/`budget_faculty_topic` or similar grouping field on `v_request_budget_income` for the reference agency's rows) before deciding whether the schema needs one more nesting level or whether a flat `LeafTable` (this plan's assumption) is sufficient. `v_request_budget_income.type_id` distinguishes the two top-level sections: `type_id == 1` = เงินอุดหนุนค่าใช้จ่ายโครงการ (confirmed convention — `Modules/Report/Controller/Request.cs:651`), `type_id == 2` = งบรายจ่ายอื่นๆ (confirmed — legacy `Personnel.cs` V3 report filters the same way for this exact section, per investigation). + +**Interfaces:** +- Consumes: `_context.v_request_budget_income`, `IncomeReportTreeSchema.OutputPrefixByLabel()` (from Task 1). + +- [ ] **Step 1a: Check the "ด้านการพัฒนาคุณภาพการศึกษา" grouping before writing the schema** + +```sql +SELECT budget_topic_name_th, project_name_th, total_budget, type_id +FROM "BUDGET".v_request_budget_income +WHERE faculty_name_th = 'คณะอุตสาหกรรมและเทคโนโลยี' AND budget_location_name_th = 'วิทยาเขตวังไกลกังวล' + AND budget_year_number = 2569 AND budget_project_name_th = 'ผลผลิต ผู้สำเร็จการศึกษาด้านวิทยาศาสตร์และเทคโนโลยี'; +``` +If every row's grouping field is the same single value for a given output (matching the PDF's single "ด้านการพัฒนาคุณภาพการศึกษา" subheading), a flat `LeafTable` per Step 1's schema is correct — the grouping label itself doesn't need to be its own tree node, just confirm it's not silently hiding multiple distinct groups that the PDF would show as separate subheadings. If you find 2+ distinct grouping values for the same output, stop and report this as a finding rather than guessing — it changes Step 1's schema shape (would need a `LeafTable` per distinct group instead of one per output). + +- [ ] **Step 1b: Convert `{prefix}-grant-proj`/`{prefix}-other` from `LeafAmount` to `LeafTable`** + +In `IncomeReportTreeSchema.cs`, `BuildOutput` (as left by Task 2 — do not reintroduce the old nested `{prefix}-grant` > `{prefix}-grant-proj` shape), change only these two lines: +```csharp +ReportTreeNodeDef.LeafAmount($"{prefix}-grant-proj", "เงินอุดหนุนค่าใช้จ่ายโครงการ"), +``` +→ +```csharp +ReportTreeNodeDef.LeafTable($"{prefix}-grant-proj", "เงินอุดหนุนค่าใช้จ่ายโครงการ", "request_budget_income"), +``` +and +```csharp +ReportTreeNodeDef.LeafAmount($"{prefix}-other", "งบรายจ่ายอื่นๆ") +``` +→ +```csharp +ReportTreeNodeDef.LeafTable($"{prefix}-other", "งบรายจ่ายอื่นๆ", "request_budget_income") +``` + +- [ ] **Step 2: Query and seed request_budget_income rows in `CreateDraft`** + +Add a block (reuse the `yearNumber` parse from Task 1/2 if it's already computed earlier in the same method — don't reparse): +```csharp +var requestBudgetIncomes = await _context.v_request_budget_income + .Where(c => c.budget_year_number == yearNumber + && c.faculty_name_th == statement.parent_agency_name + && c.budget_location_name_th == statement.agency_name_th + && (c.type_id == 1 || c.type_id == 2)) + .ToListAsync(); + +foreach (var rbi in requestBudgetIncomes) +{ + var label = rbi.budget_project_name_th?.Trim(); + if (label == null || !outputPrefixByLabel.TryGetValue(label, out var prefix)) + { + if (label != null) Console.WriteLine($"[IncomeBudgetReportService] request_budget_income budget_project_name_th '{label}' did not match any output, skipped"); + continue; + } + var nodeKey = rbi.type_id == 1 ? $"{prefix}-grant-proj" : $"{prefix}-other"; + lines.Add(new t_income_budget_report_line + { + income_budget_report_line_uid = Guid.NewGuid(), + income_budget_report_uid = reportUid, + node_key = nodeKey, + source_type = "request_budget_income", + source_uid = rbi.request_budget_income_uid, + label_th = rbi.project_name_th, + qualification_th = null, + qty = null, + amount = rbi.total_budget ?? 0, + original_amount = rbi.total_budget ?? 0, + sequence_no = seq++, + }); +} +``` +`outputPrefixByLabel` was already computed once for Task 1's invest-line seeding earlier in the same `CreateDraft` method — reuse that variable, don't call `OutputPrefixByLabel()` again. Apply the same existing-draft self-heal treatment Tasks 1/2 established (dedupe by `source_uid`). + +- [ ] **Step 3: Build** + +```bash +cd /Users/nut.looknut/Project/rmutr/rmutr-api/.worktrees/income-budget-report-group-a +dotnet build +``` +Expected: `Build succeeded. 0 Error(s)`. + +- [ ] **Step 4: Verify against the reference PDF** + +`GET .../tree`, confirm each output's `-grant-proj` node has rows matching the PDF's named โครงการ list under that ผลผลิต (e.g. under ผลผลิต ผู้สำเร็จการศึกษาด้านวิทยาศาสตร์และเทคโนโลยี: "โครงการเรียนรู้บทบาท..." = 25,500, "โครงการอบรมเชิงปฏิบัติการ...ขั้นปีที่ 2" = 48,900, etc., total เงินอุดหนุนค่าใช้จ่ายโครงการ = 166,200) and `-other` matches the PDF's งบรายจ่ายอื่นๆ line (0 in the sample — confirm the query ran and found zero `type_id == 2` rows, not that it errored). Also confirm `out1`'s total (งบเงินอุดหนุน + เงินอุดหนุนค่าใช้จ่ายโครงการ + งบลงทุน + งบรายจ่ายอื่นๆ) now sums to exactly 6,885,900, matching the PDF's ผลผลิต-level total — this is the end-to-end check that Task 2's schema restructuring and this task's data both landed correctly together. + +- [ ] **Step 5: Commit** + +```bash +git add Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs +git commit -m "feat: source v_request_budget_income (ร.6) into grant-proj/other leaf-tables" +``` + +--- + +### Task 4: Confirm `CloneNextRound` still works unmodified against the new schema + +**Files:** none (verification-only task; a real code change here would mean Tasks 1-3 broke an invariant they were supposed to preserve). + +**Why:** `CloneNextRound` copies every existing `t_income_budget_report_line` row by value regardless of `node_key` (`IncomeBudgetReportService.cs:274-288`) — it has no schema awareness at all, so Tasks 1-3's node_key changes (leaf-amount → leaf-table on 6 node keys) should require zero changes here. This task exists to actually prove that, not assume it. + +- [ ] **Step 1: Create a draft, clone it, compare** + +```bash +curl -s -X POST ".../create_draft?personnel_statement_uid=" -H "Authorization: Bearer " > /tmp/draft1.json +curl -s -X POST ".../$(jq -r .income_budget_report_uid /tmp/draft1.json)/clone_next_round" -H "Authorization: Bearer " > /tmp/draft2.json +diff <(curl -s ".../$(jq -r .income_budget_report_uid /tmp/draft1.json)/tree" -H "Authorization: Bearer " | jq 'del(.. | .lineUid?)') \ + <(curl -s ".../$(jq -r .income_budget_report_uid /tmp/draft2.json)/tree" -H "Authorization: Bearer " | jq 'del(.. | .lineUid?)') +``` +Expected: no diff (line uids differ by design, everything else — labels, amounts, row counts, node structure — must be identical between round 1 and its clone). + +- [ ] **Step 2: Record the result in the task report** + +No commit for this task — just document in the report that the diff was empty (or, if not, treat that as a real finding to fix, which would then require reopening this task with an actual code change). + +--- + +### Task 5: Frontend — render the new leaf-table columns + +**Files:** +- Modify: `rmutr-web/.worktrees/income-budget-report-group-a/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.ts` +- Modify: `.../budget-report-tree-node.component.html` +- Modify: `.../budget-report-tree-node.component.scss` + +**Why:** `budget-report-tree-node.component.html`'s `leaf-table` branch currently hardcodes 4 columns titled "รายการ / วุฒิ / อัตรา / จำนวนเงิน (บาท)" — correct for personnel rows (`Qualification` = degree, `Qty` = headcount) but wrong header wording for the new invest/budget_progress/request_budget_income rows this plan adds (`Qualification` now sometimes holds a unit label, not a degree; `Qty` sometimes holds a unit count, not headcount; a new `pricePerUnit` field needs a column for invest_asset rows specifically). Since `ReportTreeNode` already carries the leaf-table's `SourceType` is NOT currently exposed to the frontend (only used server-side to tag the schema) — check whether it needs adding to the DTO, or whether column headers can just be generalized to fit every source type without needing to branch on it. + +- [ ] **Step 1: Decide: generalize headers, or branch by source type** + +Read the current `budget-report-tree-node.component.html`'s leaf-table `` (columns: รายการ/วุฒิ/อัตรา/จำนวนเงิน (บาท)). Two options — pick whichever is a smaller diff once you see the four real row shapes side by side in a live draft (personnel / invest_asset / invest_construct / budget_progress / request_budget_income): + - (a) Generalize the header wording to something source-agnostic ("รายการ / หน่วยนับ / จำนวน / จำนวนเงิน (บาท)") that reads correctly for all five row sources, add a 5th "ราคาต่อหน่วย" column that's simply blank when `pricePerUnit` is null (most rows). + - (b) Expose `SourceType` on `ReportTreeNode` (mirror the `[JsonProperty]` pattern already used for other fields) and switch the header/column set per table based on it. +Prefer (a) unless the row shapes turn out too different to share one column set cleanly — check with a real draft before deciding, don't guess from the schema alone. + +- [ ] **Step 2: Implement the chosen option, add the `pricePerUnit` binding** + +Add `pricePerUnit?: number` to the `ReportTreeTableRow` interface in `budget-report-tree-node.component.ts` (matching backend Task 1's new field), and render it (right-aligned, `number:'1.2-2'`, matching the existing `amount` column's formatting) in the table. + +- [ ] **Step 3: Build** + +```bash +cd /Users/nut.looknut/Project/rmutr/rmutr-web/.worktrees/income-budget-report-group-a +node --max_old_space_size=14336 node_modules/@angular/cli/bin/ng build --configuration production --base-href /budget-web/ +``` +Expected: build succeeds, no new errors. + +- [ ] **Step 4: Verify visually** + +Load a draft for the reference agency/year in a browser, confirm every new section (งบดำเนินงาน's 4 categories, เงินอุดหนุนโครงการ, งบรายจ่ายอื่นๆ, ค่าครุภัณฑ์/ค่าที่ดินและสิ่งก่อสร้าง) renders its rows with sensible column headers and correct-looking amounts — not just that it doesn't crash. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/shared/components/budget-report-tree/ +git commit -m "feat: render invest/budget_progress/request_budget_income leaf-table rows" +``` + +--- + +## Explicitly out of scope for this plan + +- Group B (type_id 4,5,6 — the separate "จัดสรร/ปรับแผนเงินรายได้" 5-tab workbook report) — different report entirely, still deferred. +- The government-form-styled `.xlsx` export, dual-write into `revenue_draft_committee`, and legacy-list union — tracked separately, not touched here. +- Any UI to edit ร.1-3/ร.4/ร.5/ร.6 data at its source — this plan is read-only against those tables, same as the rest of this feature. diff --git a/docs/superpowers/plans/2026-08-20-request-budget-additional-approval-round.md b/docs/superpowers/plans/2026-08-20-request-budget-additional-approval-round.md new file mode 100644 index 0000000..dedb0fb --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-request-budget-additional-approval-round.md @@ -0,0 +1,999 @@ +# ส่งอนุมัติเพิ่มเติม (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` 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 +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`, 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 getApproveState(Guid? reference_uid, Guid? faculty_uid, Guid? budget_location_uid, + string code, ClaimsIdentity claimsIdentity) + { +``` + +to: + +```csharp + public async Task 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> 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 +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 +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 +
+ + สถานะ : อนุมัติแล้ว + สถานะ : ส่งแก้ไข + + + +
+``` + +Change to: + +```html +
+ + สถานะ : อนุมัติแล้ว ({{roundLabel()}}) + สถานะ : ส่งแก้ไข ({{roundLabel()}}) + + + +
+``` + +- [ ] **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 +
+``` + +and further down: + +```html + + +``` + +Change the card's `*ngIf` to: + +```html +
+``` + +and the `*ngFor` to: + +```html + + +``` + +`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 +
+ +
+``` + +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 +
+ + +
+``` + +- [ ] **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 `` 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 +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. diff --git a/docs/superpowers/plans/2026-09-08-request-budget-approve-workflow.md b/docs/superpowers/plans/2026-09-08-request-budget-approve-workflow.md new file mode 100644 index 0000000..50a58b8 --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-request-budget-approve-workflow.md @@ -0,0 +1,1400 @@ +# คำขอแผ่นดิน (request-budget) Approve Workflow 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:** Give `/app/request-budget` (คำขอแผ่นดิน — key=1) a self-service "ระบุผู้อนุมัติ → ส่งอนุมัติ → อนุมัติ 1/2 → ส่งแก้ไข" chain (mirroring the proven `request_expense` code `"04"`/`"05"`/`"06"`/`"07"` pattern), and give `/app/request-budget-plan-01` (key=3) a "ตรวจสอบส่วนกลาง" stage (mirroring the already-shipped `income_budget_review` feature exactly) so that: only fully-approved items show there, กองแผน staff can send an item back to the last approver with a mandatory reason, mark it reviewed, and see a small clickable dashboard of pending/returned/reviewed counts. + +**Architecture:** Add one new `request_approve_type` code (`"08"`) and one new `request_expense.type_id` (`4`) for a self-service approval envelope scoped to `budget_year_uid + faculty_uid + budget_location_uid` (same shape as the existing `type_id=2` "สรุปคำของบประมาณรายได้" envelope). Add a brand-new `RequestBudgetReview` backend module that is a near-exact copy of the already-shipped `IncomeBudgetReview` module (table, model, service, controller, DI registration) — only the request_approve_type codes it triggers on differ. Extend `ApproveState.cs`'s existing `request_expense` branches (row_order==1 gate, chain-completion trigger) with two small additive `if` conditions. On the frontend, reuse the existing shared `SetApproversDialogComponent` for "ระบุผู้อนุมัติ", and copy `IncomeBudgetReviewDashboardComponent`/`IncomeBudgetReviewStatusComponent` into new `RequestBudgetReviewDashboardComponent`/`RequestBudgetReviewStatusComponent` siblings, wiring them into the existing `RequestBudgetContainer`/`RequestBudgetListComponent` (the single shared module behind both `/app/request-budget` and `/app/request-budget-plan-01`). + +**Tech Stack:** .NET 8 / EF Core (raw idempotent SQL migration, matching this repo's convention for new tables) on `rmutr-api`; Angular 15 (RxJS, Angular Material, SweetAlert2) on `rmutr-web`; PostgreSQL, schema `"BUDGET"`. + +**Spec:** `docs/superpowers/specs/2026-09-07-approve-state-workflow-reference.md` (base approve_state mechanics) and `docs/superpowers/specs/2026-09-07-income-budget-review-stage-design.md` (the exact review-stage pattern this plan clones). This plan's own research additionally confirmed, by reading the live current source (not just the specs): +- `request_approve_type` codes `"01"`-`"07"` are all already in use; `"08"` is free. +- `/app/request-budget` (key=1), `/app/request-budget-plan` (key=2) and `/app/request-budget-plan-01` (key=3) are the **same** Angular module (`feature/budget-request/request-budget/request-budget.module.ts`), branched purely by route-data `key` → `this.keyUrl` throughout `request-budget.container.ts` and its children. This is a **different** module from `feature/budget-request/request-budget-plan/` (which backs `income-budget-plan*` and already hosts the shipped `IncomeBudgetReview` UI — useful as a live reference, not a file to edit). +- The real approval engine behind `/app/request-budget` today is `t_request_expense` (`type_id=1`), **not** `t_request_budget` — `t_request_budget`'s own `approve_state` wiring (code `"01"`, via `reference_uid`) is dead/orphaned (created in `RequestBudgetService.NewEntity()`, never read back anywhere). +- Today, `type_id=1` request_expense records auto-seed their approve_state queue from the **admin-configured** code `"03"` queue (`RequestExpenseService.NewEntity()` → `getApproveState(..., "03", ...)`), which is exactly the config-burden the reference spec recommends moving away from. This plan does **not** remove or modify that `type_id=1`/code `"03"` path — it is left completely untouched. Instead this plan adds a **new, separate** `type_id=4` self-service envelope that `/app/request-budget` switches to using going forward. Any historical `type_id=1` records are unaffected and simply won't appear in the new review dashboard (which filters on `type_id==4`). +- `request-budget-plan-01`'s current "only show fully-approved" check is a fragile hardcoded name match (`state.display_name_th === 'พนักงานทั่วไป'`) instead of checking `request_expense.is_approve == true` — this plan fixes that as part of Task 8. + +## Global Constraints + +- Every new backend file goes under `rmutr-api/Modules/RequestBudgetReview/` (mirrors `Modules/IncomeBudgetReview/` exactly). +- New DB table `request_budget_review` — create via a raw idempotent `.sql` migration file (this repo's established pattern for new tables added outside the EF Core scaffold flow — see `Migrations/add_income_budget_review.sql`), **not** `dotnet ef migrations add`. +- New request_approve_type code is the literal string `"08"`. New request_expense type_id is the literal int `4`. Do not reuse `"01"`-`"07"` or `type_id` `1`/`2`/`3`. +- Every new C# file must build: verify with `dotnet build` from `rmutr-api/` after each backend task. +- Every new/changed TS file must type-check: verify with `npx tsc -p tsconfig.app.json --noEmit` from `rmutr-web/` after each frontend task. +- Reuse existing shared components/services wherever the codebase already has one for this exact shape (`SetApproversDialogComponent`, `SweetalertService`, `thaiDate` pipe) — do not create new ones. +- Do not modify any `invest_asset`/`invest_construct`/existing `request_expense` type_id 1/2/3 behavior — every change to shared files (`ApproveState.cs`, `RequestExpenseController.cs`, `RequestExpenseService.cs`) must be strictly additive (new `if` branches, new methods, new routes), never edits to existing conditions. +- This codebase has no automated test suite for either project (confirmed during research) — "tests" in this plan mean a manual `curl`/browser verification step, not an xUnit/Jasmine test file. Follow the existing code's manual-verification convention. + +--- + +### Task 1: Migration — `request_budget_review` table + +**Files:** +- Create: `rmutr-api/Migrations/add_request_budget_review.sql` + +**Interfaces:** +- Produces: table `"BUDGET".request_budget_review` with columns `request_budget_review_uid uuid PK`, `request_expense_uid uuid`, `review_status_id smallint`, `remark text`, plus `base_table` columns (`status_id`, `created_by`, `created_datetime`, `updated_by`, `updated_datetime`, `owner_agency_uid`). + +- [ ] **Step 1: Write the migration file** + +```sql +-- Migration: สร้างตาราง request_budget_review — เก็บ log การตรวจสอบส่วนกลางของคำขอแผ่นดิน +-- (request_expense, request_approve_type in ("03","08"), type_id=4) หลังเชนอนุมัติผ่านครบ +-- ดู docs/superpowers/plans/2026-09-08-request-budget-approve-workflow.md +-- Schema placeholder {0} replaced at runtime by default_schema (e.g. BUDGET). +-- Idempotent: safe to run on every startup. + +CREATE TABLE IF NOT EXISTS "{0}".request_budget_review ( + request_budget_review_uid uuid PRIMARY KEY, + request_expense_uid uuid, + review_status_id smallint NOT NULL, + remark text, + status_id smallint, + created_by text, + created_datetime timestamptz, + updated_by text, + updated_datetime timestamptz, + owner_agency_uid uuid +); + +CREATE INDEX IF NOT EXISTS ix_request_budget_review_request_expense_uid + ON "{0}".request_budget_review (request_expense_uid); +``` + +- [ ] **Step 2: Find how `.sql` migrations under `Migrations/` get executed on startup** + +Run: `grep -rn "add_income_budget_review\|\.sql\"" /Users/nut.looknut/Project/rmutr/rmutr-api/Startup.cs /Users/nut.looknut/Project/rmutr/rmutr-api/Program.cs 2>/dev/null` + +This tells you whether `.sql` files in `Migrations/` are auto-discovered by filename glob or must be listed explicitly somewhere. If explicit, add `add_request_budget_review.sql` to that same list/array right next to `add_income_budget_review.sql`. + +- [ ] **Step 3: Build and run once locally to confirm the table is created** + +Run: `cd rmutr-api && dotnet build` +Expected: build succeeds (this is a pure `.sql` file, nothing to compile yet — this step just confirms Task 1 didn't break the existing build). + +- [ ] **Step 4: Commit** + +```bash +git add Migrations/add_request_budget_review.sql +git commit -m "feat: add request_budget_review table for คำขอแผ่นดิน central-review stage" +``` + +--- + +### Task 2: Backend model — `request_budget_review` + +**Files:** +- Create: `rmutr-api/Modules/RequestBudgetReview/Databases/Models/request_budget_review.cs` + +**Interfaces:** +- Produces: `t_request_budget_review` class (used by Task 3's `Db.cs` registration and Task 4's service). + +- [ ] **Step 1: Write the model, copying `income_budget_review.cs` exactly with names swapped** + +```csharp +using System; +using System.ComponentModel.DataAnnotations; +using SeventyOneDev.Utilities; + +namespace rmutr_budget_api.Modules.RequestBudgetReview.Databases.Models +{ + public class request_budget_review : base_table + { + [Key] public Guid? request_budget_review_uid { get; set; } + public Guid? request_expense_uid { get; set; } + /// 1 = รอตรวจสอบ, 2 = ส่งแก้ไข, 3 = ตรวจสอบแล้ว — ดูรายละเอียดที่ + /// docs/superpowers/plans/2026-09-08-request-budget-approve-workflow.md + public short review_status_id { get; set; } + public string remark { get; set; } + } + + public class t_request_budget_review : request_budget_review + { + } +} +``` + +- [ ] **Step 2: Build** + +Run: `cd rmutr-api && dotnet build` +Expected: succeeds (new file compiles standalone, no references to it yet). + +- [ ] **Step 3: Commit** + +```bash +git add Modules/RequestBudgetReview/Databases/Models/request_budget_review.cs +git commit -m "feat: add t_request_budget_review model" +``` + +--- + +### Task 3: Wire `t_request_budget_review` into the shared `Db` DbContext + +**Files:** +- Create: `rmutr-api/Modules/RequestBudgetReview/Databases/Db.cs` +- Modify: `rmutr-api/Databases/Db.cs` (find the exact line via the grep below — it currently calls `OnIncomeBudgetReviewModelCreating(builder,schema);`) + +**Interfaces:** +- Consumes: `t_request_budget_review` from Task 2. +- Produces: `_context.t_request_budget_review` (a `DbSet`) usable by Task 4's service. + +- [ ] **Step 1: Create the partial-class DbContext file, copying `Modules/IncomeBudgetReview/Databases/Db.cs`** + +```csharp +using Microsoft.EntityFrameworkCore; +using rmutr_budget_api.Modules.RequestBudgetReview.Databases.Models; + +namespace SeventyOneDev.Utilities +{ + public partial class Db : DbContext + { + public DbSet t_request_budget_review { get; set; } + + private void OnRequestBudgetReviewModelCreating(ModelBuilder builder, string schema) + { + builder.Entity().ToTable("request_budget_review", schema); + } + } +} +``` + +- [ ] **Step 2: Register the model-creating call in the main `Db.cs`** + +Run: `grep -n "OnIncomeBudgetReviewModelCreating" /Users/nut.looknut/Project/rmutr/rmutr-api/Databases/Db.cs` + +Add a new line immediately after the matched line: +```csharp + OnRequestBudgetReviewModelCreating(builder,schema); +``` +(match the exact indentation of the line above it.) + +- [ ] **Step 3: Build** + +Run: `cd rmutr-api && dotnet build` +Expected: succeeds. If it fails with a duplicate-table or ambiguous-DbSet error, confirm you didn't accidentally paste this into the same partial file as `t_income_budget_review`. + +- [ ] **Step 4: Commit** + +```bash +git add Modules/RequestBudgetReview/Databases/Db.cs Databases/Db.cs +git commit -m "feat: register t_request_budget_review DbSet" +``` + +--- + +### Task 4: Backend service — `RequestBudgetReviewService` + +**Files:** +- Create: `rmutr-api/Modules/RequestBudgetReview/Services/RequestBudgetReview.cs` + +**Interfaces:** +- Consumes: `t_request_budget_review`/`request_budget_review` (Task 2), `Db` (Task 3), `t_request_expense`/`v_request_expense` (existing, `rmutr_budget_api.Modules.Request.Databases.Models`), `t_approve_state` (existing). +- Produces: `RequestBudgetReviewService` with public methods `GetStatus(Guid)`, `GetDashboard()`, `ReturnForEdit(Guid, string, ClaimsIdentity)`, `MarkReviewed(Guid, ClaimsIdentity)` — same signatures as `IncomeBudgetReviewService`, consumed by Task 5's controller. + +- [ ] **Step 1: Write the service, copying `IncomeBudgetReview.cs` (service) with these exact changes:** + - Namespace/class names `IncomeBudgetReview*` → `RequestBudgetReview*`. + - `review_status_id` field stays the same name (matches Task 2's model). + - `GetDashboard()`'s candidate filter: `e.type_id == 2` → `e.type_id == 4` (the new self-service envelope from Task 6), **and** allow the legacy `type_id == 1` records that already fully approved through the old code `"03"` path, so nothing already-approved silently disappears from the review queue. Filter becomes `(e.type_id == 4 || e.type_id == 1)`. + +```csharp +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using rmutr_budget_api.Modules.RequestBudgetReview.Databases.Models; +using rmutr_budget_api.Modules.Request.Databases.Models; +using SeventyOneDev.Utilities; + +namespace rmutr_budget_api.Modules.RequestBudgetReview.Services +{ + public class request_budget_review_history_item + { + public DateTime? datetime { get; set; } + public string by { get; set; } + public string detail { get; set; } + } + + public class request_budget_review_status_response + { + public Guid? request_expense_uid { get; set; } + public string current_status { get; set; } + public List history { get; set; } = new(); + } + + public class request_budget_review_dashboard_item + { + public Guid? request_expense_uid { get; set; } + public Guid? budget_year_uid { get; set; } + public string budget_year_name_th { get; set; } + public Guid? faculty_uid { get; set; } + public string faculty_name_th { get; set; } + public Guid? budget_location_uid { get; set; } + public string budget_location_name_th { get; set; } + public DateTime? updated_datetime { get; set; } + } + + public class request_budget_review_dashboard + { + public List pending { get; set; } = new(); + public List returned { get; set; } = new(); + public List reviewed { get; set; } = new(); + } + + public class RequestBudgetReviewService + { + private readonly Db _context; + + public RequestBudgetReviewService(Db context) + { + _context = context; + } + + private string ComputeStatus(t_request_expense entity, t_request_budget_review latestLog) + { + if (entity.is_draft == true && entity.is_approve == false) return "returned"; + if (entity.is_approve == true) + { + if (latestLog != null && latestLog.review_status_id == 3) return "reviewed"; + return "pending"; + } + return "not_ready"; + } + + public async Task GetStatus(Guid requestExpenseUid) + { + var entity = await _context.t_request_expense + .FirstOrDefaultAsync(c => c.request_expense_uid == requestExpenseUid); + if (entity == null) return null; + + var logs = await _context.t_request_budget_review.AsNoTracking() + .Where(c => c.request_expense_uid == requestExpenseUid) + .OrderByDescending(c => c.created_datetime) + .ToListAsync(); + var latestLog = logs.FirstOrDefault(); + + var remarks = await _context.t_remark_history.AsNoTracking() + .Where(c => c.request_expense_uid == requestExpenseUid) + .OrderByDescending(c => c.updated_date) + .ToListAsync(); + + var history = remarks + .Select(r => new request_budget_review_history_item + { + datetime = r.updated_date, + by = r.updated_name, + detail = r.remark_history_name_th, + }) + .OrderByDescending(h => h.datetime) + .ToList(); + + return new request_budget_review_status_response + { + request_expense_uid = requestExpenseUid, + current_status = ComputeStatus(entity, latestLog), + history = history, + }; + } + + public async Task GetDashboard() + { + var result = new request_budget_review_dashboard(); + + var candidates = await _context.v_request_expense.AsNoTracking() + .Where(e => (e.type_id == 4 || e.type_id == 1) + && (e.is_approve == true || (e.is_draft == true && e.is_approve == false))) + .ToListAsync(); + var candidateIds = candidates.Select(c => c.request_expense_uid).ToList(); + var allLogs = await _context.t_request_budget_review.AsNoTracking() + .Where(r => candidateIds.Contains(r.request_expense_uid)) + .ToListAsync(); + var latestLogByRequest = allLogs + .GroupBy(r => r.request_expense_uid) + .ToDictionary(g => g.Key, g => g.OrderByDescending(r => r.created_datetime).First()); + + var years = await _context.v_budget_year.AsNoTracking() + .ToDictionaryAsync(c => c.budget_year_uid, c => c.budget_year_name_th); + var locations = await _context.v_budget_location.AsNoTracking() + .ToDictionaryAsync(c => c.budget_location_uid, c => c.budget_location_name_th); + var faculties = await _context.v_faculty.AsNoTracking() + .ToDictionaryAsync(c => c.faculty_uid, c => c.faculty_name_th); + + request_budget_review_dashboard_item ToItem(v_request_expense e) => new() + { + request_expense_uid = e.request_expense_uid, + budget_year_uid = e.budget_year_uid, + budget_year_name_th = e.budget_year_uid.HasValue && years.TryGetValue(e.budget_year_uid, out var y) ? y : null, + faculty_uid = e.faculty_uid, + faculty_name_th = e.faculty_uid.HasValue && faculties.TryGetValue(e.faculty_uid, out var f) ? f : e.faculty_name_th, + budget_location_uid = e.budget_location_uid, + budget_location_name_th = e.budget_location_uid.HasValue && locations.TryGetValue(e.budget_location_uid, out var l) ? l : null, + updated_datetime = e.updated_datetime, + }; + + foreach (var e in candidates) + { + latestLogByRequest.TryGetValue(e.request_expense_uid, out var latestLog); + var status = ComputeStatus(new t_request_expense + { + is_draft = e.is_draft, + is_approve = e.is_approve, + }, latestLog); + var item = ToItem(e); + if (status == "pending") result.pending.Add(item); + else if (status == "returned") result.returned.Add(item); + else if (status == "reviewed") result.reviewed.Add(item); + } + + return result; + } + + public async Task<(bool ok, string error)> ReturnForEdit(Guid requestExpenseUid, string remark, + ClaimsIdentity claimsIdentity) + { + var entity = await _context.t_request_expense + .FirstOrDefaultAsync(c => c.request_expense_uid == requestExpenseUid); + if (entity == null) return (false, "ไม่พบรายการ"); + if (entity.is_approve != true) + return (false, "รายการนี้ยังไม่ผ่านการอนุมัติครบทุกลำดับ ไม่สามารถส่งแก้ไขจากการตรวจสอบได้"); + if (string.IsNullOrWhiteSpace(remark)) + return (false, "กรุณาระบุรายละเอียดที่ส่งแก้ไข"); + + var currentRound = await _context.t_approve_state + .Where(c => c.request_expense_uid == requestExpenseUid) + .Select(c => (int?)c.round_no).MaxAsync() ?? 1; + var lastRow = await _context.t_approve_state + .Where(c => c.request_expense_uid == requestExpenseUid && c.round_no == currentRound) + .OrderByDescending(c => c.row_order) + .FirstOrDefaultAsync(); + if (lastRow == null) return (false, "ไม่พบผู้อนุมัติของรายการนี้"); + + lastRow.is_state = true; + lastRow.is_approve = null; + _context.t_approve_state.Update(lastRow); + + entity.is_draft = true; + entity.is_approve = false; + entity.current_approve_name = lastRow.display_name_th; + if (lastRow.row_order == 1) entity.is_sent = false; + _context.t_request_expense.Update(entity); + + await _context.t_remark_history.AddAsync(new t_remark_history + { + remark_history_uid = Guid.NewGuid(), + request_expense_uid = requestExpenseUid, + remark_history_name_th = remark, + updated_date = DateTime.Now, + updated_name = claimsIdentity?.FindFirst("display_name")?.Value, + }); + + await _context.t_request_budget_review.AddAsync(new t_request_budget_review + { + request_budget_review_uid = Guid.NewGuid(), + request_expense_uid = requestExpenseUid, + review_status_id = 2, + remark = remark, + }); + + await _context.SaveChangesAsync(claimsIdentity); + return (true, null); + } + + public async Task<(bool ok, string error)> MarkReviewed(Guid requestExpenseUid, ClaimsIdentity claimsIdentity) + { + var entity = await _context.t_request_expense + .FirstOrDefaultAsync(c => c.request_expense_uid == requestExpenseUid); + if (entity == null) return (false, "ไม่พบรายการ"); + if (entity.is_approve != true) + return (false, "รายการนี้ยังไม่ผ่านการอนุมัติครบทุกลำดับ"); + + var latestLog = await _context.t_request_budget_review.AsNoTracking() + .Where(c => c.request_expense_uid == requestExpenseUid) + .OrderByDescending(c => c.created_datetime) + .FirstOrDefaultAsync(); + if (latestLog == null || latestLog.review_status_id != 1) + return (false, "รายการนี้ไม่ได้อยู่ในสถานะรอตรวจสอบ"); + + await _context.t_request_budget_review.AddAsync(new t_request_budget_review + { + request_budget_review_uid = Guid.NewGuid(), + request_expense_uid = requestExpenseUid, + review_status_id = 3, + }); + await _context.SaveChangesAsync(claimsIdentity); + return (true, null); + } + } +} +``` + +- [ ] **Step 2: Build** + +Run: `cd rmutr-api && dotnet build` +Expected: succeeds. If `v_request_expense`/`t_remark_history`/`t_approve_state` namespaces don't resolve, check the exact `using` lines at the top of `Modules/IncomeBudgetReview/Services/IncomeBudgetReview.cs` and match them (this plan's `using rmutr_budget_api.Modules.Request.Databases.Models;` is the one confirmed during research, but confirm before assuming). + +- [ ] **Step 3: Commit** + +```bash +git add Modules/RequestBudgetReview/Services/RequestBudgetReview.cs +git commit -m "feat: add RequestBudgetReviewService (status/dashboard/return_for_edit/mark_reviewed)" +``` + +--- + +### Task 5: Backend controller + DI registration — `RequestBudgetReviewController` + +**Files:** +- Create: `rmutr-api/Modules/RequestBudgetReview/Controllers/RequestBudgetReview.cs` +- Create: `rmutr-api/Modules/RequestBudgetReview/Configures/RequestBudgetReview.cs` +- Modify: `rmutr-api/Startup.cs` (find via grep below) + +**Interfaces:** +- Consumes: `RequestBudgetReviewService` (Task 4). +- Produces: `GET /api/request_budget_review/status/{request_expense_uid}`, `GET /api/request_budget_review/dashboard`, `POST /api/request_budget_review/return_for_edit/{request_expense_uid}` (body `{ remark }`), `POST /api/request_budget_review/mark_reviewed/{request_expense_uid}` — consumed by Task 9's Angular service. + +- [ ] **Step 1: Write the controller, copying `IncomeBudgetReview.cs` (controller) with names swapped** + +```csharp +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using rmutr_budget_api.Modules.RequestBudgetReview.Services; + +namespace rmutr_budget_api.Modules.RequestBudgetReview.Controllers +{ + public class request_budget_return_for_edit_input + { + public string remark { get; set; } + } + + [Route("api/request_budget_review", Name = "request_budget_review")] + public class RequestBudgetReviewController : Controller + { + private readonly RequestBudgetReviewService _service; + + public RequestBudgetReviewController(RequestBudgetReviewService service) + { + _service = service; + } + + [Authorize] + [HttpGet("status/{request_expense_uid:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetStatus( + [FromRoute] Guid request_expense_uid) + { + var result = await _service.GetStatus(request_expense_uid); + return result == null ? NotFound() : Ok(result); + } + + [Authorize] + [HttpGet("dashboard")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetDashboard() + { + return Ok(await _service.GetDashboard()); + } + + [Authorize] + [HttpPost("return_for_edit/{request_expense_uid:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task ReturnForEdit([FromRoute] Guid request_expense_uid, + [FromBody] request_budget_return_for_edit_input input) + { + var result = await _service.ReturnForEdit(request_expense_uid, input?.remark, + User.Identity as ClaimsIdentity); + if (!result.ok) return BadRequest(new { description = result.error }); + return Ok(); + } + + [Authorize] + [HttpPost("mark_reviewed/{request_expense_uid:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task MarkReviewed([FromRoute] Guid request_expense_uid) + { + var result = await _service.MarkReviewed(request_expense_uid, User.Identity as ClaimsIdentity); + if (!result.ok) return BadRequest(new { description = result.error }); + return Ok(); + } + } +} +``` + +- [ ] **Step 2: Write the DI configure extension, copying `Configures/IncomeBudgetReview.cs`** + +```csharp +using Microsoft.Extensions.DependencyInjection; +using rmutr_budget_api.Modules.RequestBudgetReview.Services; + +namespace rmutr_budget_api.Modules.RequestBudgetReview.Configures +{ + public static class RequestBudgetReviewCollectionExtension + { + public static IServiceCollection AddRequestBudgetReviewServices(this IServiceCollection services) + { + services.AddScoped(); + return services; + } + } +} +``` + +- [ ] **Step 3: Register in `Startup.cs`** + +Run: `grep -n "AddIncomeBudgetReviewServices" /Users/nut.looknut/Project/rmutr/rmutr-api/Startup.cs` + +Add a new line immediately after the matched line (matching its indentation and any `using` statement it needs at the top of `Startup.cs` — check whether `IncomeBudgetReview`'s `Configures` namespace has an explicit `using` at the top of the file, and add the equivalent one for `RequestBudgetReview.Configures` if so): +```csharp + services.AddRequestBudgetReviewServices(); +``` + +- [ ] **Step 4: Build and smoke-test the new endpoints** + +Run: `cd rmutr-api && dotnet build` +Expected: succeeds. + +Run the API locally, then: +```bash +curl -s http://localhost:5000/api/request_budget_review/dashboard -H "Authorization: Bearer " +``` +Expected: `200 OK` with `{"pending":[],"returned":[],"reviewed":[]}` (empty, since no `type_id==4` records exist yet). + +- [ ] **Step 5: Commit** + +```bash +git add Modules/RequestBudgetReview/Controllers/RequestBudgetReview.cs Modules/RequestBudgetReview/Configures/RequestBudgetReview.cs Startup.cs +git commit -m "feat: add RequestBudgetReviewController + DI registration" +``` + +--- + +### Task 6: Backend — self-service approvers for `type_id=4` on `RequestExpenseController` + +**Files:** +- Modify: `rmutr-api/Modules/Requests/Controllers/RequestExpense.cs` (add new actions right after the existing `type_3/send_approve/{uid:guid}` action, i.e. right after line ~638 per current file — confirm exact line via grep below) +- Modify: `rmutr-api/Modules/Requests/Services/RequestExpense.cs` (add one new method right after `NewEntityType3`, i.e. right after line ~154 — confirm via grep below) + +**Interfaces:** +- Consumes: existing generic `SetCustomApprovers(Guid?, List, ClaimsIdentity, string approveTypeCode = "04")` and `SendApprove2(Guid?, ClaimsIdentity)` on `RequestExpenseService` — **no changes needed to either**, both already accept any code / any type_id. +- Produces: `POST /api/request/request_expense/type_4` (creates the envelope, idempotent per year+faculty+location), `POST /api/request/request_expense/type_4/set_approvers/{uid}`, `POST /api/request/request_expense/type_4/send_approve/{uid}` — consumed by Task 9's Angular service. + +- [ ] **Step 1: Confirm exact insertion points** + +Run: `grep -n "NewEntityType3\|type_3/send_approve\|class RequestExpenseController" /Users/nut.looknut/Project/rmutr/rmutr-api/Modules/Requests/Controllers/RequestExpense.cs /Users/nut.looknut/Project/rmutr/rmutr-api/Modules/Requests/Services/RequestExpense.cs` + +- [ ] **Step 2: Add `NewEntityType4` to `RequestExpenseService.cs`, right after `NewEntityType3`** + +```csharp + /// + /// สร้างชุดอนุมัติของคำขอแผ่นดิน (type_id = 4) ผูกด้วย budget_year_uid+faculty_uid+budget_location_uid + /// เหมือน type_2 — สร้างแบบ lazy (idempotent: คืนของเดิมถ้ามีอยู่แล้ว) ให้หน้า request-budget เรียกตอน + /// กด "ระบุผู้อนุมัติ" ครั้งแรก ไม่ผ่าน getApproveState/code "03" (คนละ path จาก type_id=1 เดิม โดยตั้งใจ + /// — ดู docs/superpowers/plans/2026-09-08-request-budget-approve-workflow.md) + /// + public async Task NewEntityType4(t_request_expense entity, ClaimsIdentity claimsIdentity) + { + var exist = await _context.t_request_expense.FirstOrDefaultAsync(c => + c.type_id == 4 && c.budget_year_uid == entity.budget_year_uid + && c.faculty_uid == entity.faculty_uid && c.budget_location_uid == entity.budget_location_uid); + if (exist != null) return exist; + + var agency = Helper.GetDataFilter(claimsIdentity, new List() { "agency_uid" }); + if (agency.Any()) + { + entity.owner_agency_uid = agency.FirstOrDefault(); + } + + entity.type_id = 4; + entity.status_id ??= 1; + entity.request_expense_uid = Guid.NewGuid(); + await _context.t_request_expense.AddAsync(entity); + await _context.SaveChangesAsync(claimsIdentity); + return entity; + } +``` + +- [ ] **Step 3: Add the three new controller actions to `RequestExpenseController.cs`, right after the existing `SendApprove3` action** + +```csharp + /// + /// สร้าง/ดึงชุดอนุมัติของคำขอแผ่นดิน (type_id = 4) 1 ใบต่อ ปีงบ+คณะ+พื้นที่ — idempotent + /// + [Authorize] + [HttpPost("type_4")] + [ProducesResponseType(StatusCodes.Status201Created)] + public async Task> NewEntityUid4([FromBody] t_request_expense entity) + { + var result = await _entityUidService.NewEntityType4(entity, User.Identity as ClaimsIdentity); + return Created("", result); + } + + /// กำหนดผู้อนุมัติเองสำหรับคำขอแผ่นดิน — ตรรกะเดียวกับ type_2/type_3 ทุกอย่าง ต่างแค่ code เป็น "08" + [Authorize] + [HttpPost("type_4/set_approvers/{uid:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task SetApprovers4([FromRoute] Guid uid, [FromBody] List approvers) + { + var result = await _entityUidService.SetCustomApprovers(uid, approvers, + User.Identity as ClaimsIdentity, "08"); + if (!result.ok) + { + return BadRequest(new { description = result.error }); + } + + return Ok(); + } + + /// ส่งอนุมัติลำดับ 1 ของคำขอแผ่นดิน — ใครก็ได้กดได้ แค่ตั้ง is_sent=true เหมือน type_2/type_3 + [Authorize] + [HttpPost("type_4/send_approve/{uid:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task SendApprove4([FromRoute] Guid uid) + { + var result = await _entityUidService.SendApprove2(uid, User.Identity as ClaimsIdentity); + if (!result.ok) + { + return BadRequest(new { description = result.error }); + } + + return Ok(); + } +``` + +- [ ] **Step 4: Build** + +Run: `cd rmutr-api && dotnet build` +Expected: succeeds. + +- [ ] **Step 5: Manual smoke test** + +```bash +# create the envelope +curl -s -X POST http://localhost:5000/api/request/request_expense/type_4 \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -d '{"budget_year_uid":"","faculty_uid":"","budget_location_uid":""}' +# expect 201 with a request_expense_uid in the body + +# set approvers on it +curl -s -X POST http://localhost:5000/api/request/request_expense/type_4/set_approvers/ \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -d '[{"personnel_uid":"","display_name_th":"ทดสอบ ผู้อนุมัติ"}]' +# expect 200 + +# send for approval +curl -s -X POST http://localhost:5000/api/request/request_expense/type_4/send_approve/ \ + -H "Authorization: Bearer " +# expect 200 +``` + +- [ ] **Step 6: Commit** + +```bash +git add Modules/Requests/Controllers/RequestExpense.cs Modules/Requests/Services/RequestExpense.cs +git commit -m "feat: add type_4 self-service approvers flow for คำขอแผ่นดิน (request-budget)" +``` + +--- + +### Task 7: Backend — extend `ApproveState.cs` for code `"08"` + trigger the review stage + +**Files:** +- Modify: `rmutr-api/Modules/Setting/Controllers/ApproveState.cs` + +**Interfaces:** +- Consumes: `RequestBudgetReviewService`? — **no**, do NOT inject the service here; insert the log row directly via `_db.t_request_budget_review.AddAsync(...)` exactly like the existing `"04"` → `t_income_budget_review` trigger does (keeps `ApproveStateController` free of a new service dependency, matching the existing pattern precisely). +- Produces: code `"08"` now gets the same row_order==1 `is_sent`+identity gate as `"04"`/`"05"`; chain completion for codes `"03"` or `"08"` auto-inserts a `request_budget_review` row with `review_status_id=1`. + +- [ ] **Step 1: Add `using` for the new model namespace** + +At the top of `ApproveState.cs`, alongside the existing `using rmutr_budget_api.Modules.IncomeBudgetReview.Databases.Models;`, add: +```csharp +using rmutr_budget_api.Modules.RequestBudgetReview.Databases.Models; +``` + +- [ ] **Step 2: Extend the row_order==1 gate (line ~174-175) to include `"08"`** + +Find (exact current text, confirmed during research): +```csharp + if (getResult.row_order == 1 + && (getResult.request_approve_type == "04" || getResult.request_approve_type == "05")) +``` +Replace with: +```csharp + if (getResult.row_order == 1 + && (getResult.request_approve_type == "04" || getResult.request_approve_type == "05" + || getResult.request_approve_type == "08")) +``` + +- [ ] **Step 3: Add the review-stage trigger right after the existing `"04"` → `income_budget_review` block (line ~209-218)** + +Find (exact current text): +```csharp + if (getResult.request_approve_type == "04") + { + await _db.t_income_budget_review.AddAsync(new t_income_budget_review + { + income_budget_review_uid = Guid.NewGuid(), + request_expense_uid = uid, + review_status_id = 1, + }); + await _db.SaveChangesAsync(User.Identity as ClaimsIdentity); + } +``` +Add immediately after it (same indentation, sibling `if` — do not change the existing block at all): +```csharp + // เชนอนุมัติของคำขอแผ่นดินผ่านครบ (ทั้งทาง config เดิม "03" และทาง self-service ใหม่ + // "08") — ส่งเข้าสู่ขั้นตอนตรวจสอบส่วนกลางโดยอัตโนมัติเหมือน "04" ทำกับคำขอเงินรายได้ ดู + // docs/superpowers/plans/2026-09-08-request-budget-approve-workflow.md + if (getResult.request_approve_type == "03" || getResult.request_approve_type == "08") + { + await _db.t_request_budget_review.AddAsync(new t_request_budget_review + { + request_budget_review_uid = Guid.NewGuid(), + request_expense_uid = uid, + review_status_id = 1, + }); + await _db.SaveChangesAsync(User.Identity as ClaimsIdentity); + } +``` + +- [ ] **Step 4: Build** + +Run: `cd rmutr-api && dotnet build` +Expected: succeeds. + +- [ ] **Step 5: Manual smoke test — complete the chain from Task 6's test and confirm a review row appears** + +```bash +curl -s http://localhost:5000/api/setting/approve_state/approve/ \ + -H "Authorization: Bearer " +# expect 200; the approver in this test only has 1 row (row_order=1), so this single call +# both satisfies row_order==1 AND completes the chain (no row_order==2 to advance to) + +curl -s http://localhost:5000/api/request_budget_review/dashboard -H "Authorization: Bearer " +# expect the item to now appear in "pending" +``` + +- [ ] **Step 6: Commit** + +```bash +git add Modules/Setting/Controllers/ApproveState.cs +git commit -m "feat: gate code 08 + auto-trigger request_budget_review on คำขอแผ่นดิน chain completion" +``` + +--- + +### Task 8: Backend — fix the "only fully-approved" filter used by `request-budget-plan-01` + +**Files:** +- Modify: `rmutr-api/Modules/Requests/Controllers/RequestExpense.cs` (the base `[HttpGet]` list action that `queryString(...)` hits — the one at line ~199 per the earlier controller listing) + +**Interfaces:** +- No signature change — this is a behavior-only fix so that filtering by `is_approve==true` server-side becomes possible without the frontend needing to guess. **Actually the simplest, lowest-risk fix belongs on the frontend (Task 11)** — the existing `GET api/request/request_expense?budget_year_uid=...&faculty_uid=...&budget_location_uid=...` endpoint already returns full `approve_states`/`is_approve` on each `v_request_expense` row (confirmed: this is the base query-by-example list action, no changes needed here). **Skip this backend task — no server change needed.** Leave this task's checkbox here only as a record that it was considered and intentionally not needed; proceed directly to Task 9. + +- [ ] **Step 1: Confirm no backend change is needed** + +Run: `grep -n "class v_request_expense" -A 5 /Users/nut.looknut/Project/rmutr/rmutr-api/Modules/Requests/Databases/Models/request_expense.cs` +Expected: confirms `is_approve`, `is_draft`, `type_id` are all plain scalar columns on `v_request_expense`, already returned by the existing list endpoint with no extra work — the actual fix is purely in the Angular filter logic (Task 11). + +--- + +### Task 9: Angular service — `RequestBudgetReviewService` + `RequestExpenseService` additions + +**Files:** +- Create: `rmutr-web/src/app/core/service/request-budget-review/request-budget-review.service.ts` +- Modify: `rmutr-web/src/app/core/service/request/request-expense.service.ts` + +**Interfaces:** +- Produces: `RequestBudgetReviewService` (`getStatus`, `getDashboard`, `returnForEdit`, `markReviewed`) and 4 new methods on `RequestExpenseService` (`newType4`, `setApprovers4`, `sendApprove4` — `approve`/`reject` are already generic and reused as-is). + +- [ ] **Step 1: Create the Angular service, copying `income-budget-review.service.ts` exactly with names swapped** + +```typescript +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root' +}) +export class RequestBudgetReviewService { + + private base = `${environment.rmutrApi}/api/request_budget_review`; + + constructor(private http: HttpClient) { } + + getStatus(requestExpenseUid: string) { + return this.http.get(`${this.base}/status/${requestExpenseUid}`); + } + + getDashboard() { + return this.http.get(`${this.base}/dashboard`); + } + + returnForEdit(requestExpenseUid: string, remark: string) { + return this.http.post(`${this.base}/return_for_edit/${requestExpenseUid}`, { remark }); + } + + markReviewed(requestExpenseUid: string) { + return this.http.post(`${this.base}/mark_reviewed/${requestExpenseUid}`, {}); + } +} +``` + +- [ ] **Step 2: Add the 3 new methods to `RequestExpenseService`** + +Run: `grep -n "setApprovers3\|sendApprove3\|getMyDashboard" /Users/nut.looknut/Project/rmutr/rmutr-web/src/app/core/service/request/request-expense.service.ts` + +Add right after the matched methods: +```typescript + newType4(payload: { budget_year_uid: string, faculty_uid: string, budget_location_uid: string }) { + return this.http.post(`${this.fullUrl}/type_4`, payload) + } + setApprovers4(id, approvers: { personnel_uid: string, display_name_th: string }[]) { + return this.http.post(`${this.fullUrl}/type_4/set_approvers/${id}`, approvers) + } + sendApprove4(id) { + return this.http.post(`${this.fullUrl}/type_4/send_approve/${id}`, {}) + } +``` + +- [ ] **Step 3: Type-check** + +Run: `cd rmutr-web && npx tsc -p tsconfig.app.json --noEmit` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/core/service/request-budget-review/request-budget-review.service.ts src/app/core/service/request/request-expense.service.ts +git commit -m "feat: add Angular services for คำขอแผ่นดิน type_4 approvers + review stage" +``` + +--- + +### Task 10: Angular components — `RequestBudgetReviewDashboardComponent` + `RequestBudgetReviewStatusComponent` + +**Files:** +- Create: `rmutr-web/src/app/feature/budget-request/request-budget/presenter/dashboard/request-budget-review-dashboard/request-budget-review-dashboard.component.ts` +- Create: `rmutr-web/src/app/feature/budget-request/request-budget/presenter/dashboard/request-budget-review-dashboard/request-budget-review-dashboard.component.html` +- Create: `rmutr-web/src/app/feature/budget-request/request-budget/presenter/dashboard/request-budget-review-dashboard/request-budget-review-dashboard.component.scss` +- Create: `rmutr-web/src/app/feature/budget-request/request-budget/presenter/status/request-budget-review-status/request-budget-review-status.component.ts` +- Create: `rmutr-web/src/app/feature/budget-request/request-budget/presenter/status/request-budget-review-status/request-budget-review-status.component.html` +- Create: `rmutr-web/src/app/feature/budget-request/request-budget/presenter/status/request-budget-review-status/request-budget-review-status.component.scss` +- Modify: `rmutr-web/src/app/feature/budget-request/request-budget/request-budget.module.ts` (declare the 2 new components) + +**Interfaces:** +- Consumes: `RequestBudgetReviewService` (Task 9). +- Produces: `` emitting `ReviewDashboardItem { request_expense_uid, budget_year_uid, budget_year_name_th, faculty_uid, faculty_name_th, budget_location_uid, budget_location_name_th, updated_datetime }`; `` — both consumed by Task 11. + +- [ ] **Step 1: Copy the `.scss` files verbatim (styling is generic, no renames needed)** + +Run: +```bash +mkdir -p "rmutr-web/src/app/feature/budget-request/request-budget/presenter/dashboard/request-budget-review-dashboard" +mkdir -p "rmutr-web/src/app/feature/budget-request/request-budget/presenter/status/request-budget-review-status" +cp "rmutr-web/src/app/feature/budget-request/request-budget-plan/presenter/dashboard/income-budget-review-dashboard/income-budget-review-dashboard.component.scss" \ + "rmutr-web/src/app/feature/budget-request/request-budget/presenter/dashboard/request-budget-review-dashboard/request-budget-review-dashboard.component.scss" +cp "rmutr-web/src/app/feature/budget-request/request-budget-plan/presenter/status/income-budget-review-status/income-budget-review-status.component.scss" \ + "rmutr-web/src/app/feature/budget-request/request-budget/presenter/status/request-budget-review-status/request-budget-review-status.component.scss" +``` + +- [ ] **Step 2: Write the dashboard component (`.ts`), copying `income-budget-review-dashboard.component.ts` with names swapped** + +```typescript +import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef, EventEmitter, Output } from '@angular/core'; +import { RequestBudgetReviewService } from 'src/app/core/service/request-budget-review/request-budget-review.service'; + +export interface ReviewDashboardItem { + request_expense_uid: string; + budget_year_uid: string; + budget_year_name_th: string; + faculty_uid: string; + faculty_name_th: string; + budget_location_uid: string; + budget_location_name_th: string; + updated_datetime: string; +} + +interface ReviewDashboardData { + pending: ReviewDashboardItem[]; + returned: ReviewDashboardItem[]; + reviewed: ReviewDashboardItem[]; +} + +const EMPTY_DATA: ReviewDashboardData = { pending: [], returned: [], reviewed: [] }; + +@Component({ + selector: 'app-request-budget-review-dashboard', + templateUrl: './request-budget-review-dashboard.component.html', + styleUrls: ['./request-budget-review-dashboard.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class RequestBudgetReviewDashboardComponent implements OnInit { + + @Output() onselectitem = new EventEmitter(); + + isLoading = true; + data: ReviewDashboardData = EMPTY_DATA; + expandedKey: keyof ReviewDashboardData | null = null; + + constructor( + private reviewSV: RequestBudgetReviewService, + private cd: ChangeDetectorRef, + ) { } + + ngOnInit(): void { + this.reviewSV.getDashboard().subscribe({ + next: (res: any) => { + this.data = { ...EMPTY_DATA, ...res }; + this.isLoading = false; + this.cd.markForCheck(); + }, + error: () => { + this.isLoading = false; + this.cd.markForCheck(); + } + }); + } + + get hasData(): boolean { + return this.data.pending.length > 0 || this.data.returned.length > 0 || this.data.reviewed.length > 0; + } + + toggle(key: keyof ReviewDashboardData) { + this.expandedKey = this.expandedKey === key ? null : key; + } + + selectItem(item: ReviewDashboardItem) { + this.expandedKey = null; + this.onselectitem.emit(item); + } + +} +``` + +- [ ] **Step 3: Write the dashboard component (`.html`), copying `income-budget-review-dashboard.component.html` verbatim (no renames needed inside)** + +```html +
+
+ + + +
+ +
+
+ {{item.faculty_name_th}} + {{item.budget_location_name_th}} + ปีงบ {{item.budget_year_name_th}} + {{item.updated_datetime | thaiDate}} +
+
ไม่มีรายการ
+
+
+``` + +- [ ] **Step 4: Write the status component (`.ts`), copying `income-budget-review-status.component.ts` with names swapped** + +```typescript +import { Component, Input, OnChanges, SimpleChanges, ChangeDetectionStrategy, ChangeDetectorRef, EventEmitter, Output } from '@angular/core'; +import { catchError, tap } from 'rxjs/operators'; +import { throwError } from 'rxjs'; +import { RequestBudgetReviewService } from 'src/app/core/service/request-budget-review/request-budget-review.service'; +import Swal from 'sweetalert2'; + +@Component({ + selector: 'app-request-budget-review-status', + templateUrl: './request-budget-review-status.component.html', + styleUrls: ['./request-budget-review-status.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class RequestBudgetReviewStatusComponent implements OnChanges { + + @Input() requestExpenseUid: string | null = null; + @Input() showActions = false; + @Output() onchanged = new EventEmitter(); + + isLoading = false; + status: any = null; + + statusLabel: { [key: string]: string } = { + not_ready: 'ยังไม่ผ่านการอนุมัติภายในคณะครบทุกลำดับ', + pending: 'รอตรวจสอบ', + returned: 'ถูกส่งแก้ไข รอคณะแก้ไข', + reviewed: 'ตรวจสอบแล้ว', + }; + + constructor( + private reviewSV: RequestBudgetReviewService, + private cd: ChangeDetectorRef, + ) { } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.requestExpenseUid) { + this.load(); + } + } + + load() { + this.status = null; + if (!this.requestExpenseUid) { + this.cd.markForCheck(); + return; + } + this.isLoading = true; + this.reviewSV.getStatus(this.requestExpenseUid).pipe( + tap((res: any) => { + this.status = res; + this.isLoading = false; + this.cd.markForCheck(); + }), + catchError(err => { + this.isLoading = false; + this.cd.markForCheck(); + return throwError(err); + }) + ).subscribe(); + } + + async onReturnForEdit() { + const { value: remark } = await Swal.fire({ + input: 'textarea', + inputLabel: 'รายละเอียดที่ส่งแก้ไข', + inputPlaceholder: 'กรอกรายละเอียดที่ส่งแก้ไข...', + width: 1200, + showCancelButton: true, + confirmButtonText: 'ส่งแก้ไข', + cancelButtonText: 'ยกเลิก', + inputValidator: (v) => !v ? 'กรุณาระบุรายละเอียดที่ต้องแก้ไข' : null, + }); + if (!remark) return; + + this.reviewSV.returnForEdit(this.requestExpenseUid, remark).pipe( + catchError(err => { + Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error'); + return throwError(err); + }) + ).subscribe(() => { + Swal.fire({ title: 'ส่งแก้ไขสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง' }).then(() => { + this.load(); + this.onchanged.emit(); + }); + }); + } + + onMarkReviewed() { + Swal.fire({ + icon: 'warning', + title: 'ยืนยันว่าตรวจสอบแล้วใช่หรือไม่', + showCancelButton: true, + confirmButtonText: 'ยืนยัน', + cancelButtonText: 'ยกเลิก', + }).then((result) => { + if (!result.isConfirmed) return; + this.reviewSV.markReviewed(this.requestExpenseUid).pipe( + catchError(err => { + Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error'); + return throwError(err); + }) + ).subscribe(() => { + Swal.fire({ title: 'บันทึกสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง' }).then(() => { + this.load(); + this.onchanged.emit(); + }); + }); + }); + } + +} +``` + +Note: unlike the copied `income-budget-review-status.component.ts`, this version adds `inputValidator` to `onReturnForEdit()`'s `Swal.fire` so the reason is mandatory — matching this task's explicit "ระบุรายละเอียดที่ต้องแก้ไข" requirement (the service-layer `ReturnForEdit` already rejects a blank remark too, so this is defense-in-depth, not the only guard). + +- [ ] **Step 5: Write the status component (`.html`), copying `income-budget-review-status.component.html` verbatim** + +```html +
+
+ สถานะการตรวจสอบ: + + {{statusLabel[status.current_status]}} + +
+ +
+ + + + + + + + + + + + + + + +
วันที่รายละเอียดผู้บันทึก
{{h.datetime | thaiDate}}{{h.detail}}{{h.by}}
+
+ +
+ + +
+
+``` + +- [ ] **Step 6: Declare both components in `request-budget.module.ts`** + +Add imports and add both class names to the `declarations` array, mirroring how `request-budget-plan.module.ts` declares `IncomeBudgetReviewDashboardComponent`/`IncomeBudgetReviewStatusComponent` (confirmed pattern: `import { X } from './presenter/.../x.component'; ... declarations: [..., X, Y]`). + +- [ ] **Step 7: Type-check** + +Run: `cd rmutr-web && npx tsc -p tsconfig.app.json --noEmit` +Expected: no errors. + +- [ ] **Step 8: Commit** + +```bash +git add "src/app/feature/budget-request/request-budget/presenter/dashboard/request-budget-review-dashboard" \ + "src/app/feature/budget-request/request-budget/presenter/status/request-budget-review-status" \ + src/app/feature/budget-request/request-budget/request-budget.module.ts +git commit -m "feat: add RequestBudgetReviewDashboard/Status components" +``` + +--- + +### Task 11: Wire it all into `RequestBudgetContainer` — self-service approvers on key=1, review stage on key=3 + +**Files:** +- Modify: `rmutr-web/src/app/feature/budget-request/request-budget/container/request-budget/request-budget.container.ts` +- Modify: `rmutr-web/src/app/feature/budget-request/request-budget/container/request-budget/request-budget.container.html` +- Modify: `rmutr-web/src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.ts` +- Modify: `rmutr-web/src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.html` + +**Interfaces:** +- Consumes: `RequestExpenseService.newType4/setApprovers4/sendApprove4` (Task 9), shared `SetApproversDialogComponent` (existing, `src/app/shared/components/set-approvers-dialog/set-approvers-dialog.component.ts`), ``/`` (Task 10). + +- [ ] **Step 1: Add container state + the "ระบุผู้อนุมัติ"/"ส่งอนุมัติ" flow for `keyUrl==1`** + +In `request-budget.container.ts`, add these fields near the other `approve_button`-related fields (around line 40): +```typescript + request_expense_4: any = null +``` + +Add a new `MatDialog` import and inject it in the constructor (`private dialog: MatDialog`), then add these methods (place them right after `on_resubmit`): +```typescript + ensureType4Envelope() { + if (!this.searchInput?.budget_year_uid || !this.searchInput?.faculty_uid || !this.searchInput?.budget_location_uid) { + return + } + this.RequestExpenseSV.newType4({ + budget_year_uid: this.searchInput.budget_year_uid, + faculty_uid: this.searchInput.faculty_uid, + budget_location_uid: this.searchInput.budget_location_uid, + }).pipe( + tap((x: any) => { + this.request_expense_4 = x + this.cdRef.detectChanges() + }), + ).subscribe() + } + + onSetApprovers4() { + const existing = (this.request_expense_4?.approve_states || []).slice().sort((a, b) => a.row_order - b.row_order) + const ref = this.dialog.open(SetApproversDialogComponent, { + width: '600px', + disableClose: true, + data: { existing }, + }) + ref.afterClosed().subscribe((approvers) => { + if (!approvers) return + this.RequestExpenseSV.setApprovers4(this.request_expense_4.request_expense_uid, approvers).pipe( + catchError(err => { + Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error') + return throwError(err) + }) + ).subscribe(() => { + Swal.fire({ title: 'บันทึกผู้อนุมัติสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง' }).then(() => { + this.ensureType4Envelope() + }) + }) + }) + } + + onSendApprove4() { + Swal.fire({ + icon: 'warning', + title: 'ต้องการส่งอนุมัติหรือไม่', + showCancelButton: true, + confirmButtonText: 'ส่งอนุมัติ', + cancelButtonText: 'ยกเลิก', + }).then((result) => { + if (!result.isConfirmed) return + this.RequestExpenseSV.sendApprove4(this.request_expense_4.request_expense_uid).pipe( + catchError(err => { + Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error') + return throwError(err) + }) + ).subscribe(() => { + Swal.fire({ title: 'ส่งอนุมัติสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง' }).then(() => { + this.ensureType4Envelope() + }) + }) + }) + } +``` + +Add the two new imports at the top of the file: +```typescript +import { MatDialog } from '@angular/material/dialog'; +import { SetApproversDialogComponent } from 'src/app/shared/components/set-approvers-dialog/set-approvers-dialog.component'; +``` + +In `onClickSearch(query)`, inside the `if(this.keyUrl == 1){...}` block, right after the existing `.subscribe()` call for that block, add: +```typescript + if (this.keyUrl == 1) { + this.ensureType4Envelope() + } +``` + +- [ ] **Step 2: Surface the "ระบุผู้อนุมัติ"/"ส่งอนุมัติ" buttons + waiting-for-whom message on `keyUrl==1`, and the review dashboard + status on `keyUrl==3`** + +In `request-budget.container.html`, add near the top (before ``, mirroring how `request-budget-plan/.../request-budget.container.html` places its dashboard): +```html + +``` + +Add `onSelectDashboardItem` to `request-budget.container.ts` (mirrors the existing `income-report` dashboard-click pattern): +```typescript + onSelectDashboardItem(item: any) { + const query = { + budget_year_uid: item.budget_year_uid, + faculty_uid: item.faculty_uid, + budget_location_uid: item.budget_location_uid, + } + this.onClickSearch(query) + } +``` + +After `` and before ``, add: +```html +
+ + +
+ info + รอ {{request_expense_4.current_approve_name}} อนุมัติ +
+
+ check_circle + อนุมัติครบทุกลำดับแล้ว +
+
+ + +``` + +- [ ] **Step 3: Fix the `keyUrl==3` "only fully-approved" filter** + +In `request-budget.container.ts`, replace the entire `if(this.keyUrl == 3){...}` block (the one using `state.display_name_th === 'พนักงานทั่วไป'`) with: +```typescript + if(this.keyUrl == 3){ + this.RequestExpenseSV.queryString(`?budget_year_uid=${this.searchInput.budget_year_uid}&faculty_uid=${this.searchInput.faculty_uid}&budget_location_uid=${this.searchInput.budget_location_uid}`).pipe( + tap((x:any)=>{ + const fullyApproved = (x || []).find((item: any) => item.is_approve === true) + if (!fullyApproved) { + this.is_general_staff_approved = false + this.approve_button = null + this.requestBudget = [] + this.requestBudgetType2 = [] + this.InvestAssetRequest = [] + this.InvestConstructRequest = [] + this.BudgetProgress = [] + this.BudgetProgressType3 = [] + Swal.fire({ + icon: 'info', + title: 'ยังไม่พร้อมตรวจสอบ', + text: 'คำขอนี้ต้องผ่านการอนุมัติครบทุกลำดับก่อนจึงจะแสดงในหน้านี้ได้', + confirmButtonText: 'ตกลง' + }); + this.cdRef.detectChanges() + return; + } + this.is_general_staff_approved = true + this.getApprove([fullyApproved]) + }), + ).subscribe() + } +``` + +This directly satisfies "ที่เมนูนี้จะต้องดึงข้อมูลที่ผู้อนุมัติลำดับสุดท้ายอนุมัติมาแล้วเท่านั้น ไม่มีสถานะอื่นปะปน" — the only condition checked is the entity's own `is_approve === true` (set exactly once, by `ApproveGetEntity`, only when there is no next `row_order` to advance to), replacing the old fragile approver-name string match. + +- [ ] **Step 4: Type-check** + +Run: `cd rmutr-web && npx tsc -p tsconfig.app.json --noEmit` +Expected: no errors. If `SetApproversDialogComponent`'s constructor `data` shape doesn't match what's passed here, read `src/app/shared/components/set-approvers-dialog/set-approvers-dialog.component.ts` and adjust the `data: { existing }` payload to match its actual `@Inject(MAT_DIALOG_DATA)` shape exactly (confirmed during research to seed 2 fixed rows from `existing[0]`/`existing[1]` — the payload key might be `data.existing` or something else; verify against the live file, don't guess). + +- [ ] **Step 5: Manual browser verification** + +Run the app locally (`npm start`), then: +1. Navigate to `/app/request-budget`, pick a budget year + faculty + location that has no prior `type_id=4` envelope. +2. Confirm a "ระบุผู้อนุมัติ" button appears; click it, pick 1-2 approvers, save — confirm success toast and that "ส่งอนุมัติ" now appears. +3. Click "ส่งอนุมัติ" — confirm the "รอ [ชื่อ] อนุมัติ" message appears. +4. Log in as that approver (or use their token), call `approve()` on the item — confirm the item appears at `/app/request-budget-plan-01` with the "ตรวจสอบแล้ว"/"ส่งแก้ไข" buttons visible and the dashboard chip count incrementing. +5. Click "ส่งแก้ไข", try submitting with an empty reason — confirm it's blocked; fill in a reason — confirm success, and that the item disappears from `request-budget-plan-01`'s "only fully-approved" view (since `is_approve` flips back to `false`). +6. Click "ตรวจสอบแล้ว" on a pending item — confirm it moves to the "ตรวจสอบแล้ว" dashboard bucket. + +- [ ] **Step 6: Commit** + +```bash +git add 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 \ + src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.ts \ + src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.html +git commit -m "feat: wire self-service approvers (key=1) + review stage (key=3) into request-budget pages" +``` + +--- + +## Explicitly out of scope (YAGNI, matching the income_budget_review precedent's own scope cuts) + +- Real-time/email/LINE notifications — dashboard loads on page entry, same as `income_budget_review`. +- Fine-grained reviewer permissions beyond "can access this menu" — matches the existing `income_budget_review` scope cut exactly. +- Editing the underlying budget numbers from `request-budget-plan-01` — this plan only adds status display + status-changing buttons, not a data-editing UI. +- Migrating or touching the existing `type_id=1`/code `"03"` admin-config approval path — left completely untouched; historical records using it are unaffected.