Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4471b224cf | ||
|
|
24e25f8c9d | ||
|
|
90c9775937 | ||
|
|
f883f64c82 | ||
|
|
29f2da97da | ||
|
|
f0441b9e96 | ||
|
|
d93df3c624 | ||
|
|
f834bbefd8 | ||
|
|
5f634c1bf8 | ||
|
|
efbc4618c2 |
@@ -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
|
||||||
|
<button mat-raised-button cdkFocusInitial (click)="close_request_budget()">ยกเลิก</button>
|
||||||
|
<button mat-raised-button class="bg-bpi-primary-color" (click)="save()" *ngIf="typeUrl != 2001" color="primary">บันทึก</button>
|
||||||
|
```
|
||||||
|
Change to:
|
||||||
|
```html
|
||||||
|
<span *ngIf="typeUrl == 21 && isLocked" style="color:#ef4444;font-weight:600;margin-right:12px;">ส่งกองนโยบายและแผนแล้ว — ไม่สามารถแก้ไขได้</span>
|
||||||
|
<button mat-raised-button cdkFocusInitial (click)="close_request_budget()">ยกเลิก</button>
|
||||||
|
<button mat-raised-button class="bg-bpi-primary-color" (click)="save()" *ngIf="typeUrl != 2001 && typeUrl != 21" color="primary">บันทึก</button>
|
||||||
|
<button mat-raised-button class="bg-bpi-primary-color" (click)="save()" *ngIf="typeUrl == 21 && !isLocked" color="primary">บันทึกฉบับร่าง</button>
|
||||||
|
```
|
||||||
|
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<any[]>()` — 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<any>(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
|
||||||
|
<div class="list-card">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button mat-raised-button color="primary" class="btn-add" (click)="add()">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
เพิ่มคำขอระหว่างปี
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table mat-table [dataSource]="dataSource" class="list-table">
|
||||||
|
<tr mat-header-row
|
||||||
|
*matHeaderRowDef="['action','budget_year_name_th','budget_location_name_th','faculty_name_th','project_name_th','budget_project_name_th','budget_topic_name_th','request_type','total_budget']">
|
||||||
|
</tr>
|
||||||
|
<tr mat-row
|
||||||
|
*matRowDef="let myRowData; columns: ['action','budget_year_name_th','budget_location_name_th','faculty_name_th','project_name_th','budget_project_name_th','budget_topic_name_th','request_type','total_budget']">
|
||||||
|
</tr>
|
||||||
|
<tr mat-footer-row
|
||||||
|
*matFooterRowDef="['action','budget_year_name_th','budget_location_name_th','faculty_name_th','project_name_th','budget_project_name_th','budget_topic_name_th','request_type','total_budget']">
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Action -->
|
||||||
|
<ng-container matColumnDef="action">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-action"></th>
|
||||||
|
<td mat-cell *matCellDef="let x" class="col-action">
|
||||||
|
<span class="material-icons action-icon" (click)="edit(x)" title="แก้ไข">edit</span>
|
||||||
|
<span class="material-icons action-icon action-icon--delete" (click)="delete(x)" title="ลบ">delete</span>
|
||||||
|
</td>
|
||||||
|
<td mat-footer-cell *matFooterCellDef></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- ปีงบประมาณ -->
|
||||||
|
<ng-container matColumnDef="budget_year_name_th">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-year">ปีงบฯ</th>
|
||||||
|
<td mat-cell *matCellDef="let x" class="col-year">
|
||||||
|
<span class="badge-year">{{x.budget_year_name_th}}</span>
|
||||||
|
</td>
|
||||||
|
<td mat-footer-cell *matFooterCellDef></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- พื้นที่ -->
|
||||||
|
<ng-container matColumnDef="budget_location_name_th">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-location">พื้นที่</th>
|
||||||
|
<td mat-cell *matCellDef="let x" class="col-location">
|
||||||
|
<span class="text-clip">{{x.budget_location_name_th}}</span>
|
||||||
|
</td>
|
||||||
|
<td mat-footer-cell *matFooterCellDef></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- หน่วยงาน/คณะ -->
|
||||||
|
<ng-container matColumnDef="faculty_name_th">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-faculty">หน่วยงาน/คณะ</th>
|
||||||
|
<td mat-cell *matCellDef="let x" class="col-faculty">
|
||||||
|
<span class="text-clip">{{x.responsible_faculty_name_th}}</span>
|
||||||
|
</td>
|
||||||
|
<td mat-footer-cell *matFooterCellDef></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- ชื่อโครงการ -->
|
||||||
|
<ng-container matColumnDef="project_name_th">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-project">ชื่อโครงการ</th>
|
||||||
|
<td mat-cell *matCellDef="let x" class="col-project">
|
||||||
|
<span class="text-clip project-name">{{x.project_name_th}}</span>
|
||||||
|
</td>
|
||||||
|
<td mat-footer-cell *matFooterCellDef></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- ผลผลิต -->
|
||||||
|
<ng-container matColumnDef="budget_project_name_th">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-project-type">ผลผลิต</th>
|
||||||
|
<td mat-cell *matCellDef="let x" class="col-project-type">
|
||||||
|
<span class="text-clip">{{x.budget_project_name_th}}</span>
|
||||||
|
</td>
|
||||||
|
<td mat-footer-cell *matFooterCellDef></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- ด้าน -->
|
||||||
|
<ng-container matColumnDef="budget_topic_name_th">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-topic">ด้าน</th>
|
||||||
|
<td mat-cell *matCellDef="let x" class="col-topic">
|
||||||
|
<span class="text-clip">{{x.budget_topic_name_th}}</span>
|
||||||
|
</td>
|
||||||
|
<td mat-footer-cell *matFooterCellDef></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- ประเภทคำขอ -->
|
||||||
|
<ng-container matColumnDef="request_type">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-request-type">ประเภทคำขอ</th>
|
||||||
|
<td mat-cell *matCellDef="let x" class="col-request-type">
|
||||||
|
<span class="badge badge-between">คำขอระหว่างปี</span>
|
||||||
|
</td>
|
||||||
|
<td mat-footer-cell *matFooterCellDef></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- สถานะ -->
|
||||||
|
<!-- <ng-container matColumnDef="status">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-status">สถานะ</th>
|
||||||
|
<td mat-cell *matCellDef="let x" class="col-status">
|
||||||
|
<span *ngIf="x.is_ok == true" class="badge badge-success">ตรวจสอบแล้ว</span>
|
||||||
|
<span *ngIf="x.is_modify == true" class="badge badge-warning">ส่งแก้ไข</span>
|
||||||
|
<span *ngIf="!x.is_ok && !x.is_modify && x.status_id == 1" class="badge badge-pending">รอตรวจสอบ</span>
|
||||||
|
<span *ngIf="x.status_id == 0" class="badge badge-draft">ร่าง</span>
|
||||||
|
</td>
|
||||||
|
<td mat-footer-cell *matFooterCellDef class="footer-label">จำนวนเงินรวม</td>
|
||||||
|
</ng-container> -->
|
||||||
|
|
||||||
|
<!-- วงเงิน -->
|
||||||
|
<ng-container matColumnDef="total_budget">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-amount">วงเงิน (บาท)</th>
|
||||||
|
<td mat-cell *matCellDef="let x" class="col-amount">
|
||||||
|
{{x.total_budget | number:'1.2-2'}}
|
||||||
|
</td>
|
||||||
|
<td mat-footer-cell *matFooterCellDef class="col-amount footer-total">
|
||||||
|
{{data | total:'total_budget' | number:'1.2-2'}}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-paginator [pageSizeOptions]="[20, 50, 100]" showFirstLastButtons (page)="change($event)"></mat-paginator>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace it entirely with:
|
||||||
|
```html
|
||||||
|
<div class="list-card">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button mat-raised-button color="primary" class="btn-add" (click)="add()">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
เพิ่มคำขอระหว่างปี
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="accent" class="btn-send" [disabled]="selection.selected.length === 0" (click)="sendToStrategic()">
|
||||||
|
<mat-icon>send</mat-icon>
|
||||||
|
ส่งกองนโยบายและแผน
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table mat-table [dataSource]="dataSource" class="list-table">
|
||||||
|
<tr mat-header-row
|
||||||
|
*matHeaderRowDef="['checkbox','action','budget_year_name_th','budget_location_name_th','faculty_name_th','project_name_th','budget_project_name_th','budget_topic_name_th','request_type','status','total_budget']">
|
||||||
|
</tr>
|
||||||
|
<tr mat-row
|
||||||
|
*matRowDef="let myRowData; columns: ['checkbox','action','budget_year_name_th','budget_location_name_th','faculty_name_th','project_name_th','budget_project_name_th','budget_topic_name_th','request_type','status','total_budget']">
|
||||||
|
</tr>
|
||||||
|
<tr mat-footer-row
|
||||||
|
*matFooterRowDef="['checkbox','action','budget_year_name_th','budget_location_name_th','faculty_name_th','project_name_th','budget_project_name_th','budget_topic_name_th','request_type','status','total_budget']">
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- เลือก -->
|
||||||
|
<ng-container matColumnDef="checkbox">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-checkbox">
|
||||||
|
<mat-checkbox
|
||||||
|
(change)="$event ? masterToggle() : null"
|
||||||
|
[checked]="selection.hasValue() && isAllSelected()"
|
||||||
|
[indeterminate]="selection.hasValue() && !isAllSelected()">
|
||||||
|
</mat-checkbox>
|
||||||
|
</th>
|
||||||
|
<td mat-cell *matCellDef="let x" class="col-checkbox">
|
||||||
|
<mat-checkbox
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
(change)="$event ? selection.toggle(x) : null"
|
||||||
|
[checked]="selection.isSelected(x)"
|
||||||
|
[aria-label]="checkboxLabel(x)"
|
||||||
|
[disabled]="x.status_id !== 0">
|
||||||
|
</mat-checkbox>
|
||||||
|
</td>
|
||||||
|
<td mat-footer-cell *matFooterCellDef></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Action -->
|
||||||
|
<ng-container matColumnDef="action">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-action"></th>
|
||||||
|
<td mat-cell *matCellDef="let x" class="col-action">
|
||||||
|
<span class="material-icons action-icon" (click)="edit(x)" title="แก้ไข">edit</span>
|
||||||
|
<span *ngIf="x.status_id === 0" class="material-icons action-icon action-icon--delete" (click)="delete(x)" title="ลบ">delete</span>
|
||||||
|
<span *ngIf="x.status_id !== 0" class="material-icons action-icon action-icon--disabled" title="ส่งไปงานแผนแล้ว ลบไม่ได้">delete</span>
|
||||||
|
</td>
|
||||||
|
<td mat-footer-cell *matFooterCellDef></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- ปีงบประมาณ -->
|
||||||
|
<ng-container matColumnDef="budget_year_name_th">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-year">ปีงบฯ</th>
|
||||||
|
<td mat-cell *matCellDef="let x" class="col-year">
|
||||||
|
<span class="badge-year">{{x.budget_year_name_th}}</span>
|
||||||
|
</td>
|
||||||
|
<td mat-footer-cell *matFooterCellDef></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- พื้นที่ -->
|
||||||
|
<ng-container matColumnDef="budget_location_name_th">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-location">พื้นที่</th>
|
||||||
|
<td mat-cell *matCellDef="let x" class="col-location">
|
||||||
|
<span class="text-clip">{{x.budget_location_name_th}}</span>
|
||||||
|
</td>
|
||||||
|
<td mat-footer-cell *matFooterCellDef></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- หน่วยงาน/คณะ -->
|
||||||
|
<ng-container matColumnDef="faculty_name_th">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-faculty">หน่วยงาน/คณะ</th>
|
||||||
|
<td mat-cell *matCellDef="let x" class="col-faculty">
|
||||||
|
<span class="text-clip">{{x.responsible_faculty_name_th}}</span>
|
||||||
|
</td>
|
||||||
|
<td mat-footer-cell *matFooterCellDef></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- ชื่อโครงการ -->
|
||||||
|
<ng-container matColumnDef="project_name_th">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-project">ชื่อโครงการ</th>
|
||||||
|
<td mat-cell *matCellDef="let x" class="col-project">
|
||||||
|
<span class="text-clip project-name">{{x.project_name_th}}</span>
|
||||||
|
</td>
|
||||||
|
<td mat-footer-cell *matFooterCellDef></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- ผลผลิต -->
|
||||||
|
<ng-container matColumnDef="budget_project_name_th">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-project-type">ผลผลิต</th>
|
||||||
|
<td mat-cell *matCellDef="let x" class="col-project-type">
|
||||||
|
<span class="text-clip">{{x.budget_project_name_th}}</span>
|
||||||
|
</td>
|
||||||
|
<td mat-footer-cell *matFooterCellDef></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- ด้าน -->
|
||||||
|
<ng-container matColumnDef="budget_topic_name_th">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-topic">ด้าน</th>
|
||||||
|
<td mat-cell *matCellDef="let x" class="col-topic">
|
||||||
|
<span class="text-clip">{{x.budget_topic_name_th}}</span>
|
||||||
|
</td>
|
||||||
|
<td mat-footer-cell *matFooterCellDef></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- ประเภทคำขอ -->
|
||||||
|
<ng-container matColumnDef="request_type">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-request-type">ประเภทคำขอ</th>
|
||||||
|
<td mat-cell *matCellDef="let x" class="col-request-type">
|
||||||
|
<span class="badge badge-between">คำขอระหว่างปี</span>
|
||||||
|
</td>
|
||||||
|
<td mat-footer-cell *matFooterCellDef></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- สถานะ -->
|
||||||
|
<ng-container matColumnDef="status">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-status">สถานะ</th>
|
||||||
|
<td mat-cell *matCellDef="let x" class="col-status">
|
||||||
|
<span *ngIf="x.is_ok == true" class="badge badge-success">ตรวจสอบแล้ว</span>
|
||||||
|
<span *ngIf="x.is_modify == true" class="badge badge-warning">ส่งแก้ไข</span>
|
||||||
|
<span *ngIf="!x.is_ok && !x.is_modify && x.status_id == 1" class="badge badge-pending">ส่งงานแผน</span>
|
||||||
|
<span *ngIf="x.status_id == 0" class="badge badge-draft">ร่าง</span>
|
||||||
|
</td>
|
||||||
|
<td mat-footer-cell *matFooterCellDef></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- วงเงิน -->
|
||||||
|
<ng-container matColumnDef="total_budget">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-amount">วงเงิน (บาท)</th>
|
||||||
|
<td mat-cell *matCellDef="let x" class="col-amount">
|
||||||
|
{{x.total_budget | number:'1.2-2'}}
|
||||||
|
</td>
|
||||||
|
<td mat-footer-cell *matFooterCellDef class="col-amount footer-total">
|
||||||
|
{{data | total:'total_budget' | number:'1.2-2'}}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-paginator [pageSizeOptions]="[20, 50, 100]" showFirstLastButtons (page)="change($event)"></mat-paginator>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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
|
||||||
|
<ng-container *ngIf="typeUrl == 21">
|
||||||
|
<app-list-between-year
|
||||||
|
[dataSource]="dataSourceBetweenYear"
|
||||||
|
(ondelete)="onDelete($event)"
|
||||||
|
(onchange)="change($event)"
|
||||||
|
(onexcel)="excel($event)"
|
||||||
|
></app-list-between-year>
|
||||||
|
</ng-container>
|
||||||
|
```
|
||||||
|
Change to:
|
||||||
|
```html
|
||||||
|
<ng-container *ngIf="typeUrl == 21">
|
||||||
|
<app-list-between-year
|
||||||
|
[dataSource]="dataSourceBetweenYear"
|
||||||
|
(ondelete)="onDelete($event)"
|
||||||
|
(onchange)="change($event)"
|
||||||
|
(onexcel)="excel($event)"
|
||||||
|
(onsend)="sendBetweenYear($event)"
|
||||||
|
></app-list-between-year>
|
||||||
|
</ng-container>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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.
|
||||||
+1272
File diff suppressed because it is too large
Load Diff
@@ -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<string,string>` (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<string, string> OutputPrefixByLabel()
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, string>();
|
||||||
|
CollectOutputPrefixesRecursive(ForType1(), result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CollectOutputPrefixesRecursive(ReportTreeNodeDef def, Dictionary<string, string> 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 = <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<t_income_budget_report_line> BuildBudgetProgressCategoryLines(
|
||||||
|
Guid reportUid, string nodePrefix, string budgetProjectNameTh, t_personnel_statement statement, ref int seq)
|
||||||
|
{
|
||||||
|
var categoryNodeKeys = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["ค่าตอบแทน"] = $"{nodePrefix}-comp",
|
||||||
|
["ค่าใช้สอย"] = $"{nodePrefix}-serv",
|
||||||
|
["ค่าวัสดุ"] = $"{nodePrefix}-mat",
|
||||||
|
["ค่าสาธารณูปโภค"] = $"{nodePrefix}-util",
|
||||||
|
};
|
||||||
|
var lines = new List<t_income_budget_report_line>();
|
||||||
|
|
||||||
|
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<v_budget_progress_type>())
|
||||||
|
{
|
||||||
|
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<v_budget_progress_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=<uid>" -H "Authorization: Bearer <token>" > /tmp/draft1.json
|
||||||
|
curl -s -X POST ".../$(jq -r .income_budget_report_uid /tmp/draft1.json)/clone_next_round" -H "Authorization: Bearer <token>" > /tmp/draft2.json
|
||||||
|
diff <(curl -s ".../$(jq -r .income_budget_report_uid /tmp/draft1.json)/tree" -H "Authorization: Bearer <token>" | jq 'del(.. | .lineUid?)') \
|
||||||
|
<(curl -s ".../$(jq -r .income_budget_report_uid /tmp/draft2.json)/tree" -H "Authorization: Bearer <token>" | 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 `<thead>` (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.
|
||||||
@@ -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<int>` calls — one for table `t_approve_state` (or whatever the mapped table name is, check against `AddColumn` calls for `position_name_th` in earlier migrations for the exact table-name casing this project uses) column `round_no`, and one for `t_remark_history` column `round_no`, both with `defaultValue: 1`. If EF generated anything else (e.g. it picked up unrelated pending model changes), stop and investigate before proceeding — do not hand-edit the migration to remove unrelated changes without understanding why they appeared.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build to confirm everything compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build rmutr-budget-api.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `0 Error(s)` (warning count may match the pre-existing baseline of 244 warnings from before this plan — new warnings related to `round_no` would indicate a mistake).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Modules/Setting/Databases/Models/approve_state.cs Modules/Requests/Databases/Models/remark_history.cs Migrations/
|
||||||
|
git commit -m "feat: add round_no column to t_approve_state and t_remark_history
|
||||||
|
|
||||||
|
Enables tracking multiple approval rounds per request_expense — existing
|
||||||
|
rows default to round_no=1.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
||||||
|
Claude-Session: https://claude.ai/code/session_01MNN1mgyALe9skewgUDSMLK"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Backend — round-aware `getApproveState`, round-scoped approve/reject, new `resubmit` endpoint
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Modules/Setting/Services/ApproveState.cs`
|
||||||
|
- Modify: `Modules/Setting/Controllers/ApproveState.cs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `t_approve_state.round_no` (from Task 1).
|
||||||
|
- Produces: `ApproveStateService.getApproveState(Guid? reference_uid, Guid? faculty_uid, Guid? budget_location_uid, string code, ClaimsIdentity claimsIdentity, int roundNo = 1)` (new optional 6th parameter — existing callers unaffected). New route `GET /api/setting/approve_state/resubmit/{uid:guid}` returning `ActionResult<v_approve_state>`, consumed by frontend Task 4.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the `roundNo` parameter to `getApproveState` and stamp it on cloned rows**
|
||||||
|
|
||||||
|
In `Modules/Setting/Services/ApproveState.cs`, change the method signature (currently):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<string> getApproveState(Guid? reference_uid, Guid? faculty_uid, Guid? budget_location_uid,
|
||||||
|
string code, ClaimsIdentity claimsIdentity)
|
||||||
|
{
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<string> getApproveState(Guid? reference_uid, Guid? faculty_uid, Guid? budget_location_uid,
|
||||||
|
string code, ClaimsIdentity claimsIdentity, int roundNo = 1)
|
||||||
|
{
|
||||||
|
```
|
||||||
|
|
||||||
|
Then inside the method, in the `.Select(c => { var result = new t_approve_state() { ... } ...})` block, add `round_no = roundNo,` to the object initializer (after `position_name_th = c.position_name_th,`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var result = new t_approve_state()
|
||||||
|
{
|
||||||
|
approve_state_uid = Guid.NewGuid(),
|
||||||
|
personnel_uid = c.personnel_uid,
|
||||||
|
display_name_th = c.display_name_th,
|
||||||
|
row_order = c.row_order,
|
||||||
|
is_state = false,
|
||||||
|
request_approve_type = request_approve_type.request_approve_type_code,
|
||||||
|
position_name_th = c.position_name_th,
|
||||||
|
round_no = roundNo,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add round-scoping to `ApproveGetEntity`'s `request_expense` branch**
|
||||||
|
|
||||||
|
In `Modules/Setting/Controllers/ApproveState.cs`, the `if (request_expense != null)` block inside `ApproveGetEntity` currently reads (after the earlier area_uid permission fix):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (request_expense != null)
|
||||||
|
{
|
||||||
|
getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
|
||||||
|
c.request_expense_uid == uid && c.is_state == true);
|
||||||
|
if (getResult != null)
|
||||||
|
{
|
||||||
|
// ขั้นแรก (หน่วยงานส่ง row_order==1): ไม่บังคับสิทธิ์ที่แอดมินตั้งไว้แล้ว
|
||||||
|
// แต่บังคับว่าผู้ส่งต้องอยู่ "พื้นที่" เดียวกับคำขอนี้ (area_uid ของผู้ใช้ ==
|
||||||
|
// budget_location_uid ของคำขอ) เพราะเจ้าหน้าที่ส่วนกลาง (เช่น กองนโยบายและแผน)
|
||||||
|
// คีย์/ส่งคำขอแทนหลายคณะในพื้นที่เดียวกันได้ ไม่ได้จำกัดแค่คณะต้นสังกัดตัวเอง
|
||||||
|
if (getResult.row_order == 1)
|
||||||
|
{
|
||||||
|
var userAreaUidRaw = (User.Identity as ClaimsIdentity)?.FindFirst("area_uid")?.Value;
|
||||||
|
var isOwnArea = !string.IsNullOrEmpty(userAreaUidRaw)
|
||||||
|
&& Guid.TryParse(userAreaUidRaw, out var userAreaUid)
|
||||||
|
&& request_expense.budget_location_uid == userAreaUid;
|
||||||
|
if (!isOwnArea)
|
||||||
|
{
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden,
|
||||||
|
"ไม่มีสิทธิ์ส่งอนุมัติ (พื้นที่ของผู้ใช้ไม่ตรงกับคำขอนี้)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getResult.is_state = null;
|
||||||
|
getResult.is_approve = true;
|
||||||
|
request_expense.current_approve_name = null;
|
||||||
|
_db.t_approve_state.Update(getResult);
|
||||||
|
_db.t_request_expense.Update(request_expense);
|
||||||
|
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
|
||||||
|
var check_all_state = await _db.t_approve_state
|
||||||
|
.Where(c => c.request_expense_uid == uid && c.is_approve != true).ToListAsync();
|
||||||
|
if (!check_all_state.Any())
|
||||||
|
{
|
||||||
|
request_expense.is_approve = true;
|
||||||
|
_db.t_request_expense.Update(request_expense);
|
||||||
|
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
|
||||||
|
}
|
||||||
|
|
||||||
|
var check_getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
|
||||||
|
c.request_expense_uid == uid && c.row_order == getResult.row_order + 1);
|
||||||
|
if (check_getResult != null)
|
||||||
|
{
|
||||||
|
check_getResult.is_state = true;
|
||||||
|
request_expense.current_approve_name = check_getResult.display_name_th;
|
||||||
|
_db.t_request_expense.Update(request_expense);
|
||||||
|
_db.t_approve_state.Update(check_getResult);
|
||||||
|
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the whole block with (adds `currentRound` lookup, filters the three `t_approve_state` queries by it):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (request_expense != null)
|
||||||
|
{
|
||||||
|
var currentRound = await _db.t_approve_state
|
||||||
|
.Where(c => c.request_expense_uid == uid)
|
||||||
|
.Select(c => (int?)c.round_no)
|
||||||
|
.MaxAsync() ?? 1;
|
||||||
|
getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
|
||||||
|
c.request_expense_uid == uid && c.round_no == currentRound && c.is_state == true);
|
||||||
|
if (getResult != null)
|
||||||
|
{
|
||||||
|
// ขั้นแรก (หน่วยงานส่ง row_order==1): ไม่บังคับสิทธิ์ที่แอดมินตั้งไว้แล้ว
|
||||||
|
// แต่บังคับว่าผู้ส่งต้องอยู่ "พื้นที่" เดียวกับคำขอนี้ (area_uid ของผู้ใช้ ==
|
||||||
|
// budget_location_uid ของคำขอ) เพราะเจ้าหน้าที่ส่วนกลาง (เช่น กองนโยบายและแผน)
|
||||||
|
// คีย์/ส่งคำขอแทนหลายคณะในพื้นที่เดียวกันได้ ไม่ได้จำกัดแค่คณะต้นสังกัดตัวเอง
|
||||||
|
if (getResult.row_order == 1)
|
||||||
|
{
|
||||||
|
var userAreaUidRaw = (User.Identity as ClaimsIdentity)?.FindFirst("area_uid")?.Value;
|
||||||
|
var isOwnArea = !string.IsNullOrEmpty(userAreaUidRaw)
|
||||||
|
&& Guid.TryParse(userAreaUidRaw, out var userAreaUid)
|
||||||
|
&& request_expense.budget_location_uid == userAreaUid;
|
||||||
|
if (!isOwnArea)
|
||||||
|
{
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden,
|
||||||
|
"ไม่มีสิทธิ์ส่งอนุมัติ (พื้นที่ของผู้ใช้ไม่ตรงกับคำขอนี้)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getResult.is_state = null;
|
||||||
|
getResult.is_approve = true;
|
||||||
|
request_expense.current_approve_name = null;
|
||||||
|
_db.t_approve_state.Update(getResult);
|
||||||
|
_db.t_request_expense.Update(request_expense);
|
||||||
|
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
|
||||||
|
var check_all_state = await _db.t_approve_state
|
||||||
|
.Where(c => c.request_expense_uid == uid && c.round_no == currentRound && c.is_approve != true)
|
||||||
|
.ToListAsync();
|
||||||
|
if (!check_all_state.Any())
|
||||||
|
{
|
||||||
|
request_expense.is_approve = true;
|
||||||
|
_db.t_request_expense.Update(request_expense);
|
||||||
|
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
|
||||||
|
}
|
||||||
|
|
||||||
|
var check_getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
|
||||||
|
c.request_expense_uid == uid && c.round_no == currentRound
|
||||||
|
&& c.row_order == getResult.row_order + 1);
|
||||||
|
if (check_getResult != null)
|
||||||
|
{
|
||||||
|
check_getResult.is_state = true;
|
||||||
|
request_expense.current_approve_name = check_getResult.display_name_th;
|
||||||
|
_db.t_request_expense.Update(request_expense);
|
||||||
|
_db.t_approve_state.Update(check_getResult);
|
||||||
|
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add round-scoping to `RejectGetEntity`'s `request_expense` branch**
|
||||||
|
|
||||||
|
In the same file, the `if (request_expense_approve != null)` block inside `RejectGetEntity` currently reads:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (request_expense_approve != null)
|
||||||
|
{
|
||||||
|
getResult = request_expense_approve;
|
||||||
|
var result_data_lists = await _db.t_approve_state
|
||||||
|
.Where(c => c.request_expense_uid == uid)
|
||||||
|
.OrderBy(o => o.row_order).ToListAsync();
|
||||||
|
var index_current = result_data_lists.FindIndex(c => c.is_state == true);
|
||||||
|
if (index_current > 0)
|
||||||
|
{
|
||||||
|
result_data_lists[index_current].is_state = null;
|
||||||
|
result_data_lists[index_current].is_approve = null;
|
||||||
|
result_data_lists[index_current - 1].is_state = true;
|
||||||
|
result_data_lists[index_current - 1].is_approve = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// var result_data = result_data_lists.Select(c =>
|
||||||
|
// {
|
||||||
|
// c.is_state = null;
|
||||||
|
// c.is_approve = null;
|
||||||
|
// if (c.row_order == 1)
|
||||||
|
// {
|
||||||
|
// c.is_state = true;
|
||||||
|
// }
|
||||||
|
// return c;
|
||||||
|
// }).ToList();
|
||||||
|
_db.t_approve_state.UpdateRange(result_data_lists);
|
||||||
|
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
|
||||||
|
var request_expense =
|
||||||
|
await _db.t_request_expense.FirstOrDefaultAsync(c => c.request_expense_uid == uid);
|
||||||
|
if (request_expense != null)
|
||||||
|
{
|
||||||
|
request_expense.current_approve_name = null;
|
||||||
|
var approve = await _db.t_approve_state
|
||||||
|
.Where(c => c.request_expense_uid == uid && c.is_state == true).OrderBy(c => c.row_order)
|
||||||
|
.ToListAsync();
|
||||||
|
if (approve.Count > 0)
|
||||||
|
{
|
||||||
|
request_expense.current_approve_name = approve.First().display_name_th;
|
||||||
|
}
|
||||||
|
|
||||||
|
request_expense.is_draft = true;
|
||||||
|
request_expense.is_approve = false;
|
||||||
|
_db.t_request_expense.Update(request_expense);
|
||||||
|
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with (adds `currentRound` lookup, filters the `Where(request_expense_uid == uid)` list-fetch and the later `approve` re-check by it — the `request_expense_approve` variable that gated entry into this branch also needs the same round filter, see Step 3b below):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (request_expense_approve != null)
|
||||||
|
{
|
||||||
|
getResult = request_expense_approve;
|
||||||
|
var currentRound = request_expense_approve.round_no;
|
||||||
|
var result_data_lists = await _db.t_approve_state
|
||||||
|
.Where(c => c.request_expense_uid == uid && c.round_no == currentRound)
|
||||||
|
.OrderBy(o => o.row_order).ToListAsync();
|
||||||
|
var index_current = result_data_lists.FindIndex(c => c.is_state == true);
|
||||||
|
if (index_current > 0)
|
||||||
|
{
|
||||||
|
result_data_lists[index_current].is_state = null;
|
||||||
|
result_data_lists[index_current].is_approve = null;
|
||||||
|
result_data_lists[index_current - 1].is_state = true;
|
||||||
|
result_data_lists[index_current - 1].is_approve = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.t_approve_state.UpdateRange(result_data_lists);
|
||||||
|
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
|
||||||
|
var request_expense =
|
||||||
|
await _db.t_request_expense.FirstOrDefaultAsync(c => c.request_expense_uid == uid);
|
||||||
|
if (request_expense != null)
|
||||||
|
{
|
||||||
|
request_expense.current_approve_name = null;
|
||||||
|
var approve = await _db.t_approve_state
|
||||||
|
.Where(c => c.request_expense_uid == uid && c.round_no == currentRound && c.is_state == true)
|
||||||
|
.OrderBy(c => c.row_order)
|
||||||
|
.ToListAsync();
|
||||||
|
if (approve.Count > 0)
|
||||||
|
{
|
||||||
|
request_expense.current_approve_name = approve.First().display_name_th;
|
||||||
|
}
|
||||||
|
|
||||||
|
request_expense.is_draft = true;
|
||||||
|
request_expense.is_approve = false;
|
||||||
|
_db.t_request_expense.Update(request_expense);
|
||||||
|
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Dropped the already-commented-out dead code block during this edit — it was inert and unrelated to the round fix.)
|
||||||
|
|
||||||
|
- [ ] **Step 3b: Round-scope the query that finds `request_expense_approve` itself**
|
||||||
|
|
||||||
|
A few lines earlier in `RejectGetEntity`, the initial lookup reads:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var request_expense_approve =
|
||||||
|
await _db.t_approve_state.FirstOrDefaultAsync(c => c.request_expense_uid == uid && c.is_state == true);
|
||||||
|
```
|
||||||
|
|
||||||
|
This one does **not** need a round filter — `is_state == true` is already unique per `request_expense_uid` across all rounds combined (only the live round ever has a row with `is_state == true`; every completed round's rows are left with `is_state == null`). Leave this line unchanged. (Documented here so the round_no on `request_expense_approve`, used as `currentRound` in Step 3, is trustworthy — it always belongs to the one truly-active round.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the `resubmit` endpoint**
|
||||||
|
|
||||||
|
In `Modules/Setting/Controllers/ApproveState.cs`, add a new action inside `ApproveStateController`, directly after `RejectGetEntity` (before the closing `}` of the class):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpGet("resubmit/{uid:guid}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<v_approve_state>> ResubmitGetEntity([FromRoute] Guid uid)
|
||||||
|
{
|
||||||
|
var request_expense = await _db.t_request_expense.FirstOrDefaultAsync(c => c.request_expense_uid == uid);
|
||||||
|
if (request_expense == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request_expense.is_approve != true)
|
||||||
|
{
|
||||||
|
return BadRequest("คำขอนี้ยังไม่ได้รับการอนุมัติครบทุกขั้น ไม่สามารถส่งอนุมัติเพิ่มเติมได้");
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentMaxRound = await _db.t_approve_state
|
||||||
|
.Where(c => c.request_expense_uid == uid)
|
||||||
|
.Select(c => (int?)c.round_no)
|
||||||
|
.MaxAsync() ?? 0;
|
||||||
|
var nextRound = currentMaxRound + 1;
|
||||||
|
|
||||||
|
request_expense.is_approve = null;
|
||||||
|
request_expense.current_approve_name = await _entityUidService.getApproveState(
|
||||||
|
uid, request_expense.faculty_uid, request_expense.budget_location_uid, "03",
|
||||||
|
User.Identity as ClaimsIdentity, nextRound);
|
||||||
|
_db.t_request_expense.Update(request_expense);
|
||||||
|
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
|
||||||
|
|
||||||
|
var getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
|
||||||
|
c.request_expense_uid == uid && c.round_no == nextRound && c.row_order == 1);
|
||||||
|
return Ok(getResult);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build to confirm everything compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api
|
||||||
|
dotnet build rmutr-budget-api.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `0 Error(s)`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Modules/Setting/Services/ApproveState.cs Modules/Setting/Controllers/ApproveState.cs
|
||||||
|
git commit -m "feat: round-scope approve/reject and add resubmit endpoint
|
||||||
|
|
||||||
|
approve/reject's request_expense branch now filters t_approve_state
|
||||||
|
queries by the current round_no, so a second approval round's rows
|
||||||
|
can never collide with the first round's already-resolved rows.
|
||||||
|
Added GET approve_state/resubmit/{uid} to start a fresh round once
|
||||||
|
the current one is fully approved, reusing getApproveState's existing
|
||||||
|
clone-from-admin-config logic with a new roundNo parameter.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
||||||
|
Claude-Session: https://claude.ai/code/session_01MNN1mgyALe9skewgUDSMLK"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Backend — deploy to production
|
||||||
|
|
||||||
|
**Files:** none (deploy only)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 1 + Task 2's committed backend changes.
|
||||||
|
- Produces: live `GET /api/setting/approve_state/resubmit/{uid}` endpoint and round-scoped approve/reject on production, with the `round_no` column added to the production database (auto-applied by `Database.Migrate()` on service start).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Back up the production database**
|
||||||
|
|
||||||
|
SSH to the server and take a `pg_dump` before restarting the service (the restart auto-applies the migration):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SSHPASS='@Dmin2241' sshpass -e ssh -o StrictHostKeyChecking=accept-new k8-node-1@172.27.30.135 \
|
||||||
|
"pg_dump -Fc \$(grep -oP '(?<=Database=)[^;]+' /var/www/rmutr-budget-api/appsettings.json 2>/dev/null || echo rmutr_budget) > /tmp/rmutr_budget_pre_round_no_$(date +%Y%m%d_%H%M%S).dump 2>&1 || echo BACKUP_FAILED"
|
||||||
|
```
|
||||||
|
|
||||||
|
If this prints `BACKUP_FAILED` or an error, stop and manually determine the correct database name/credentials (check `/var/www/rmutr-budget-api/appsettings.json`'s `databases:postgres` connection string on the server) before proceeding — do not deploy a migration without a confirmed-good backup.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Deploy**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api
|
||||||
|
bash script.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: script completes printing `==> Done` (uploads `rmutr-budget-api.dll`, restarts the `rmutr-budget-api` systemd service). Note from this session's experience: the `scp` step can appear to stall for a long time under poor network conditions without actually failing — if the command seems stuck, verify via a separate terminal/session whether the remote file timestamp and `systemctl show rmutr-budget-api --property=ActiveEnterTimestamp` have already moved forward before concluding it's actually hung.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the service restarted healthy and the migration applied**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SSHPASS='@Dmin2241' sshpass -e ssh -o StrictHostKeyChecking=accept-new k8-node-1@172.27.30.135 \
|
||||||
|
"systemctl status rmutr-budget-api --no-pager | head -10"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `Active: active (running)` with a recent `ActiveEnterTimestamp`. Then confirm the column exists:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SSHPASS='@Dmin2241' sshpass -e ssh -o StrictHostKeyChecking=accept-new k8-node-1@172.27.30.135 \
|
||||||
|
"journalctl -u rmutr-budget-api --since '5 minutes ago' --no-pager | grep -i 'migrat\|round_no\|error\|exception' | tail -30"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no exception/error lines related to migration; absence of output here is fine (it just means nothing noteworthy was logged, which is normal for a clean migration).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Frontend — service method + round-scoped component logic
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app/core/service/request/request-expense.service.ts`
|
||||||
|
- Modify: `src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `GET /api/setting/approve_state/resubmit/{id}` (from Task 3, now live in production).
|
||||||
|
- Produces: `RequestExpenseService.resubmit(id)`; on `RequestBudgetListComponent` — `currentRound(): number`, `currentRoundApproveStates(): any[]`, `isFullyApproved(): boolean`, `roundLabel(): string`, `currentRoundRemarkHistorys(): any[]`, updated `hasActiveStep(rowOrder)` and `approveStatus()` (both now round-scoped), updated `resole()` (stamps `round_no` on new remarks). Task 5's template and new button consume all of these.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `resubmit()` to `RequestExpenseService`**
|
||||||
|
|
||||||
|
Edit `src/app/core/service/request/request-expense.service.ts` — current full file:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { BaseService } from '../../base/base-service';
|
||||||
|
import { environment } from 'src/environments/environment';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class RequestExpenseService extends BaseService {
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public http: HttpClient
|
||||||
|
) {
|
||||||
|
super('/request/request_expense',http);
|
||||||
|
}
|
||||||
|
approve(id) {
|
||||||
|
return this.http.get(`${environment.rmutrApi}/api/setting/approve_state/approve/${id}`)
|
||||||
|
}
|
||||||
|
reject(id) {
|
||||||
|
return this.http.get(`${environment.rmutrApi}/api/setting/approve_state/reject/${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the closing of the class to add `resubmit`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
approve(id) {
|
||||||
|
return this.http.get(`${environment.rmutrApi}/api/setting/approve_state/approve/${id}`)
|
||||||
|
}
|
||||||
|
reject(id) {
|
||||||
|
return this.http.get(`${environment.rmutrApi}/api/setting/approve_state/reject/${id}`)
|
||||||
|
}
|
||||||
|
resubmit(id) {
|
||||||
|
return this.http.get(`${environment.rmutrApi}/api/setting/approve_state/resubmit/${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `currentRound()` and round-scope `hasActiveStep()`/`approveStatus()`**
|
||||||
|
|
||||||
|
Edit `src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.ts`. Current relevant block:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
approveStatus(){
|
||||||
|
let data = this.approve_button?.approve_states.find(x=>x.row_order == 1).is_approve
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* สถานะจริงของขั้นตอนอนุมัติ (is_state/row_order) backend เก็บไว้ใน approve_states[]
|
||||||
|
* เท่านั้น — field is_state/row_order ที่ระดับบนสุดของ approve_button เป็น null เสมอ
|
||||||
|
* (ไม่เคย sync ค่า) ห้ามใช้ approve_button?.is_state ตรงๆ ให้ใช้ hasActiveStep() แทน
|
||||||
|
*/
|
||||||
|
hasActiveStep(rowOrder: number): boolean {
|
||||||
|
return !!this.approve_button?.approve_states?.some(s => s.row_order === rowOrder && s.is_state === true)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
approveStatus(){
|
||||||
|
let data = this.currentRoundApproveStates().find(x=>x.row_order == 1)?.is_approve
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* เลขรอบอนุมัติปัจจุบัน (1 = รอบปกติ, 2+ = รอบเพิ่มเติม) — คำนวณจาก round_no สูงสุดที่มีอยู่ใน
|
||||||
|
* approve_states[] แทนการเก็บ field แยกใน request_expense เอง กัน field ไม่ sync กับข้อมูลจริง
|
||||||
|
*/
|
||||||
|
currentRound(): number {
|
||||||
|
const rounds = this.approve_button?.approve_states?.map(s => s.round_no ?? 1) ?? [1]
|
||||||
|
return Math.max(1, ...rounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** approve_states[] เฉพาะของรอบปัจจุบัน — กันรอบเก่าที่อนุมัติไปแล้วมาปนกับรอบใหม่ */
|
||||||
|
currentRoundApproveStates(): any[] {
|
||||||
|
const round = this.currentRound()
|
||||||
|
return this.approve_button?.approve_states?.filter(s => (s.round_no ?? 1) === round) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* สถานะจริงของขั้นตอนอนุมัติ (is_state/row_order) backend เก็บไว้ใน approve_states[]
|
||||||
|
* เท่านั้น — field is_state/row_order ที่ระดับบนสุดของ approve_button เป็น null เสมอ
|
||||||
|
* (ไม่เคย sync ค่า) ห้ามใช้ approve_button?.is_state ตรงๆ ให้ใช้ hasActiveStep() แทน
|
||||||
|
*/
|
||||||
|
hasActiveStep(rowOrder: number): boolean {
|
||||||
|
return this.currentRoundApproveStates().some(s => s.row_order === rowOrder && s.is_state === true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** true เมื่ออนุมัติครบทุกขั้นของรอบปัจจุบันแล้ว — ใช้คุมปุ่ม "ส่งอนุมัติเพิ่มเติม" */
|
||||||
|
isFullyApproved(): boolean {
|
||||||
|
return this.approve_button?.is_approve === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ป้ายบอกรอบ ต่อท้ายข้อความ "สถานะ" — "รอบปกติ" (รอบ 1) หรือ "รอบเพิ่มเติมที่ N" (รอบ ≥2) */
|
||||||
|
roundLabel(): string {
|
||||||
|
const round = this.currentRound()
|
||||||
|
return round <= 1 ? 'รอบปกติ' : `รอบเพิ่มเติมที่ ${round}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** remark_historys[] เฉพาะของรอบปัจจุบัน — กันคอมเมนต์เก่าจากรอบก่อนมาปนกับรอบใหม่ที่เพิ่งเริ่ม */
|
||||||
|
currentRoundRemarkHistorys(): any[] {
|
||||||
|
const round = this.currentRound()
|
||||||
|
return this.approve_button?.remark_historys?.filter(r => (r.round_no ?? 1) === round) ?? []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Stamp `round_no` on new remarks and round-filter the remark list in `resole()`/`del_array_dec()`**
|
||||||
|
|
||||||
|
Current `resole()` (relevant "else" branch that adds a brand-new remark) and `del_array_dec()`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
} else {
|
||||||
|
const { value: text } = await Swal.fire({
|
||||||
|
input: 'textarea',
|
||||||
|
inputValue: ``,
|
||||||
|
inputLabel: 'รายละเอียดที่ส่งแก้ไข',
|
||||||
|
inputPlaceholder: 'กรอกรายละเอียดที่ส่งแก้ไข...',
|
||||||
|
width: 1200,
|
||||||
|
inputAttributes: {
|
||||||
|
'aria-label': 'กรอกรายละเอียดที่ส่งแก้ไข'
|
||||||
|
},
|
||||||
|
showCancelButton: true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
Swal.fire('เพิ่มข้อมูลสำเร็จ')
|
||||||
|
console.log(text)
|
||||||
|
let data = {
|
||||||
|
remark_history_name_th: text,
|
||||||
|
updated_name: this.odic.user.profile.display_name,
|
||||||
|
updated_date: new Date(),
|
||||||
|
}
|
||||||
|
this.approve_button.remark_historys.push(data)
|
||||||
|
this.cdRef.detectChanges()
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
del_array_dec(i) {
|
||||||
|
// let form_array = this.form.get('remark_historys') as FormArray
|
||||||
|
// form_array.removeAt(i)
|
||||||
|
this.approve_button.remark_historys.splice(i,1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the `let data = {...}` object literal to include `round_no`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (text) {
|
||||||
|
Swal.fire('เพิ่มข้อมูลสำเร็จ')
|
||||||
|
console.log(text)
|
||||||
|
let data = {
|
||||||
|
remark_history_name_th: text,
|
||||||
|
updated_name: this.odic.user.profile.display_name,
|
||||||
|
updated_date: new Date(),
|
||||||
|
round_no: this.currentRound(),
|
||||||
|
}
|
||||||
|
this.approve_button.remark_historys.push(data)
|
||||||
|
this.cdRef.detectChanges()
|
||||||
|
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(`del_array_dec` needs no change — it deletes by array index, and the template in Task 5 will iterate the round-filtered list, so indexes stay consistent within that filtered view. See Task 5 Step 2 for how the template's `*ngFor` switches to a filtered getter.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build to confirm everything compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/nut.looknut/Project/rmutr/rmutr-web
|
||||||
|
node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng build --configuration production --base-href /budget-web/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: exit code 0, same warning baseline as before (exceljs/oidc-client/Syncfusion CJS + selector warnings only — no new errors).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/app/core/service/request/request-expense.service.ts src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.ts
|
||||||
|
git commit -m "feat: round-scoped approval state helpers + resubmit service call
|
||||||
|
|
||||||
|
hasActiveStep()/approveStatus() now read only the current round's
|
||||||
|
approve_states entries (find() would otherwise match a stale round-1
|
||||||
|
entry once round 2 exists). Added currentRound()/roundLabel()/
|
||||||
|
isFullyApproved()/currentRoundRemarkHistorys() and RequestExpenseService.resubmit().
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
||||||
|
Claude-Session: https://claude.ai/code/session_01MNN1mgyALe9skewgUDSMLK"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Frontend — template (badge, remark filter, new button) + container wiring
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.html`
|
||||||
|
- Modify: `src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.ts`
|
||||||
|
- Modify: `src/app/feature/budget-request/request-budget/container/request-budget/request-budget.container.ts`
|
||||||
|
- Modify: `src/app/feature/budget-request/request-budget/container/request-budget/request-budget.container.html`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `currentRound()`, `roundLabel()`, `isFullyApproved()`, `currentRoundRemarkHistorys()` (from Task 4). `nextApproverLabel()` and the `Swal.fire` confirm/success pattern already in `on_approve()` (existing, from earlier in this session).
|
||||||
|
- Produces: new `@Output() onresubmit` event on `RequestBudgetListComponent`, new `on_resubmit()` handler on `RequestBudgetContainer`, wired end to end.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the round badge next to the existing status text**
|
||||||
|
|
||||||
|
Edit `request-budget-list.component.html`. Current:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="no-data-detail" *ngIf="keyUrl != 2">
|
||||||
|
|
||||||
|
<span *ngIf="approveStatus() == true">สถานะ : อนุมัติแล้ว</span>
|
||||||
|
<span *ngIf="approveStatus() == false">สถานะ : ส่งแก้ไข</span>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Change to:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="no-data-detail" *ngIf="keyUrl != 2">
|
||||||
|
|
||||||
|
<span *ngIf="approveStatus() == true">สถานะ : อนุมัติแล้ว ({{roundLabel()}})</span>
|
||||||
|
<span *ngIf="approveStatus() == false">สถานะ : ส่งแก้ไข ({{roundLabel()}})</span>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Switch the remark-history table to the round-filtered list**
|
||||||
|
|
||||||
|
Current (the card's `*ngIf` was already fixed earlier this session to require non-empty remarks; this step only changes what list the table iterates and re-derives that `*ngIf` from the same filtered list for consistency):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="rmutr_card" *ngIf="(keyUrl == 1 && approve_button?.remark_historys?.length > 0) || (keyUrl == 3 && hasActiveStep(2))" >
|
||||||
|
```
|
||||||
|
|
||||||
|
and further down:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<ng-container >
|
||||||
|
<tr *ngFor="let detail of approve_button.remark_historys;let detailIndex = index">
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the card's `*ngIf` to:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="rmutr_card" *ngIf="(keyUrl == 1 && currentRoundRemarkHistorys().length > 0) || (keyUrl == 3 && hasActiveStep(2))" >
|
||||||
|
```
|
||||||
|
|
||||||
|
and the `*ngFor` to:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<ng-container >
|
||||||
|
<tr *ngFor="let detail of currentRoundRemarkHistorys();let detailIndex = index">
|
||||||
|
```
|
||||||
|
|
||||||
|
`del_array_dec(detailIndex)` inside that loop keeps calling `this.approve_button.remark_historys.splice(i,1)` (unchanged, from Task 4 Step 3's note) — this is safe as long as remark_historys only ever contains one round's worth of *unsaved* entries at a time in practice for this UI, which holds here since the card is only ever shown/editable for the currently active round. No further change needed here.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the "ส่งอนุมัติเพิ่มเติม" button and its output event**
|
||||||
|
|
||||||
|
In `request-budget-list.component.ts`, add a new `@Output` next to the existing ones. Current:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
@Output() onapprove = new EventEmitter();
|
||||||
|
@Output() onapprove2 = new EventEmitter();
|
||||||
|
@Output() onreject = new EventEmitter();
|
||||||
|
```
|
||||||
|
|
||||||
|
Change to:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
@Output() onapprove = new EventEmitter();
|
||||||
|
@Output() onapprove2 = new EventEmitter();
|
||||||
|
@Output() onreject = new EventEmitter();
|
||||||
|
@Output() onresubmit = new EventEmitter();
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add a `resubmit()` method next to the existing `approve()` method. Current:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
approve(){
|
||||||
|
|
||||||
|
if (!this.query.budget_year_uid) return this.swSV.errText("โปรดเลือกปีงบประมาณ")
|
||||||
|
if (!this.query.faculty_uid) return this.swSV.errText("โปรดเลือกหน่วยงาน/คณะ")
|
||||||
|
|
||||||
|
this.onapprove.emit(1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add directly after it:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
resubmit(){
|
||||||
|
if (!this.query.budget_year_uid) return this.swSV.errText("โปรดเลือกปีงบประมาณ")
|
||||||
|
if (!this.query.faculty_uid) return this.swSV.errText("โปรดเลือกหน่วยงาน/คณะ")
|
||||||
|
|
||||||
|
this.onresubmit.emit(1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `request-budget-list.component.html`, the `keyUrl == 1` button block currently reads:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="flex justify-start w-full mt-3 " *ngIf="keyUrl == 1" >
|
||||||
|
<button mat-raised-button class="buttonColorApprove" *ngIf="hasActiveStep(1) && isOwnArea()" style="color: white;margin-right: 10px;width:120px" (click)="approve()" [disabled]="isPeriodClosed()">ส่งอนุมัติ</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Change to add the new button alongside (shown only once the current round is fully approved, mirroring the existing button's style/permission/period-closed guard):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="flex justify-start w-full mt-3 " *ngIf="keyUrl == 1" >
|
||||||
|
<button mat-raised-button class="buttonColorApprove" *ngIf="hasActiveStep(1) && isOwnArea()" style="color: white;margin-right: 10px;width:120px" (click)="approve()" [disabled]="isPeriodClosed()">ส่งอนุมัติ</button>
|
||||||
|
<button mat-raised-button class="buttonColorApprove" *ngIf="isFullyApproved() && isOwnArea()" style="color: white;margin-right: 10px;width:160px" (click)="resubmit()" [disabled]="isPeriodClosed()">ส่งอนุมัติเพิ่มเติม</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Wire the new output through the container template**
|
||||||
|
|
||||||
|
In `src/app/feature/budget-request/request-budget/container/request-budget/request-budget.container.html`, find the existing `(onapprove)="on_approve($event)"` binding on `<app-request-budget-list>` and add a sibling binding directly after it:
|
||||||
|
|
||||||
|
```html
|
||||||
|
(onapprove)="on_approve($event)"
|
||||||
|
(onresubmit)="on_resubmit($event)"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add `on_resubmit()` to the container**
|
||||||
|
|
||||||
|
In `request-budget.container.ts`, `on_approve()` currently reads:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
on_approve(el){
|
||||||
|
const nextApprover = this.nextApproverLabel()
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'warning',
|
||||||
|
title: 'ต้องการส่งอนุมัติหรือไม่',
|
||||||
|
text: nextApprover ? `ระบบจะส่งคำขอนี้ไปให้ ${nextApprover} เป็นผู้ดำเนินการขั้นถัดไป` : undefined,
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: `บันทึก`,
|
||||||
|
cancelButtonText: `ยกเลิก`,
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
|
||||||
|
this.RequestExpenseSV.approve(`${this.approve_button.request_expense_uid}`).pipe(
|
||||||
|
catchError(err => {
|
||||||
|
Swal.fire(err.error.description, '', 'error')
|
||||||
|
return throwError(err)
|
||||||
|
})).subscribe(
|
||||||
|
(res) => {
|
||||||
|
Swal.fire({
|
||||||
|
title: 'บันทึกสำเร็จ',
|
||||||
|
text: nextApprover ? `ส่งไปให้ ${nextApprover} เรียบร้อยแล้ว` : undefined,
|
||||||
|
icon: 'success',
|
||||||
|
confirmButtonText: 'ตกลง'
|
||||||
|
}).then(
|
||||||
|
|
||||||
|
(result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
// this.back()
|
||||||
|
this.onClickSearch(this.searchInput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a new method directly after `on_approve()`'s closing (before whatever comes next in the file — the commented-out dead code block that already follows `on_approve()`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
|
||||||
|
on_resubmit(el){
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'warning',
|
||||||
|
title: 'ต้องการส่งอนุมัติเพิ่มเติมหรือไม่',
|
||||||
|
text: 'คำขอนี้อนุมัติครบทุกขั้นไปแล้ว การส่งเพิ่มเติมจะเริ่มรอบอนุมัติใหม่ทั้งสาย (ผู้อนุมัติทุกคนต้องพิจารณาใหม่ รวมรายการเดิมที่เคยอนุมัติแล้วด้วย)',
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: `บันทึก`,
|
||||||
|
cancelButtonText: `ยกเลิก`,
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
this.RequestExpenseSV.resubmit(`${this.approve_button.request_expense_uid}`).pipe(
|
||||||
|
catchError(err => {
|
||||||
|
Swal.fire(err.error.description, '', 'error')
|
||||||
|
return throwError(err)
|
||||||
|
})).subscribe(
|
||||||
|
(res) => {
|
||||||
|
Swal.fire({
|
||||||
|
title: 'ส่งอนุมัติเพิ่มเติมสำเร็จ',
|
||||||
|
icon: 'success',
|
||||||
|
confirmButtonText: 'ตกลง'
|
||||||
|
}).then(
|
||||||
|
(result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
this.onClickSearch(this.searchInput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build to confirm everything compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/nut.looknut/Project/rmutr/rmutr-web
|
||||||
|
node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng build --configuration production --base-href /budget-web/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: exit code 0, same warning baseline as before.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.html src/app/feature/budget-request/request-budget/presenter/list/request-budget-list/request-budget-list.component.ts src/app/feature/budget-request/request-budget/container/request-budget/request-budget.container.ts src/app/feature/budget-request/request-budget/container/request-budget/request-budget.container.html
|
||||||
|
git commit -m "feat: add ส่งอนุมัติเพิ่มเติม button, round badge, round-filtered remark card
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
||||||
|
Claude-Session: https://claude.ai/code/session_01MNN1mgyALe9skewgUDSMLK"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Frontend — deploy to production
|
||||||
|
|
||||||
|
**Files:** none (deploy only)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 4 + Task 5's committed frontend changes, and Task 3's already-live backend endpoint.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Deploy**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/nut.looknut/Project/rmutr/rmutr-web
|
||||||
|
SSHPASS='@Dmin2241' sshpass -e bash script.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: script completes printing `Deployment complete!`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the deployed `index.html` timestamp matches**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SSHPASS='@Dmin2241' sshpass -e ssh -o StrictHostKeyChecking=accept-new k8-node-1@172.27.30.135 \
|
||||||
|
"ls -la /var/www/html/budget-web/index.html; date"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `index.html`'s timestamp is within a minute or two of the current time printed by `date`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: End-to-end smoke test on production
|
||||||
|
|
||||||
|
**Files:** none (manual verification only — this repo has no automated test harness for this flow, see Global Constraints)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: everything from Tasks 1–6.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Confirm existing (round 1) data still renders correctly**
|
||||||
|
|
||||||
|
Have a user with the right area (e.g. คุณศุภาภรณ์, or the original ศาลายา/คณะวิทยาศาสตร์และเทคโนโลยี/ปีงบ 2569 record referenced throughout this session) load `/app/request-budget` with those filters. Expected: status line now reads `สถานะ : ... (รอบปกติ)`, and behavior otherwise matches what was already verified working before this plan (card + buttons as established in this session).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Drive that record through full approval if not already fully approved**
|
||||||
|
|
||||||
|
Using accounts with the right area (row 1) and `SEND-APPROVE` permission (row 2), click through "ส่งอนุมัติ" until `approve_button.is_approve === true` for that record. Expected: at each step, the existing behavior (confirm dialog naming the next approver, success dialog, badge showing "รอบปกติ") continues to work exactly as before — this is the regression check that Task 2's round-scoping didn't break the existing single-round path.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Trigger an additional round**
|
||||||
|
|
||||||
|
Once fully approved, reload the page for that same record. Expected: the original "ส่งอนุมัติ" button is gone (as before), a new "ส่งอนุมัติเพิ่มเติม" button appears. Click it, confirm the dialog. Expected: success dialog, page refreshes, status line now reads `... (รอบเพิ่มเติมที่ 2)`, and the "รายละเอียดสำหรับการส่งแก้ไข" card is hidden again (no remarks yet in round 2, even though round 1 may have had some).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Drive round 2 through to completion and confirm round 1 history is untouched**
|
||||||
|
|
||||||
|
Approve round 2 through to full completion the same way as Step 2. At any point during this, use the Network tab (same technique used earlier in this session) to inspect the raw `request_expense` API response and confirm `approve_states` contains **both** round 1's rows (untouched, `is_approve: true` throughout) **and** round 2's rows with their own `round_no` — proving history was preserved rather than overwritten.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Report results**
|
||||||
|
|
||||||
|
Summarize pass/fail for each step back to the user, including a screenshot or the raw JSON snippet from Step 4 if anything looks off.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,737 @@
|
|||||||
|
# ครุภัณฑ์ (invest_asset) — ผู้ส่งเลือกผู้อนุมัติเอง Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** เปลี่ยนกลไกอนุมัติของ "แผนความต้องการงบลงทุนค่าครุภัณฑ์" (`/app/durable-articles`,
|
||||||
|
`invest_asset`) จากการดึงคิวผู้อนุมัติจาก config แอดมิน ("จัดการเมนูการอนุมัติ", `request_approve_type="01"`)
|
||||||
|
เป็นให้ผู้ส่งพิมพ์ชื่อผู้อนุมัติเองก่อนกด "ส่งอนุมัติ" — mirror กลไกเดียวกับ "สรุปคำของบประมาณรายได้"
|
||||||
|
(`request_expense`, code `"04"`) ทุกจุด **แทนที่ไปเลย** (ไม่เก็บเป็นทางเลือกคู่ขนาน)
|
||||||
|
|
||||||
|
**Architecture:** ไม่เพิ่มฟิลด์ `is_sent` ใหม่ — `invest_asset.is_draft` (`true`=ยังไม่ส่ง,
|
||||||
|
`null`=ส่งแล้ว) ทำหน้าที่เดียวกันอยู่แล้ว เพิ่มเมธอด `SetCustomApprovers` ใน `InvestAssetService`
|
||||||
|
(mirror `RequestExpenseService.SetCustomApprovers`) + endpoint ใหม่ 1 ตัว, เพิ่ม guard เช็ค
|
||||||
|
`is_draft`+ชื่อผู้กดใน `ApproveGetEntity` (endpoint กลางที่ invest_asset/invest_construct/
|
||||||
|
request_expense ใช้ร่วมกัน) เฉพาะเมื่อ `request_approve_type` เป็นรหัสใหม่เท่านั้น ไม่กระทบ
|
||||||
|
"01"/"02"/"03"/"04" เดิม `send_approve` (`SendApproveEntityId`) **ไม่ต้องแก้เลย** เพราะเช็คอยู่แล้วว่า
|
||||||
|
มี approve_state แถวไหนอยู่ก่อนหรือไม่ ถ้ามี (จาก SetCustomApprovers) จะข้ามการดึง config แอดมินให้เอง
|
||||||
|
Frontend เพิ่ม dialog "ระบุผู้อนุมัติ" (copy จาก `set-approvers-dialog` ของ income-report, ป้าย 2 ลำดับ
|
||||||
|
เดียวกัน: หัวหน้าหน่วยงาน/รองอธิการบดี) เสียบเข้าหน้า `durable-articles-form` เฉพาะโหมด `edit`
|
||||||
|
(โหมด `add`/`add_approve()` เป็นคนละ code path ที่ไม่ผ่าน `send_approve`/approve_state เลย ไม่แตะ)
|
||||||
|
|
||||||
|
**Tech Stack:** ASP.NET Core 8 / EF Core / Npgsql (backend), Angular (frontend), PostgreSQL
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-09-07-invest-asset-self-service-approval-design.md`
|
||||||
|
(อ้างอิงเพิ่ม: `docs/superpowers/specs/2026-09-07-approve-state-workflow-reference.md`)
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- รหัส `request_approve_type` ใหม่ต้อง**ไม่ชนกับข้อมูลจริง** — Task 1 เช็คสดกับ production DB ก่อน
|
||||||
|
ทุกโค้ดถัดไปในแผนนี้เขียนโดยสมมติว่าผลเช็คคือ `"05"` (คาดว่าว่างจริงจากการอ่านโค้ด แต่ยังไม่ได้ยืนยันกับ
|
||||||
|
DB เพราะ connection timeout ตอนออกแบบ) — ถ้า Task 1 พบว่า `"05"` ถูกใช้แล้ว ให้แทนที่ด้วยรหัสว่างจริงที่
|
||||||
|
เจอ **ในทุกจุด** ที่ระบุไว้ในแผนนี้ (มี 3 จุด: `InvestAssetService.SetCustomApprovers`,
|
||||||
|
`ApproveState.cs` guard, และ comment อ้างอิงใน design spec) ก่อนดำเนินการ Task ถัดไป
|
||||||
|
- ระบบมี `t_approve_state`/`v_approve_state` ตารางเดียวใช้ร่วมกันทั้ง `invest_asset`/`invest_construct`/
|
||||||
|
`request_expense` ผ่าน FK คนละคอลัมน์ — ห้ามเปลี่ยนพฤติกรรมของ branch อื่นที่ไม่ใช่ `invest_asset` ใน
|
||||||
|
`ApproveStateController` (`Modules/Setting/Controllers/ApproveState.cs`) โดยเด็ดขาด
|
||||||
|
- คำขอครุภัณฑ์เก่าที่มี `approve_state` แบบ `request_approve_type="01"` ค้างอยู่ระหว่างอนุมัติต้องเดินหน้า
|
||||||
|
อนุมัติ/ปฏิเสธต่อได้ตามปกติ — guard ใหม่ทุกจุดต้องเช็คเงื่อนไข `request_approve_type == "<รหัสใหม่>"`
|
||||||
|
เสมอ ไม่ใช่เช็คว่าเป็น `invest_asset` เฉยๆ
|
||||||
|
- โค้ดเบสนี้ไม่มี backend unit test project และ frontend spec ที่มีอยู่เป็น boilerplate ที่ไม่เคยรันจริง —
|
||||||
|
ทุก task verify ด้วย `dotnet build`/`ng build` ให้ผ่านไม่มี error ก่อน แล้ว verify ด้วย `curl` ยิง endpoint
|
||||||
|
จริงด้วย JWT ทดสอบ เทียบกับข้อมูลจริงใน DB (query ตรงด้วย `psycopg2`) — สร้างข้อมูลทดสอบสังเคราะห์
|
||||||
|
(`created_by = 'test.verify'`) ผ่าน SQL แล้วลบทิ้งหลังตรวจเสร็จ ไม่แตะข้อมูลจริงของผู้ใช้
|
||||||
|
- Session นี้ทำงานตรงบน `master` ของทั้ง `rmutr-api`/`rmutr-web` และ deploy จริงทันทีหลังแต่ละ task
|
||||||
|
ผ่าน (ไม่ใช้ branch/PR) — ข้าม `finishing-a-development-branch` ทั้งหมด, จบแผนด้วยการยืนยันผลบน
|
||||||
|
production โดยตรง
|
||||||
|
- Deploy จริง: backend = `dotnet publish -c Release -r linux-x64 --self-contained false` แล้ว `scp`
|
||||||
|
ไป `k8-node-1` (`/var/www/rmutr-budget-api/`) restart `systemctl restart rmutr-budget-api`
|
||||||
|
(`rmutr-api/script.sh`); frontend = `ng build --configuration production --base-href /budget-web/`
|
||||||
|
แล้ว `rsync` ไป `k8-node-1:/var/www/html/budget-web/` (`rmutr-web/script.sh`) — เช็ค
|
||||||
|
[[project_rmutr_rsync_deploy_race]] ก่อน rsync เสมอ (มี `ng build` ค้างอยู่จาก session อื่นหรือไม่)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: ยืนยันรหัส `request_approve_type` ว่างจริงกับ production DB
|
||||||
|
|
||||||
|
**Files:** ไม่มีไฟล์โค้ด — ตรวจสอบข้อมูลเท่านั้น
|
||||||
|
|
||||||
|
- [ ] **Step 1: เชื่อมต่อ DB แล้ว query รหัสที่ใช้อยู่จริง**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - <<'EOF'
|
||||||
|
import psycopg2
|
||||||
|
conn = psycopg2.connect(host="203.158.221.28", port=31620,
|
||||||
|
dbname="rmutr_budget_050625", user="devadmin",
|
||||||
|
password="vtwi,yo0tpkd-okfouh", connect_timeout=8)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute('SELECT DISTINCT request_approve_type FROM "BUDGET".approve_state ORDER BY 1')
|
||||||
|
print("approve_state distinct codes:", cur.fetchall())
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: รายการโค้ดที่มีอยู่จริง (คาดว่า `01, 02, 03, 04`) — ถ้า `"05"` **ไม่อยู่** ในผลลัพธ์ ใช้ `"05"`
|
||||||
|
ต่อได้เลยตามที่แผนนี้เขียนไว้ทุกจุด ถ้า `"05"` มีอยู่แล้ว ให้เลือกรหัส 2 หลักตัวถัดไปที่ไม่อยู่ในผลลัพธ์
|
||||||
|
(เช่น `"06"`) แล้วแทนที่คำว่า `"05"` ด้วยรหัสนั้นในทุก code block ของ Task 2, 4 ก่อนเริ่มเขียนโค้ดจริง
|
||||||
|
|
||||||
|
- [ ] **Step 2: บันทึกผลลัพธ์**
|
||||||
|
|
||||||
|
จดรหัสที่ยืนยันแล้วไว้ใช้อ้างอิงตลอดแผนที่เหลือ (ไม่ต้อง commit ที่ไหน แค่เป็น context สำหรับ
|
||||||
|
ผู้ implement)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Backend — เพิ่ม `SetCustomApprovers` ใน `InvestAssetService`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Modules/Invests/Services/InvestAsset.cs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `ApproverInput` class จาก `rmutr_budget_api.Modules.RequestExpense.Services`
|
||||||
|
(มีอยู่แล้ว, fields: `Guid? personnel_uid`, `string display_name_th`), `t_approve_state` จาก
|
||||||
|
`rmutr_budget_api.Modules.General.Databases.Models` (import แล้วในไฟล์นี้)
|
||||||
|
- Produces: `Task<(bool ok, string error)> SetCustomApprovers(Guid? investAssetUid, List<ApproverInput> approvers, ClaimsIdentity claimsIdentity)`
|
||||||
|
— ให้ Task 3 (controller) เรียกใช้
|
||||||
|
|
||||||
|
- [ ] **Step 1: เพิ่ม using directive**
|
||||||
|
|
||||||
|
เปิด `Modules/Invests/Services/InvestAsset.cs` เพิ่มบรรทัดนี้ต่อท้าย using เดิม (บรรทัด 13):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using rmutr_budget_api.Modules.RequestExpense.Services;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: เพิ่มเมธอด `SetCustomApprovers`**
|
||||||
|
|
||||||
|
เพิ่มเมธอดนี้ในคลาส `InvestAssetService` (ต่อจากเมธอด `ListEntityIdApprove` หรือจุดใดก็ได้ในคลาส):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// ให้ผู้ส่งอนุมัติกำหนดคิวผู้อนุมัติ (ลำดับ 1, 2) เองก่อนกด "ส่งอนุมัติ" แทนที่จะดึงจาก config
|
||||||
|
/// "จัดการเมนูการอนุมัติ" ที่แอดมินต้องตั้งไว้ล่วงหน้า (getApproveState("01", ...)) — mirror
|
||||||
|
/// RequestExpenseService.SetCustomApprovers (code "04") ทุกจุด ดู
|
||||||
|
/// docs/superpowers/specs/2026-09-07-invest-asset-self-service-approval-design.md ข้อ 3.1(a)
|
||||||
|
/// ใช้ได้เฉพาะรายการที่ยังเป็นฉบับร่าง (is_draft==true) และยังไม่เคยเข้าสู่กระบวนการอนุมัติจริงเลย
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(bool ok, string error)> SetCustomApprovers(Guid? investAssetUid,
|
||||||
|
List<ApproverInput> approvers, ClaimsIdentity claimsIdentity)
|
||||||
|
{
|
||||||
|
var entity = await _context.t_invest_asset
|
||||||
|
.FirstOrDefaultAsync(c => c.invest_asset_uid == investAssetUid);
|
||||||
|
if (entity == null) return (false, "ไม่พบรายการ");
|
||||||
|
if (entity.is_draft != true)
|
||||||
|
return (false, "รายการนี้ต้องอยู่ในสถานะฉบับร่างก่อนจึงจะกำหนดผู้อนุมัติได้");
|
||||||
|
|
||||||
|
var existing = await _context.t_approve_state
|
||||||
|
.Where(c => c.invest_asset_uid == investAssetUid).ToListAsync();
|
||||||
|
if (existing.Any(s => s.is_approve == true))
|
||||||
|
return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว ไม่สามารถกำหนดผู้อนุมัติใหม่ได้");
|
||||||
|
|
||||||
|
approvers = (approvers ?? new List<ApproverInput>())
|
||||||
|
.Where(a => !string.IsNullOrWhiteSpace(a?.display_name_th)).ToList();
|
||||||
|
if (!approvers.Any()) return (false, "กรุณาระบุผู้อนุมัติอย่างน้อย 1 คน");
|
||||||
|
|
||||||
|
if (existing.Any())
|
||||||
|
{
|
||||||
|
_context.t_approve_state.RemoveRange(existing);
|
||||||
|
await _context.SaveChangesAsync(claimsIdentity);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = approvers.Select((a, idx) => new t_approve_state
|
||||||
|
{
|
||||||
|
approve_state_uid = Guid.NewGuid(),
|
||||||
|
invest_asset_uid = investAssetUid,
|
||||||
|
personnel_uid = a.personnel_uid,
|
||||||
|
display_name_th = a.display_name_th,
|
||||||
|
row_order = idx + 1,
|
||||||
|
is_state = idx == 0,
|
||||||
|
request_approve_type = "05",
|
||||||
|
}).ToList();
|
||||||
|
await _context.t_approve_state.AddRangeAsync(rows);
|
||||||
|
entity.current_approve_name = rows.First().display_name_th;
|
||||||
|
_context.t_invest_asset.Update(entity);
|
||||||
|
await _context.SaveChangesAsync(claimsIdentity);
|
||||||
|
return (true, null);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(แทนที่ `"05"` ด้วยรหัสจริงจาก Task 1 ถ้าไม่ใช่ `"05"`)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build ตรวจ compile**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rmutr-api && dotnet build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeded, ไม่มี error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Backend — เพิ่ม endpoint `set_approvers` ใน `InvestAssetController`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Modules/Invests/Controllers/InvestAsset.cs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `InvestAssetService.SetCustomApprovers` จาก Task 2
|
||||||
|
- Produces: `POST /api/invest/invest_asset/set_approvers/{uid}` — ให้ Task 7 (frontend service)
|
||||||
|
เรียกใช้
|
||||||
|
|
||||||
|
- [ ] **Step 1: เพิ่ม using directive**
|
||||||
|
|
||||||
|
เปิด `Modules/Invests/Controllers/InvestAsset.cs` เพิ่มต่อท้าย using เดิม (บรรทัด 16):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using rmutr_budget_api.Modules.RequestExpense.Services;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: เพิ่ม endpoint**
|
||||||
|
|
||||||
|
เพิ่มเมธอดนี้ในคลาส `InvestAssetController` (ต่อจาก `SendApproveEntityId`, หลังบรรทัด 253):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>ผู้ส่งกำหนดผู้อนุมัติเอง (ลำดับ 1, 2) ก่อนกด "ส่งอนุมัติ" — ดู SetCustomApprovers</summary>
|
||||||
|
[Authorize]
|
||||||
|
[HttpPost("set_approvers/{uid:guid}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult> SetApprovers([FromRoute] Guid uid, [FromBody] List<ApproverInput> approvers)
|
||||||
|
{
|
||||||
|
var result = await _entityUidService.SetCustomApprovers(uid, approvers, User.Identity as ClaimsIdentity);
|
||||||
|
if (!result.ok)
|
||||||
|
{
|
||||||
|
return BadRequest(new { description = result.error });
|
||||||
|
}
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build ตรวจ compile**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rmutr-api && dotnet build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeded
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Backend — ปิดช่องโหว่อนุมัติก่อนส่งใน `ApproveGetEntity`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Modules/Setting/Controllers/ApproveState.cs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `t_approve_state.request_approve_type` (รหัสใหม่จาก Task 1/2), `t_invest_asset.is_draft`
|
||||||
|
- Produces: ไม่มี (แก้ behavior ของ endpoint ที่มีอยู่แล้ว `GET api/setting/approve_state/approve/{uid}`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: เพิ่ม guard ใน invest_asset branch ของ `ApproveGetEntity`**
|
||||||
|
|
||||||
|
หาบล็อกนี้ (บรรทัด ~50-59):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (invest_asset != null)
|
||||||
|
{
|
||||||
|
getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
|
||||||
|
c.invest_asset_uid == uid && c.is_state == true);
|
||||||
|
if (getResult == null)
|
||||||
|
{
|
||||||
|
// ยังไม่เคยกด "ส่งอนุมัติ" จึงไม่มี approve_state ที่ active ให้เห็นชอบ
|
||||||
|
return BadRequest(new { description = "รายการนี้ยังไม่ได้ถูกส่งเข้าสู่กระบวนการอนุมัติ กรุณากด \"ส่งอนุมัติ\" ก่อน" });
|
||||||
|
}
|
||||||
|
if (getResult != null)
|
||||||
|
{
|
||||||
|
getResult.is_state = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
แทนที่ด้วย (เพิ่ม guard block ก่อน `getResult.is_state = false;`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (invest_asset != null)
|
||||||
|
{
|
||||||
|
getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
|
||||||
|
c.invest_asset_uid == uid && c.is_state == true);
|
||||||
|
if (getResult == null)
|
||||||
|
{
|
||||||
|
// ยังไม่เคยกด "ส่งอนุมัติ" จึงไม่มี approve_state ที่ active ให้เห็นชอบ
|
||||||
|
return BadRequest(new { description = "รายการนี้ยังไม่ได้ถูกส่งเข้าสู่กระบวนการอนุมัติ กรุณากด \"ส่งอนุมัติ\" ก่อน" });
|
||||||
|
}
|
||||||
|
if (getResult != null)
|
||||||
|
{
|
||||||
|
// ครุภัณฑ์แบบผู้ส่งเลือกผู้อนุมัติเอง (code "05"): SetCustomApprovers สร้างแถว
|
||||||
|
// is_state=true ไว้ล่วงหน้าตั้งแต่ก่อนกด "ส่งอนุมัติ" (invest_asset.is_draft ยังเป็น
|
||||||
|
// true อยู่) ต้องกันไม่ให้ใครก็ได้กด approve ทะลุขั้นตอนไปก่อน + ต้องเป็นคนที่ถูกระบุชื่อ
|
||||||
|
// ไว้ในคิวลำดับ 1 เท่านั้นเป็นผู้กด (เหมือน request_expense code "04")
|
||||||
|
if (getResult.row_order == 1 && getResult.request_approve_type == "05")
|
||||||
|
{
|
||||||
|
if (invest_asset.is_draft == true)
|
||||||
|
{
|
||||||
|
return BadRequest(new { description = "ยังไม่ได้ส่งอนุมัติ กรุณากด \"ส่งอนุมัติ\" ก่อน" });
|
||||||
|
}
|
||||||
|
var currentDisplayName = (User.Identity as ClaimsIdentity)?.FindFirst("display_name")?.Value;
|
||||||
|
if (string.IsNullOrEmpty(currentDisplayName) || currentDisplayName != getResult.display_name_th)
|
||||||
|
{
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden,
|
||||||
|
new { description = "ไม่มีสิทธิ์อนุมัติ (ต้องเป็นผู้ที่ถูกกำหนดเป็นผู้อนุมัติลำดับ 1 เท่านั้น)" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getResult.is_state = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
(แทนที่ `"05"` ด้วยรหัสจริงจาก Task 1 ถ้าไม่ใช่ `"05"` — ต้องตรงกับค่าที่ใช้ใน Task 2 เป๊ะ)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build ตรวจ compile**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rmutr-api && dotnet build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeded
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Backend — Deploy และ verify ด้วยข้อมูลทดสอบจริง
|
||||||
|
|
||||||
|
**Files:** ไม่มีไฟล์โค้ดเพิ่ม — deploy + verification เท่านั้น
|
||||||
|
|
||||||
|
- [ ] **Step 1: Publish และ deploy**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rmutr-api && ./script.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: สคริปต์รันจบไม่ error, service `rmutr-budget-api` restart สำเร็จ
|
||||||
|
|
||||||
|
- [ ] **Step 2: สร้างข้อมูลทดสอบสังเคราะห์**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - <<'EOF'
|
||||||
|
import psycopg2, uuid
|
||||||
|
conn = psycopg2.connect(host="203.158.221.28", port=31620,
|
||||||
|
dbname="rmutr_budget_050625", user="devadmin",
|
||||||
|
password="vtwi,yo0tpkd-okfouh")
|
||||||
|
cur = conn.cursor()
|
||||||
|
test_uid = str(uuid.uuid4())
|
||||||
|
cur.execute('''
|
||||||
|
INSERT INTO "BUDGET".invest_asset
|
||||||
|
(invest_asset_uid, is_draft, created_by, invest_asset_name_th, status_id)
|
||||||
|
VALUES (%s, true, 'test.verify', 'ทดสอบระบุผู้อนุมัติเอง', 1)
|
||||||
|
''', (test_uid,))
|
||||||
|
conn.commit()
|
||||||
|
print("test invest_asset_uid:", test_uid)
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
(ปรับคอลัมน์ตาม NOT NULL constraint จริงของตาราง ถ้า insert error ให้เช็ค schema ก่อนแล้วเติมคอลัมน์ที่
|
||||||
|
ขาดให้ครบ)
|
||||||
|
|
||||||
|
- [ ] **Step 3: ทดสอบ `set_approvers` ก่อนส่งอนุมัติ — ต้องสำเร็จ**
|
||||||
|
|
||||||
|
ยิง `POST /api/invest/invest_asset/set_approvers/{test_uid}` ด้วย JWT ทดสอบ (login_name/display_name/
|
||||||
|
agency_uid ตาม pattern ที่ใช้ตลอด session นี้) body:
|
||||||
|
```json
|
||||||
|
[{"display_name_th": "ทดสอบ หัวหน้าหน่วยงาน"}, {"display_name_th": "ทดสอบ รองอธิการบดี"}]
|
||||||
|
```
|
||||||
|
Expected: 200 OK ตรวจด้วย SQL ว่ามี `t_approve_state` 2 แถวผูกกับ `test_uid`, `request_approve_type='05'`,
|
||||||
|
row_order 1/2, row_order=1 มี `is_state=true`
|
||||||
|
|
||||||
|
- [ ] **Step 4: ทดสอบ approve ก่อนกด "ส่งอนุมัติ" — ต้องถูกบล็อก**
|
||||||
|
|
||||||
|
ยิง `GET /api/setting/approve_state/approve/{test_uid}` (ตอนที่ invest_asset ยัง `is_draft=true`)
|
||||||
|
Expected: 400 BadRequest, message "ยังไม่ได้ส่งอนุมัติ กรุณากด \"ส่งอนุมัติ\" ก่อน"
|
||||||
|
|
||||||
|
- [ ] **Step 5: กด "ส่งอนุมัติ" แล้วทดสอบ approve ด้วยคนละชื่อ — ต้องถูกบล็อก 403**
|
||||||
|
|
||||||
|
ยิง `GET /api/invest/invest_asset/send_approve/{test_uid}` ก่อน (ตรวจว่า `is_draft` เปลี่ยนเป็น `null`)
|
||||||
|
แล้วยิง `GET /api/setting/approve_state/approve/{test_uid}` ด้วย JWT ที่ `display_name` **ไม่ตรง**กับ
|
||||||
|
"ทดสอบ หัวหน้าหน่วยงาน"
|
||||||
|
Expected: 403 Forbidden
|
||||||
|
|
||||||
|
- [ ] **Step 6: approve ด้วยชื่อที่ถูกต้อง — ต้องสำเร็จและเลื่อนคิว**
|
||||||
|
|
||||||
|
ยิง `GET /api/setting/approve_state/approve/{test_uid}` ด้วย JWT ที่ `display_name` = "ทดสอบ
|
||||||
|
หัวหน้าหน่วยงาน" Expected: 200 OK, ตรวจ SQL ว่า row_order=1 มี `is_approve=true`, row_order=2 มี
|
||||||
|
`is_state=true`
|
||||||
|
|
||||||
|
- [ ] **Step 7: ลบข้อมูลทดสอบ**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - <<'EOF'
|
||||||
|
import psycopg2
|
||||||
|
conn = psycopg2.connect(host="203.158.221.28", port=31620,
|
||||||
|
dbname="rmutr_budget_050625", user="devadmin",
|
||||||
|
password="vtwi,yo0tpkd-okfouh")
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute('DELETE FROM "BUDGET".approve_state WHERE invest_asset_uid = %s', (test_uid,))
|
||||||
|
cur.execute('DELETE FROM "BUDGET".invest_asset WHERE invest_asset_uid = %s', (test_uid,))
|
||||||
|
conn.commit()
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Frontend — สร้าง dialog "ระบุผู้อนุมัติ" สำหรับครุภัณฑ์
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `rmutr-web/src/app/feature/budget-request/investment-budget/durable-articles/presenter/popup/set-invest-approvers-dialog/set-invest-approvers-dialog.component.ts`
|
||||||
|
- Create: `.../set-invest-approvers-dialog/set-invest-approvers-dialog.component.html`
|
||||||
|
- Create: `.../set-invest-approvers-dialog/set-invest-approvers-dialog.component.scss`
|
||||||
|
- Modify: `rmutr-web/src/app/feature/budget-request/investment-budget/durable-articles/durable-articles.module.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `AgencyService.getHr()` (มีอยู่แล้ว, `src/app/core/service/general/agency.service`),
|
||||||
|
`SearchAutoPipe` (มาจาก `SharedModule` ที่ import อยู่แล้วในโมดูลนี้)
|
||||||
|
- Produces: `SetInvestApproversDialogComponent` — ให้ Task 8 เรียกผ่าน `MatDialog.open()`,
|
||||||
|
`dialogRef.afterClosed()` คืนค่า `{ personnel_uid, display_name_th }[]` หรือ `null` ถ้ากดยกเลิก
|
||||||
|
|
||||||
|
- [ ] **Step 1: สร้าง component ts** (copy โครงจาก
|
||||||
|
`feature/income-report/presenter/popup/set-approvers-dialog/set-approvers-dialog.component.ts`
|
||||||
|
เปลี่ยนชื่อคลาส/selector เท่านั้น เนื้อหาเหมือนกันทุกจุด)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Component, Inject } from '@angular/core';
|
||||||
|
import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
|
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { AgencyService } from 'src/app/core/service/general/agency.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-set-invest-approvers-dialog',
|
||||||
|
templateUrl: './set-invest-approvers-dialog.component.html',
|
||||||
|
styleUrls: ['./set-invest-approvers-dialog.component.scss']
|
||||||
|
})
|
||||||
|
export class SetInvestApproversDialogComponent {
|
||||||
|
form: FormGroup
|
||||||
|
agency$: Observable<any>
|
||||||
|
errorText: string = null
|
||||||
|
approverLabels = ['หัวหน้าหน่วยงาน', 'รองอธิการบดี']
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private fb: FormBuilder,
|
||||||
|
private agencySV: AgencyService,
|
||||||
|
public dialogRef: MatDialogRef<SetInvestApproversDialogComponent>,
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: any,
|
||||||
|
) {
|
||||||
|
this.agency$ = this.agencySV.getHr()
|
||||||
|
const existing = (this.data?.approvers || []) as any[]
|
||||||
|
this.form = this.fb.group({
|
||||||
|
approvers: this.fb.array([
|
||||||
|
this.approverForm(existing[0]?.personnel_uid, existing[0]?.display_name_th, true),
|
||||||
|
this.approverForm(existing[1]?.personnel_uid, existing[1]?.display_name_th, false),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
get approvers(): FormArray {
|
||||||
|
return this.form.get('approvers') as FormArray
|
||||||
|
}
|
||||||
|
|
||||||
|
approverForm(personnel_uid: any = null, display_name_th: string = '', required: boolean = false): FormGroup {
|
||||||
|
return this.fb.group({
|
||||||
|
personnel_uid: [personnel_uid],
|
||||||
|
display_name_th: [display_name_th, required ? [Validators.required] : []],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
click_person(item: any, group: FormGroup) {
|
||||||
|
group.get('personnel_uid').setValue(item.personnel_uid)
|
||||||
|
group.get('display_name_th').setValue(item.display_name_th)
|
||||||
|
}
|
||||||
|
|
||||||
|
save() {
|
||||||
|
this.errorText = null
|
||||||
|
this.approvers.markAllAsTouched()
|
||||||
|
if (!this.approvers.at(0).value.display_name_th) {
|
||||||
|
this.errorText = 'กรุณาระบุหัวหน้าหน่วยงาน'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const value = (this.form.value.approvers || []).filter((a: any) => !!a.display_name_th)
|
||||||
|
this.dialogRef.close(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
this.dialogRef.close(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: สร้าง component html** (copy จาก
|
||||||
|
`set-approvers-dialog.component.html` เปลี่ยนแค่คำอธิบาย `.dialog-hint` ให้ตรงบริบทครุภัณฑ์)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<h2 mat-dialog-title>กำหนดผู้อนุมัติ</h2>
|
||||||
|
<div mat-dialog-content [formGroup]="form">
|
||||||
|
<p class="dialog-hint">ระบุผู้อนุมัติคำขอครุภัณฑ์ (หัวหน้าหน่วยงานจะได้รับก่อน เมื่ออนุมัติแล้วจะส่งต่อไปยังรองอธิการบดีอัตโนมัติ) — บันทึกไว้ก่อน แล้วค่อยกด "ส่งอนุมัติ" ด้านนอกอีกครั้งเพื่อส่งจริง</p>
|
||||||
|
|
||||||
|
<div formArrayName="approvers">
|
||||||
|
<div class="approver-row" *ngFor="let group of approvers.controls; let i = index" [formGroupName]="i">
|
||||||
|
<span class="row-order-badge">{{ approverLabels[i] }} <span class="required-asterisk" *ngIf="i === 0">*</span></span>
|
||||||
|
<mat-form-field appearance="outline" class="approver-input">
|
||||||
|
<input autocomplete="off" type="text" matInput placeholder="ค้นหาชื่อผู้อนุมัติ"
|
||||||
|
formControlName="display_name_th" [matAutocomplete]="autoApprover">
|
||||||
|
<mat-icon matSuffix>search</mat-icon>
|
||||||
|
<mat-autocomplete #autoApprover="matAutocomplete">
|
||||||
|
<mat-option (click)="click_person(item, group)"
|
||||||
|
*ngFor="let item of agency$ | async | searchAuto : group.value.display_name_th : 'display_name_th'"
|
||||||
|
[value]="item.display_name_th">
|
||||||
|
{{ item.display_name_th }}
|
||||||
|
</mat-option>
|
||||||
|
</mat-autocomplete>
|
||||||
|
<mat-error *ngIf="group.get('display_name_th').hasError('required')">กรุณาระบุ{{ approverLabels[i] }}</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="error-text" *ngIf="errorText">{{ errorText }}</p>
|
||||||
|
</div>
|
||||||
|
<div mat-dialog-actions align="end">
|
||||||
|
<button mat-button (click)="cancel()">ยกเลิก</button>
|
||||||
|
<button mat-raised-button color="primary" (click)="save()">บันทึก</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: สร้าง component scss** (copy ตรงจาก `set-approvers-dialog.component.scss`)
|
||||||
|
|
||||||
|
```scss
|
||||||
|
.dialog-hint {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approver-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-order-badge {
|
||||||
|
min-width: 130px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #444;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required-asterisk {
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approver-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #c62828;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: ประกาศ component ใน module**
|
||||||
|
|
||||||
|
เปิด `durable-articles.module.ts` เพิ่ม import (ต่อจากบรรทัด 45):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { SetInvestApproversDialogComponent } from './presenter/popup/set-invest-approvers-dialog/set-invest-approvers-dialog.component';
|
||||||
|
```
|
||||||
|
|
||||||
|
เพิ่มเข้า `declarations` array (ต่อจาก `ListApprove5Component,` บรรทัด 99):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
SetInvestApproversDialogComponent,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build ตรวจ compile**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rmutr-web && ng build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build ผ่านไม่มี error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Frontend — เพิ่ม `setApprovers` ใน `InvestAssetService` (Angular)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `rmutr-web/src/app/core/service/invest/invest-asset.service.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `POST /api/invest/invest_asset/set_approvers/{uid}` จาก Task 3
|
||||||
|
- Produces: `setApprovers(id, approvers)` — ให้ Task 8 เรียกใช้
|
||||||
|
|
||||||
|
- [ ] **Step 1: เพิ่มเมธอด**
|
||||||
|
|
||||||
|
เปิดไฟล์ เพิ่มเมธอดนี้ต่อจาก `reject(id)` (บรรทัด 21):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
setApprovers(id, approvers: { personnel_uid: string, display_name_th: string }[]) {
|
||||||
|
return this.http.post(`${environment.rmutrApi}/api/invest/invest_asset/set_approvers/${id}`, approvers)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build ตรวจ compile**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rmutr-web && ng build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build ผ่านไม่มี error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Frontend — เสียบปุ่ม "ระบุผู้อนุมัติ" เข้าฟอร์มครุภัณฑ์
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `rmutr-web/src/app/feature/budget-request/investment-budget/durable-articles/presenter/form/durable-articles-form/durable-articles-form.component.ts`
|
||||||
|
- Modify: `.../durable-articles-form.component.html`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `SetInvestApproversDialogComponent` (Task 6), `InvestAssetService.setApprovers` (Task 7)
|
||||||
|
|
||||||
|
- [ ] **Step 1: import dialog component**
|
||||||
|
|
||||||
|
เปิด `durable-articles-form.component.ts` เพิ่ม import ต่อจากบรรทัด 25:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { SetInvestApproversDialogComponent } from '../../popup/set-invest-approvers-dialog/set-invest-approvers-dialog.component';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: เพิ่ม field เก็บ approve_states ปัจจุบัน**
|
||||||
|
|
||||||
|
เพิ่ม field นี้ต่อจาก `canSendApprove = false` (บรรทัด 45):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** approve_states ปัจจุบันของ invest_asset นี้ (เซ็ตใน setUpform) ใช้เช็คว่ากำหนดคิวผู้อนุมัติแล้วหรือยัง */
|
||||||
|
currentApproveStates: any[] = []
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: เพิ่ม `canDefineApprovers()`/`hasApproveQueue()`**
|
||||||
|
|
||||||
|
เพิ่มเมธอดนี้ต่อจาก `canApprove()` (หลังบรรทัด 53):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** แสดงปุ่ม "ระบุผู้อนุมัติ" เฉพาะรายการที่ยังไม่เคยเข้าสู่กระบวนการอนุมัติจริงเลย */
|
||||||
|
canDefineApprovers(): boolean {
|
||||||
|
if (this.form.value.is_draft !== true) return false
|
||||||
|
if (this.form.value.is_approve === true) return false
|
||||||
|
return !(this.currentApproveStates || []).some((s: any) => s.is_approve === true)
|
||||||
|
}
|
||||||
|
/** มีคิวผู้อนุมัติที่กำหนดไว้แล้ว — ใช้บังคับให้ต้อง "ระบุผู้อนุมัติ" ก่อนจึงจะกด "ส่งอนุมัติ" ได้ */
|
||||||
|
hasApproveQueue(): boolean {
|
||||||
|
return (this.currentApproveStates || []).some((s: any) => s.row_order === 1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: เก็บ `approve_states` ตอนโหลดฟอร์ม**
|
||||||
|
|
||||||
|
เปิด `setUpform(res)` (บรรทัด 142) เพิ่มบรรทัดแรกในเมธอด:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
setUpform(res) {
|
||||||
|
this.currentApproveStates = res.approve_states || []
|
||||||
|
this.form.patchValue(res)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: เพิ่มเมธอด `onSetApprovers()`**
|
||||||
|
|
||||||
|
เพิ่มเมธอดนี้ต่อจาก `save_approve()` (หลังบรรทัด 788, ก่อน `add_approve()`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** ให้ผู้ส่งอนุมัติเลือกเองว่าจะส่งให้ใครอนุมัติลำดับ 1, 2 แทนการดึงจาก "จัดการเมนูการอนุมัติ" ที่แอดมินตั้งค่าล่วงหน้า */
|
||||||
|
onSetApprovers() {
|
||||||
|
const existingApprovers = (this.currentApproveStates || [])
|
||||||
|
.slice()
|
||||||
|
.sort((a: any, b: any) => (a.row_order ?? 0) - (b.row_order ?? 0))
|
||||||
|
.map((s: any) => ({ personnel_uid: s.personnel_uid, display_name_th: s.display_name_th }))
|
||||||
|
const dialogRef = this.dialog.open(SetInvestApproversDialogComponent, {
|
||||||
|
width: '600px',
|
||||||
|
disableClose: true,
|
||||||
|
data: { approvers: existingApprovers },
|
||||||
|
})
|
||||||
|
dialogRef.afterClosed().subscribe((approvers) => {
|
||||||
|
if (!approvers) return
|
||||||
|
this.InvestAssetSV.setApprovers(this.form.getRawValue().invest_asset_uid, approvers).pipe(
|
||||||
|
catchError(err => {
|
||||||
|
Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error')
|
||||||
|
return throwError(err)
|
||||||
|
}),
|
||||||
|
).subscribe(() => {
|
||||||
|
Swal.fire({
|
||||||
|
title: 'บันทึกผู้อนุมัติสำเร็จ',
|
||||||
|
text: 'กด "ส่งอนุมัติ" อีกครั้งเพื่อส่งเข้าสู่กระบวนการอนุมัติ',
|
||||||
|
icon: 'success',
|
||||||
|
confirmButtonText: 'ตกลง'
|
||||||
|
}).then(() => {
|
||||||
|
this.InvestAssetSV.get(this.uniquekey).subscribe((res: any) => {
|
||||||
|
this.setUpform(res)
|
||||||
|
this.cdRef.detectChanges()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: เพิ่มปุ่มและแก้เงื่อนไขปุ่ม "ส่งอนุมัติ" เดิม (โหมด edit เท่านั้น)**
|
||||||
|
|
||||||
|
เปิด `durable-articles-form.component.html` หาบรรทัด (บรรทัด 362):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<button mat-raised-button class="buttonColorApprove" *ngIf="!viewMode && state == 'edit' && form.value.is_draft == true && canApprove()" (click)="save_approve()"
|
||||||
|
color="primary" style="margin-right: 10px;">ส่งอนุมัติ</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
แทนที่ด้วย (เพิ่มปุ่ม "ระบุผู้อนุมัติ" ก่อนหน้า + เพิ่ม `hasApproveQueue()` เป็นเงื่อนไขของปุ่ม
|
||||||
|
"ส่งอนุมัติ" เดิม บังคับให้ต้องกำหนดผู้อนุมัติก่อนเสมอ):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<button mat-raised-button color="primary"
|
||||||
|
*ngIf="!viewMode && state == 'edit' && form.value.is_draft == true && canApprove() && canDefineApprovers()"
|
||||||
|
(click)="onSetApprovers()" style="margin-right: 10px;">
|
||||||
|
<mat-icon>playlist_add_check</mat-icon>
|
||||||
|
ระบุผู้อนุมัติ
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button class="buttonColorApprove" *ngIf="!viewMode && state == 'edit' && form.value.is_draft == true && canApprove() && hasApproveQueue()" (click)="save_approve()"
|
||||||
|
color="primary" style="margin-right: 10px;">ส่งอนุมัติ</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**หมายเหตุสำคัญ:** ปุ่ม "ส่งอนุมัติ" ของโหมด `add` (บรรทัด 360, `add_approve()`) **ห้ามแตะ** —
|
||||||
|
เป็น code path คนละเส้นทาง (`.add()` ตรงๆ ไม่ผ่าน `send_approve`/approve_state) ไม่เกี่ยวกับงานนี้
|
||||||
|
|
||||||
|
- [ ] **Step 7: Build ตรวจ compile**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rmutr-web && ng build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build ผ่านไม่มี error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: ทดสอบ end-to-end บนเบราว์เซอร์จริงและ deploy
|
||||||
|
|
||||||
|
**Files:** ไม่มีไฟล์โค้ดเพิ่ม — verification เท่านั้น
|
||||||
|
|
||||||
|
- [ ] **Step 1: Deploy frontend**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rmutr-web && ./script.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: build+rsync สำเร็จ (เช็คก่อนว่าไม่มี `ng build` ค้างจาก session อื่น
|
||||||
|
[[project_rmutr_rsync_deploy_race]])
|
||||||
|
|
||||||
|
- [ ] **Step 2: เดินตามหน้าเว็บจริงด้วยบัญชีทดสอบ**
|
||||||
|
|
||||||
|
เปิด `https://rmis.rmutr.ac.th/budget-web/app/durable-articles/add` → กรอกฟอร์มขั้นต่ำ →
|
||||||
|
"บันทึกฉบับร่าง" → เปิดแก้ไขรายการที่เพิ่งสร้าง (โหมด edit) → ตรวจว่าเห็นปุ่ม "ระบุผู้อนุมัติ" และปุ่ม
|
||||||
|
"ส่งอนุมัติ" **ยังไม่ขึ้น** จนกว่าจะกำหนดผู้อนุมัติก่อน → กด "ระบุผู้อนุมัติ" กรอกชื่อ 2 ลำดับ → บันทึก →
|
||||||
|
ตรวจว่าปุ่ม "ส่งอนุมัติ" ขึ้นมาแล้ว → กด "ส่งอนุมัติ" → ตรวจสถานะเปลี่ยนถูกต้อง
|
||||||
|
|
||||||
|
- [ ] **Step 3: ตรวจว่าคำขอครุภัณฑ์เก่า (code "01") ไม่ได้รับผลกระทบ**
|
||||||
|
|
||||||
|
หาคำขอครุภัณฑ์เก่าที่กำลังอยู่ระหว่างอนุมัติจริง (query `approve_state` ที่ `request_approve_type='01'`
|
||||||
|
และ `is_approve is null`) เปิดดูในหน้าเว็บ ตรวจว่าปุ่มอนุมัติ/ปฏิเสธของผู้อนุมัติเดิมยังทำงานปกติ ไม่มีปุ่ม
|
||||||
|
"ระบุผู้อนุมัติ" ปนมาให้เห็น (เพราะ `canDefineApprovers()` เช็ค `is_draft` ซึ่งรายการที่ส่งไปแล้วเป็น `null`)
|
||||||
@@ -0,0 +1,760 @@
|
|||||||
|
# สิ่งก่อสร้าง (invest_construct) — ผู้ส่งเลือกผู้อนุมัติเอง Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** เปลี่ยนกลไกอนุมัติของ "แผนความต้องการงบลงทุนค่าที่ดินและสิ่งก่อสร้าง" (`/app/invest-construct`,
|
||||||
|
`invest_construct`) จากการดึงคิวผู้อนุมัติจาก config แอดมิน (`request_approve_type="02"`) เป็นให้ผู้ส่ง
|
||||||
|
พิมพ์ชื่อผู้อนุมัติเองก่อนกด "ส่งอนุมัติ" — **mirror งานเดียวกันที่ทำกับครุภัณฑ์ (invest_asset)
|
||||||
|
ทุกจุด แบบคำต่อคำ** (ผู้ใช้ขอตรงๆ ว่า "ให้เหมือนครุภัณฑ์") **แทนที่ไปเลย** เหมือนกัน
|
||||||
|
|
||||||
|
**Architecture:** เหมือนกับ invest_asset ทุกประการ — ยืนยันจากการอ่านโค้ดจริงแล้วว่า
|
||||||
|
`InvestConstructService`/`InvestConstructController`/`invest_construct` model มีโครงสร้าง
|
||||||
|
byte-for-byte เดียวกับ `InvestAssetService`/`InvestAssetController`/`invest_asset` (มี `is_draft`
|
||||||
|
ความหมายเดียวกัน, `send_approve` endpoint โครงเดียวกัน, ผ่าน `ApproveStateController` ตัวเดียวกัน)
|
||||||
|
ดู `docs/superpowers/specs/2026-09-07-invest-asset-self-service-approval-design.md` เป็น pattern
|
||||||
|
authority ไม่เขียน design doc ใหม่ซ้ำ (bounded change ต่อยอดจาก pattern ที่ผ่าน review มาแล้ว)
|
||||||
|
|
||||||
|
**ข้อต่างจาก invest_asset ที่ต้องรู้ (ไม่ต้องแก้อะไร แค่บันทึกไว้):** `RejectGetEntity` ของ
|
||||||
|
`invest_construct` (ต่างจาก `invest_asset` ที่รีเซ็ตกลับไปลำดับ 1 เสมอ) จะถอยกลับแค่ 1 ลำดับ
|
||||||
|
เท่านั้นเมื่อถูกปฏิเสธจากลำดับ 2+ (กลับไปลำดับ 1 เฉพาะตอนถูกปฏิเสธจากลำดับ 1 เอง) — เป็นพฤติกรรม
|
||||||
|
เดิมของระบบ ไม่เกี่ยวกับงานนี้ ไม่ต้องแตะ `RejectGetEntity` เลย (เหมือน invest_asset ที่ก็ไม่ได้แตะ)
|
||||||
|
|
||||||
|
**Tech Stack:** ASP.NET Core 8 / EF Core / Npgsql (backend), Angular (frontend), PostgreSQL
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-09-07-invest-asset-self-service-approval-design.md`
|
||||||
|
(ใช้เป็น pattern authority — ทุก decision ที่นั่นใช้กับ invest_construct เหมือนกันทุกจุด ยกเว้น
|
||||||
|
ข้อต่าง reject ด้านบนซึ่งไม่กระทบงานนี้)
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- รหัส `request_approve_type` ใหม่สำหรับ invest_construct ต้อง**ไม่ชนกับข้อมูลจริงและไม่ชนกับรหัส
|
||||||
|
ใหม่ที่ invest_asset เพิ่งใช้ไป** — invest_asset ใช้ `"05"` (คาดว่าว่างจริง แต่ยังไม่ได้ยืนยันกับ DB
|
||||||
|
เพราะ VPN ไปเครือข่ายภายในหลุดอยู่ตอนที่วางแผนนี้) แผนนี้ใช้ `"06"` ต่อจากนั้น — Task 1 ต้องเช็คสด
|
||||||
|
กับ DB **ทั้งสองรหัส (`"05"` และ `"06"`) พร้อมกัน** ก่อนเริ่ม เพราะทั้งสองแผนพึ่ง DB เดียวกันและอาจ
|
||||||
|
ยังไม่ได้ verify มาก่อน ถ้ารหัสใดชนให้เลือกรหัสว่างจริงถัดไปแทนเฉพาะแผนที่ชน
|
||||||
|
- ระบบมี `t_approve_state`/`v_approve_state` ตารางเดียวใช้ร่วมกันทั้ง `invest_asset`/`invest_construct`/
|
||||||
|
`request_expense` — ห้ามเปลี่ยนพฤติกรรมของ branch อื่นที่ไม่ใช่ `invest_construct` ใน
|
||||||
|
`ApproveStateController` (`Modules/Setting/Controllers/ApproveState.cs`) โดยเด็ดขาด (รวมถึง guard
|
||||||
|
ที่เพิ่งเพิ่มให้ `invest_asset` code `"05"` เมื่อวานนี้ — ห้ามแก้ไข)
|
||||||
|
- คำขอสิ่งก่อสร้างเก่าที่มี `approve_state` แบบ `request_approve_type="02"` ค้างอยู่ระหว่างอนุมัติต้อง
|
||||||
|
เดินหน้าอนุมัติ/ปฏิเสธต่อได้ตามปกติ — guard ใหม่ทุกจุดต้องเช็คเงื่อนไข
|
||||||
|
`request_approve_type == "<รหัสใหม่>"` เสมอ ไม่ใช่เช็คว่าเป็น `invest_construct` เฉยๆ
|
||||||
|
- ไม่ต้องแตะ `RejectGetEntity` เลย (ดูข้อต่างด้านบน) — เหมือน invest_asset ที่ก็ไม่แตะเช่นกัน
|
||||||
|
- ห้ามแตะปุ่ม/เมธอดกลุ่มเซ็นภายในหน่วยงาน (`save_header_approve`/`save_chief_approve`/
|
||||||
|
`save_dean_approve`/`save_president_approve`, ปุ่ม "เห็นชอบ"/"ส่งแก้ไข" ของ "รองอธิการบดี") และ
|
||||||
|
ห้ามแตะปุ่ม/เมธอดของโหมด `state=='add'` (`add_approve()`) — คนละ code path ไม่ผ่าน
|
||||||
|
`send_approve`/approve_state เลย เหมือนกับที่ invest_asset ทำไว้
|
||||||
|
- โค้ดเบสนี้ไม่มี backend unit test project — verify ด้วย `dotnet build`/`ng build` ก่อน แล้ว verify
|
||||||
|
ด้วย `curl` ยิง endpoint จริงด้วย JWT ทดสอบ เทียบกับข้อมูลจริงใน DB — สร้างข้อมูลทดสอบสังเคราะห์
|
||||||
|
(`created_by = 'test.verify'`) ผ่าน SQL แล้วลบทิ้งหลังตรวจเสร็จ
|
||||||
|
- Session นี้ทำงานตรงบน `master` ของทั้ง `rmutr-api`/`rmutr-web` และ deploy จริงทันทีหลังแต่ละ task
|
||||||
|
ผ่าน (ไม่ใช้ branch/PR) — ข้าม `finishing-a-development-branch` ทั้งหมด
|
||||||
|
- Deploy จริง: backend = `dotnet publish -c Release -r linux-x64 --self-contained false` แล้ว `scp`
|
||||||
|
ไป `k8-node-1` restart `systemctl restart rmutr-budget-api` (`rmutr-api/script.sh`); frontend =
|
||||||
|
`ng build --configuration production --base-href /budget-web/` แล้ว `rsync` ไป
|
||||||
|
`k8-node-1:/var/www/html/budget-web/` (`rmutr-web/script.sh`) — ทั้งคู่ต้องผ่าน VPN เข้าเครือข่าย
|
||||||
|
ภายในก่อน (เช็คว่า VPN เชื่อมอยู่ก่อนเริ่ม Task 5/9)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: ยืนยันรหัส `request_approve_type` ว่างจริงกับ production DB (ทั้ง "05" และ "06")
|
||||||
|
|
||||||
|
**Files:** ไม่มีไฟล์โค้ด — ตรวจสอบข้อมูลเท่านั้น
|
||||||
|
|
||||||
|
- [ ] **Step 1: เช็ค VPN ก่อน**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nc -zv -w5 203.158.221.28 31620
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `succeeded` — ถ้ายัง timeout อยู่ ให้หยุดรอ VPN ก่อน ไม่ต้องเดาต่อ
|
||||||
|
|
||||||
|
- [ ] **Step 2: query รหัสที่ใช้อยู่จริง**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - <<'EOF'
|
||||||
|
import psycopg2
|
||||||
|
conn = psycopg2.connect(host="203.158.221.28", port=31620,
|
||||||
|
dbname="rmutr_budget_050625", user="devadmin",
|
||||||
|
password="vtwi,yo0tpkd-okfouh", connect_timeout=8)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute('SELECT DISTINCT request_approve_type FROM "BUDGET".approve_state ORDER BY 1')
|
||||||
|
print("approve_state distinct codes:", cur.fetchall())
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: รายการรหัสที่ใช้อยู่จริง (คาดว่า `01, 02, 03, 04`) — ถ้า `"05"` ไม่อยู่ในผลลัพธ์
|
||||||
|
invest_asset (แผนที่ทำไปแล้วเมื่อวาน, commit `3bdbcd73`/`38b98800`/`b3608fc1`) ใช้ `"05"` ได้ถูกต้อง
|
||||||
|
ไม่ต้องแก้อะไร ถ้า `"05"` มีอยู่แล้ว (ชนกับข้อมูลจริง) ต้องกลับไปแก้ 3 จุดในโค้ดของ invest_asset ที่
|
||||||
|
deploy ไปแล้ว (`InvestAssetService.SetCustomApprovers`, guard ใน `ApproveState.cs`) เป็นรหัสว่างจริง
|
||||||
|
ก่อน แล้วค่อย deploy ใหม่ — นี่เป็นเหตุการณ์นอกเหนือแผนนี้ ให้แจ้งผู้ใช้ก่อนแก้ย้อนหลัง
|
||||||
|
|
||||||
|
ถ้า `"06"` **ไม่อยู่** ในผลลัพธ์ (และไม่ชนกับ `"05"` ที่ invest_asset ใช้) ใช้ `"06"` ต่อได้เลยตามที่
|
||||||
|
แผนนี้เขียนไว้ทุกจุด ถ้า `"06"` มีอยู่แล้ว ให้เลือกรหัส 2 หลักตัวถัดไปที่ไม่อยู่ในผลลัพธ์และไม่ใช่ `"05"`
|
||||||
|
(เช่น `"07"`) แล้วแทนที่คำว่า `"06"` ด้วยรหัสนั้นในทุก code block ของ Task 2, 4 ก่อนเริ่มเขียนโค้ดจริง
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Backend — เพิ่ม `SetCustomApprovers` ใน `InvestConstructService`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Modules/Invests/Services/InvestConstruct.cs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `ApproverInput` class จาก `rmutr_budget_api.Modules.RequestExpense.Services` (มีอยู่แล้ว),
|
||||||
|
`t_approve_state` จาก `rmutr_budget_api.Modules.General.Databases.Models` (import แล้วในไฟล์นี้)
|
||||||
|
- Produces: `Task<(bool ok, string error)> SetCustomApprovers(Guid? investConstructUid, List<ApproverInput> approvers, ClaimsIdentity claimsIdentity)`
|
||||||
|
— ให้ Task 3 (controller) เรียกใช้
|
||||||
|
|
||||||
|
- [ ] **Step 1: เพิ่ม using directive**
|
||||||
|
|
||||||
|
เปิด `Modules/Invests/Services/InvestConstruct.cs` เพิ่มบรรทัดนี้ต่อท้าย using เดิม (บรรทัด 13):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using rmutr_budget_api.Modules.RequestExpense.Services;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: เพิ่มเมธอด `SetCustomApprovers`**
|
||||||
|
|
||||||
|
เพิ่มเมธอดนี้ในคลาส `InvestConstructService` (ต่อจากเมธอด `ListEntityIdApprove` หรือจุดใดก็ได้ในคลาส
|
||||||
|
— คลาสนี้มี field `_context` อยู่แล้วชื่อเดียวกับ `InvestAssetService`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// ให้ผู้ส่งอนุมัติกำหนดคิวผู้อนุมัติ (ลำดับ 1, 2) เองก่อนกด "ส่งอนุมัติ" แทนที่จะดึงจาก config
|
||||||
|
/// "จัดการเมนูการอนุมัติ" ที่แอดมินต้องตั้งไว้ล่วงหน้า (getApproveState("02", ...)) — mirror
|
||||||
|
/// InvestAssetService.SetCustomApprovers (code "05") ทุกจุด ดู
|
||||||
|
/// docs/superpowers/specs/2026-09-07-invest-asset-self-service-approval-design.md ข้อ 3.1(a)
|
||||||
|
/// ใช้ได้เฉพาะรายการที่ยังเป็นฉบับร่าง (is_draft==true) และยังไม่เคยเข้าสู่กระบวนการอนุมัติจริงเลย
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(bool ok, string error)> SetCustomApprovers(Guid? investConstructUid,
|
||||||
|
List<ApproverInput> approvers, ClaimsIdentity claimsIdentity)
|
||||||
|
{
|
||||||
|
var entity = await _context.t_invest_construct
|
||||||
|
.FirstOrDefaultAsync(c => c.invest_construct_uid == investConstructUid);
|
||||||
|
if (entity == null) return (false, "ไม่พบรายการ");
|
||||||
|
if (entity.is_draft != true)
|
||||||
|
return (false, "รายการนี้ต้องอยู่ในสถานะฉบับร่างก่อนจึงจะกำหนดผู้อนุมัติได้");
|
||||||
|
|
||||||
|
var existing = await _context.t_approve_state
|
||||||
|
.Where(c => c.invest_construct_uid == investConstructUid).ToListAsync();
|
||||||
|
if (existing.Any(s => s.is_approve == true))
|
||||||
|
return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว ไม่สามารถกำหนดผู้อนุมัติใหม่ได้");
|
||||||
|
|
||||||
|
approvers = (approvers ?? new List<ApproverInput>())
|
||||||
|
.Where(a => !string.IsNullOrWhiteSpace(a?.display_name_th)).ToList();
|
||||||
|
if (!approvers.Any()) return (false, "กรุณาระบุผู้อนุมัติอย่างน้อย 1 คน");
|
||||||
|
|
||||||
|
if (existing.Any())
|
||||||
|
{
|
||||||
|
_context.t_approve_state.RemoveRange(existing);
|
||||||
|
await _context.SaveChangesAsync(claimsIdentity);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = approvers.Select((a, idx) => new t_approve_state
|
||||||
|
{
|
||||||
|
approve_state_uid = Guid.NewGuid(),
|
||||||
|
invest_construct_uid = investConstructUid,
|
||||||
|
personnel_uid = a.personnel_uid,
|
||||||
|
display_name_th = a.display_name_th,
|
||||||
|
row_order = idx + 1,
|
||||||
|
is_state = idx == 0,
|
||||||
|
request_approve_type = "06",
|
||||||
|
}).ToList();
|
||||||
|
await _context.t_approve_state.AddRangeAsync(rows);
|
||||||
|
entity.current_approve_name = rows.First().display_name_th;
|
||||||
|
_context.t_invest_construct.Update(entity);
|
||||||
|
await _context.SaveChangesAsync(claimsIdentity);
|
||||||
|
return (true, null);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(แทนที่ `"06"` ด้วยรหัสจริงจาก Task 1 ถ้าไม่ใช่ `"06"`)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build ตรวจ compile**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rmutr-api && dotnet build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeded, ไม่มี error (ถ้า build ค้างนาน เช็ค `ps aux | grep -i "dotnet build\|VBCSCompiler"`
|
||||||
|
ก่อนคิดว่า hang จริง — เครื่องนี้มีปัญหา VBCSCompiler bloat เป็นระยะ ให้รอจนกว่า log ใน output ขยับ
|
||||||
|
ก่อนสรุปว่าค้าง)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Backend — เพิ่ม endpoint `set_approvers` ใน `InvestConstructController`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Modules/Invests/Controllers/InvestConstruct.cs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `InvestConstructService.SetCustomApprovers` จาก Task 2
|
||||||
|
- Produces: `POST /api/invest/invest_construct/set_approvers/{uid}` — ให้ Task 7 (frontend service)
|
||||||
|
เรียกใช้
|
||||||
|
|
||||||
|
- [ ] **Step 1: เพิ่ม using directive**
|
||||||
|
|
||||||
|
เปิด `Modules/Invests/Controllers/InvestConstruct.cs` เพิ่มต่อท้าย using เดิม (บรรทัด 14):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using rmutr_budget_api.Modules.RequestExpense.Services;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: เพิ่ม endpoint**
|
||||||
|
|
||||||
|
เพิ่มเมธอดนี้ในคลาส `InvestConstructController` (ต่อจาก `SendApproveEntityId`, หลังบรรทัด 243):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>ผู้ส่งกำหนดผู้อนุมัติเอง (ลำดับ 1, 2) ก่อนกด "ส่งอนุมัติ" — ดู SetCustomApprovers</summary>
|
||||||
|
[Authorize]
|
||||||
|
[HttpPost("set_approvers/{uid:guid}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult> SetApprovers([FromRoute] Guid uid, [FromBody] List<ApproverInput> approvers)
|
||||||
|
{
|
||||||
|
var result = await _entityUidService.SetCustomApprovers(uid, approvers, User.Identity as ClaimsIdentity);
|
||||||
|
if (!result.ok)
|
||||||
|
{
|
||||||
|
return BadRequest(new { description = result.error });
|
||||||
|
}
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build ตรวจ compile**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rmutr-api && dotnet build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeded
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Backend — ปิดช่องโหว่อนุมัติก่อนส่งใน `ApproveGetEntity` (branch invest_construct)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Modules/Setting/Controllers/ApproveState.cs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `t_approve_state.request_approve_type` (รหัสใหม่จาก Task 1/2), `t_invest_construct.is_draft`
|
||||||
|
- Produces: ไม่มี (แก้ behavior ของ endpoint ที่มีอยู่แล้ว `GET api/setting/approve_state/approve/{uid}`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: เพิ่ม guard ใน invest_construct branch ของ `ApproveGetEntity`**
|
||||||
|
|
||||||
|
หาบล็อกนี้ (บรรทัด ~106-113 — **ระวัง: นี่คือ branch `invest_construct`, คนละบล็อกกับ branch
|
||||||
|
`invest_asset` ที่เพิ่มไปแล้วเมื่อวาน ห้ามแก้ผิดบล็อก**):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (invest_construct != null)
|
||||||
|
{
|
||||||
|
getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
|
||||||
|
c.invest_construct_uid == uid && c.is_state == true);
|
||||||
|
if (getResult != null)
|
||||||
|
{
|
||||||
|
getResult.is_state = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
แทนที่ด้วย (เพิ่ม guard block ก่อน `getResult.is_state = false;`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (invest_construct != null)
|
||||||
|
{
|
||||||
|
getResult = await _db.t_approve_state.FirstOrDefaultAsync(c =>
|
||||||
|
c.invest_construct_uid == uid && c.is_state == true);
|
||||||
|
if (getResult != null)
|
||||||
|
{
|
||||||
|
// สิ่งก่อสร้างแบบผู้ส่งเลือกผู้อนุมัติเอง (code "06"): SetCustomApprovers สร้างแถว
|
||||||
|
// is_state=true ไว้ล่วงหน้าตั้งแต่ก่อนกด "ส่งอนุมัติ" (invest_construct.is_draft ยังเป็น
|
||||||
|
// true อยู่) ต้องกันไม่ให้ใครก็ได้กด approve ทะลุขั้นตอนไปก่อน + ต้องเป็นคนที่ถูกระบุชื่อ
|
||||||
|
// ไว้ในคิวลำดับ 1 เท่านั้นเป็นผู้กด (เหมือน invest_asset code "05")
|
||||||
|
if (getResult.row_order == 1 && getResult.request_approve_type == "06")
|
||||||
|
{
|
||||||
|
if (invest_construct.is_draft == true)
|
||||||
|
{
|
||||||
|
return BadRequest(new { description = "ยังไม่ได้ส่งอนุมัติ กรุณากด \"ส่งอนุมัติ\" ก่อน" });
|
||||||
|
}
|
||||||
|
var currentDisplayName = (User.Identity as ClaimsIdentity)?.FindFirst("display_name")?.Value;
|
||||||
|
if (string.IsNullOrEmpty(currentDisplayName) || currentDisplayName != getResult.display_name_th)
|
||||||
|
{
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden,
|
||||||
|
new { description = "ไม่มีสิทธิ์อนุมัติ (ต้องเป็นผู้ที่ถูกกำหนดเป็นผู้อนุมัติลำดับ 1 เท่านั้น)" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getResult.is_state = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
(แทนที่ `"06"` ด้วยรหัสจริงจาก Task 1 ถ้าไม่ใช่ `"06"` — ต้องตรงกับค่าที่ใช้ใน Task 2 เป๊ะ)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build ตรวจ compile**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rmutr-api && dotnet build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeded
|
||||||
|
|
||||||
|
- [ ] **Step 3: ยืนยันด้วย git diff ว่าแตะเฉพาะ branch `invest_construct` เท่านั้น**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff Modules/Setting/Controllers/ApproveState.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: เห็นการเพิ่มเฉพาะในบล็อก `if (invest_construct != null)` เท่านั้น บล็อก `invest_asset`/
|
||||||
|
`request_expense` ต้องไม่มีการเปลี่ยนแปลงใดๆ เลย (รวมถึง guard ของ invest_asset code "05" ที่เพิ่ม
|
||||||
|
ไปเมื่อวาน ต้องอยู่ตรงเดิมไม่ถูกแตะ)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Backend — Deploy และ verify ด้วยข้อมูลทดสอบจริง
|
||||||
|
|
||||||
|
**Files:** ไม่มีไฟล์โค้ดเพิ่ม — deploy + verification เท่านั้น
|
||||||
|
|
||||||
|
- [ ] **Step 1: เช็ค VPN แล้ว publish/deploy**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nc -zv -w5 172.27.30.135 22 && cd rmutr-api && ./script.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: สคริปต์รันจบไม่ error, service `rmutr-budget-api` restart สำเร็จ
|
||||||
|
|
||||||
|
- [ ] **Step 2: สร้างข้อมูลทดสอบสังเคราะห์**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - <<'EOF'
|
||||||
|
import psycopg2, uuid
|
||||||
|
conn = psycopg2.connect(host="203.158.221.28", port=31620,
|
||||||
|
dbname="rmutr_budget_050625", user="devadmin",
|
||||||
|
password="vtwi,yo0tpkd-okfouh")
|
||||||
|
cur = conn.cursor()
|
||||||
|
test_uid = str(uuid.uuid4())
|
||||||
|
cur.execute('''
|
||||||
|
INSERT INTO "BUDGET".invest_construct
|
||||||
|
(invest_construct_uid, is_draft, created_by, invest_construct_name_th, status_id)
|
||||||
|
VALUES (%s, true, 'test.verify', 'ทดสอบระบุผู้อนุมัติเอง', 1)
|
||||||
|
''', (test_uid,))
|
||||||
|
conn.commit()
|
||||||
|
print("test invest_construct_uid:", test_uid)
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
(ปรับคอลัมน์ตาม NOT NULL constraint จริงของตาราง `invest_construct` ถ้า insert error ให้เช็ค schema
|
||||||
|
ก่อนแล้วเติมคอลัมน์ที่ขาดให้ครบ — เทียบเคียงกับที่ทำสำเร็จตอน invest_asset)
|
||||||
|
|
||||||
|
- [ ] **Step 3: ทดสอบ `set_approvers` ก่อนส่งอนุมัติ — ต้องสำเร็จ**
|
||||||
|
|
||||||
|
ยิง `POST /api/invest/invest_construct/set_approvers/{test_uid}` ด้วย JWT ทดสอบ body:
|
||||||
|
```json
|
||||||
|
[{"display_name_th": "ทดสอบ หัวหน้าหน่วยงาน"}, {"display_name_th": "ทดสอบ รองอธิการบดี"}]
|
||||||
|
```
|
||||||
|
Expected: 200 OK ตรวจด้วย SQL ว่ามี `t_approve_state` 2 แถวผูกกับ `test_uid`,
|
||||||
|
`request_approve_type='06'`, row_order 1/2, row_order=1 มี `is_state=true`
|
||||||
|
|
||||||
|
- [ ] **Step 4: ทดสอบ approve ก่อนกด "ส่งอนุมัติ" — ต้องถูกบล็อก**
|
||||||
|
|
||||||
|
ยิง `GET /api/setting/approve_state/approve/{test_uid}` (ตอนที่ invest_construct ยัง `is_draft=true`)
|
||||||
|
Expected: 400 BadRequest, message "ยังไม่ได้ส่งอนุมัติ กรุณากด \"ส่งอนุมัติ\" ก่อน"
|
||||||
|
|
||||||
|
- [ ] **Step 5: กด "ส่งอนุมัติ" แล้วทดสอบ approve ด้วยคนละชื่อ — ต้องถูกบล็อก 403**
|
||||||
|
|
||||||
|
ยิง `GET /api/invest/invest_construct/send_approve/{test_uid}` ก่อน (ตรวจว่า `is_draft` เปลี่ยนเป็น
|
||||||
|
`null`) แล้วยิง `GET /api/setting/approve_state/approve/{test_uid}` ด้วย JWT ที่ `display_name`
|
||||||
|
**ไม่ตรง**กับ "ทดสอบ หัวหน้าหน่วยงาน"
|
||||||
|
Expected: 403 Forbidden
|
||||||
|
|
||||||
|
- [ ] **Step 6: approve ด้วยชื่อที่ถูกต้อง — ต้องสำเร็จและเลื่อนคิว**
|
||||||
|
|
||||||
|
ยิง `GET /api/setting/approve_state/approve/{test_uid}` ด้วย JWT ที่ `display_name` = "ทดสอบ
|
||||||
|
หัวหน้าหน่วยงาน" Expected: 200 OK, ตรวจ SQL ว่า row_order=1 มี `is_approve=true`, row_order=2 มี
|
||||||
|
`is_state=true`
|
||||||
|
|
||||||
|
- [ ] **Step 7: ลบข้อมูลทดสอบ**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - <<'EOF'
|
||||||
|
import psycopg2
|
||||||
|
conn = psycopg2.connect(host="203.158.221.28", port=31620,
|
||||||
|
dbname="rmutr_budget_050625", user="devadmin",
|
||||||
|
password="vtwi,yo0tpkd-okfouh")
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute('DELETE FROM "BUDGET".approve_state WHERE invest_construct_uid = %s', (test_uid,))
|
||||||
|
cur.execute('DELETE FROM "BUDGET".invest_construct WHERE invest_construct_uid = %s', (test_uid,))
|
||||||
|
conn.commit()
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Frontend — สร้าง dialog "ระบุผู้อนุมัติ" สำหรับสิ่งก่อสร้าง
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `rmutr-web/src/app/feature/budget-request/investment-budget/invest-construct/presenter/popup/set-invest-construct-approvers-dialog/set-invest-construct-approvers-dialog.component.ts`
|
||||||
|
- Create: `.../set-invest-construct-approvers-dialog/set-invest-construct-approvers-dialog.component.html`
|
||||||
|
- Create: `.../set-invest-construct-approvers-dialog/set-invest-construct-approvers-dialog.component.scss`
|
||||||
|
- Modify: `rmutr-web/src/app/feature/budget-request/investment-budget/invest-construct/invest-construct.module.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `AgencyService.getHr()` (`src/app/core/service/general/agency.service`, มีอยู่แล้ว),
|
||||||
|
`SearchAutoPipe` (มาจาก `SharedModule` ที่ import อยู่แล้วในโมดูลนี้)
|
||||||
|
- Produces: `SetInvestConstructApproversDialogComponent` — ให้ Task 8 เรียกผ่าน `MatDialog.open()`,
|
||||||
|
`dialogRef.afterClosed()` คืนค่า `{ personnel_uid, display_name_th }[]` หรือ `null` ถ้ากดยกเลิก
|
||||||
|
|
||||||
|
- [ ] **Step 1: สร้าง component ts** (เนื้อหาเหมือน
|
||||||
|
`feature/budget-request/investment-budget/durable-articles/presenter/popup/set-invest-approvers-dialog/set-invest-approvers-dialog.component.ts`
|
||||||
|
ทุกจุด เปลี่ยนแค่ชื่อคลาส/selector)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Component, Inject } from '@angular/core';
|
||||||
|
import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
|
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { AgencyService } from 'src/app/core/service/general/agency.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-set-invest-construct-approvers-dialog',
|
||||||
|
templateUrl: './set-invest-construct-approvers-dialog.component.html',
|
||||||
|
styleUrls: ['./set-invest-construct-approvers-dialog.component.scss']
|
||||||
|
})
|
||||||
|
export class SetInvestConstructApproversDialogComponent {
|
||||||
|
form: FormGroup
|
||||||
|
agency$: Observable<any>
|
||||||
|
errorText: string = null
|
||||||
|
approverLabels = ['หัวหน้าหน่วยงาน', 'รองอธิการบดี']
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private fb: FormBuilder,
|
||||||
|
private agencySV: AgencyService,
|
||||||
|
public dialogRef: MatDialogRef<SetInvestConstructApproversDialogComponent>,
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: any,
|
||||||
|
) {
|
||||||
|
this.agency$ = this.agencySV.getHr()
|
||||||
|
const existing = (this.data?.approvers || []) as any[]
|
||||||
|
this.form = this.fb.group({
|
||||||
|
approvers: this.fb.array([
|
||||||
|
this.approverForm(existing[0]?.personnel_uid, existing[0]?.display_name_th, true),
|
||||||
|
this.approverForm(existing[1]?.personnel_uid, existing[1]?.display_name_th, false),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
get approvers(): FormArray {
|
||||||
|
return this.form.get('approvers') as FormArray
|
||||||
|
}
|
||||||
|
|
||||||
|
approverForm(personnel_uid: any = null, display_name_th: string = '', required: boolean = false): FormGroup {
|
||||||
|
return this.fb.group({
|
||||||
|
personnel_uid: [personnel_uid],
|
||||||
|
display_name_th: [display_name_th, required ? [Validators.required] : []],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
click_person(item: any, group: FormGroup) {
|
||||||
|
group.get('personnel_uid').setValue(item.personnel_uid)
|
||||||
|
group.get('display_name_th').setValue(item.display_name_th)
|
||||||
|
}
|
||||||
|
|
||||||
|
save() {
|
||||||
|
this.errorText = null
|
||||||
|
this.approvers.markAllAsTouched()
|
||||||
|
if (!this.approvers.at(0).value.display_name_th) {
|
||||||
|
this.errorText = 'กรุณาระบุหัวหน้าหน่วยงาน'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const value = (this.form.value.approvers || []).filter((a: any) => !!a.display_name_th)
|
||||||
|
this.dialogRef.close(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
this.dialogRef.close(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: สร้าง component html**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<h2 mat-dialog-title>กำหนดผู้อนุมัติ</h2>
|
||||||
|
<div mat-dialog-content [formGroup]="form">
|
||||||
|
<p class="dialog-hint">ระบุผู้อนุมัติคำขอสิ่งก่อสร้าง (หัวหน้าหน่วยงานจะได้รับก่อน เมื่ออนุมัติแล้วจะส่งต่อไปยังรองอธิการบดีอัตโนมัติ) — บันทึกไว้ก่อน แล้วค่อยกด "ส่งอนุมัติ" ด้านนอกอีกครั้งเพื่อส่งจริง</p>
|
||||||
|
|
||||||
|
<div formArrayName="approvers">
|
||||||
|
<div class="approver-row" *ngFor="let group of approvers.controls; let i = index" [formGroupName]="i">
|
||||||
|
<span class="row-order-badge">{{ approverLabels[i] }} <span class="required-asterisk" *ngIf="i === 0">*</span></span>
|
||||||
|
<mat-form-field appearance="outline" class="approver-input">
|
||||||
|
<input autocomplete="off" type="text" matInput placeholder="ค้นหาชื่อผู้อนุมัติ"
|
||||||
|
formControlName="display_name_th" [matAutocomplete]="autoApprover">
|
||||||
|
<mat-icon matSuffix>search</mat-icon>
|
||||||
|
<mat-autocomplete #autoApprover="matAutocomplete">
|
||||||
|
<mat-option (click)="click_person(item, group)"
|
||||||
|
*ngFor="let item of agency$ | async | searchAuto : group.value.display_name_th : 'display_name_th'"
|
||||||
|
[value]="item.display_name_th">
|
||||||
|
{{ item.display_name_th }}
|
||||||
|
</mat-option>
|
||||||
|
</mat-autocomplete>
|
||||||
|
<mat-error *ngIf="group.get('display_name_th').hasError('required')">กรุณาระบุ{{ approverLabels[i] }}</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="error-text" *ngIf="errorText">{{ errorText }}</p>
|
||||||
|
</div>
|
||||||
|
<div mat-dialog-actions align="end">
|
||||||
|
<button mat-button (click)="cancel()">ยกเลิก</button>
|
||||||
|
<button mat-raised-button color="primary" (click)="save()">บันทึก</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: สร้าง component scss**
|
||||||
|
|
||||||
|
```scss
|
||||||
|
.dialog-hint {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approver-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-order-badge {
|
||||||
|
min-width: 130px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #444;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required-asterisk {
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approver-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #c62828;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: ประกาศ component ใน module**
|
||||||
|
|
||||||
|
เปิด `invest-construct.module.ts` เพิ่ม import (ต่อจากบรรทัด 45):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { SetInvestConstructApproversDialogComponent } from './presenter/popup/set-invest-construct-approvers-dialog/set-invest-construct-approvers-dialog.component';
|
||||||
|
```
|
||||||
|
|
||||||
|
เพิ่มเข้า `declarations` array ต่อจาก `ListApprove5Component,` (บรรทัด 93):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
SetInvestConstructApproversDialogComponent,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build ตรวจ compile**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rmutr-web && ng build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build ผ่านไม่มี error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Frontend — เพิ่ม `setApprovers` ใน `InvestConstructService` (Angular)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `rmutr-web/src/app/core/service/invest/invest-construct.service.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `POST /api/invest/invest_construct/set_approvers/{uid}` จาก Task 3
|
||||||
|
- Produces: `setApprovers(id, approvers)` — ให้ Task 8 เรียกใช้
|
||||||
|
|
||||||
|
- [ ] **Step 1: เพิ่มเมธอด**
|
||||||
|
|
||||||
|
เปิดไฟล์ เพิ่มเมธอดนี้ต่อจาก `reject(id)` (บรรทัด 21):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
setApprovers(id, approvers: { personnel_uid: string, display_name_th: string }[]) {
|
||||||
|
return this.http.post(`${environment.rmutrApi}/api/invest/invest_construct/set_approvers/${id}`, approvers)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build ตรวจ compile**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rmutr-web && ng build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build ผ่านไม่มี error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Frontend — เสียบปุ่ม "ระบุผู้อนุมัติ" เข้าฟอร์มสิ่งก่อสร้าง
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `rmutr-web/src/app/feature/budget-request/investment-budget/invest-construct/presenter/form/invest-construct-form/invest-construct-form.component.ts`
|
||||||
|
- Modify: `.../invest-construct-form.component.html`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `SetInvestConstructApproversDialogComponent` (Task 6), `InvestConstructService.setApprovers` (Task 7)
|
||||||
|
|
||||||
|
- [ ] **Step 1: import dialog component**
|
||||||
|
|
||||||
|
เปิด `invest-construct-form.component.ts` เพิ่ม import ต่อจากบรรทัด 26:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { SetInvestConstructApproversDialogComponent } from '../../popup/set-invest-construct-approvers-dialog/set-invest-construct-approvers-dialog.component';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: เพิ่ม field เก็บ approve_states ปัจจุบัน**
|
||||||
|
|
||||||
|
เพิ่ม field นี้ต่อจาก `canSendApprove = false` (บรรทัด 51):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** approve_states ปัจจุบันของ invest_construct นี้ (เซ็ตใน setUpform) ใช้เช็คว่ากำหนดคิวผู้อนุมัติแล้วหรือยัง */
|
||||||
|
currentApproveStates: any[] = []
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: เพิ่ม `canDefineApprovers()`/`hasApproveQueue()`**
|
||||||
|
|
||||||
|
เพิ่มเมธอดนี้ต่อจาก `canApprove()` (หลังบรรทัด 59):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** แสดงปุ่ม "ระบุผู้อนุมัติ" เฉพาะรายการที่ยังไม่เคยเข้าสู่กระบวนการอนุมัติจริงเลย */
|
||||||
|
canDefineApprovers(): boolean {
|
||||||
|
if (this.form.value.is_draft !== true) return false
|
||||||
|
if (this.form.value.is_approve === true) return false
|
||||||
|
return !(this.currentApproveStates || []).some((s: any) => s.is_approve === true)
|
||||||
|
}
|
||||||
|
/** มีคิวผู้อนุมัติที่กำหนดไว้แล้ว — ใช้บังคับให้ต้อง "ระบุผู้อนุมัติ" ก่อนจึงจะกด "ส่งอนุมัติ" ได้ */
|
||||||
|
hasApproveQueue(): boolean {
|
||||||
|
return (this.currentApproveStates || []).some((s: any) => s.row_order === 1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: เก็บ `approve_states` ตอนโหลดฟอร์ม**
|
||||||
|
|
||||||
|
เปิด `setUpform(res)` (บรรทัด 161) เพิ่มบรรทัดแรกในเมธอด:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
setUpform(res) {
|
||||||
|
this.currentApproveStates = res.approve_states || []
|
||||||
|
this.form.patchValue(res)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: เพิ่มเมธอด `onSetApprovers()`**
|
||||||
|
|
||||||
|
เพิ่มเมธอดนี้ต่อจาก `save_approve()` (หลังบรรทัด 781, ก่อน `add_approve()`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** ให้ผู้ส่งอนุมัติเลือกเองว่าจะส่งให้ใครอนุมัติลำดับ 1, 2 แทนการดึงจาก "จัดการเมนูการอนุมัติ" ที่แอดมินตั้งค่าล่วงหน้า */
|
||||||
|
onSetApprovers() {
|
||||||
|
const existingApprovers = (this.currentApproveStates || [])
|
||||||
|
.slice()
|
||||||
|
.sort((a: any, b: any) => (a.row_order ?? 0) - (b.row_order ?? 0))
|
||||||
|
.map((s: any) => ({ personnel_uid: s.personnel_uid, display_name_th: s.display_name_th }))
|
||||||
|
const dialogRef = this.dialog.open(SetInvestConstructApproversDialogComponent, {
|
||||||
|
width: '600px',
|
||||||
|
disableClose: true,
|
||||||
|
data: { approvers: existingApprovers },
|
||||||
|
})
|
||||||
|
dialogRef.afterClosed().subscribe((approvers) => {
|
||||||
|
if (!approvers) return
|
||||||
|
this.InvestConstructSV.setApprovers(this.form.getRawValue().invest_construct_uid, approvers).pipe(
|
||||||
|
catchError(err => {
|
||||||
|
Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error')
|
||||||
|
return throwError(err)
|
||||||
|
}),
|
||||||
|
).subscribe(() => {
|
||||||
|
Swal.fire({
|
||||||
|
title: 'บันทึกผู้อนุมัติสำเร็จ',
|
||||||
|
text: 'กด "ส่งอนุมัติ" อีกครั้งเพื่อส่งเข้าสู่กระบวนการอนุมัติ',
|
||||||
|
icon: 'success',
|
||||||
|
confirmButtonText: 'ตกลง'
|
||||||
|
}).then(() => {
|
||||||
|
this.InvestConstructSV.get(this.uniquekey).subscribe((res: any) => {
|
||||||
|
this.setUpform(res)
|
||||||
|
this.cdRef.detectChanges()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: เพิ่มปุ่มและแก้เงื่อนไขปุ่ม "ส่งอนุมัติ" เดิม (โหมด edit เท่านั้น)**
|
||||||
|
|
||||||
|
เปิด `invest-construct-form.component.html` หาบรรทัด (บรรทัด 368):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<button mat-raised-button class="buttonColorApprove" *ngIf="!viewMode && state == 'edit' && form.value.is_draft == true && canApprove()" (click)="save_approve()"
|
||||||
|
color="primary" style="margin-right: 10px;">ส่งอนุมัติ</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
แทนที่ด้วย (เพิ่มปุ่ม "ระบุผู้อนุมัติ" ก่อนหน้า + เพิ่ม `hasApproveQueue()` เป็นเงื่อนไขของปุ่ม
|
||||||
|
"ส่งอนุมัติ" เดิม):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<button mat-raised-button color="primary"
|
||||||
|
*ngIf="!viewMode && state == 'edit' && form.value.is_draft == true && canApprove() && canDefineApprovers()"
|
||||||
|
(click)="onSetApprovers()" style="margin-right: 10px;">
|
||||||
|
<mat-icon>playlist_add_check</mat-icon>
|
||||||
|
ระบุผู้อนุมัติ
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button class="buttonColorApprove" *ngIf="!viewMode && state == 'edit' && form.value.is_draft == true && canApprove() && hasApproveQueue()" (click)="save_approve()"
|
||||||
|
color="primary" style="margin-right: 10px;">ส่งอนุมัติ</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**หมายเหตุสำคัญ:** ปุ่ม "ส่งอนุมัติ" ของโหมด `add` (บรรทัด 366, `add_approve()`) และปุ่ม
|
||||||
|
"เห็นชอบ"/"ส่งแก้ไข" ของ "รองอธิการบดี" (บรรทัด 349, 355 — มีเงื่อนไข `isLastApprover` พิเศษที่
|
||||||
|
invest_asset ไม่มี) **ห้ามแตะทั้งคู่** — คนละ code path ไม่เกี่ยวกับงานนี้
|
||||||
|
|
||||||
|
- [ ] **Step 7: Build ตรวจ compile**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rmutr-web && ng build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build ผ่านไม่มี error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: ทดสอบ end-to-end บนเบราว์เซอร์จริงและ deploy
|
||||||
|
|
||||||
|
**Files:** ไม่มีไฟล์โค้ดเพิ่ม — verification เท่านั้น
|
||||||
|
|
||||||
|
- [ ] **Step 1: เช็ค VPN แล้ว deploy frontend**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nc -zv -w5 172.27.30.135 22 && cd rmutr-web && ./script.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: build+rsync สำเร็จ (เช็คก่อนว่าไม่มี `ng build` ค้างจาก session อื่น
|
||||||
|
[[project_rmutr_rsync_deploy_race]])
|
||||||
|
|
||||||
|
- [ ] **Step 2: เดินตามหน้าเว็บจริงด้วยบัญชีทดสอบ**
|
||||||
|
|
||||||
|
เปิด `https://rmis.rmutr.ac.th/budget-web/app/invest-construct/add` → กรอกฟอร์มขั้นต่ำ →
|
||||||
|
"บันทึกฉบับร่าง" → เปิดแก้ไขรายการที่เพิ่งสร้าง (โหมด edit) → ตรวจว่าเห็นปุ่ม "ระบุผู้อนุมัติ" และ
|
||||||
|
ปุ่ม "ส่งอนุมัติ" **ยังไม่ขึ้น** จนกว่าจะกำหนดผู้อนุมัติก่อน → กด "ระบุผู้อนุมัติ" กรอกชื่อ 2 ลำดับ →
|
||||||
|
บันทึก → ตรวจว่าปุ่ม "ส่งอนุมัติ" ขึ้นมาแล้ว → กด "ส่งอนุมัติ" → ตรวจสถานะเปลี่ยนถูกต้อง
|
||||||
|
|
||||||
|
- [ ] **Step 3: ตรวจว่าคำขอสิ่งก่อสร้างเก่า (code "02") ไม่ได้รับผลกระทบ**
|
||||||
|
|
||||||
|
หาคำขอสิ่งก่อสร้างเก่าที่กำลังอยู่ระหว่างอนุมัติจริง (query `approve_state` ที่
|
||||||
|
`request_approve_type='02'` และ `is_approve is null`) เปิดดูในหน้าเว็บ ตรวจว่าปุ่มอนุมัติ/ปฏิเสธของ
|
||||||
|
ผู้อนุมัติเดิมยังทำงานปกติ ไม่มีปุ่ม "ระบุผู้อนุมัติ" ปนมาให้เห็น
|
||||||
|
|
||||||
|
- [ ] **Step 4: ตรวจว่าครุภัณฑ์ (invest_asset, code "05") ไม่ได้รับผลกระทบจากงานนี้**
|
||||||
|
|
||||||
|
หาคำขอครุภัณฑ์ที่ยังทดสอบ/ใช้งานอยู่ (ถ้ามี) เปิดดูในหน้าเว็บ ตรวจว่าปุ่ม "ระบุผู้อนุมัติ"/
|
||||||
|
"ส่งอนุมัติ" ของครุภัณฑ์ยังทำงานปกติเหมือนก่อนเริ่มงานนี้ (regression check ข้ามแผน เพราะทั้งคู่แชร์
|
||||||
|
`ApproveStateController` ตัวเดียวกัน)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,441 @@
|
|||||||
|
# ระบบอนุมัติ (approve_state) — เอกสารอ้างอิงสำหรับนำไปใช้กับ "คำขอแผ่นดิน"
|
||||||
|
|
||||||
|
**วันที่:** 2026-09-07
|
||||||
|
**ขอบเขต:** สรุปกลไก "ระบุผู้อนุมัติ → ส่งอนุมัติ → อนุมัติ 2 ลำดับ → ส่งแก้ไข" ที่ใช้อยู่จริงในหน้า
|
||||||
|
"ส่งอนุมัติคำขอเงินรายได้" (`request_expense`, `type_id=2`, `request_approve_type="04"`) พร้อมโค้ดจริง
|
||||||
|
คอมเมนต์กำกับ และจุดที่ต้องระวังถ้าจะนำแพทเทิร์นเดียวกันไปทำกับ **คำขอแผ่นดิน** (entity ใหม่)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ภาพรวม: ตารางเดียว ใช้ร่วมกันหลาย entity
|
||||||
|
|
||||||
|
`approve_state` เป็นตารางกลาง **ใช้ร่วมกัน** สำหรับกระบวนการอนุมัติของหลาย entity ในระบบ โดยแยกกันด้วย
|
||||||
|
FK คนละคอลัมน์ (ไม่ใช่ FK เดียวที่ใช้ polymorphic reference):
|
||||||
|
|
||||||
|
| entity | FK คอลัมน์ใน approve_state | request_approve_type code | ที่มาของคิวผู้อนุมัติ |
|
||||||
|
|-----------------|------------------------------|----------------------------|-----------------------------------------------------|
|
||||||
|
| invest_asset | `invest_asset_uid` | `"01"` | config "จัดการเมนูการอนุมัติ" (ตั้งล่วงหน้าตามคณะ+พื้นที่) |
|
||||||
|
| invest_construct| `invest_construct_uid` | `"02"` | config "จัดการเมนูการอนุมัติ" |
|
||||||
|
| request_expense | `request_expense_uid` | `"03"` | config "จัดการเมนูการอนุมัติ" (type_id=1 เดิม) |
|
||||||
|
| request_expense | `request_expense_uid` | `"04"` | **ผู้ส่งอนุมัติกำหนดเอง** ผ่าน `SetCustomApprovers` (type_id=2 — หน้าที่เราคุยกันอยู่) |
|
||||||
|
|
||||||
|
**สิ่งที่ต้องรู้ก่อนเริ่ม:** code `"04"` ถูกสร้างขึ้นทีหลัง เพื่อ**เลิกพึ่งพา** config ที่แอดมินต้องตั้งไว้
|
||||||
|
ล่วงหน้าทีละคณะ/พื้นที่ (แบบ `"01"`/`"02"`/`"03"`) — ผู้ส่งอนุมัติ (ใครก็ได้) เลือกผู้อนุมัติเองตอนจะส่งเรื่อง
|
||||||
|
**ถ้าจะทำ "คำขอแผ่นดิน" ควรพิจารณาแบบ `"04"` เป็นค่าเริ่มต้น** เว้นแต่มีเหตุผลเฉพาะที่ต้องบังคับคิว
|
||||||
|
ผู้อนุมัติตายตัวต่อคณะ/พื้นที่แบบ config เดิม
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. โมเดลข้อมูล
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class approve_state : base_table
|
||||||
|
{
|
||||||
|
[Key] public Guid? approve_state_uid { get; set; }
|
||||||
|
|
||||||
|
// FK แยกตาม entity — เพิ่ม FK ใหม่ตรงนี้เวลาจะรองรับ entity ใหม่ (เช่น request_budget_uid)
|
||||||
|
public Guid? invest_asset_uid { get; set; }
|
||||||
|
public Guid? invest_construct_uid { get; set; }
|
||||||
|
public Guid? request_expense_uid { get; set; }
|
||||||
|
|
||||||
|
public int? row_order { get; set; } // ลำดับผู้อนุมัติ 1, 2, 3, ...
|
||||||
|
public bool? is_approve { get; set; } // true = แถวนี้ (ลำดับนี้) อนุมัติผ่านแล้ว
|
||||||
|
public bool? is_state { get; set; } // true = "คิวปัจจุบัน" กำลังรอผู้อนุมัติแถวนี้อยู่
|
||||||
|
public DateTime? approve_date { get; set; }
|
||||||
|
public Guid? personnel_uid { get; set; }
|
||||||
|
public string display_name_th { get; set; } // ⚠️ ใช้ match ตัวตนผู้อนุมัติแบบ exact string เท่านั้น
|
||||||
|
public string request_approve_type { get; set; } // "01".."04" ตามตารางข้างบน
|
||||||
|
public string position_name_th { get; set; }
|
||||||
|
public int round_no { get; set; } = 1; // รอบการอนุมัติ (เผื่อมี "ส่งอนุมัติเพิ่มเติม" รอบใหม่)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class request_expense : base_table
|
||||||
|
{
|
||||||
|
[Key] public Guid? request_expense_uid { get; set; }
|
||||||
|
public bool? is_approve { get; set; } // true = อนุมัติผ่านครบทุกลำดับแล้ว (ทั้งรอบ)
|
||||||
|
public bool? is_draft { get; set; } // true = ถูกส่งแก้ไข (ตีกลับ) อยู่ ต้องแก้แล้วส่งใหม่/อนุมัติใหม่
|
||||||
|
public bool? is_sent { get; set; } // true = มีคนกด "ส่งอนุมัติ" แล้วจริง (เกตสำคัญ — ดูข้อ 4)
|
||||||
|
public string current_approve_name { get; set; } // ชื่อผู้อนุมัติที่กำลังรอคิวอยู่ตอนนี้ (denormalize ไว้โชว์ในตาราง)
|
||||||
|
[NotMapped] public bool? is_state { get; set; } // ไม่ได้ map กับ DB — ใช้ชั่วคราวตอนคำนวณผลใน controller เท่านั้น
|
||||||
|
[NotMapped] public int? row_order { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**สถานะที่เป็นไปได้ของ `request_expense` (ผสมกันได้ตามตาราง):**
|
||||||
|
|
||||||
|
| is_sent | is_draft | is_approve | ความหมาย |
|
||||||
|
|---------|----------|------------|-----------|
|
||||||
|
| null | null | null | ยังไม่เคยกด "ส่งอนุมัติ" เลย (แม้จะตั้งผู้อนุมัติไว้แล้วก็ตาม) |
|
||||||
|
| true | null | null | ส่งอนุมัติแล้ว กำลังรอผู้อนุมัติแถวที่ `is_state=true` อยู่ (ลำดับใดก็ได้) |
|
||||||
|
| true | true | false | ถูกตีกลับ (ปฏิเสธ) จากลำดับ > 1 กลับมาลำดับก่อนหน้า — **ยังไม่ถึงมือผู้สร้าง** |
|
||||||
|
| false | true | false | ถูกตีกลับจากลำดับ 1 กลับมาหา**ผู้สร้าง** — ต้องแก้ไขแล้วกด "ส่งอนุมัติ" ใหม่ |
|
||||||
|
| — | — | true | อนุมัติผ่านครบทุกลำดับของรอบนี้แล้ว |
|
||||||
|
|
||||||
|
> ⚠️ **กับดักที่เจอจริง:** ตอน "ระบุผู้อนุมัติ" (`SetCustomApprovers`) ระบบจะ seed แถว `row_order=1`
|
||||||
|
> ให้ `is_state=true` ทันที **ก่อน** ที่จะมีใครกด "ส่งอนุมัติ" เลย ถ้า query ฝั่งอ่านไปเช็คแค่
|
||||||
|
> `approve_state.is_state==true` โดยไม่เช็ค `request_expense.is_sent==true` ควบคู่ จะนับรายการที่ยัง
|
||||||
|
> กดอนุมัติจริงไม่ได้ (เพราะ endpoint อนุมัติเองก็ block ด้วย error "ยังไม่ได้ส่งอนุมัติ") ว่าเป็น "รออนุมัติ"
|
||||||
|
> ไปด้วย — เจอบั๊กนี้จริงตอนทำ dashboard (ดูข้อ 6)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Flow 1 — การระบุผู้อนุมัติ (`SetCustomApprovers`)
|
||||||
|
|
||||||
|
ผู้ส่งอนุมัติ (ใครก็ได้ที่มีสิทธิ์เข้าถึงเรื่องนี้) เลือกคิวผู้อนุมัติเอง **ก่อน** กด "ส่งอนุมัติ"
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Modules/Requests/Services/RequestExpense.cs
|
||||||
|
public async Task<(bool ok, string error)> SetCustomApprovers(Guid? requestExpenseUid,
|
||||||
|
List<ApproverInput> approvers, ClaimsIdentity claimsIdentity)
|
||||||
|
{
|
||||||
|
var entity = await _context.t_request_expense
|
||||||
|
.FirstOrDefaultAsync(c => c.request_expense_uid == requestExpenseUid);
|
||||||
|
if (entity == null) return (false, "ไม่พบรายการ");
|
||||||
|
|
||||||
|
// กันเขียนทับคิวที่เข้ากระบวนการอนุมัติไปแล้ว (ทั้งอนุมัติผ่าน/ถูกส่งแก้ไข/ถูกส่งอนุมัติแล้ว)
|
||||||
|
if (entity.is_draft != null || entity.is_approve != null)
|
||||||
|
return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว ไม่สามารถกำหนดผู้อนุมัติใหม่ได้");
|
||||||
|
if (entity.is_sent == true)
|
||||||
|
return (false, "รายการนี้ถูกส่งอนุมัติไปแล้ว ไม่สามารถกำหนดผู้อนุมัติใหม่ได้");
|
||||||
|
|
||||||
|
var existing = await _context.t_approve_state
|
||||||
|
.Where(c => c.request_expense_uid == requestExpenseUid).ToListAsync();
|
||||||
|
if (existing.Any(s => s.is_approve == true))
|
||||||
|
return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว ไม่สามารถกำหนดผู้อนุมัติใหม่ได้");
|
||||||
|
|
||||||
|
approvers = (approvers ?? new List<ApproverInput>())
|
||||||
|
.Where(a => !string.IsNullOrWhiteSpace(a?.display_name_th)).ToList();
|
||||||
|
if (!approvers.Any()) return (false, "กรุณาระบุผู้อนุมัติอย่างน้อย 1 คน");
|
||||||
|
|
||||||
|
// ล้างคิวเก่า (ถ้ามี) แล้วสร้างใหม่ทั้งหมด — ไม่ merge ทีละคน
|
||||||
|
if (existing.Any())
|
||||||
|
{
|
||||||
|
_context.t_approve_state.RemoveRange(existing);
|
||||||
|
await _context.SaveChangesAsync(claimsIdentity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// สร้างคิวใหม่ตามลำดับที่ frontend ส่งมา (idx 0 = row_order 1 = ลำดับแรก)
|
||||||
|
var rows = approvers.Select((a, idx) => new t_approve_state
|
||||||
|
{
|
||||||
|
approve_state_uid = Guid.NewGuid(),
|
||||||
|
request_expense_uid = requestExpenseUid,
|
||||||
|
personnel_uid = a.personnel_uid,
|
||||||
|
display_name_th = a.display_name_th, // ⚠️ ต้องตรงกับ claim "display_name" ของผู้อนุมัติ
|
||||||
|
// แบบตัวอักษรต่อตัวอักษร รวมช่องว่าง — ดูข้อ 7
|
||||||
|
row_order = idx + 1,
|
||||||
|
is_state = idx == 0, // เฉพาะลำดับแรกที่ตั้ง is_state=true ตั้งแต่ตอนนี้ (ก่อนส่งอนุมัติด้วยซ้ำ!)
|
||||||
|
request_approve_type = "04",
|
||||||
|
}).ToList();
|
||||||
|
await _context.t_approve_state.AddRangeAsync(rows);
|
||||||
|
entity.current_approve_name = rows.First().display_name_th;
|
||||||
|
_context.t_request_expense.Update(entity);
|
||||||
|
await _context.SaveChangesAsync(claimsIdentity);
|
||||||
|
return (true, null);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend เรียกผ่าน:** `POST /api/request/request_expense/type_2/set_approvers/{uid}`
|
||||||
|
Body: `[{ personnel_uid, display_name_th }, ...]` เรียงตามลำดับที่จะให้อนุมัติ
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Flow 2 — การส่งอนุมัติ (`SendApprove2`)
|
||||||
|
|
||||||
|
**หลักการสำคัญ:** "ส่งอนุมัติ" กับ "อนุมัติจริง (ลำดับ 1)" เป็นคนละการกระทำ คนละคนกันได้ —
|
||||||
|
ใครก็ได้ (ผู้รับผิดชอบกรอกข้อมูล) กดส่งอนุมัติได้ แต่การ "อนุมัติ" ต้องเป็นคนที่ระบุชื่อไว้ในคิวลำดับ 1 จริงๆ
|
||||||
|
เท่านั้นถึงจะกดผ่าน (ดูเงื่อนไขเช็คสิทธิ์ในข้อ 5)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Modules/Requests/Services/RequestExpense.cs
|
||||||
|
public async Task<(bool ok, string error)> SendApprove2(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, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว");
|
||||||
|
|
||||||
|
// สถานะที่ส่งได้ 2 แบบเท่านั้น:
|
||||||
|
// (1) ยังไม่เคยแตะต้องเลย (pristine — is_draft/is_approve เป็น null ทั้งคู่)
|
||||||
|
// (2) เพิ่งถูกส่งแก้ไขกลับมาถึงมือผู้สร้าง (is_draft=true, is_approve=false)
|
||||||
|
// ต้องยอมให้ส่งใหม่ได้เสมอ แม้ is_sent จะยังค้างเป็น true จากรอบก่อน
|
||||||
|
var isPristine = entity.is_draft == null && entity.is_approve == null;
|
||||||
|
var isRejectedReadyToResend = entity.is_draft == true && entity.is_approve == false;
|
||||||
|
if (!isPristine && !isRejectedReadyToResend)
|
||||||
|
return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว");
|
||||||
|
if (entity.is_sent == true && !isRejectedReadyToResend)
|
||||||
|
return (false, "รายการนี้ถูกส่งอนุมัติไปแล้ว");
|
||||||
|
|
||||||
|
// ต้องมีคิวผู้อนุมัติลำดับ 1 ตั้งไว้แล้ว (ผ่าน SetCustomApprovers) ก่อนถึงจะส่งได้
|
||||||
|
var pending = await _context.t_approve_state.FirstOrDefaultAsync(c =>
|
||||||
|
c.request_expense_uid == requestExpenseUid && c.is_state == true);
|
||||||
|
if (pending == null || pending.row_order != 1)
|
||||||
|
return (false, "ยังไม่พร้อมส่งอนุมัติ กรุณากำหนดผู้อนุมัติก่อน");
|
||||||
|
|
||||||
|
entity.is_sent = true;
|
||||||
|
entity.is_draft = null;
|
||||||
|
entity.is_approve = null;
|
||||||
|
_context.t_request_expense.Update(entity);
|
||||||
|
await _context.SaveChangesAsync(claimsIdentity);
|
||||||
|
return (true, null);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend เรียกผ่าน:** `POST /api/request/request_expense/type_2/send_approve/{uid}` (body ว่าง)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Flow 3 — การอนุมัติ (2 ลำดับขึ้นไป, `ApproveGetEntity`)
|
||||||
|
|
||||||
|
Endpoint นี้ **ใช้ร่วมกันทั้ง 3 entity** (`invest_asset`, `invest_construct`, `request_expense`) — โค้ดข้างล่าง
|
||||||
|
ตัดมาเฉพาะ branch ของ `request_expense`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Modules/Setting/Controllers/ApproveState.cs — GET /api/setting/approve_state/approve/{uid}
|
||||||
|
if (request_expense != null)
|
||||||
|
{
|
||||||
|
// รองรับหลายรอบ (round_no) เผื่อมี "ส่งอนุมัติเพิ่มเติม" หลังอนุมัติผ่านรอบแรกไปแล้ว
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
// ── เกตพิเศษเฉพาะ "04" ลำดับ 1: ต้องส่งอนุมัติแล้วจริง (is_sent) และต้องเป็นตัวจริงในคิวเท่านั้น ──
|
||||||
|
if (getResult.row_order == 1 && getResult.request_approve_type == "04")
|
||||||
|
{
|
||||||
|
if (request_expense.is_sent != true)
|
||||||
|
return BadRequest(new { description = "ยังไม่ได้ส่งอนุมัติ กรุณากด \"ส่งอนุมัติ\" ก่อน" });
|
||||||
|
|
||||||
|
var currentDisplayName = (User.Identity as ClaimsIdentity)?.FindFirst("display_name")?.Value;
|
||||||
|
if (string.IsNullOrEmpty(currentDisplayName) || currentDisplayName != getResult.display_name_th)
|
||||||
|
return StatusCode(403, new { description = "ไม่มีสิทธิ์อนุมัติ (ต้องเป็นผู้ที่ถูกกำหนดเป็นผู้อนุมัติลำดับ 1 เท่านั้น)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ปิดคิวลำดับปัจจุบัน = อนุมัติผ่าน
|
||||||
|
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);
|
||||||
|
|
||||||
|
// ถ้าไม่เหลือลำดับไหนใน "รอบนี้" ที่ยัง is_approve!=true เลย = ผ่านครบทุกลำดับ
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// เดินหน้าไปลำดับถัดไป (row_order + 1) ถ้ามี — ถ้าไม่มี = ลำดับนี้คือลำดับสุดท้าย
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**ตัวอย่างการไล่ 2 ลำดับ (row_order 1 → 2):**
|
||||||
|
|
||||||
|
1. `SetCustomApprovers` สร้างแถว `row_order=1 (is_state=true)`, `row_order=2 (is_state=false)`
|
||||||
|
2. ใครก็ได้กด "ส่งอนุมัติ" → `is_sent=true`
|
||||||
|
3. คนที่ระบุไว้ที่ `row_order=1` กด "อนุมัติ" → แถว 1 ปิด (`is_state=null, is_approve=true`), แถว 2 เปิด (`is_state=true`), `request_expense.current_approve_name` เปลี่ยนเป็นชื่อคนที่ 2
|
||||||
|
4. คนที่ `row_order=2` กด "อนุมัติ" → แถว 2 ปิด, ไม่มีแถวถัดไป → `request_expense.is_approve=true` (จบกระบวนการ)
|
||||||
|
|
||||||
|
**Frontend เรียกผ่าน:** `GET /api/setting/approve_state/approve/{uid}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Flow 4 — การส่งแก้ไข (`RejectGetEntity`)
|
||||||
|
|
||||||
|
**หลักการ:** ปฏิเสธ = ถอยกลับ **ทีละ 1 ลำดับ** เสมอ ไม่ใช่กระโดดกลับไปลำดับ 1 หรือคนสร้างตรงๆ
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Modules/Setting/Controllers/ApproveState.cs — GET /api/setting/approve_state/reject/{uid}
|
||||||
|
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); // ตำแหน่ง (0-based) ของคิวปัจจุบัน
|
||||||
|
|
||||||
|
if (index_current > 0)
|
||||||
|
{
|
||||||
|
// ยังไม่ใช่ลำดับแรก — ถอยกลับไปลำดับก่อนหน้า (index_current - 1)
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// ⚠️ ถ้า index_current == 0 (ลำดับ 1 ปฏิเสธเอง) — โค้ด "ไม่ทำอะไรกับ approve_state เลย"
|
||||||
|
// is_state ของแถวลำดับ 1 จึงยังค้างเป็น true อยู่ (ไม่ถูกเคลียร์!) แม้ว่าลูกบอลจะไปอยู่ที่ผู้สร้างแล้ว
|
||||||
|
// ก็ตาม — นี่คือกับดักที่ทำให้ query ฝั่งอ่านต้องเช็ค is_sent ควบคู่เสมอ ไม่งั้นจะเข้าใจผิดว่า
|
||||||
|
// "ยังรอผู้อนุมัติลำดับ 1 อยู่" ทั้งที่จริงๆ คนละสถานะ (ดูข้อ 2 ตารางสถานะ, ข้อ 7)
|
||||||
|
|
||||||
|
_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);
|
||||||
|
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;
|
||||||
|
if (index_current == 0)
|
||||||
|
{
|
||||||
|
// ลำดับ 1 ปฏิเสธเอง = ไม่มีลำดับก่อนหน้าให้ตีกลับ → คืนสถานะ "ยังไม่ได้ส่งอนุมัติ"
|
||||||
|
// ให้ผู้กดส่งอนุมัติ (ใครก็ได้) กลับมาส่งใหม่ได้อีกครั้งหลังแก้ไขข้อมูล
|
||||||
|
request_expense.is_sent = false;
|
||||||
|
}
|
||||||
|
_db.t_request_expense.Update(request_expense);
|
||||||
|
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend เรียกผ่าน:** `GET /api/setting/approve_state/reject/{uid}`
|
||||||
|
(หน้า income-report เรียกผ่าน `this.RequestExpenseSV.put(...)` เพื่ออัปเดตข้อมูลฟอร์มก่อน แล้วค่อยเรียก `reject()`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Dashboard สรุปสถานะรายบุคคล (`my_dashboard`)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Modules/Requests/Services/RequestExpense.cs
|
||||||
|
public async Task<request_expense_dashboard> GetMyDashboard(string loginName, string displayName)
|
||||||
|
{
|
||||||
|
var result = new request_expense_dashboard();
|
||||||
|
if (string.IsNullOrEmpty(loginName) && string.IsNullOrEmpty(displayName)) return result;
|
||||||
|
|
||||||
|
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);
|
||||||
|
// request_expense.faculty_name_th มักเป็น null แม้ faculty_uid จะมีค่า (พบ 89/91 แถวในข้อมูลจริง) —
|
||||||
|
// ห้ามไว้ใจคอลัมน์ denormalize แบบนี้ ต้อง join จากตาราง faculty เอาเองเสมอ
|
||||||
|
var faculties = await _context.v_faculty.AsNoTracking()
|
||||||
|
.ToDictionaryAsync(c => c.faculty_uid, c => c.faculty_name_th);
|
||||||
|
|
||||||
|
request_expense_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,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── ในฐานะ "ผู้ส่ง" (สร้างเรื่อง) — match ด้วย created_by == login_name ──
|
||||||
|
if (!string.IsNullOrEmpty(loginName))
|
||||||
|
{
|
||||||
|
var myRequests = await _context.v_request_expense.AsNoTracking()
|
||||||
|
.Include(e => e.approve_states)
|
||||||
|
.Where(e => e.type_id == 2 && e.created_by == loginName)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
result.sender_pending = myRequests
|
||||||
|
.Where(e => e.is_sent == true && e.is_approve != true && e.is_draft != true)
|
||||||
|
.Select(ToItem).ToList();
|
||||||
|
result.sender_returned = myRequests
|
||||||
|
.Where(e => e.is_draft == true && e.is_approve == false && e.is_sent != true)
|
||||||
|
.Select(ToItem).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ในฐานะ "ผู้อนุมัติ" — match ด้วย approve_state.display_name_th == display_name ──
|
||||||
|
if (!string.IsNullOrEmpty(displayName))
|
||||||
|
{
|
||||||
|
var myApproverRequests = await _context.v_request_expense.AsNoTracking()
|
||||||
|
.Include(e => e.approve_states)
|
||||||
|
.Where(e => e.type_id == 2 && e.approve_states.Any(a => a.display_name_th == displayName))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// is_sent==true คือเงื่อนไขบังคับว่า "กดส่งอนุมัติแล้วจริง" — ไม่งั้นจะรวมกรณี approve_state
|
||||||
|
// แถวลำดับ 1 ที่ถูก seed ไว้ตอนสร้างเรื่อง (is_state=true) แต่ยังไม่มีใครกด "ส่งอนุมัติ" เลย
|
||||||
|
// และกรณีลำดับ 1 ปฏิเสธของตัวเองกลับไปหาผู้สร้าง (is_state ของแถวตัวเองไม่ถูกเคลียร์ แต่ลูกบอล
|
||||||
|
// ไปอยู่ที่ผู้สร้างแล้ว ไม่ใช่ผู้อนุมัติอีกต่อไป — ดูข้อ 6)
|
||||||
|
result.approver_pending = myApproverRequests
|
||||||
|
.Where(e => e.is_sent == true && e.is_draft != true &&
|
||||||
|
e.approve_states.Any(a => a.display_name_th == displayName && a.is_state == true))
|
||||||
|
.Select(ToItem).ToList();
|
||||||
|
result.approver_returned = myApproverRequests
|
||||||
|
.Where(e => e.is_sent == true && e.is_draft == true && e.is_approve == false &&
|
||||||
|
e.approve_states.Any(a => a.display_name_th == displayName && a.is_state == true))
|
||||||
|
.Select(ToItem).ToList();
|
||||||
|
result.approver_approved = myApproverRequests
|
||||||
|
.Where(e => e.approve_states.Any(a => a.display_name_th == displayName && a.is_approve == true))
|
||||||
|
.Select(ToItem).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/request/request_expense/type_2/my_dashboard` (ต้อง `[Authorize]`, ไม่รับ query
|
||||||
|
parameter ใดๆ — อ่าน `login_name`/`display_name` จาก JWT claim ของผู้ที่ล็อกอินอยู่เอง)
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `income-report-approve-dashboard.component.ts` — เรียก endpoint ตอน `ngOnInit`, แสดงเป็น chip กดได้
|
||||||
|
- กด chip → กางลิสต์ใต้การ์ด (คณะ/พื้นที่/ปีงบ/วันที่)
|
||||||
|
- กดแถวในลิสต์ → เซ็ตค่าฟอร์มค้นหา (`presetQuery` input บน search component) แล้วเรียก `onSearch()` ทันที
|
||||||
|
(หน้านี้แสดงทีละ 1 เรื่องต่อ ปีงบ+คณะ+พื้นที่ ไม่มีตารางรายการแยกต่างหาก จึงต้อง pre-fill+ค้นหาแทนการลิงก์ไปหน้าอื่น)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. เช็คลิสต์ถ้าจะนำไปใช้กับ "คำขอแผ่นดิน"
|
||||||
|
|
||||||
|
1. **เพิ่มคอลัมน์ FK ใหม่ใน `approve_state`** เช่น `request_budget_uid` (ตาม pattern เดียวกับ
|
||||||
|
`invest_asset_uid`/`invest_construct_uid`/`request_expense_uid`) — **อย่า reuse `request_expense_uid`**
|
||||||
|
ถ้า entity คำขอแผ่นดินเป็นคนละตารางกับ `request_expense`
|
||||||
|
|
||||||
|
2. **กำหนด `request_approve_type` code ใหม่** (ตอนนี้ใช้ `"01"`-`"04"` ไปแล้ว เช็คตาราง config
|
||||||
|
`v_request_approve_type` ก่อนว่ามี `"05"` ใช้อยู่แล้วหรือยัง) แล้วเพิ่มเงื่อนไขเกต `is_sent` ในจุดเดิม
|
||||||
|
(`ApproveState.cs` บรรทัดที่เช็ค `request_approve_type == "04"`) ให้ครอบคลุม code ใหม่ด้วย — ไม่งั้น
|
||||||
|
ระบบใหม่จะข้ามการเช็ค "ส่งอนุมัติหรือยัง" ไปเลย
|
||||||
|
|
||||||
|
3. **เขียนฟังก์ชันคู่ขนานของ `SetCustomApprovers`/`SendApprove2`** สำหรับ entity ใหม่ (โครงเดียวกันได้เลย
|
||||||
|
เปลี่ยนแค่ชื่อ FK คอลัมน์และชื่อตาราง) หรือถ้าอยากได้แบบ config-ตายตัวต่อคณะ/พื้นที่แทน ให้ใช้
|
||||||
|
`getApproveState()` (`Modules/Setting/Services/ApproveState.cs`) แบบเดียวกับ code `"01"`-`"03"`
|
||||||
|
|
||||||
|
4. **เพิ่ม branch ใหม่ใน `ApproveGetEntity`/`RejectGetEntity`** (`ApproveState.cs`) สำหรับ FK ใหม่
|
||||||
|
— โครงสร้าง if-block เหมือนกับ `invest_asset`/`invest_construct`/`request_expense` เป๊ะ ก็อปแล้วเปลี่ยน
|
||||||
|
ชื่อ field ได้เลย แต่**อย่าลืม**ใส่เกต `is_sent`+เช็คสิทธิ์ `display_name` แบบเดียวกับ `request_expense`
|
||||||
|
ถ้าอยากได้พฤติกรรม "ใครก็ได้ส่งได้ แต่อนุมัติต้องเป็นตัวจริง" แบบเดียวกัน
|
||||||
|
|
||||||
|
5. **⚠️ ระวังเรื่อง `display_name_th` string matching** — ทั้งระบบ match ตัวตนผู้อนุมัติด้วยการเทียบ
|
||||||
|
string `display_name_th` (ตอนตั้งคิว) กับ claim `display_name` (ตอนล็อกอิน) แบบ exact match
|
||||||
|
**เจอจริงในข้อมูล** ว่าชื่อเดียวกันมีทั้ง `"นางสาวนภาพร สูนาสวน"` (ไม่มีวรรค) และ
|
||||||
|
`"นางสาว นภาพร สูนาสวน"` (มีวรรค) ทำให้ match ไม่ติดถ้าฟอร์แมตไม่ตรงกันเป๊ะ — ถ้าออกแบบใหม่
|
||||||
|
มีโอกาสควรพิจารณา match ด้วย `personnel_uid` แทน (มีอยู่แล้วในตาราง แต่โค้ดปัจจุบันไม่ได้ใช้จริงจัง)
|
||||||
|
|
||||||
|
6. **ห้ามไว้ใจฟิลด์ `faculty_name_th`/ชื่ออื่นที่ denormalize ไว้บน entity ตัวเอง** — เจอจริงว่า
|
||||||
|
`request_expense.faculty_name_th` เป็น null เกือบทั้งหมด แม้ `faculty_uid` จะมีค่า ให้ join จากตาราง
|
||||||
|
ต้นทาง (`faculty`, `budget_year`, `budget_location`) เอาเองเสมอเวลาจะโชว์ชื่อ
|
||||||
|
|
||||||
|
7. **ทำ dashboard คู่ขนาน** — ก๊อปโครง `GetMyDashboard` ได้เลย เปลี่ยนแค่ `type_id`/FK คอลัมน์/entity —
|
||||||
|
จำไว้ว่า `approver_pending`/`approver_returned` **ต้องมี `is_sent == true`** เสมอ ไม่งั้นจะเจอบั๊กเดียวกับ
|
||||||
|
ที่แก้ไปแล้วในเวอร์ชันนี้ (นับรายการที่ยังกดอนุมัติจริงไม่ได้ว่าเป็น "รออนุมัติ")
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# ขั้นตอน "ตรวจสอบส่วนกลาง" หลังเชนอนุมัติคำขอเงินรายได้เสร็จสิ้น
|
||||||
|
|
||||||
|
**วันที่:** 2026-09-07
|
||||||
|
**อ้างอิง:** [[2026-09-07-approve-state-workflow-reference]] (โครงสร้าง `request_expense`/`approve_state` เดิม)
|
||||||
|
|
||||||
|
## 1. ปัญหา / เป้าหมาย
|
||||||
|
|
||||||
|
หน้า "ส่งอนุมัติคำขอเงินรายได้" (`/app/income-report`) มีเชนอนุมัติภายในคณะ/พื้นที่ตัวเอง (ผู้ส่ง →
|
||||||
|
ผู้อนุมัติ 1 → ผู้อนุมัติ 2 ...) ที่ทำงานอยู่แล้ว (ดูเอกสารอ้างอิงด้านบน) เมื่อผู้อนุมัติ**คนสุดท้าย**ของเชนนั้น
|
||||||
|
กด "อนุมัติ" (`request_expense.is_approve` เปลี่ยนเป็น `true`) ต้องการให้ระบบ:
|
||||||
|
|
||||||
|
1. ส่งข้อมูลนั้นเข้าสู่ขั้นตอน**ตรวจสอบส่วนกลาง**โดยอัตโนมัติ — ปรากฏที่เมนู
|
||||||
|
`/app/income-budget-plan-02` ("ตรวจสรุปคำของบประมาณเงินรายได้")
|
||||||
|
2. ฝั่งคณะเห็นสถานะของตัวเองที่เมนู `/app/income-budget-plan-01` ("สรุปคำของบประมาณเงินรายได้")
|
||||||
|
|
||||||
|
**ผู้ตรวจสอบ:** คนกลาง/ส่วนกลาง (เช่น กองนโยบายและแผน) คนเดียวตรวจได้ทุกคณะ/ทุกพื้นที่ — ไม่ได้ผูกกับ
|
||||||
|
รายชื่อเฉพาะเจาะจงแบบผู้อนุมัติในเชนเดิม สิทธิ์เข้าถึงอิงจากสิทธิ์เข้าเมนูตามระบบเดิม ไม่ใช่รายชื่อต่อเรื่อง
|
||||||
|
|
||||||
|
**การค้นหา/กรอง:** ที่ `income-budget-plan-02` ต้องกรองตาม **ปีงบประมาณ + หน่วยงาน/คณะ + พื้นที่**
|
||||||
|
เหมือนหน้า income-report เดิม (ยืนยันจากผู้ใช้)
|
||||||
|
|
||||||
|
**เมื่อคนกลางกด "ส่งแก้ไข":** ต้องส่งกลับไปหา**ผู้อนุมัติคนสุดท้าย**ของเชนเดิม เพื่อเข้าสู่ขั้นตอน
|
||||||
|
"ส่งกลับให้ต้นทาง" แบบเดียวกับที่เชน income-report มีอยู่แล้ว (ผู้อนุมัติคนสุดท้ายเห็นว่าเรื่องถูกตีกลับ
|
||||||
|
แก้ไขได้ตามปกติ หรือจะกดปฏิเสธต่อไปยังลำดับก่อนหน้าจนถึงผู้สร้างก็ได้ตามกลไกเดิมทุกประการ) — ไม่ใช่การ
|
||||||
|
สร้างสถานะ "ส่งแก้ไข" แยกลอยๆ ที่ไม่เชื่อมกับเชนเดิม (ยืนยันจากผู้ใช้)
|
||||||
|
|
||||||
|
## 2. ทำไมไม่ใช้กลไก `round_no` (resubmit) ที่มีอยู่แล้ว
|
||||||
|
|
||||||
|
ระบบมี "ส่งอนุมัติเพิ่มเติม" (`ResubmitGetEntity`, เพิ่ม `round_no`) อยู่แล้ว แต่ **ผูกตายตัวกับ
|
||||||
|
`request_approve_type = "03"`** ซึ่งต้องมีแอดมินตั้งคิวผู้อนุมัติล่วงหน้าเป็นรายคณะ/พื้นที่ในเมนู
|
||||||
|
"จัดการเมนูการอนุมัติ" ก่อน — ขัดกับข้อกำหนดว่าผู้ตรวจสอบเป็น "คนกลางคนเดียว ไม่ผูกคณะ/พื้นที่" และ
|
||||||
|
จะเพิ่มภาระแอดมินที่ code `"04"` เคยถูกสร้างมาเพื่อเลิกพึ่งพาอยู่แล้ว จึง **ไม่ต่อยอด `round_no` เดิม**
|
||||||
|
แต่สร้างชั้นสถานะใหม่แยกต่างหาก ผูกกับ `request_expense_uid` โดยตรง
|
||||||
|
|
||||||
|
## 3. โมเดลข้อมูลใหม่
|
||||||
|
|
||||||
|
ตารางใหม่ `income_budget_review` — **ใช้เป็น log/ประวัติเท่านั้น** ไม่ใช่ตัวเก็บ "สถานะปัจจุบัน" — สถานะ
|
||||||
|
จริงที่ว่าเรื่องกำลังอยู่ระหว่างแก้ไขหรือไม่ยังคงมาจาก `request_expense.is_draft`/`is_approve` เดิมเป๊ะ
|
||||||
|
(ดูเหตุผลในข้อ 4):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class income_budget_review : base_table
|
||||||
|
{
|
||||||
|
[Key] public Guid? income_budget_review_uid { get; set; }
|
||||||
|
public Guid? request_expense_uid { get; set; } // FK -> request_expense (เชนเดิมจาก income-report)
|
||||||
|
public short status_id { get; set; } // 1=รอตรวจสอบ, 2=ส่งแก้ไข, 3=ตรวจสอบแล้ว
|
||||||
|
public string remark { get; set; } // เหตุผล/รายละเอียดตอนกด "ส่งแก้ไข"
|
||||||
|
public string reviewed_by { get; set; } // login_name ผู้ตรวจ (ตอนกด ส่งแก้ไข/ตรวจสอบแล้ว)
|
||||||
|
public DateTime? reviewed_datetime { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Trigger สร้างแถวใหม่ (status_id=1, รอตรวจสอบ):**
|
||||||
|
ในจุดเดียวกับที่ `ApproveGetEntity` (ApproveState.cs) ตั้ง `request_expense.is_approve = true` (เคสที่ไม่มี
|
||||||
|
ลำดับถัดไปแล้ว — ดู [[2026-09-07-approve-state-workflow-reference]] ข้อ 5) ให้เพิ่ม insert
|
||||||
|
`income_budget_review` แถวใหม่ `status_id=1` **เฉพาะตอน `request_approve_type == "04"`** (ไม่กระทบ
|
||||||
|
`invest_asset`/`invest_construct`/`request_expense` code อื่นๆ ที่ใช้ engine เดียวกัน) — จุด trigger นี้จะยิง
|
||||||
|
ซ้ำทุกครั้งที่เชนอนุมัติผ่านครบ **ไม่ว่าจะเป็นครั้งแรกหรือหลังถูกคนกลางตีกลับแล้วแก้ไขจนอนุมัติผ่านใหม่**
|
||||||
|
เพราะเป็น branch เดียวกันของโค้ดเดิม (ดูข้อ 4) — ระบบจึงวนกลับเข้า "รอตรวจสอบ" ให้อัตโนมัติโดยไม่ต้องมีปุ่ม
|
||||||
|
"ส่งตรวจสอบใหม่" แยกต่างหาก
|
||||||
|
|
||||||
|
**สถานะปัจจุบันของเรื่องหนึ่งๆ (สำหรับ dashboard/badge):**
|
||||||
|
- ถ้า `request_expense.is_draft == true && is_approve == false` → **"ถูกส่งแก้ไข"** (กำลังอยู่ในเชน
|
||||||
|
income-report รอฝั่งคณะแก้ไข — เช็คจาก `request_expense` โดยตรง ไม่ต้องพึ่ง log)
|
||||||
|
- ไม่งั้น ดูแถว `income_budget_review` ล่าสุด (`OrderByDescending(created_datetime).First()`) ของ
|
||||||
|
`request_expense_uid` นั้น: `status_id=1` → "รอตรวจสอบ", `status_id=3` → "ตรวจสอบแล้ว"
|
||||||
|
|
||||||
|
## 4. Flow — "ส่งแก้ไข" จากส่วนกลาง = เปิดคิวผู้อนุมัติคนสุดท้ายกลับมาใหม่
|
||||||
|
|
||||||
|
หัวใจของดีไซน์นี้: การกด "ส่งแก้ไข" ที่ `income-budget-plan-02` **ไม่ใช่สถานะแยกลอย** แต่เป็นการจำลอง
|
||||||
|
สิ่งที่จะเกิดขึ้นถ้า "ผู้อนุมัติคนสุดท้าย" ของเชนกด "ส่งแก้ไข" เอง — เปิดแถว `approve_state` ที่
|
||||||
|
`row_order` สูงสุดของรอบล่าสุดกลับมา `is_state=true` อีกครั้ง แล้วปล่อยให้กลไก "ส่งแก้ไข"/"อนุมัติ" เดิมของ
|
||||||
|
income-report ([[2026-09-07-approve-state-workflow-reference]] ข้อ 5-6) ทำงานต่อตามปกติทุกประการ —
|
||||||
|
รวมถึงถ้าผู้อนุมัติคนสุดท้ายกดปฏิเสธต่อ ก็จะไหลกลับไปลำดับก่อนหน้าจนถึงผู้สร้างได้เหมือนเดิม
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// สรุปพฤติกรรม ReturnForEdit(requestExpenseUid, remark, claimsIdentity) — รายละเอียด field เป๊ะๆ
|
||||||
|
// จะสรุปอีกครั้งตอนทำ implementation plan
|
||||||
|
var entity = await _context.t_request_expense.FirstOrDefaultAsync(c => c.request_expense_uid == requestExpenseUid);
|
||||||
|
if (entity.is_approve != true)
|
||||||
|
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(); // ผู้อนุมัติคนสุดท้าย
|
||||||
|
|
||||||
|
lastRow.is_state = true;
|
||||||
|
lastRow.is_approve = null; // เปิดคิวนี้กลับมาใหม่ เหมือนยังไม่เคยอนุมัติ
|
||||||
|
|
||||||
|
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; // เชนมีคนเดียว = เท่ากับตีกลับไปหาผู้สร้างเลย (เหมือน RejectGetEntity index_current==0)
|
||||||
|
|
||||||
|
// บันทึก remark ผ่านกลไกเดิมที่ income-report ใช้แสดงในตาราง "รายละเอียดสำหรับการส่งแก้ไข" (remark_history)
|
||||||
|
await _context.t_remark_history.AddAsync(new t_remark_history {
|
||||||
|
request_expense_uid = requestExpenseUid,
|
||||||
|
name = "ส่งแก้ไขจากการตรวจสอบส่วนกลาง",
|
||||||
|
remark = remark,
|
||||||
|
});
|
||||||
|
|
||||||
|
// log ไว้สำหรับ dashboard/ประวัติที่ plan-02 เท่านั้น — ไม่ใช่ตัวกำหนด state จริง
|
||||||
|
await _context.income_budget_review.AddAsync(new t_income_budget_review {
|
||||||
|
request_expense_uid = requestExpenseUid, status_id = 2, remark = remark,
|
||||||
|
reviewed_by = claimsIdentity?.FindFirst("login_name")?.Value, reviewed_datetime = DateTime.Now,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
ผลลัพธ์: ผู้อนุมัติคนสุดท้ายเห็นเรื่องนี้ที่หน้า income-report **เหมือนถูกตีกลับตามปกติทุกประการ**
|
||||||
|
(remark จากส่วนกลางโชว์ในตารางเดิมที่มีอยู่แล้ว) แก้ไข/ส่งอนุมัติใหม่ตามกลไกเดิมจนอนุมัติผ่านครบอีกครั้ง
|
||||||
|
→ trigger เดิมในข้อ 3 จะ log "รอตรวจสอบ" ให้อัตโนมัติ วนกลับไป plan-02
|
||||||
|
|
||||||
|
```
|
||||||
|
income-report เชนอนุมัติผ่านครบ (is_approve=true)
|
||||||
|
│ (auto insert income_budget_review status_id=1)
|
||||||
|
▼
|
||||||
|
income-budget-plan-02 (ฝั่งส่วนกลาง) เห็นเรื่องนี้ใน dashboard "รอตรวจสอบ"
|
||||||
|
│
|
||||||
|
┌────┴─────────────────────┐
|
||||||
|
│ │
|
||||||
|
กด "ตรวจสอบแล้ว" กด "ส่งแก้ไข" + remark
|
||||||
|
(status_id=3, จบ) │
|
||||||
|
เปิดแถว approve_state ล่าสุด (row_order สูงสุด) กลับเป็น is_state=true
|
||||||
|
request_expense.is_draft=true, is_approve=false
|
||||||
|
บันทึก remark ผ่าน remark_history เดิม + log status_id=2
|
||||||
|
│
|
||||||
|
เรื่องนี้กลับเข้าเชน income-report ปกติ (ผู้อนุมัติคนสุดท้ายเห็น "ถูกส่งแก้ไข"
|
||||||
|
แก้ไข/ปฏิเสธต่อไปยังลำดับก่อนหน้า/ส่งอนุมัติใหม่ได้ตามกลไกเดิมทุกประการ)
|
||||||
|
│
|
||||||
|
เชนอนุมัติผ่านครบอีกครั้ง (is_approve=true)
|
||||||
|
│
|
||||||
|
(auto insert income_budget_review status_id=1 — วนกลับไป "รอตรวจสอบ")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Backend API (ใหม่)
|
||||||
|
|
||||||
|
โมดูล `Modules/IncomeBudgetReview/` (ตามแพทเทิร์นโฟลเดอร์เดิมของโปรเจกต์):
|
||||||
|
|
||||||
|
| Endpoint | Method | หน้าที่ |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/income_budget_review/search` | GET | query `budget_year_uid`, `faculty_uid`, `budget_location_uid` → คืน `request_expense` 1 เรื่อง (ถ้ามี) + สถานะปัจจุบัน (คำนวณตามข้อ 3) + ประวัติ `income_budget_review`/`remark_history` ทั้งหมด |
|
||||||
|
| `/api/income_budget_review/dashboard` | GET | นับจำนวนตามสถานะทั่วทั้งระบบ (ไม่กรองคณะ — ผู้ตรวจสอบเป็นส่วนกลาง): รอตรวจสอบ, ถูกส่งแก้ไข(ที่ยังไม่ผ่านการอนุมัติใหม่), ตรวจสอบแล้ว — พร้อม list รายละเอียด (คณะ/พื้นที่/ปีงบ) ต่อหมวด เหมือน `request_expense/type_2/my_dashboard` |
|
||||||
|
| `/api/income_budget_review/return_for_edit/{request_expense_uid}` | POST | body `{ remark }` → เปิดแถวผู้อนุมัติคนสุดท้ายกลับมา + ตั้ง `is_draft`/`is_approve` ตามข้อ 4 + insert `remark_history` + log `status_id=2` |
|
||||||
|
| `/api/income_budget_review/mark_reviewed/{request_expense_uid}` | POST | insert แถวใหม่ `status_id=3` — เงื่อนไข: `request_expense.is_approve==true` และแถว log ล่าสุดต้องเป็น `status_id=1` เท่านั้น (กันกดซ้ำ/กดข้ามสถานะ) |
|
||||||
|
|
||||||
|
ไม่มี endpoint "resubmit" แยกต่างหากฝั่งคณะ — การกลับเข้า "รอตรวจสอบ" เกิดขึ้นอัตโนมัติผ่าน trigger เดิม
|
||||||
|
(ข้อ 3) เมื่อเชน income-report อนุมัติผ่านครบอีกครั้ง
|
||||||
|
|
||||||
|
ทุก endpoint ตรวจสอบเงื่อนไข "สถานะปัจจุบันของเรื่องนี้ต้องเป็นอะไร" ก่อนทำงานเสมอ กัน race condition/
|
||||||
|
กดปุ่มผิดจังหวะ (เช่น กด "ตรวจสอบแล้ว"/"ส่งแก้ไข" ซ้ำ หรือกดตอนเชนยังไม่อนุมัติผ่านครบ)
|
||||||
|
|
||||||
|
## 6. Frontend
|
||||||
|
|
||||||
|
**`income-budget-plan-02`** (เพิ่มเข้าไปในหน้าเดิม เหนือฟอร์มค้นหา — โครงเดียวกับ dashboard ที่ทำให้
|
||||||
|
income-report):
|
||||||
|
- การ์ด dashboard: chip กดได้ 3 อัน — "รอตรวจสอบ N", "ถูกส่งแก้ไข N" (ที่ยังไม่ผ่านการอนุมัติใหม่),
|
||||||
|
"ตรวจสอบแล้ว N" — กด chip กางลิสต์ (คณะ/พื้นที่/ปีงบ), กดแถวในลิสต์ = pre-fill ช่องค้นหาแล้วค้นหาทันที
|
||||||
|
(รูปแบบเดียวกับ `income-report-approve-dashboard.component.ts` ที่มีอยู่แล้ว — reuse โครงสร้างเดิม)
|
||||||
|
- หลังค้นหาแล้วเจอเรื่อง: แสดง **สถานะปัจจุบันชัดเจน** (badge: "รอตรวจสอบ" / "ถูกส่งแก้ไข รอคณะแก้ไข" /
|
||||||
|
"ตรวจสอบแล้ว") + ตาราง**ประวัติการส่งแก้ไข**ทั้งหมดของเรื่องนี้ (วันที่, remark, ผู้ตรวจ) — โครงตาราง
|
||||||
|
เดียวกับ "รายละเอียดสำหรับการส่งแก้ไข" ที่มีอยู่แล้วในหน้า income-report (`remark_history` — ตารางเดียวกัน
|
||||||
|
ที่ผู้อนุมัติคนสุดท้ายจะเห็น remark จากส่วนกลางด้วย)
|
||||||
|
- ปุ่ม "ส่งแก้ไข" (เปิด dialog ให้พิมพ์ remark ก่อนยืนยัน) และ "ตรวจสอบแล้ว" — โชว์เฉพาะตอนสถานะปัจจุบัน
|
||||||
|
เป็น "รอตรวจสอบ" เท่านั้น
|
||||||
|
|
||||||
|
**`income-budget-plan-01`** (เพิ่มเข้าไปในหน้าเดิม — ฝั่งคณะดูอย่างเดียว ไม่มีปุ่มใหม่):
|
||||||
|
- Badge สถานะเดียวกัน ("รอตรวจสอบ" / "ถูกส่งแก้ไข" + remark ล่าสุด / "ตรวจสอบแล้ว") ต่อเรื่องที่ค้นหาอยู่ —
|
||||||
|
ตอน "ถูกส่งแก้ไข" ฝั่งคณะแก้ไข/ส่งอนุมัติใหม่ผ่านหน้า income-report ตามกลไกเดิมที่มีอยู่แล้ว ไม่ต้องมี
|
||||||
|
ปุ่มใหม่ที่หน้านี้
|
||||||
|
|
||||||
|
## 7. นอกขอบเขตรอบนี้ (YAGNI)
|
||||||
|
|
||||||
|
- แจ้งเตือนแบบ real-time/อีเมล/LINE — dashboard โหลดตอนเข้าเมนูเหมือนเดิมพอ
|
||||||
|
- สิทธิ์ผู้ตรวจสอบละเอียดกว่า "เข้าเมนูนี้ได้ = ตรวจได้" (เช่น แบ่งสิทธิ์ตามประเภทงบ) — ยังไม่มีความต้องการ
|
||||||
|
ตอนนี้ ถ้าจะเพิ่มทีหลังค่อยทำเป็นรอบใหม่
|
||||||
|
- แก้ไขข้อมูลตัวเลขจริงๆ ที่หน้า plan-01/02 — สมมติว่าคณะยังกลับไปแก้ที่ income-report/ฟอร์มเดิมตามปกติ
|
||||||
|
plan-01/02 รอบนี้ทำแค่ "แสดงสถานะ + ปุ่มเปลี่ยนสถานะ" ไม่ใช่หน้าแก้ไขตัวเลข
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# แผนความต้องการงบลงทุนค่าครุภัณฑ์ — เปลี่ยนเป็นผู้ส่งเลือกผู้อนุมัติเอง (Design)
|
||||||
|
|
||||||
|
**สถานะ:** ผ่าน design review แล้ว รอเขียน implementation plan
|
||||||
|
**อ้างอิง:** `docs/superpowers/specs/2026-09-07-approve-state-workflow-reference.md` (การอนุมัติ 2 ลำดับ + การส่งอนุมัติ ของ code "04")
|
||||||
|
|
||||||
|
## 1. บริบทและปัญหา
|
||||||
|
|
||||||
|
หน้า **แผนความต้องการงบลงทุน ⟶ ค่าครุภัณฑ์** ที่ใช้งานจริงตอนนี้คือ
|
||||||
|
`/app/durable-articles` (โฟลเดอร์
|
||||||
|
`feature/budget-request/investment-budget/durable-articles/`) ใช้กลไกอนุมัติแบบ
|
||||||
|
**แอดมินตั้งคิวล่วงหน้า** (`request_approve_type = "01"`) เท่านั้น: ตอนกด "ส่งอนุมัติ"
|
||||||
|
(`GET /invest/invest_asset/send_approve/{uid}`) ระบบจะเรียก
|
||||||
|
`ApproveStateService.getApproveState(..., "01", ...)` ซึ่งอ่านคิวจากตาราง config
|
||||||
|
`request_approve_type` / `request_approve_type_state` ที่แอดมินต้องตั้งไว้ล่วงหน้าเป็น
|
||||||
|
รายหน่วยงาน/พื้นที่ผ่านหน้า "จัดการเมนูการอนุมัติ" — ถ้าไม่ตั้งไว้ หรือหน่วยงานใหม่ยังไม่ถูก
|
||||||
|
ตั้งค่า จะไม่มีผู้อนุมัติให้เข้าคิวเลย
|
||||||
|
|
||||||
|
ผู้ใช้ต้องการเปลี่ยนกลไกนี้ **ทั้งหมด** ให้เป็นแบบเดียวกับสรุปคำของบประมาณรายได้
|
||||||
|
(`request_expense`, code `"04"`): **ผู้ส่งเป็นคนพิมพ์ชื่อผู้อนุมัติเองตอนส่งอนุมัติ**
|
||||||
|
แทนที่การพึ่ง config แอดมินล่วงหน้า
|
||||||
|
|
||||||
|
## 2. ของเดิมที่ยังใช้ได้ (ไม่แตะ)
|
||||||
|
|
||||||
|
จากการอ่านโค้ดจริงของหน้านี้ พบว่ามีกลไกที่คล้ายกับ `is_sent` ของ request_expense อยู่แล้ว
|
||||||
|
เพียงแต่ใช้ฟิลด์คนละตัว:
|
||||||
|
|
||||||
|
| request_expense (code "04") | invest_asset (ของเดิม) |
|
||||||
|
|---|---|
|
||||||
|
| `is_sent` (`false`/`null`→ยังไม่ส่ง, `true`→ส่งแล้ว) | `is_draft` (`true`→ยังไม่ส่ง/ฉบับร่าง, `null`→ส่งแล้ว) |
|
||||||
|
| `SendApprove2()` เซ็ต `is_sent=true` | `SendApproveEntityId()` เซ็ต `is_draft=null` (มีอยู่แล้ว) |
|
||||||
|
| `ApproveGetEntity`/`RejectGetEntity` ใช้ `t_approve_state` ร่วมกับ invest_asset/invest_construct | เหมือนกันเป๊ะ — เป็น controller เดียวกัน (`ApproveStateController`) |
|
||||||
|
|
||||||
|
**สิ่งที่ไม่ต้องแก้เพราะทำงานถูกต้องอยู่แล้ว:**
|
||||||
|
- `SendApproveEntityId` (`Modules/Invests/Controllers/InvestAsset.cs:206`) — เช็คอยู่แล้วว่ามี
|
||||||
|
`approve_state` แถวที่ผูกกับ `invest_asset_uid` นี้หรือยัง (`check_approve`) ถ้า**มีแล้ว**
|
||||||
|
(เช่น มาจากการเรียก `SetCustomApprovers` ก่อนหน้า) จะ**ข้าม**การดึง config แอดมินให้เอง
|
||||||
|
ไม่ต้องเขียนโค้ดเพิ่ม
|
||||||
|
- `RejectGetEntity` (invest_asset branch) — รีสตาร์ทคิวกลับไป row_order 1 เสมอเมื่อถูกปฏิเสธ
|
||||||
|
ทำงานได้เหมือนกันไม่ว่าคิวจะมาจากแอดมินตั้งหรือผู้ส่งตั้งเอง (อ่านจาก `t_approve_state` ตรงๆ)
|
||||||
|
- `save_header_approve` / `save_chief_approve` / `save_dean_approve` ในฟอร์ม — เป็นการเซ็นภายใน
|
||||||
|
หน่วยงานตัวเอง (fixed role, เซ็ตฟิลด์ `is_header_approve`/`is_chief_approve`/`is_dean_approve`
|
||||||
|
ตรงบน entity) **คนละกลไกกับคิวอนุมัติกลาง ไม่เกี่ยวกัน ไม่แตะ**
|
||||||
|
|
||||||
|
## 3. ของใหม่ที่ต้องเพิ่ม
|
||||||
|
|
||||||
|
### 3.1 Backend
|
||||||
|
|
||||||
|
**a) `SetCustomApprovers` ใหม่ใน `InvestAssetService`**
|
||||||
|
(`Modules/Invests/Services/InvestAsset.cs`) — mirror
|
||||||
|
`RequestExpenseService.SetCustomApprovers` ทุกจุด:
|
||||||
|
|
||||||
|
```
|
||||||
|
Task<(bool ok, string error)> SetCustomApprovers(
|
||||||
|
Guid? investAssetUid, List<ApproverInput> approvers, ClaimsIdentity claimsIdentity)
|
||||||
|
```
|
||||||
|
|
||||||
|
- โหลด `t_invest_asset` ตาม uid, ไม่พบ → error "ไม่พบรายการ"
|
||||||
|
- Guard: `entity.is_draft != true` → error "รายการนี้ต้องอยู่ในสถานะฉบับร่างก่อนจึงจะกำหนด
|
||||||
|
ผู้อนุมัติได้" (invest_asset ใช้ `is_draft==true` แทนความหมาย "ยังไม่ส่ง" ตรงข้ามกับ
|
||||||
|
request_expense ที่ใช้ null/null — ต้องเทียบ semantics ให้ถูก ไม่ใช่ copy เงื่อนไขตรงตัว)
|
||||||
|
- Guard: มีแถว `t_approve_state` ที่ `invest_asset_uid==uid && is_approve==true` อยู่แล้ว →
|
||||||
|
error "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว"
|
||||||
|
- รับ `List<ApproverInput>` (มี `personnel_uid`, `display_name_th`), กรองแถวว่างทิ้ง, ถ้าไม่มี
|
||||||
|
เหลือเลย → error "กรุณาระบุผู้อนุมัติอย่างน้อย 1 คน"
|
||||||
|
- ลบแถว `t_approve_state` เดิมของ invest_asset นี้ทั้งหมด (ถ้ามี, เผื่อกดกำหนดซ้ำก่อนส่ง),
|
||||||
|
สร้างแถวใหม่ row_order 1..N, `is_state = (row_order==1)`, **`request_approve_type =
|
||||||
|
<รหัสใหม่>`** (ดูข้อ 4)
|
||||||
|
- เซ็ต `entity.current_approve_name` เป็นชื่อผู้อนุมัติคนแรก, save
|
||||||
|
|
||||||
|
หมายเหตุ: โค้ดต้นแบบ (`RequestExpenseService.SetCustomApprovers`) มี docstring บอกว่า
|
||||||
|
"ห้ามระบุตัวเองเป็นผู้อนุมัติ" แต่จากการอ่านโค้ดจริงไม่มีการเช็คนี้ในฝั่ง backend
|
||||||
|
(ปล่อยให้ UI เป็นคนกรองตอนเลือกจากรายชื่อ) — เวอร์ชันใหม่นี้จะ mirror พฤติกรรมจริงที่มีอยู่
|
||||||
|
คือไม่เช็คใน backend เช่นกัน เพื่อความสอดคล้องกัน ไม่ใช่ bug ใหม่ที่เพิ่มเข้ามา
|
||||||
|
|
||||||
|
**b) Endpoint ใหม่**
|
||||||
|
`POST /api/invest/invest_asset/{uid}/set_approvers` ใน `InvestAssetController` เรียก
|
||||||
|
`SetCustomApprovers` ข้างบน, คืน 200 + error message ถ้าไม่ผ่าน guard (mirror ทรงเดียวกับ
|
||||||
|
endpoint ของ request_expense)
|
||||||
|
|
||||||
|
**c) ปิดช่องโหว่ใน `ApproveGetEntity` (approve endpoint ที่ใช้ร่วมกัน)**
|
||||||
|
|
||||||
|
ที่ `Modules/Setting/Controllers/ApproveState.cs`, invest_asset branch (บรรทัด ~50-86)
|
||||||
|
ปัจจุบันไม่มีการเช็คว่าใครเป็นคนกด และไม่เช็คว่า "ส่งอนุมัติ" แล้วหรือยัง — เพราะของเดิม
|
||||||
|
approve_state จะถูกสร้างพร้อมกับตอนกด "ส่งอนุมัติ" เท่านั้น (ไม่มีช่วงเวลาที่แถวมีอยู่ก่อน
|
||||||
|
ส่งจริง) แต่พอเพิ่ม `SetCustomApprovers` ที่สร้างแถว (พร้อม `is_state=true` ที่แถวแรก) **ก่อน**
|
||||||
|
กด "ส่งอนุมัติ" จะเกิดช่องโหว่ทันที: ใครก็เรียก `GET approve/{uid}` อนุมัติแทนได้เลยตั้งแต่
|
||||||
|
ยังไม่กด "ส่งอนุมัติ" ด้วยซ้ำ
|
||||||
|
|
||||||
|
ต้องเพิ่ม guard แบบเดียวกับที่ `request_expense` code "04" มี (บรรทัด 133-150 เดิม) แต่สลับให้
|
||||||
|
ตรงกับ semantics ของ `is_draft`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (invest_asset != null && getResult != null)
|
||||||
|
{
|
||||||
|
if (getResult.row_order == 1 && getResult.request_approve_type == "<รหัสใหม่>")
|
||||||
|
{
|
||||||
|
if (invest_asset.is_draft == true)
|
||||||
|
{
|
||||||
|
return BadRequest(new { description = "ยังไม่ได้ส่งอนุมัติ กรุณากด \"ส่งอนุมัติ\" ก่อน" });
|
||||||
|
}
|
||||||
|
var currentDisplayName = (User.Identity as ClaimsIdentity)?.FindFirst("display_name")?.Value;
|
||||||
|
if (string.IsNullOrEmpty(currentDisplayName) || currentDisplayName != getResult.display_name_th)
|
||||||
|
{
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden,
|
||||||
|
new { description = "ไม่มีสิทธิ์อนุมัติ (ต้องเป็นผู้ที่ถูกกำหนดเป็นผู้อนุมัติลำดับ 1 เท่านั้น)" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ... โค้ดเดิมที่เหลือ (getResult.is_state=false, is_approve=true, ฯลฯ) ไม่เปลี่ยน
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
เช็คนี้ทำเฉพาะ `row_order==1` เหมือน "04" เป๊ะ (ลำดับถัดไปไม่เช็ค ตาม pattern เดิม
|
||||||
|
ที่มีอยู่แล้วในระบบ — ไม่ใช่ scope ของงานนี้ที่จะไปแก้ pattern เดิม)
|
||||||
|
|
||||||
|
`send_approve` (`SendApproveEntityId`) **ไม่ต้องแก้** ตามที่อธิบายในข้อ 2
|
||||||
|
|
||||||
|
### 3.2 Frontend
|
||||||
|
|
||||||
|
**a) Dialog ใหม่ "ระบุผู้อนุมัติ" สำหรับครุภัณฑ์**
|
||||||
|
|
||||||
|
Copy โครงจาก `feature/income-report/presenter/popup/set-approvers-dialog/` มาสร้างใน
|
||||||
|
`feature/budget-request/investment-budget/durable-articles/presenter/popup/set-approvers-dialog/`
|
||||||
|
(หรือ path เทียบเท่าในโมดูลเดียวกับฟอร์มครุภัณฑ์) ปรับ:
|
||||||
|
- แหล่งข้อมูลบุคลากรให้ตรงกับที่ใช้ในหน้าครุภัณฑ์ (เดิม `AgencyService.getHr()`) — คงไว้ ใช้ตัวเดิม
|
||||||
|
- **2 ลำดับตายตัวเหมือน request_expense**: `approverLabels = ['หัวหน้าหน่วยงาน', 'รองอธิการบดี']`
|
||||||
|
(ยืนยันกับผู้ใช้แล้วว่าใช้ชุดเดียวกัน ไม่ต้องออกแบบ label ใหม่) — ลำดับแรก required,
|
||||||
|
ลำดับสอง optional เหมือนต้นแบบ (`this.approverForm(..., required=true)` /
|
||||||
|
`(..., required=false)`)
|
||||||
|
|
||||||
|
**b) แก้ `durable-articles-form.component.ts`**
|
||||||
|
|
||||||
|
`save_approve()` (บรรทัด 734) ปัจจุบันเรียก `.put()` แล้วต่อด้วย `send_approve` ตรงๆ — เปลี่ยน
|
||||||
|
เป็น: เปิด dialog ระบุผู้อนุมัติก่อน (ถ้ายังไม่เคยกำหนด) → เรียก `set_approvers` endpoint ใหม่
|
||||||
|
→ ต่อด้วย `send_approve` เดิม (concatMap ต่อกัน คล้ายที่ income-report ทำ)
|
||||||
|
|
||||||
|
**c) ไม่แตะ:** `save_header_approve/save_chief_approve/save_dean_approve/save_president_approve`,
|
||||||
|
`save_draft`, `save_draft_as`, `add_approve` — เป็นคนละกลไก/คนละปุ่ม
|
||||||
|
|
||||||
|
## 4. รหัส `request_approve_type` ใหม่
|
||||||
|
|
||||||
|
โค้ด `"01"`/`"02"`/`"03"`/`"04"` ถูกใช้แล้ว (invest_asset เดิม / invest_construct /
|
||||||
|
request_expense เดิม / request_expense self-service) ต้องเลือกรหัสใหม่ที่**ไม่ชนกับข้อมูล
|
||||||
|
จริงในตาราง** `approve_state`/`request_approve_type` — ขณะออกแบบนี้ต่อฐานข้อมูล production
|
||||||
|
ไม่ได้ชั่วคราว (connection timeout ที่ port 31620) **จะเช็คแบบสดตอนเริ่ม task แรกของแผน
|
||||||
|
implementation ก่อนเขียนโค้ดจริง** (`SELECT DISTINCT request_approve_type FROM approve_state`
|
||||||
|
+ ดูตาราง `request_approve_type`) แล้วเลือกรหัสว่างจริง (คาดว่าจะเป็น `"05"` แต่ต้องยืนยันก่อน)
|
||||||
|
|
||||||
|
## 5. Backward compatibility
|
||||||
|
|
||||||
|
- คำขอครุภัณฑ์เก่าที่มี `approve_state` แบบ `request_approve_type="01"` ค้างอยู่ (กำลังอยู่
|
||||||
|
ระหว่างอนุมัติ) **ไม่ถูกแตะ** — เดินหน้าอนุมัติ/ปฏิเสธต่อได้ตามปกติ เพราะ guard ใหม่ในข้อ 3.1(c)
|
||||||
|
เช็คเฉพาะรหัสใหม่เท่านั้น
|
||||||
|
- โค้ด `getApproveState(...,"01",...)` และตาราง config เดิมยังอยู่ในระบบ (ไม่ลบ) แต่จะไม่มีใคร
|
||||||
|
ตั้งค่าใหม่อีกต่อไปเพราะ frontend บังคับให้ต้องกำหนดผู้อนุมัติเองก่อนส่งเสมอ — ตรงตามที่ผู้ใช้
|
||||||
|
ต้องการ ("แทนที่ไปเลย")
|
||||||
|
|
||||||
|
## 6. Testing
|
||||||
|
|
||||||
|
ตาม methodology เดียวกับที่ใช้ทำ income-budget-review-stage ใน session นี้: สร้างข้อมูลทดสอบ
|
||||||
|
สังเคราะห์ (`test.verify`) ผ่าน SQL, ยิง endpoint จริงด้วย JWT ทดสอบ, ตรวจผลผ่าน SQL,
|
||||||
|
ลบข้อมูลทดสอบทิ้งหลังตรวจเสร็จ — ไม่มี automated test suite ในโปรเจกต์นี้
|
||||||
Reference in New Issue
Block a user