# evaluate-collect → evaluate-follow Approve Workflow Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a 2-level self-service approval chain to `evaluate_form` (evaluate-collect) with a central-review stage on evaluate-follow, mirroring the existing request-budget type_4 approve_state/remark_history/request_budget_review pattern. **Architecture:** Reuse the existing shared `approve_state`/`remark_history` tables (add an `evaluate_form_uid` FK column to each, new `request_approve_type` code `"09"`), add three status fields to `evaluate_form` (`is_sent`/`is_approve`/`current_approve_name`), extend the shared `ApproveStateController` approve/reject endpoints with a new branch, and add a new `evaluate_form_review` table + controller mirroring `request_budget_review` for the evaluate-follow central-review stage. Frontend ports the exact button/dialog/service pattern already used for คำขอแผ่นดิน (type_4) onto `evaluate-collect-send` and a new review-status component on `evaluate-follow-send`. **Tech Stack:** ASP.NET Core 8 / EF Core (rmutr-api, branch `master`), Angular 17 standalone-ish module app with Angular Material (rmutr-web, branch `master`). No automated test framework in either repo — verification is manual `curl` + live SQL checks (`python3`+`psycopg2`, since `psql` is not installed locally) + `ng build` + browser walkthrough, per this project's established convention (see `docs/superpowers/plans/2026-09-08-request-budget-approve-workflow.md` line 27). **Spec:** `docs/superpowers/specs/2026-09-24-evaluate-collect-approve-workflow-design.md` ## Global Constraints - New `request_approve_type` code for this feature is **`"09"`** — confirmed live against the production DB (`rmutr_budget_050625`, the DB the deployed API's `appsettings.json` `"postgres"` connection string actually points at) on 2026-09-24: `SELECT DISTINCT request_approve_type FROM "BUDGET".approve_state` returned `01,02,03,04,05,07,08`, and a full-repo grep of every `request_approve_type ==`/`= "..."` literal confirmed `"06"` is also already reserved (invest_construct self-service) even though no row uses it yet. Re-run Task 1's verification step before writing code, in case another branch claimed `"09"` since this plan was written. - Approver labels are fixed 2-level, identical to request-budget: `['หัวหน้าหน่วยงาน', 'รองอธิการบดี']` — level 1 required, level 2 optional. Reuse `SetApproversDialogComponent` (`src/app/shared/components/set-approvers-dialog/`) unchanged; it is already generic. - Identity check on approve/reject must be enforced at **every** `row_order`, not just `row_order == 1` (the bug already fixed for `request_expense` in commit `c12b93bd` — do not reintroduce the narrower check for the new branch). - Reject during the approval chain steps back **one row_order at a time** (not straight back to the creator) — `row_order == 1` rejecting itself resets `is_sent = false` and returns to the data-entry user; `row_order > 1` rejecting steps back to `row_order - 1` without touching `is_sent`. - `return_for_edit` on the central-review stage reopens the **last** `approve_state` row (highest `row_order`), not row 1. - Production DB connection (already in `rmutr-api/appsettings.json`, not a new secret): `Host=203.158.221.28;Username=devadmin;Password=vtwi,yo0tpkd-okfouh;Database=rmutr_budget_050625;port=31620`. All SQL verification steps in this plan use this connection via `python3 -c "import psycopg2; ..."`. - No `psql` binary is available in this environment — every "verify via SQL" step in this plan uses a small inline `python3` + `psycopg2` script instead. - Test data must use `created_by = 'test.verify'` (matches the project's established synthetic-data convention, e.g. `docs/superpowers/plans/2026-09-07-invest-asset-self-service-approval.md` line 42) and must be deleted at the end of each backend task's verification. - Backend `dotnet build` from `/Users/nut.looknut/Project/rmutr/rmutr-api` takes ~8-9 minutes when the worktree is otherwise idle (per this session's own measurement) — check for stray concurrent `dotnet build`/`dotnet publish` processes (`ps aux | grep dotnet`) before starting a build, per this project's known worktree-contention issue. - Frontend `npm run build` (`ng build --configuration=production`) from `/Users/nut.looknut/Project/rmutr/rmutr-web` takes under a minute when idle; use plain `ng build` (dev config) for faster iteration during a task, and the production build only as the task's final verification step. - Do all work in a fresh git worktree per repo (`superpowers:using-git-worktrees` convention already used elsewhere in this project, e.g. `.worktrees/feat-request-budget-approve-workflow`), not directly in the primary checkout — this project has had concurrent-session build collisions when that rule was skipped. ## Review Focus - **Approve/reject called before "ส่งอนุมัติ" was ever clicked.** `SetCustomApprovers` seeds `row_order 1` with `is_state = true` immediately, before `is_sent` is ever set — calling `approve`/`reject` in that window must 400 with "ยังไม่ได้ส่งอนุมัติ กรุณากด \"ส่งอนุมัติ\" ก่อน", not silently approve. Covered in Task 7. - **Only one approver configured (row 2 left empty).** The final approval on `row_order 1` alone (no `row_order 2` row exists) must still correctly flip `evaluate_form.is_approve = true`, trigger `final_active`, and insert the pending `evaluate_form_review` row — not get stuck waiting for a `row_order + 1` that will never exist. Covered in Task 7. - **Wrong person clicks approve/reject.** A JWT whose `display_name` claim does not match `approve_state.display_name_th` for the currently pending row must get 403, at every `row_order` (not just row 1). Covered in Task 7. - **Row 1 rejects itself (self-reject).** Must reset `is_sent = false` (so "ส่งอนุมัติ" can be clicked again after the data-entry user fixes something) and must leave `evaluate_form.is_active` untouched (the record was never finalized). Covered in Task 8. - **Central-review actions called on a record that hasn't finished the 2-level chain.** `return_for_edit`/`mark_reviewed` on an `evaluate_form` where `is_approve != true` must 400, not silently log a status row for a record still mid-approval. Covered in Task 9. --- ## Task 1: Confirm the next free `request_approve_type` code **Files:** none (verification-only task; its output, `"09"`, is a literal substituted into every later task's code — do not leave `` placeholders anywhere below). **Interfaces:** - Produces: the literal string `"09"`, used as `request_approve_type` in Tasks 5-8. - [ ] **Step 1: Re-run the live check** ```bash python3 <<'EOF' import psycopg2 conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin", password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15) cur = conn.cursor() cur.execute('SELECT DISTINCT request_approve_type, count(*) FROM "BUDGET".approve_state GROUP BY 1 ORDER BY 1;') for row in cur.fetchall(): print(row) conn.close() EOF ``` Expected: rows for `01,02,03,04,05,07,08` (no `06` row is expected — it's reserved in code for invest_construct self-service but has zero live rows — and no `09` row should exist yet). If a `09` row now exists, stop and pick `"10"` instead, updating every later task's literal accordingly. - [ ] **Step 2: Confirm no code beyond what Step 1 shows is reserved in source** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api grep -rn 'request_approve_type\s*==\s*"[0-9]*"\|request_approve_type\s*=\s*"[0-9]*"\|approveTypeCode\s*=\s*"[0-9]*"' --include="*.cs" . | grep -v .worktrees | sort -u ``` Expected: only codes `"03","04","05","06","07","08"` appear (plus the default-param `"04"`). If `"09"` appears anywhere already, someone else claimed it — pick the next free code instead and update this plan's Global Constraints line and every task below before proceeding. - [ ] **Step 3: No commit for this task** (verification only — proceed to Task 2 using `"09"`) --- ## Task 2: `evaluate_form_uid` column on `approve_state`/`remark_history` **Files:** - Modify: `Modules/Setting/Databases/Models/approve_state.cs` - Modify: `Modules/Requests/Databases/Models/remark_history.cs` - Modify: `Modules/Evaluate/Databases/Models/evaluate_form/evaluate_form.cs` - Modify: `Modules/Setting/Databases/Db.cs` (wire `v_approve_state.evaluate_form` ↔ `evaluate_form.approve_states`) - Modify: `Modules/Requests/Databases/Db.cs` (wire `v_remark_history.evaluate_form` ↔ `evaluate_form.remark_historys`) - Create: `Migrations/_AddEvaluateFormUidToApproveStateAndRemarkHistory.cs` (via `dotnet ef migrations add`) - Create: `Migrations/add_evaluate_form_uid_to_approve_state_and_remark_history_views.sql` - Modify: `Startup.cs` (register the new raw-SQL migration) **Interfaces:** - Produces: `approve_state.evaluate_form_uid` (nullable `Guid?`) and `remark_history.evaluate_form_uid` (nullable `Guid?`), both queryable through `v_approve_state`/`v_remark_history`; `evaluate_form.approve_states : List` and `evaluate_form.remark_historys : List` reverse-nav collections, mirroring exactly how `request_expense.approve_states`/`.remark_historys` are wired (confirmed by reading `Modules/Setting/Databases/Db.cs:316-326` and `Modules/Requests/Databases/Db.cs:91-95` — the `request_expense`/`invest_asset`/`invest_construct` precedent). - [ ] **Step 1: Add the property to `approve_state`** Edit `Modules/Setting/Databases/Models/approve_state.cs`, in the `approve_state` base class (after `request_expense_uid`): ```csharp public Guid? request_expense_uid { get; set; } public Guid? evaluate_form_uid { get; set; } ``` Also add the nav property to `v_approve_state`: ```csharp public class v_approve_state : approve_state { [JsonIgnore] public v_invest_asset invest_asset { get; set; } [JsonIgnore] public v_invest_construct invest_construct { get; set; } [JsonIgnore] public v_request_expense request_expense { get; set; } [JsonIgnore] public v_evaluate_form evaluate_form { get; set; } } ``` (Add `using rmutr_budget_api.Modules.Evaluate.Databases.Models;` to this file's `using` block for `v_evaluate_form` to resolve.) - [ ] **Step 2: Add the property to `remark_history`** Edit `Modules/Requests/Databases/Models/remark_history.cs`, in the `remark_history` base class (after `request_expense_uid`): ```csharp public Guid? request_expense_uid { get; set; } public Guid? evaluate_form_uid { get; set; } ``` Also add the reverse nav to `v_remark_history` (mirroring `v_remark_history.request_expense` a few lines above it): ```csharp [JsonIgnore] public v_request_expense request_expense { get; set; } [JsonIgnore] public v_evaluate_form evaluate_form { get; set; } ``` (Add `using rmutr_budget_api.Modules.Evaluate.Databases.Models;` to this file's `using` block.) - [ ] **Step 3: Add the reverse-nav collections to `evaluate_form` and wire them in the two `Db.cs` files** Edit `Modules/Evaluate/Databases/Models/evaluate_form/evaluate_form.cs`, add to the `evaluate_form` base class: ```csharp [Include] public Guid? evaluate_form_uid { get; set; } ``` (that line already exists — do not duplicate it; instead add these two new lines near the other `[Include]` collections, e.g. right after `evaluate_time_period_uid`): ```csharp [Include] public List approve_states { get; set; } [Include] public List remark_historys { get; set; } ``` Add `using rmutr_budget_api.Modules.General.Databases.Models;` and `using rmutr_budget_api.Modules.Request.Databases.Models;` to this file's `using` block for `v_approve_state`/`v_remark_history` to resolve. Edit `Modules/Setting/Databases/Db.cs`, add immediately after the existing `v_approve_state.request_expense` wiring (after line 326, `.OnDelete(DeleteBehavior.Cascade);`): ```csharp builder.Entity().HasOne(p => p.evaluate_form).WithMany(p => p.approve_states) .HasForeignKey(p => p.evaluate_form_uid) .OnDelete(DeleteBehavior.Cascade); ``` (Add `using rmutr_budget_api.Modules.Evaluate.Databases.Models;` to this file's `using` block if not already present.) Edit `Modules/Requests/Databases/Db.cs`, add immediately after the existing `v_remark_history.request_expense` wiring (after the block ending `.OnDelete(DeleteBehavior.Cascade);` around line 94): ```csharp builder.Entity().HasOne(p => p.evaluate_form).WithMany(p => p.remark_historys) .HasForeignKey(p => p.evaluate_form_uid) .OnDelete(DeleteBehavior.Cascade); builder.Entity().HasOne(p => p.evaluate_form).WithMany(p => p.remark_historys) .HasForeignKey(p => p.evaluate_form_uid) .OnDelete(DeleteBehavior.Cascade); ``` Note `t_remark_history` also needs an `evaluate_form` nav property for the first of these two calls to compile — add `[JsonIgnore] public t_evaluate_form evaluate_form { get; set; }` to the `t_remark_history` class in `remark_history.cs` (next to its other `[JsonIgnore] public t_request_expense request_expense { get; set; }` line), and add the matching `using` for `t_evaluate_form`. - [ ] **Step 4: Generate the EF Core migration** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api dotnet ef migrations add AddEvaluateFormUidToApproveStateAndRemarkHistory ``` Open the generated `Migrations/_AddEvaluateFormUidToApproveStateAndRemarkHistory.cs` and confirm its `Up()` contains exactly two `migrationBuilder.AddColumn(name: "evaluate_form_uid", schema: "BUDGET", table: "approve_state"/"remark_history", type: "uuid", nullable: true)` calls (mirroring the shape of `20260820041930_AddRoundNoToApproveStateAndRemarkHistory.cs`) — if `dotnet ef` also picked up unrelated model drift, stop and investigate before proceeding (do not let an unrelated migration ship inside this task's commit). - [ ] **Step 5: Write the view-refresh raw SQL migration** Create `Migrations/add_evaluate_form_uid_to_approve_state_and_remark_history_views.sql`: ```sql -- Migration: expose evaluate_form_uid (added to approve_state/remark_history base tables by -- AddEvaluateFormUidToApproveStateAndRemarkHistory) through their matching views. -- Schema placeholder {0} replaced at runtime by default_schema (e.g. BUDGET). -- Idempotent: CREATE OR REPLACE VIEW is safe to run on every startup — appending a -- column at the end of the SELECT list does not require dropping the view first. CREATE OR REPLACE VIEW "{0}".v_approve_state AS SELECT approve_state.approve_state_uid, approve_state.status_id, approve_state.created_by, approve_state.created_datetime, approve_state.updated_by, approve_state.updated_datetime, approve_state.owner_agency_uid, approve_state.reference_uid, approve_state.row_order, approve_state.is_approve, approve_state.is_state, approve_state.approve_date, approve_state.personnel_uid, approve_state.display_name_th, approve_state.request_approve_type, approve_state.position_name_th, approve_state.invest_asset_uid, approve_state.invest_construct_uid, approve_state.request_expense_uid, approve_state.round_no, approve_state.evaluate_form_uid FROM "{0}".approve_state; CREATE OR REPLACE VIEW "{0}".v_remark_history AS SELECT remark_history.remark_history_uid, remark_history.status_id, remark_history.created_by, remark_history.created_datetime, remark_history.updated_by, remark_history.updated_datetime, remark_history.owner_agency_uid, remark_history.request_budget_uid, remark_history.remark_history_code, remark_history.remark_history_short_name_en, remark_history.remark_history_short_name_th, remark_history.remark_history_name_th, remark_history.remark_history_name_en, remark_history.remark_history_image, remark_history.updated_date, remark_history.updated_name, remark_history.request_budget_income_uid, remark_history.budget_progress_uid, remark_history.invest_asset_request_information_uid, remark_history.invest_asset_request_uid, remark_history.invest_asset_uid, remark_history.invest_construct_request_information_uid, remark_history.invest_construct_request_uid, remark_history.invest_construct_uid, remark_history.request_budget_strategic_uid, remark_history.request_expense_uid, remark_history.round_no, remark_history.evaluate_form_uid FROM "{0}".remark_history; ``` - [ ] **Step 6: Register the new raw-SQL migration in `Startup.cs`** Find the line `ApplyRawSqlMigration(dbContext, setting.Value.default_schema, "add_round_no_to_views.sql");` and add immediately after it: ```csharp ApplyRawSqlMigration(dbContext, setting.Value.default_schema, "add_evaluate_form_uid_to_approve_state_and_remark_history_views.sql"); ``` - [ ] **Step 7: Build** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api ps aux | grep -E "dotnet build|dotnet publish" | grep -v grep # confirm nothing else is building first dotnet build rmutr-budget-api.csproj ``` Expected: `0 Error(s)`. - [ ] **Step 8: Apply the migration and verify against the live DB** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api dotnet run --urls http://localhost:5000 & sleep 25 # give Database.Migrate()/ApplyRawSqlMigration a chance to run on startup kill %1 ``` Then verify: ```bash python3 <<'EOF' import psycopg2 conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin", password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15) cur = conn.cursor() cur.execute("""SELECT column_name FROM information_schema.columns WHERE table_schema='BUDGET' AND table_name IN ('approve_state','remark_history') AND column_name='evaluate_form_uid';""") print(cur.fetchall()) cur.execute("""SELECT column_name FROM information_schema.columns WHERE table_schema='BUDGET' AND table_name IN ('v_approve_state','v_remark_history') AND column_name='evaluate_form_uid';""") print(cur.fetchall()) conn.close() EOF ``` Expected: both queries return two rows each (one per table/view). - [ ] **Step 9: Commit** ```bash git add Modules/Setting/Databases/Models/approve_state.cs Modules/Requests/Databases/Models/remark_history.cs Modules/Evaluate/Databases/Models/evaluate_form/evaluate_form.cs Modules/Setting/Databases/Db.cs Modules/Requests/Databases/Db.cs Migrations/*AddEvaluateFormUidToApproveStateAndRemarkHistory* Migrations/add_evaluate_form_uid_to_approve_state_and_remark_history_views.sql Startup.cs git commit -m "feat(evaluate_form): add evaluate_form_uid FK + approve_states/remark_historys nav to approve_state/remark_history" ``` --- ## Task 3: `is_sent`/`is_approve`/`current_approve_name` on `evaluate_form` **Files:** - Modify: `Modules/Evaluate/Databases/Models/evaluate_form/evaluate_form.cs` - Create: `Migrations/_AddApprovalFieldsToEvaluateForm.cs` (via `dotnet ef migrations add`) - Create: `Migrations/add_approval_fields_to_evaluate_form_view.sql` - Modify: `Startup.cs` **Interfaces:** - Consumes: none new. - Produces: `evaluate_form.is_sent : bool?`, `evaluate_form.is_approve : bool?`, `evaluate_form.current_approve_name : string`, all queryable through `v_evaluate_form`. - [ ] **Step 1: Add the properties** Edit `Modules/Evaluate/Databases/Models/evaluate_form/evaluate_form.cs`, in the `evaluate_form` base class: ```csharp public DateTime? active_date { get; set; } public bool? is_active { get; set; } public bool? is_sent { get; set; } public bool? is_approve { get; set; } public string current_approve_name { get; set; } ``` - [ ] **Step 2: Generate the EF Core migration** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api dotnet ef migrations add AddApprovalFieldsToEvaluateForm ``` Confirm the generated `Up()` adds `is_sent boolean` (nullable), `is_approve boolean` (nullable), `current_approve_name character varying`/`text` (nullable) to `evaluate_form` in schema `BUDGET` — and nothing else unrelated. - [ ] **Step 3: Write the view-refresh raw SQL migration** Create `Migrations/add_approval_fields_to_evaluate_form_view.sql` (base SELECT list captured live from production on 2026-09-24 via `pg_get_viewdef('"BUDGET".v_evaluate_form'::regclass, true)`, with the three new columns appended): ```sql -- Migration: expose is_sent/is_approve/current_approve_name (added to evaluate_form by -- AddApprovalFieldsToEvaluateForm) through v_evaluate_form. -- Schema placeholder {0} replaced at runtime by default_schema (e.g. BUDGET). -- Idempotent: CREATE OR REPLACE VIEW is safe to run on every startup. CREATE OR REPLACE VIEW "{0}".v_evaluate_form AS SELECT evaluate_form.evaluate_form_uid, evaluate_form.status_id, evaluate_form.created_by, evaluate_form.created_datetime, evaluate_form.updated_by, evaluate_form.updated_datetime, evaluate_form.owner_agency_uid, evaluate_form.budget_year_uid, evaluate_form.evaluate_time_period_uid, evaluate_form.request_date, evaluate_form.is_active, evaluate_form.active_date, evaluate_form.is_success, budget_year.budget_year_name_th, evaluate_time_period.evaluate_time_period_name_th, evaluate_indicator.evaluate_indicator_name_th, evaluate_time_period.end_date, evaluate_form.is_sent, evaluate_form.is_approve, evaluate_form.current_approve_name FROM "{0}".evaluate_form LEFT JOIN "{0}".evaluate_time_period ON evaluate_time_period.evaluate_time_period_uid = evaluate_form.evaluate_time_period_uid LEFT JOIN "{0}".evaluate_time ON evaluate_time.evaluate_time_uid = evaluate_time_period.evaluate_time_uid LEFT JOIN "{0}".budget_year ON budget_year.budget_year_uid = evaluate_form.budget_year_uid LEFT JOIN "{0}".evaluate_indicator ON evaluate_indicator.evaluate_indicator_uid = evaluate_time.evaluate_indicator_uid; ``` - [ ] **Step 4: Register in `Startup.cs`** Add immediately after the line added in Task 2 Step 5: ```csharp ApplyRawSqlMigration(dbContext, setting.Value.default_schema, "add_approval_fields_to_evaluate_form_view.sql"); ``` - [ ] **Step 5: Build** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api dotnet build rmutr-budget-api.csproj ``` Expected: `0 Error(s)`. - [ ] **Step 6: Apply and verify** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api dotnet run --urls http://localhost:5000 & sleep 25 kill %1 ``` ```bash python3 <<'EOF' import psycopg2 conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin", password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15) cur = conn.cursor() cur.execute("""SELECT column_name FROM information_schema.columns WHERE table_schema='BUDGET' AND table_name='evaluate_form' AND column_name IN ('is_sent','is_approve','current_approve_name') ORDER BY 1;""") print(cur.fetchall()) cur.execute("""SELECT pg_get_viewdef('"BUDGET".v_evaluate_form'::regclass, true);""") print(cur.fetchone()[0]) conn.close() EOF ``` Expected: three rows in the first query; the view definition in the second includes `is_sent`, `is_approve`, `current_approve_name`. - [ ] **Step 7: Commit** ```bash git add Modules/Evaluate/Databases/Models/evaluate_form/evaluate_form.cs Migrations/*AddApprovalFieldsToEvaluateForm* Migrations/add_approval_fields_to_evaluate_form_view.sql Startup.cs git commit -m "feat(evaluate_form): add is_sent/is_approve/current_approve_name status fields" ``` --- ## Task 4: `evaluate_form_review` table (central-review log) **Files:** - Create: `Migrations/add_evaluate_form_review.sql` - Create: `Modules/EvaluateFormReview/Databases/Models/evaluate_form_review.cs` - Create: `Modules/EvaluateFormReview/Databases/Db.cs` - Modify: `Databases/Db.cs` - Modify: `Startup.cs` **Interfaces:** - Produces: `t_evaluate_form_review` DbSet, table `"BUDGET".evaluate_form_review` with columns `evaluate_form_review_uid, evaluate_form_uid, review_status_id (1=รอตรวจสอบ, 2=ส่งแก้ไข, 3=ตรวจสอบแล้ว), remark`. - [ ] **Step 1: Raw SQL migration** Create `Migrations/add_evaluate_form_review.sql` (mirrors `add_request_budget_review.sql` exactly, entity swapped): ```sql -- Migration: สร้างตาราง evaluate_form_review — เก็บ log การตรวจสอบส่วนกลางของ evaluate_form -- (evaluate-follow) หลังเชนอนุมัติ 2 ลำดับผ่านครบ (request_approve_type = "09") -- Schema placeholder {0} replaced at runtime by default_schema (e.g. BUDGET). -- Idempotent: safe to run on every startup. CREATE TABLE IF NOT EXISTS "{0}".evaluate_form_review ( evaluate_form_review_uid uuid PRIMARY KEY, evaluate_form_uid uuid, review_status_id smallint NOT NULL, remark text, status_id smallint, created_by text, created_datetime timestamptz, updated_by text, updated_datetime timestamptz, owner_agency_uid uuid ); CREATE INDEX IF NOT EXISTS ix_evaluate_form_review_evaluate_form_uid ON "{0}".evaluate_form_review (evaluate_form_uid); ``` - [ ] **Step 2: Model** Create `Modules/EvaluateFormReview/Databases/Models/evaluate_form_review.cs`: ```csharp using System; using System.ComponentModel.DataAnnotations; using SeventyOneDev.Utilities; namespace rmutr_budget_api.Modules.EvaluateFormReview.Databases.Models { public class evaluate_form_review : base_table { [Key] public Guid? evaluate_form_review_uid { get; set; } public Guid? evaluate_form_uid { get; set; } /// 1 = รอตรวจสอบ, 2 = ส่งแก้ไข, 3 = ตรวจสอบแล้ว — mirrors request_budget_review, /// see docs/superpowers/specs/2026-09-24-evaluate-collect-approve-workflow-design.md §3.3 public short review_status_id { get; set; } public string remark { get; set; } } public class t_evaluate_form_review : evaluate_form_review { } } ``` - [ ] **Step 3: DbContext registration** Create `Modules/EvaluateFormReview/Databases/Db.cs`: ```csharp using Microsoft.EntityFrameworkCore; using rmutr_budget_api.Modules.EvaluateFormReview.Databases.Models; namespace SeventyOneDev.Utilities { public partial class Db : DbContext { public DbSet t_evaluate_form_review { get; set; } private void OnEvaluateFormReviewModelCreating(ModelBuilder builder, string schema) { builder.Entity().ToTable("evaluate_form_review", schema); } } } ``` Edit `Databases/Db.cs`, add after the `OnRequestBudgetReviewModelCreating(builder,schema);` line: ```csharp OnRequestBudgetReviewModelCreating(builder,schema); OnEvaluateFormReviewModelCreating(builder,schema); ``` - [ ] **Step 4: Register the raw SQL migration in `Startup.cs`** Add after the line added in Task 3 Step 4: ```csharp ApplyRawSqlMigration(dbContext, setting.Value.default_schema, "add_evaluate_form_review.sql"); ``` - [ ] **Step 5: Build** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api dotnet build rmutr-budget-api.csproj ``` Expected: `0 Error(s)`. (No `dotnet ef migrations add` needed here — this table is created by the raw SQL file directly, not EF code-first, matching `request_budget_review`'s precedent of not having a Designer.cs migration.) - [ ] **Step 6: Apply and verify** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api dotnet run --urls http://localhost:5000 & sleep 25 kill %1 ``` ```bash python3 <<'EOF' import psycopg2 conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin", password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15) cur = conn.cursor() cur.execute("""SELECT table_name FROM information_schema.tables WHERE table_schema='BUDGET' AND table_name='evaluate_form_review';""") print(cur.fetchall()) conn.close() EOF ``` Expected: one row. - [ ] **Step 7: Commit** ```bash git add Migrations/add_evaluate_form_review.sql Modules/EvaluateFormReview Databases/Db.cs Startup.cs git commit -m "feat(evaluate_form_review): add central-review log table" ``` --- ## Task 5: `SetCustomApprovers` for `evaluate_form` **Files:** - Modify: `Modules/Evaluate/Services/EvaluateForm.cs` - Modify: `Modules/Evaluate/Controllers/EvaluateForm.cs` **Interfaces:** - Consumes: `t_approve_state`, `t_evaluate_form` (via `_context`, already injected in `EvaluateFormService`). - Produces: `Task<(bool ok, string error)> SetCustomApprovers(Guid? evaluateFormUid, List approvers, ClaimsIdentity claimsIdentity)` on `EvaluateFormService`, and `POST /api/evaluate/evaluate_form/set_approvers/{uid}`. - `ApproverInput` (`personnel_uid: Guid?`, `display_name_th: string`) already exists in `rmutr_budget_api.Modules.RequestExpense.Services` — reference it via `using rmutr_budget_api.Modules.RequestExpense.Services;` rather than redefining it. - [ ] **Step 1: Add the method to `EvaluateFormService`** Edit `Modules/Evaluate/Services/EvaluateForm.cs`, add `using rmutr_budget_api.Modules.RequestExpense.Services;` and `using rmutr_budget_api.Modules.General.Databases.Models;` to the `using` block, then add this method to the class body: ```csharp /// /// ให้ผู้กรอกข้อมูลกำหนดคิวผู้อนุมัติ (ลำดับ 1, 2) เองก่อนกด "ส่งอนุมัติ" — mirror /// RequestExpenseService.SetCustomApprovers ทุกจุด (code "09") /// public async Task<(bool ok, string error)> SetCustomApprovers(Guid? evaluateFormUid, List approvers, ClaimsIdentity claimsIdentity) { var entity = await _context.t_evaluate_form .FirstOrDefaultAsync(c => c.evaluate_form_uid == evaluateFormUid); if (entity == null) return (false, "ไม่พบรายการ"); if (entity.is_sent != null || entity.is_approve != null) return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว ไม่สามารถกำหนดผู้อนุมัติใหม่ได้"); var existing = await _context.t_approve_state .Where(c => c.evaluate_form_uid == evaluateFormUid).ToListAsync(); if (existing.Any(s => s.is_approve == true)) return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว ไม่สามารถกำหนดผู้อนุมัติใหม่ได้"); approvers = (approvers ?? new List()) .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(), evaluate_form_uid = evaluateFormUid, personnel_uid = a.personnel_uid, display_name_th = a.display_name_th, row_order = idx + 1, is_state = idx == 0, request_approve_type = "09", }).ToList(); await _context.t_approve_state.AddRangeAsync(rows); entity.current_approve_name = rows.First().display_name_th; _context.t_evaluate_form.Update(entity); await _context.SaveChangesAsync(claimsIdentity); return (true, null); } ``` - [ ] **Step 2: Add the endpoint** Edit `Modules/Evaluate/Controllers/EvaluateForm.cs`, add after `FinalActiveAgencyEvaluate` (before `SendBackEvaluate`): ```csharp [Authorize] [HttpPost("set_approvers/{evaluate_form_uid:guid}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task SetApprovers([FromRoute] Guid evaluate_form_uid, [FromBody] List approvers) { var result = await _entityUidService.SetCustomApprovers(evaluate_form_uid, approvers, User.Identity as ClaimsIdentity); if (!result.ok) return BadRequest(new { description = result.error }); return Ok(); } ``` - [ ] **Step 3: Build** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api dotnet build rmutr-budget-api.csproj ``` Expected: `0 Error(s)`. - [ ] **Step 4: Run locally and verify with synthetic data + curl** Start the API: `cd /Users/nut.looknut/Project/rmutr/rmutr-api && dotnet run --urls http://localhost:5000` (leave running in this shell for Tasks 5-9's manual verification; obtain a bearer token by logging into the running rmutr-web app locally against this API and copying the `Authorization` header value from any XHR request, or from the OIDC token in browser storage — same convention as `docs/superpowers/plans/2026-09-08-request-budget-approve-workflow.md`). Create a synthetic `evaluate_form` row via SQL (adjust `budget_year_uid`/`evaluate_time_period_uid` to real existing UIDs from your DB — query `SELECT budget_year_uid FROM "BUDGET".budget_year LIMIT 1;` and `SELECT evaluate_time_period_uid FROM "BUDGET".evaluate_time_period LIMIT 1;` first if needed): ```bash python3 <<'EOF' import psycopg2, uuid conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin", password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15) cur = conn.cursor() cur.execute('SELECT budget_year_uid FROM "BUDGET".budget_year LIMIT 1;') byu = cur.fetchone()[0] form_uid = uuid.uuid4() cur.execute("""INSERT INTO "BUDGET".evaluate_form (evaluate_form_uid, budget_year_uid, request_date, created_by, status_id) VALUES (%s, %s, now(), 'test.verify', 1);""", (str(form_uid), str(byu))) conn.commit() print("evaluate_form_uid:", form_uid) conn.close() EOF ``` ```bash curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/set_approvers/" \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '[{"display_name_th":"ทดสอบ หัวหน้าหน่วยงาน"},{"display_name_th":"ทดสอบ รองอธิการบดี"}]' ``` Expected: `200 OK`. ```bash python3 <<'EOF' import psycopg2 conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin", password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15) cur = conn.cursor() cur.execute("""SELECT row_order, is_state, display_name_th, request_approve_type FROM "BUDGET".approve_state WHERE evaluate_form_uid = %s ORDER BY row_order;""", ("",)) print(cur.fetchall()) conn.close() EOF ``` Expected: two rows, `row_order 1` with `is_state=True`, `row_order 2` with `is_state=False`, both `request_approve_type='09'`. **Do not delete this test data yet** — Tasks 6-9 reuse it. - [ ] **Step 5: Commit** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api git add Modules/Evaluate/Services/EvaluateForm.cs Modules/Evaluate/Controllers/EvaluateForm.cs git commit -m "feat(evaluate_form): add SetCustomApprovers + set_approvers endpoint" ``` --- ## Task 6: `SendApprove` for `evaluate_form` **Files:** - Modify: `Modules/Evaluate/Services/EvaluateForm.cs` - Modify: `Modules/Evaluate/Controllers/EvaluateForm.cs` **Interfaces:** - Produces: `Task<(bool ok, string error)> SendApprove(Guid? evaluateFormUid, ClaimsIdentity claimsIdentity)` on `EvaluateFormService`, and `POST /api/evaluate/evaluate_form/send_approve/{uid}`. - [ ] **Step 1: Add the method** Add to `Modules/Evaluate/Services/EvaluateForm.cs`: ```csharp /// /// "ส่งอนุมัติ" — ใครก็ได้กดได้ แค่ตั้ง is_sent=true ไม่แตะ approve_state — mirror /// RequestExpenseService.SendApprove2 /// public async Task<(bool ok, string error)> SendApprove(Guid? evaluateFormUid, ClaimsIdentity claimsIdentity) { var entity = await _context.t_evaluate_form .FirstOrDefaultAsync(c => c.evaluate_form_uid == evaluateFormUid); if (entity == null) return (false, "ไม่พบรายการ"); if (entity.is_approve == true) return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว"); var isPristine = entity.is_sent == null && entity.is_approve == null; var isRejectedReadyToResend = entity.is_sent == false && entity.is_approve == false; if (!isPristine && !isRejectedReadyToResend) return (false, "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว"); var pending = await _context.t_approve_state.FirstOrDefaultAsync(c => c.evaluate_form_uid == evaluateFormUid && c.is_state == true); if (pending == null || pending.row_order != 1) return (false, "ยังไม่พร้อมส่งอนุมัติ กรุณากำหนดผู้อนุมัติก่อน"); entity.is_sent = true; entity.is_approve = null; _context.t_evaluate_form.Update(entity); await _context.SaveChangesAsync(claimsIdentity); return (true, null); } ``` - [ ] **Step 2: Add the endpoint** Add to `Modules/Evaluate/Controllers/EvaluateForm.cs`, after `SetApprovers`: ```csharp [Authorize] [HttpPost("send_approve/{evaluate_form_uid:guid}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task SendApprove([FromRoute] Guid evaluate_form_uid) { var result = await _entityUidService.SendApprove(evaluate_form_uid, User.Identity as ClaimsIdentity); if (!result.ok) return BadRequest(new { description = result.error }); return Ok(); } ``` - [ ] **Step 3: Build** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api dotnet build rmutr-budget-api.csproj ``` Expected: `0 Error(s)`. - [ ] **Step 4: Verify (reuse Task 5's test `evaluate_form`)** Restart `dotnet run` if it isn't still running, then: ```bash curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/send_approve/" \ -H "Authorization: Bearer " ``` Expected: `200 OK`. ```bash python3 <<'EOF' import psycopg2 conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin", password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15) cur = conn.cursor() cur.execute('SELECT is_sent, is_approve FROM "BUDGET".evaluate_form WHERE evaluate_form_uid = %s;', ("",)) print(cur.fetchone()) conn.close() EOF ``` Expected: `(True, None)`. - [ ] **Step 5: Commit** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api git add Modules/Evaluate/Services/EvaluateForm.cs Modules/Evaluate/Controllers/EvaluateForm.cs git commit -m "feat(evaluate_form): add SendApprove + send_approve endpoint" ``` --- ## Task 7: Extend `ApproveGetEntity` (approve) with the `evaluate_form` branch **Files:** - Modify: `Modules/Setting/Controllers/ApproveState.cs` **Interfaces:** - Consumes: `_db.t_evaluate_form`, `_db.t_approve_state`, `_db.t_evaluate_form_review` (add `using rmutr_budget_api.Modules.EvaluateFormReview.Databases.Models;`). - [ ] **Step 1: Add the branch** Edit `ApproveGetEntity` in `Modules/Setting/Controllers/ApproveState.cs`. First, add `evaluate_form` to the initial lookup block: ```csharp var invest_asset = await _db.t_invest_asset.FirstOrDefaultAsync(c => c.invest_asset_uid == uid); var invest_construct = await _db.t_invest_construct.FirstOrDefaultAsync(c => c.invest_construct_uid == uid); var request_expense = await _db.t_request_expense.FirstOrDefaultAsync(c => c.request_expense_uid == uid); var evaluate_form = await _db.t_evaluate_form.FirstOrDefaultAsync(c => c.evaluate_form_uid == uid); if (invest_asset == null && invest_construct == null && request_expense == null && evaluate_form == null) { return BadRequest("Error, Not Found Approve State."); } ``` Then add a new `if (evaluate_form != null)` block, placed after the existing `if (request_expense != null) { ... }` block and before `return Ok(getResult);`: ```csharp if (evaluate_form != null) { getResult = await _db.t_approve_state.FirstOrDefaultAsync(c => c.evaluate_form_uid == uid && c.is_state == true); if (getResult != null) { // ประเมินผล (evaluate_form) แบบผู้กรอกข้อมูลเลือกผู้อนุมัติเอง (code "09") — เช็คสิทธิ์ // ทุก row_order ตั้งแต่วันแรก (ไม่ใช่แค่ row_order==1) ตาม fix ที่ทำกับ request_expense // ไปแล้วเมื่อ 2026-09-08 — ดู docs/superpowers/specs/2026-09-24-evaluate-collect-approve-workflow-design.md §6 if (getResult.row_order == 1 && evaluate_form.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(StatusCodes.Status403Forbidden, new { description = "ไม่มีสิทธิ์อนุมัติ (ต้องเป็นผู้ที่ถูกกำหนดเป็นผู้อนุมัติในลำดับนี้เท่านั้น)" }); } getResult.is_state = null; getResult.is_approve = true; evaluate_form.current_approve_name = null; _db.t_approve_state.Update(getResult); _db.t_evaluate_form.Update(evaluate_form); await _db.SaveChangesAsync(User.Identity as ClaimsIdentity); var check_all_state = await _db.t_approve_state .Where(c => c.evaluate_form_uid == uid && c.is_approve != true).ToListAsync(); if (!check_all_state.Any()) { evaluate_form.is_approve = true; evaluate_form.is_active = true; evaluate_form.active_date = DateTime.UtcNow.Date; _db.t_evaluate_form.Update(evaluate_form); await _db.SaveChangesAsync(User.Identity as ClaimsIdentity); await _db.t_evaluate_form_review.AddAsync(new t_evaluate_form_review { evaluate_form_review_uid = Guid.NewGuid(), evaluate_form_uid = uid, review_status_id = 1, }); await _db.SaveChangesAsync(User.Identity as ClaimsIdentity); } var check_getResult = await _db.t_approve_state.FirstOrDefaultAsync(c => c.evaluate_form_uid == uid && c.row_order == getResult.row_order + 1); if (check_getResult != null) { check_getResult.is_state = true; evaluate_form.current_approve_name = check_getResult.display_name_th; _db.t_evaluate_form.Update(evaluate_form); _db.t_approve_state.Update(check_getResult); await _db.SaveChangesAsync(User.Identity as ClaimsIdentity); } } } ``` - [ ] **Step 2: Build** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api dotnet build rmutr-budget-api.csproj ``` Expected: `0 Error(s)`. - [ ] **Step 3: Verify the "approve before send" guard (Review Focus item 1)** Using a **fresh** synthetic `evaluate_form` (repeat Task 5 Step 4's insert + `set_approvers` call, but skip `send_approve`): ```bash curl -s -w "\n%{http_code}\n" "http://localhost:5000/api/setting/approve_state/approve/" \ -H "Authorization: Bearer " ``` Expected: `400` with `description` containing `ยังไม่ได้ส่งอนุมัติ`. - [ ] **Step 4: Verify the wrong-person guard (Review Focus item 3)** Call `send_approve` on the same record, then call `approve` with a token whose `display_name` claim does **not** match `"ทดสอบ หัวหน้าหน่วยงาน"`: ```bash curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/send_approve/" -H "Authorization: Bearer " curl -s -w "\n%{http_code}\n" "http://localhost:5000/api/setting/approve_state/approve/" -H "Authorization: Bearer " ``` Expected: `403`. - [ ] **Step 5: Verify the single-approver happy path (Review Focus item 2)** Create a **third** synthetic `evaluate_form`, call `set_approvers` with **only one** approver (`row_order 2` never created), `send_approve`, then `approve` with a token whose `display_name` matches that one approver's `display_name_th`: ```bash python3 <<'EOF' import psycopg2, uuid conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin", password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15) cur = conn.cursor() cur.execute('SELECT budget_year_uid FROM "BUDGET".budget_year LIMIT 1;') byu = cur.fetchone()[0] form_uid = uuid.uuid4() cur.execute("""INSERT INTO "BUDGET".evaluate_form (evaluate_form_uid, budget_year_uid, request_date, created_by, status_id) VALUES (%s, %s, now(), 'test.verify', 1);""", (str(form_uid), str(byu))) conn.commit() print("single_approver_form_uid:", form_uid) conn.close() EOF ``` ```bash curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/set_approvers/" \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '[{"display_name_th":"ทดสอบ หัวหน้าหน่วยงาน"}]' curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/send_approve/" -H "Authorization: Bearer " curl -s "http://localhost:5000/api/setting/approve_state/approve/" -H "Authorization: Bearer " ``` ```bash python3 <<'EOF' import psycopg2 conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin", password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15) cur = conn.cursor() cur.execute('SELECT is_approve, is_active FROM "BUDGET".evaluate_form WHERE evaluate_form_uid = %s;', ("",)) print(cur.fetchone()) cur.execute('SELECT review_status_id FROM "BUDGET".evaluate_form_review WHERE evaluate_form_uid = %s;', ("",)) print(cur.fetchall()) conn.close() EOF ``` Expected: `(True, True)`; one `evaluate_form_review` row with `review_status_id=1`. - [ ] **Step 6: Clean up all synthetic data from Tasks 5-7** ```bash python3 <<'EOF' import psycopg2 conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin", password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15) cur = conn.cursor() cur.execute("""DELETE FROM "BUDGET".evaluate_form_review WHERE evaluate_form_uid IN (SELECT evaluate_form_uid FROM "BUDGET".evaluate_form WHERE created_by='test.verify');""") cur.execute("""DELETE FROM "BUDGET".approve_state WHERE evaluate_form_uid IN (SELECT evaluate_form_uid FROM "BUDGET".evaluate_form WHERE created_by='test.verify');""") cur.execute("""DELETE FROM "BUDGET".evaluate_form WHERE created_by='test.verify';""") conn.commit() conn.close() EOF ``` (Leave the two-approver form from Task 5 alive if Task 8 still needs it — Task 8 creates its own fresh data instead, so it's safe to delete everything here.) - [ ] **Step 7: Commit** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api git add Modules/Setting/Controllers/ApproveState.cs git commit -m "feat(approve_state): add evaluate_form branch to approve endpoint" ``` --- ## Task 8: Extend `RejectGetEntity` (reject) with the `evaluate_form` branch **Files:** - Modify: `Modules/Setting/Controllers/ApproveState.cs` - [ ] **Step 1: Add the branch** In `RejectGetEntity`, add `evaluate_form` to the initial lookup block: ```csharp var request_expense_approve = await _db.t_approve_state.FirstOrDefaultAsync(c => c.request_expense_uid == uid && c.is_state == true); var evaluate_form_approve = await _db.t_approve_state.FirstOrDefaultAsync(c => c.evaluate_form_uid == uid && c.is_state == true); if (invest_asset_approve == null && invest_construct_approve == null && request_expense_approve == null && evaluate_form_approve == null) { return BadRequest("Error, Not Found Approve State."); } ``` Then add a new block after the existing `if (request_expense_approve != null) { ... }` block and before `return Ok(getResult);`: ```csharp if (evaluate_form_approve != null) { getResult = evaluate_form_approve; var result_data_lists = await _db.t_approve_state .Where(c => c.evaluate_form_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; } _db.t_approve_state.UpdateRange(result_data_lists); await _db.SaveChangesAsync(User.Identity as ClaimsIdentity); var evaluate_form = await _db.t_evaluate_form.FirstOrDefaultAsync(c => c.evaluate_form_uid == uid); if (evaluate_form != null) { evaluate_form.current_approve_name = null; var approve = await _db.t_approve_state .Where(c => c.evaluate_form_uid == uid && c.is_state == true) .OrderBy(c => c.row_order).ToListAsync(); if (approve.Count > 0) { evaluate_form.current_approve_name = approve.First().display_name_th; } evaluate_form.is_approve = false; if (index_current == 0) { // ลำดับ 1 ส่งแก้ไขเอง: ไม่มีลำดับก่อนหน้าให้ตีกลับ ต้องรีเซ็ต is_sent เพื่อให้กลับไปเป็น // สถานะ "ยังไม่ได้ส่งอนุมัติ" ให้ผู้กรอกข้อมูล (ใครก็ได้) กลับมาส่งใหม่ได้อีกครั้งหลังแก้ไข evaluate_form.is_sent = false; } _db.t_evaluate_form.Update(evaluate_form); await _db.SaveChangesAsync(User.Identity as ClaimsIdentity); } } ``` - [ ] **Step 2: Build** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api dotnet build rmutr-budget-api.csproj ``` Expected: `0 Error(s)`. - [ ] **Step 3: Verify self-reject at row 1 (Review Focus item 4)** Create a fresh two-approver synthetic form (repeat Task 5 Step 4), `send_approve`, then reject as the row-1 approver: ```bash curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/send_approve/" -H "Authorization: Bearer " curl -s "http://localhost:5000/api/setting/approve_state/reject/" -H "Authorization: Bearer " ``` ```bash python3 <<'EOF' import psycopg2 conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin", password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15) cur = conn.cursor() cur.execute('SELECT is_sent, is_approve, is_active FROM "BUDGET".evaluate_form WHERE evaluate_form_uid = %s;', ("",)) print(cur.fetchone()) conn.close() EOF ``` Expected: `(False, False, None)` — `is_sent` reset to `False`, `is_active` untouched (still `None`, never finalized). - [ ] **Step 4: Verify row-2 reject steps back to row 1 (not straight back to the creator)** Create another fresh two-approver form, run it through `set_approvers` → `send_approve` → `approve` as the row-1 approver (advances the queue to row 2), then reject as the row-2 approver: ```bash curl -s -X POST "http://localhost:5000/api/evaluate/evaluate_form/send_approve/" -H "Authorization: Bearer " curl -s "http://localhost:5000/api/setting/approve_state/approve/" -H "Authorization: Bearer " curl -s "http://localhost:5000/api/setting/approve_state/reject/" -H "Authorization: Bearer " ``` ```bash python3 <<'EOF' import psycopg2 conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin", password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15) cur = conn.cursor() cur.execute("""SELECT row_order, is_state, is_approve FROM "BUDGET".approve_state WHERE evaluate_form_uid = %s ORDER BY row_order;""", ("",)) print(cur.fetchall()) cur.execute('SELECT is_sent, is_approve FROM "BUDGET".evaluate_form WHERE evaluate_form_uid = %s;', ("",)) print(cur.fetchone()) conn.close() EOF ``` Expected: `row_order 1` has `is_state=True, is_approve=None` (reopened), `row_order 2` has `is_state=None, is_approve=None` (not straight back to row 1's original never-approved state — it was `is_approve=True` before this reject and must now be cleared); `evaluate_form.is_sent` stays `True` (unlike the row-1 self-reject case in Step 3), `is_approve=False`. - [ ] **Step 5: Clean up** ```bash python3 <<'EOF' import psycopg2 conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin", password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15) cur = conn.cursor() cur.execute("""DELETE FROM "BUDGET".approve_state WHERE evaluate_form_uid IN (SELECT evaluate_form_uid FROM "BUDGET".evaluate_form WHERE created_by='test.verify');""") cur.execute("""DELETE FROM "BUDGET".evaluate_form WHERE created_by='test.verify';""") conn.commit() conn.close() EOF ``` - [ ] **Step 6: Commit** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api git add Modules/Setting/Controllers/ApproveState.cs git commit -m "feat(approve_state): add evaluate_form branch to reject endpoint" ``` --- ## Task 9: `EvaluateFormReview` controller + service (central-review stage) **Files:** - Create: `Modules/EvaluateFormReview/Services/EvaluateFormReview.cs` - Create: `Modules/EvaluateFormReview/Controllers/EvaluateFormReview.cs` **Interfaces:** - Produces: `GET /api/evaluate_form_review/status/{evaluate_form_uid}`, `GET /api/evaluate_form_review/dashboard`, `POST /api/evaluate_form_review/return_for_edit/{evaluate_form_uid}` (body `{remark}`), `POST /api/evaluate_form_review/mark_reviewed/{evaluate_form_uid}`. - [ ] **Step 1: Service** Create `Modules/EvaluateFormReview/Services/EvaluateFormReview.cs` (mirrors `RequestBudgetReviewService` exactly, entity swapped; `evaluate_form` has no `faculty_uid`/`budget_location_uid` denormalized fields to join for a dashboard item, so the dashboard item is intentionally smaller than `request_budget_review_dashboard_item`): ```csharp using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using rmutr_budget_api.Modules.EvaluateFormReview.Databases.Models; using rmutr_budget_api.Modules.Evaluate.Databases.Models; using SeventyOneDev.Utilities; namespace rmutr_budget_api.Modules.EvaluateFormReview.Services { public class evaluate_form_review_history_item { public DateTime? datetime { get; set; } public string by { get; set; } public string detail { get; set; } } public class evaluate_form_review_status_response { public Guid? evaluate_form_uid { get; set; } public string current_status { get; set; } public List history { get; set; } = new(); } public class evaluate_form_review_dashboard_item { public Guid? evaluate_form_uid { get; set; } public Guid? budget_year_uid { get; set; } public string budget_year_name_th { get; set; } public DateTime? updated_datetime { get; set; } } public class evaluate_form_review_dashboard { public List pending { get; set; } = new(); public List returned { get; set; } = new(); public List reviewed { get; set; } = new(); } public class EvaluateFormReviewService { private readonly Db _context; public EvaluateFormReviewService(Db context) { _context = context; } private string ComputeStatus(t_evaluate_form entity, t_evaluate_form_review latestLog) { if (entity.is_sent == false && entity.is_approve == false) return "returned"; if (entity.is_approve == true) { if (latestLog != null && latestLog.review_status_id == 3) return "reviewed"; return "pending"; } return "not_ready"; } public async Task GetStatus(Guid evaluateFormUid) { var entity = await _context.t_evaluate_form .FirstOrDefaultAsync(c => c.evaluate_form_uid == evaluateFormUid); if (entity == null) return null; var logs = await _context.t_evaluate_form_review.AsNoTracking() .Where(c => c.evaluate_form_uid == evaluateFormUid) .OrderByDescending(c => c.created_datetime) .ToListAsync(); var latestLog = logs.FirstOrDefault(); var remarks = await _context.t_remark_history.AsNoTracking() .Where(c => c.evaluate_form_uid == evaluateFormUid) .OrderByDescending(c => c.updated_date) .ToListAsync(); var history = remarks .Select(r => new evaluate_form_review_history_item { datetime = r.updated_date, by = r.updated_name, detail = r.remark_history_name_th, }) .OrderByDescending(h => h.datetime) .ToList(); return new evaluate_form_review_status_response { evaluate_form_uid = evaluateFormUid, current_status = ComputeStatus(entity, latestLog), history = history, }; } public async Task GetDashboard() { var result = new evaluate_form_review_dashboard(); var candidates = await _context.v_evaluate_form.AsNoTracking() .Where(e => e.is_approve == true || (e.is_sent == false && e.is_approve == false)) .ToListAsync(); var candidateIds = candidates.Select(c => c.evaluate_form_uid).ToList(); var allLogs = await _context.t_evaluate_form_review.AsNoTracking() .Where(r => candidateIds.Contains(r.evaluate_form_uid)) .ToListAsync(); var latestLogByForm = allLogs .GroupBy(r => r.evaluate_form_uid) .ToDictionary(g => g.Key, g => g.OrderByDescending(r => r.created_datetime).First()); evaluate_form_review_dashboard_item ToItem(v_evaluate_form e) => new() { evaluate_form_uid = e.evaluate_form_uid, budget_year_uid = e.budget_year_uid, budget_year_name_th = e.budget_year_name_th, updated_datetime = e.updated_datetime, }; foreach (var e in candidates) { latestLogByForm.TryGetValue(e.evaluate_form_uid, out var latestLog); var status = ComputeStatus(new t_evaluate_form { is_sent = e.is_sent, is_approve = e.is_approve, }, latestLog); var item = ToItem(e); if (status == "pending") result.pending.Add(item); else if (status == "returned") result.returned.Add(item); else if (status == "reviewed") result.reviewed.Add(item); } return result; } public async Task<(bool ok, string error)> ReturnForEdit(Guid evaluateFormUid, string remark, ClaimsIdentity claimsIdentity) { var entity = await _context.t_evaluate_form .FirstOrDefaultAsync(c => c.evaluate_form_uid == evaluateFormUid); if (entity == null) return (false, "ไม่พบรายการ"); if (entity.is_approve != true) return (false, "รายการนี้ยังไม่ผ่านการอนุมัติครบทุกลำดับ ไม่สามารถส่งแก้ไขจากการตรวจสอบได้"); if (string.IsNullOrWhiteSpace(remark)) return (false, "กรุณาระบุรายละเอียดที่ส่งแก้ไข"); var lastRow = await _context.t_approve_state .Where(c => c.evaluate_form_uid == evaluateFormUid) .OrderByDescending(c => c.row_order) .FirstOrDefaultAsync(); if (lastRow == null) return (false, "ไม่พบผู้อนุมัติของรายการนี้"); lastRow.is_state = true; lastRow.is_approve = null; _context.t_approve_state.Update(lastRow); entity.is_approve = false; entity.current_approve_name = lastRow.display_name_th; _context.t_evaluate_form.Update(entity); await _context.t_remark_history.AddAsync(new rmutr_budget_api.Modules.Request.Databases.Models.t_remark_history { remark_history_uid = Guid.NewGuid(), evaluate_form_uid = evaluateFormUid, remark_history_name_th = remark, updated_date = DateTime.Now, updated_name = claimsIdentity?.FindFirst("display_name")?.Value, }); await _context.t_evaluate_form_review.AddAsync(new t_evaluate_form_review { evaluate_form_review_uid = Guid.NewGuid(), evaluate_form_uid = evaluateFormUid, review_status_id = 2, remark = remark, }); await _context.SaveChangesAsync(claimsIdentity); return (true, null); } public async Task<(bool ok, string error)> MarkReviewed(Guid evaluateFormUid, ClaimsIdentity claimsIdentity) { var entity = await _context.t_evaluate_form .FirstOrDefaultAsync(c => c.evaluate_form_uid == evaluateFormUid); if (entity == null) return (false, "ไม่พบรายการ"); if (entity.is_approve != true) return (false, "รายการนี้ยังไม่ผ่านการอนุมัติครบทุกลำดับ"); var latestLog = await _context.t_evaluate_form_review.AsNoTracking() .Where(c => c.evaluate_form_uid == evaluateFormUid) .OrderByDescending(c => c.created_datetime) .FirstOrDefaultAsync(); var isPendingWithLog = latestLog != null && latestLog.review_status_id == 1; var isPendingWithoutLog = latestLog == null && entity.is_approve == true; if (!isPendingWithLog && !isPendingWithoutLog) return (false, "รายการนี้ไม่ได้อยู่ในสถานะรอตรวจสอบ"); await _context.t_evaluate_form_review.AddAsync(new t_evaluate_form_review { evaluate_form_review_uid = Guid.NewGuid(), evaluate_form_uid = evaluateFormUid, review_status_id = 3, }); await _context.SaveChangesAsync(claimsIdentity); return (true, null); } } } ``` - [ ] **Step 2: Controller** Create `Modules/EvaluateFormReview/Controllers/EvaluateFormReview.cs`: ```csharp using System; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using rmutr_budget_api.Modules.EvaluateFormReview.Services; namespace rmutr_budget_api.Modules.EvaluateFormReview.Controllers { public class evaluate_form_return_for_edit_input { public string remark { get; set; } } [Route("api/evaluate_form_review", Name = "evaluate_form_review")] public class EvaluateFormReviewController : Controller { private readonly EvaluateFormReviewService _service; public EvaluateFormReviewController(EvaluateFormReviewService service) { _service = service; } [Authorize] [HttpGet("status/{evaluate_form_uid:guid}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> GetStatus([FromRoute] Guid evaluate_form_uid) { var result = await _service.GetStatus(evaluate_form_uid); return result == null ? NotFound() : Ok(result); } [Authorize] [HttpGet("dashboard")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> GetDashboard() { return Ok(await _service.GetDashboard()); } [Authorize] [HttpPost("return_for_edit/{evaluate_form_uid:guid}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task ReturnForEdit([FromRoute] Guid evaluate_form_uid, [FromBody] evaluate_form_return_for_edit_input input) { var result = await _service.ReturnForEdit(evaluate_form_uid, input?.remark, User.Identity as ClaimsIdentity); if (!result.ok) return BadRequest(new { description = result.error }); return Ok(); } [Authorize] [HttpPost("mark_reviewed/{evaluate_form_uid:guid}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task MarkReviewed([FromRoute] Guid evaluate_form_uid) { var result = await _service.MarkReviewed(evaluate_form_uid, User.Identity as ClaimsIdentity); if (!result.ok) return BadRequest(new { description = result.error }); return Ok(); } } } ``` `EvaluateFormReviewService` needs to be registered for DI — check `Startup.cs`'s `ConfigureServices` for how `RequestBudgetReviewService` is registered (likely `services.AddScoped();` or an assembly-scan) and add the equivalent line for `EvaluateFormReviewService` if registration is explicit rather than scanned. - [ ] **Step 3: Build** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api dotnet build rmutr-budget-api.csproj ``` Expected: `0 Error(s)`. - [ ] **Step 4: Verify the not-yet-approved guard (Review Focus item 5)** Create a fresh `evaluate_form` with **no** approvers configured at all (`is_approve` still `null`): ```bash curl -s -w "\n%{http_code}\n" -X POST "http://localhost:5000/api/evaluate_form_review/mark_reviewed/" -H "Authorization: Bearer " curl -s -w "\n%{http_code}\n" -X POST "http://localhost:5000/api/evaluate_form_review/return_for_edit/" \ -H "Authorization: Bearer " -H "Content-Type: application/json" -d '{"remark":"test"}' ``` Expected: both `400`. - [ ] **Step 5: Verify the full happy path end-to-end** Reuse (or recreate) a two-approver form, run it through `set_approvers` → `send_approve` → `approve` (row 1) → `approve` (row 2) → confirm `is_approve=true` and an `evaluate_form_review` row with `review_status_id=1` exists (already covered by Task 7 Step 5's assertions) → then: ```bash curl -s "http://localhost:5000/api/evaluate_form_review/status/" -H "Authorization: Bearer " ``` Expected: `{"current_status":"pending", ...}`. ```bash curl -s -X POST "http://localhost:5000/api/evaluate_form_review/mark_reviewed/" -H "Authorization: Bearer " curl -s "http://localhost:5000/api/evaluate_form_review/status/" -H "Authorization: Bearer " ``` Expected: `{"current_status":"reviewed", ...}`. - [ ] **Step 6: Verify the full `return_for_edit` → re-approve → `mark_reviewed` loop (spec §11 test path 4)** Run a fresh two-approver form through the full chain to `pending` (repeat Task 7 Step 5's flow with two approvers instead of one), then: ```bash curl -s -X POST "http://localhost:5000/api/evaluate_form_review/return_for_edit/" \ -H "Authorization: Bearer " -H "Content-Type: application/json" -d '{"remark":"ทดสอบส่งแก้ไขจากการตรวจสอบ"}' ``` ```bash python3 <<'EOF' import psycopg2 conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin", password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15) cur = conn.cursor() cur.execute("""SELECT row_order, is_state, is_approve FROM "BUDGET".approve_state WHERE evaluate_form_uid = %s ORDER BY row_order;""", ("",)) print(cur.fetchall()) cur.execute('SELECT is_approve, current_approve_name FROM "BUDGET".evaluate_form WHERE evaluate_form_uid = %s;', ("",)) print(cur.fetchone()) conn.close() EOF ``` Expected: `row_order 2` (the last row, "รองอธิการบดี") has `is_state=True, is_approve=None` — reopened, **not** `row_order 1` — and `evaluate_form.is_approve=False`, `current_approve_name` matches `row_order 2`'s name. Re-approve as the row-2 approver and confirm the loop closes back to `pending`: ```bash curl -s "http://localhost:5000/api/setting/approve_state/approve/" -H "Authorization: Bearer " curl -s "http://localhost:5000/api/evaluate_form_review/status/" -H "Authorization: Bearer " ``` Expected: `{"current_status":"pending", "history":[{"detail":"ทดสอบส่งแก้ไขจากการตรวจสอบ", ...}]}` (a second `evaluate_form_review` row with `review_status_id=1` was inserted; `evaluate_form.is_approve` is `true` again). - [ ] **Step 7: Clean up all remaining synthetic data** ```bash python3 <<'EOF' import psycopg2 conn = psycopg2.connect(host="203.158.221.28", port=31620, user="devadmin", password="vtwi,yo0tpkd-okfouh", dbname="rmutr_budget_050625", connect_timeout=15) cur = conn.cursor() cur.execute("""DELETE FROM "BUDGET".remark_history WHERE evaluate_form_uid IN (SELECT evaluate_form_uid FROM "BUDGET".evaluate_form WHERE created_by='test.verify');""") cur.execute("""DELETE FROM "BUDGET".evaluate_form_review WHERE evaluate_form_uid IN (SELECT evaluate_form_uid FROM "BUDGET".evaluate_form WHERE created_by='test.verify');""") cur.execute("""DELETE FROM "BUDGET".approve_state WHERE evaluate_form_uid IN (SELECT evaluate_form_uid FROM "BUDGET".evaluate_form WHERE created_by='test.verify');""") cur.execute("""DELETE FROM "BUDGET".evaluate_form WHERE created_by='test.verify';""") conn.commit() conn.close() EOF ``` Stop the local `dotnet run` process. - [ ] **Step 8: Commit** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api git add Modules/EvaluateFormReview/Services/EvaluateFormReview.cs Modules/EvaluateFormReview/Controllers/EvaluateFormReview.cs Startup.cs git commit -m "feat(evaluate_form_review): add central-review status/dashboard/return_for_edit/mark_reviewed endpoints" ``` Backend work is complete after this task. Push the branch and merge into `master` per this project's normal flow before starting frontend tasks, so the frontend tasks can build against the real deployed (or at least locally running) API. --- ## Task 10: "กำหนดผู้อนุมัติ" button on evaluate-collect-send **Files:** - Modify: `src/app/core/service/evaluate/evaluate-form.service.ts` - Modify: `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.ts` - Modify: `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html` **Interfaces:** - Produces: `EvaluateFormService.setApprovers(id, approvers)`, `EvaluateCollectSendComponent.onSetApprovers()`. - Consumes: `SetApproversDialogComponent` (`src/app/shared/components/set-approvers-dialog/set-approvers-dialog.component.ts`) — unchanged. - [ ] **Step 1: Add the service method** Edit `src/app/core/service/evaluate/evaluate-form.service.ts`: ```typescript import {HttpClient} from '@angular/common/http'; import {Injectable} from '@angular/core'; import {BaseService} from '../../base/base-service'; @Injectable({ providedIn: 'root' }) export class EvaluateFormService extends BaseService { constructor( public http: HttpClient ) { super('/evaluate/evaluate_form', http); } setApprovers(id: string, approvers: { personnel_uid: string, display_name_th: string }[]) { return this.post(`set_approvers/${id}`, approvers); } sendApprove(id: string) { return this.post(`send_approve/${id}`, {}); } } ``` (Check `BaseService.post()`'s signature in `src/app/core/base/base-service.ts` before writing this — if it doesn't build a URL the same way `RequestExpenseService`'s raw `this.http.post` calls do, use the same `this.http.post` pattern instead, pointed at `${this.fullUrl}/set_approvers/${id}` / `${this.fullUrl}/send_approve/${id}`.) - [ ] **Step 2: Add the dialog wiring to the component** Edit `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.ts`, add imports: ```typescript import {SetApproversDialogComponent} from '../../../../shared/components/set-approvers-dialog/set-approvers-dialog.component'; import Swal from 'sweetalert2'; ``` Add the method (near `onSend`): ```typescript onSetApprovers() { const existing = (this.dataForm.approve_states || []).slice().sort((a, b) => a.row_order - b.row_order); const ref = this.dialog.open(SetApproversDialogComponent, { width: '600px', disableClose: true, data: {approvers: existing}, }); ref.afterClosed().subscribe((approvers) => { if (!approvers) return; this.evaluateFormService.setApprovers(this.uid, approvers).subscribe( () => { Swal.fire({title: 'บันทึกผู้อนุมัติสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง'}).then(() => this.getData()); }, (err) => { Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error'); } ); }); } ``` This relies on `dataForm.approve_states` and `dataForm.is_sent`/`is_approve` being present on the entity returned by `GET /evaluate/evaluate_form/{uid}` — Task 2 already wired `evaluate_form.approve_states : List` (via `[Include]` + the `HasOne`/`WithMany` in `Modules/Setting/Databases/Db.cs`), so no further backend change is needed here. - [ ] **Step 3: Add the button to the template** Edit `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html`, add before the existing `onSend()` button (around line 306): ```html ``` - [ ] **Step 4: Build** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-web ng build ``` Expected: compiles with no new errors. - [ ] **Step 5: Manual browser walkthrough** Start the app (`npm run start` or `ng serve`), navigate to `/app/research-and-evaluation/evaluate-collect`, open an existing (or newly created) form's detail page. Click "กำหนดผู้อนุมัติ" — the dialog opens with the two approver fields (level 1 required, level 2 optional), pick a level-1 approver from the autocomplete, click save. Expected: success toast, and re-loading the page shows the button state doesn't change yet (that's Task 11/12) but no console errors. - [ ] **Step 6: Commit** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-web git add src/app/core/service/evaluate/evaluate-form.service.ts src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.ts src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html git commit -m "feat(evaluate-collect): add กำหนดผู้อนุมัติ button" ``` --- ## Task 11: Replace "ส่งผล" with "ส่งอนุมัติ" on evaluate-collect-send **Files:** - Modify: `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.ts` - Modify: `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html` - [ ] **Step 1: Replace `onSend()`** Edit `evaluate-collect-send.component.ts`, replace the existing `onSend()` method: ```typescript async onSend() { const confirmed = await this.sweetalertService.confirm('ต้องการส่งอนุมัติหรือไม่', 'ส่งอนุมัติ').toPromise(); if (!confirmed?.isConfirmed) return; try { await this.evaluateFormService.put(this.dataForm).toPromise(); await this.evaluateFormService.sendApprove(this.uid).toPromise(); this.sweetalertService.saveSuccess(); this.onCancel(); } catch (err) { this.sweetalertService.errText(err.error?.description || 'เกิดข้อผิดพลาด'); } } ``` (Check `SweetalertService.confirm()`'s exact signature used elsewhere in this component — it's already called with `(message, title)` in `onSendBackAgency()` above, so the call above matches that existing convention.) - [ ] **Step 2: Update the button label and guard** Edit `evaluate-collect-send.component.html` line ~306: ```html ``` - [ ] **Step 3: Build** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-web ng build ``` Expected: compiles with no new errors. - [ ] **Step 4: Manual browser walkthrough** On the same test form from Task 10 (approvers already set), confirm "ส่งอนุมัติ" now shows (and "กำหนดผู้อนุมัติ" is still visible since `is_sent` is still falsy), click it, confirm the dialog, confirm success. Reload the page — "กำหนดผู้อนุมัติ" should now be hidden (per Task 10 Step 3's `!dataForm.is_sent` guard) and "ส่งอนุมัติ" should also be hidden (per this task's guard). - [ ] **Step 5: Commit** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-web git add src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.ts src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html git commit -m "feat(evaluate-collect): replace ส่งผล with ส่งอนุมัติ" ``` --- ## Task 12: Approve/reject-with-remark UI on evaluate-collect-send **Files:** - Create: `src/app/@common/utils/evaluate-form-approval.ts` - Modify: `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.ts` - Modify: `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html` **Interfaces:** - Produces (in `evaluate-form-approval.ts`): `isPristineApproval`, `canDefineApprovers`, `canApproveNow`, `canReject`, `approveStatusText` — same shapes as `request-expense-approval.ts` but reading `is_sent`/`is_approve`/`approve_states` directly off the `evaluate_form` entity (no `is_draft` field exists on `evaluate_form`, so every `is_draft`-based branch in the original collapses to always-false, which matches this feature's simpler pristine model from spec §3.2). - [ ] **Step 1: Write the util** Create `src/app/@common/utils/evaluate-form-approval.ts`: ```typescript /** * ตรรกะสถานะ/สิทธิ์ของ "ชุดอนุมัติ" ที่เก็บใน evaluate_form (t_approve_state เป็นคิวผู้อนุมัติ) — * เหมือน request-expense-approval.ts แต่ evaluate_form ไม่มี is_draft, ใช้แค่ is_sent/is_approve * ดู docs/superpowers/specs/2026-09-24-evaluate-collect-approve-workflow-design.md §3.2 */ export function isPristineApproval(form: any): boolean { if (!form) return false if (form.is_sent != null || form.is_approve != null) return false return !(form.approve_states || []).some((s: any) => s.is_approve == true) } export function canDefineApprovers(form: any): boolean { return isPristineApproval(form) } export function wasJustRejected(form: any): boolean { return form?.is_sent === false && form?.is_approve === false } export function canAnyoneSend(form: any): boolean { if (!form) return false if (form.is_approve == true) return false if (form.is_sent == true) return false const pending = (form.approve_states || []).find((s: any) => s.is_state == true) return !!pending && pending.row_order == 1 } export function canApproveRow1(form: any, myName: string): boolean { if (!form) return false if (form.is_approve == true) return false if (form.is_sent != true) return false const pending = (form.approve_states || []).find((s: any) => s.is_state == true) if (!pending || pending.row_order != 1) return false return !!myName && myName === pending.display_name_th } export function canApproveNow(form: any, myName: string): boolean { // ไม่ใช้ canApproveRow1 + OR แบบเดิม เพราะ is_state/row_order เป็นฟิลด์ของแถว approve_state // แต่ละแถว ไม่ใช่ฟิลด์บน form เอง — เช็คจากแถวที่ is_state==true ตรงๆ ครอบคลุมทุก row_order // (ดู docs/superpowers/specs/2026-09-24-evaluate-collect-approve-workflow-design.md §6) if (!form) return false if (form.is_approve == true) return false if (form.is_sent != true) return false const pending = (form.approve_states || []).find((s: any) => s.is_state == true) return !!pending && !!myName && myName === pending.display_name_th } export function canReject(form: any, myName: string): boolean { return canApproveNow(form, myName) } export function approveStatusText(form: any): string { if (!form) return 'ยังไม่ได้กำหนดผู้อนุมัติ' if (form.is_approve == true) return 'อนุมัติแล้ว' const pending = (form.approve_states || []).find((s: any) => s.is_state == true) if (!pending) return 'ยังไม่ได้กำหนดผู้อนุมัติ' if (pending.row_order > 1) return `ส่งอนุมัติแล้ว รอ${pending.display_name_th}เป็นผู้อนุมัติ` if (wasJustRejected(form)) { return form.is_sent == true ? 'ถูกส่งแก้ไขจากลำดับถัดไป รอแก้ไขแล้วให้ผู้อนุมัติกด "อนุมัติ" อีกครั้ง' : 'ถูกส่งแก้ไข กรุณาแก้ไขข้อมูลแล้วส่งอนุมัติใหม่' } if (form.is_sent == true) return `ส่งอนุมัติแล้ว รอ${pending.display_name_th}เป็นผู้อนุมัติ` return 'ยังไม่ได้ส่งอนุมัติ' } ``` - [ ] **Step 2: Wire approve/reject into the component** Edit `evaluate-collect-send.component.ts`, add imports: ```typescript import {canApproveNow, canReject} from '../../../../@common/utils/evaluate-form-approval'; import {RequestExpenseService} from '../../../../core/service/request/request-expense.service'; import {RemarkHistoryService} from '../../../../core/service/request/remark-history.service'; import {AppTokenService} from '../../../../core/service/security/app-token.service'; ``` Inject `RemarkHistorySV: RemarkHistoryService` and `AppTokenSV: AppTokenService` in the constructor (add as new constructor params). Note: approve/reject themselves reuse the **generic** `/setting/approve_state/approve|reject/{uid}` endpoints — `RequestExpenseService.approve(id)`/`.reject(id)` already point at those generic paths regardless of entity type (they don't touch `/request/request_expense/...`), so it is safe and correct to reuse `RequestExpenseService` here purely for those two generic calls rather than adding duplicate methods to `EvaluateFormService`. Add the methods: ```typescript isCurrentApprover(): boolean { const currentDisplayName = this.AppTokenSV.payload?.display; return canApproveNow(this.dataForm, currentDisplayName); } canRejectNow(): boolean { const currentDisplayName = this.AppTokenSV.payload?.display; return canReject(this.dataForm, currentDisplayName); } onApprove() { Swal.fire({ icon: 'warning', title: 'ต้องการอนุมัติหรือไม่', showCancelButton: true, confirmButtonText: 'อนุมัติ', cancelButtonText: 'ยกเลิก', }).then((result) => { if (!result.isConfirmed) return; this.requestExpenseService.approve(this.uid).subscribe( () => Swal.fire({title: 'อนุมัติสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง'}).then(() => this.getData()), (err) => Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error') ); }); } async onReject() { const {value: remark} = await Swal.fire({ input: 'textarea', inputLabel: 'รายละเอียดที่ส่งแก้ไข', inputPlaceholder: 'กรอกรายละเอียดที่ส่งแก้ไข...', width: 1200, showCancelButton: true, confirmButtonText: 'ส่งแก้ไข', cancelButtonText: 'ยกเลิก', confirmButtonColor: '#d33', inputValidator: (v) => !v ? 'กรุณาระบุรายละเอียดที่ต้องแก้ไข' : null, }); if (!remark) return; this.remarkHistoryService.add({ evaluate_form_uid: this.uid, remark_history_name_th: remark, updated_name: this.AppTokenSV.payload?.display, updated_date: new Date(), }).pipe( concatMap(() => this.requestExpenseService.reject(this.uid)) ).subscribe( () => Swal.fire({title: 'ส่งแก้ไขสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง'}).then(() => this.getData()), (err) => Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error') ); } ``` Add `import {concatMap} from 'rxjs/operators';` and rename the injected `RequestExpenseService`/`RemarkHistoryService` instances to match whatever casing convention this file already uses for its other injected services (the constructor list above uses `public evaluateFormService`, `public hrAgencyService` — lowercase-first; follow that, i.e. `private requestExpenseService: RequestExpenseService` and `private remarkHistoryService: RemarkHistoryService`, and update the method bodies above to match). - [ ] **Step 3: Add buttons + remark history table to the template** Edit `evaluate-collect-send.component.html`, add near the existing action buttons (before the "ส่งอนุมัติ" button from Task 11): ```html ``` Add a remark history table near the bottom of the form, gated on `dataForm.remark_historys?.length` (mirrors `currentRound4RemarkHistorys()`'s table in `request-budget.container.html`, simplified since `evaluate_form` has no `round_no` concept): ```html
วันที่รายละเอียดผู้บันทึก
{{r.updated_date | thaiDate}} {{r.remark_history_name_th}} {{r.updated_name}}
``` This relies on the `GET /evaluate/evaluate_form/{uid}` response including a `remark_historys` navigation collection — Task 2 already wired `evaluate_form.remark_historys : List` (via `[Include]` + the `HasOne`/`WithMany` in `Modules/Requests/Databases/Db.cs`), so no further backend change is needed here. - [ ] **Step 4: Build** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-web ng build ``` Expected: compiles with no new errors. - [ ] **Step 5: Manual browser walkthrough** Using the test form from Tasks 10-11 (already sent for approval), log in as the level-1 approver (matching `display_name` claim) and confirm the "อนุมัติ"/"ส่งแก้ไข" buttons appear on evaluate-collect-send; log in as a different user and confirm neither button appears. Then, on a **second** test form with both approvers configured, log in as the level-1 approver and click "อนุมัติ" — reload as the level-2 approver ("รองอธิการบดี") and confirm "อนุมัติ"/"ส่งแก้ไข" now appear for them too (this is the case the row_order>1 branch of `canApproveNow`/`canReject` must get right — verify it explicitly, don't skip straight to level 1 only). Click "ส่งแก้ไข" as the level-2 approver, fill the remark, confirm it appears in the remark-history table on reload. - [ ] **Step 6: Commit** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-web git add src/app/@common/utils/evaluate-form-approval.ts src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.ts src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html git commit -m "feat(evaluate-collect): add approve/ส่งแก้ไข UI + remark history table" ``` --- ## Task 13: Disable "ส่งกลับหน่วยงาน" while pending approval **Files:** - Modify: `src/app/feature/research-and-evaluation/evaluate-collect/send/evaluate-collect-send.component.html` - [ ] **Step 1: Add the guard** Edit the existing "ส่งกลับหน่วยงาน" button (line ~303): ```html ``` Create `evaluate-form-review-status.component.scss` with this exact content (copied verbatim from `request-budget-review-status.component.scss`): ```scss .review-status-card { background: #ffffff; border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); padding: 1rem 1.5rem; margin-bottom: 1rem; } .status-row { display: flex; align-items: center; gap: 8px; margin-bottom: 0.75rem; } .status-label { font-size: 13px; font-weight: 600; color: #37474f; } .status-badge { font-size: 13px; font-weight: 600; padding: 4px 12px; border-radius: 16px; &.status-pending { background: #e3f2fd; color: #1565c0; } &.status-returned { background: #fff3e0; color: #e65100; } &.status-reviewed { background: #e8f5e9; color: #2e7d32; } &.status-not_ready { background: #eceff1; color: #607d8b; } } .revision-table-wrapper { overflow-x: auto; margin-bottom: 0.75rem; } .revision-table-header { display: flex; align-items: center; gap: 4px; padding: 6px 0; font-size: 13px; font-weight: 600; color: #37474f; cursor: pointer; user-select: none; &:hover { color: #0C7469; } } .revision-table-header__chevron { font-size: 18px; transition: transform 0.15s ease; &--open { transform: rotate(90deg); } } .revision-table { width: 100%; border-collapse: collapse; font-size: 13px; th, td { padding: 6px 8px; border-bottom: 1px solid #eceff1; text-align: left; } th { background: linear-gradient(135deg, #118F82 0%, #0C7469 100%); color: #ffffff; font-weight: 600; } } .status-actions { display: flex; gap: 8px; } .btn-mark-reviewed { background-color: #2e7d32 !important; color: #ffffff !important; &:hover { background-color: #27682b !important; } } ``` - [ ] **Step 3: Declare in `research-and-evaluation.module.ts`** Edit `src/app/feature/research-and-evaluation/research-and-evaluation.module.ts`: import `EvaluateFormReviewStatusComponent` from its new path and add it to the module's `declarations` array (follow the exact pattern already used there for `EvaluateCollectSendComponent`/`EvaluateFollowSendComponent` — same module, same array). - [ ] **Step 4: Replace `onSendBack()` in evaluate-follow-send** Edit `evaluate-follow-send.component.ts`: delete the `onSendBack()` method entirely (and the now-unused `loadComments()`/`loadHistory()`/`commentHistory` if nothing else in the component reads `follow_comment`/`commentHistory` — check the `.html` template for other usages of `follow_comment`/`commentHistory` before deleting; if the template still displays historical `follow_comment` values read-only elsewhere, keep `loadComments()`/`loadHistory()` and only delete `onSendBack()`). - [ ] **Step 5: Add the component to the template** Edit `evaluate-follow-send.component.html`, replace the "ส่งกลับแก้ไข" button block (line ~313-315) with: ```html ``` - [ ] **Step 6: Build** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-web ng build ``` Expected: compiles with no new errors. - [ ] **Step 7: Manual browser walkthrough** Using the fully-approved test form (`is_approve=true` from Task 9's happy-path verification, or run a fresh one through the full chain via the UI), navigate to its evaluate-follow-send detail page. Confirm the status card shows "รอตรวจสอบ" with "ส่งแก้ไข"/"ตรวจสอบแล้ว" buttons. Click "ส่งแก้ไข", fill a remark, confirm status flips to "ถูกส่งแก้ไข รอผู้รวบรวมข้อมูลแก้ไข" and the old "ส่งกลับแก้ไข" button/behavior is gone. On a different form still `pending`, click "ตรวจสอบแล้ว" and confirm status flips to "ตรวจสอบแล้ว". - [ ] **Step 8: Commit** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-web git add src/app/core/service/evaluate/evaluate-form-review.service.ts src/app/feature/research-and-evaluation/evaluate-follow/presenter/evaluate-form-review-status src/app/feature/research-and-evaluation/research-and-evaluation.module.ts src/app/feature/research-and-evaluation/evaluate-follow/send/evaluate-follow-send.component.ts src/app/feature/research-and-evaluation/evaluate-follow/send/evaluate-follow-send.component.html git commit -m "feat(evaluate-follow): replace ส่งกลับแก้ไข with ส่งแก้ไข/ตรวจสอบแล้ว review-status card" ``` --- ## Task 15: Status buckets on evaluate-follow-index **Files:** - Modify: `src/app/feature/research-and-evaluation/evaluate-follow/index/evaluate-follow-index.component.ts` - Modify: `src/app/feature/research-and-evaluation/evaluate-follow/index/evaluate-follow-index.component.html` **Interfaces:** - Consumes: `EvaluateFormReviewService.getDashboard()` (from Task 14). - [ ] **Step 1: Load the dashboard alongside the existing list** Edit `evaluate-follow-index.component.ts`, add the service injection and a field: ```typescript import {EvaluateFormReviewService} from '../../../../core/service/evaluate/evaluate-form-review.service'; ``` ```typescript reviewDashboard: any = {pending: [], returned: [], reviewed: []}; ``` Add `private evaluateFormReviewService: EvaluateFormReviewService,` to the constructor, and load it in `ngOnInit`: ```typescript async ngOnInit() { this.select.budgetYear = await this.budgetYearService.getAll().toPromise(); await this.getData(); this.reviewDashboard = await this.evaluateFormReviewService.getDashboard().toPromise(); } ``` - [ ] **Step 2: Add a status column to the table** Edit `evaluate-follow-index.component.ts`'s `getData()` to attach each row's status from the dashboard (matched by `evaluate_form_uid`): ```typescript async getData($event?) { const url = `agency_evaluate/final_active`; const dataSource = await this.evaluateFormService.query>(this.setUrl(url, $event)).toPromise(); if (!this._markedSeen) { this.menuBadgeService.markSeen('evaluate-follow', dataSource?.row_count ?? 0); this._markedSeen = true; } this.dataSource = this.setDataSource(dataSource); const reviewDashboard: any = await this.evaluateFormReviewService.getDashboard().toPromise(); const statusByUid: {[key: string]: string} = {}; (reviewDashboard.pending || []).forEach((r: any) => statusByUid[r.evaluate_form_uid] = 'pending'); (reviewDashboard.returned || []).forEach((r: any) => statusByUid[r.evaluate_form_uid] = 'returned'); (reviewDashboard.reviewed || []).forEach((r: any) => statusByUid[r.evaluate_form_uid] = 'reviewed'); this.dataSource.map((item: IEvaluateFormResult) => { item.budget_year_name_th = this.select.budgetYear.find((f) => f.budget_year_uid === item.budget_year_uid).budget_year_name_th; (item as any).review_status = statusByUid[(item as any).evaluate_form_uid] || 'not_ready'; }); } ``` (This replaces the separate `this.reviewDashboard = ...` load added in Step 1 — keep only this version in `getData()`; drop the duplicate call from `ngOnInit`.) - [ ] **Step 3: Show the status in the table** Edit `evaluate-follow-index.component.ts`, add `'review_status'` to `displayedColumns`: ```typescript displayedColumns: string[] = ['budget_year_uid', 'rounds', 'name', 'review_status', 'created_datetime', 'action']; ``` Edit `evaluate-follow-index.component.html`, add a new `matColumnDef` block (matching the existing pattern used by `'rounds'`/`'name'`) right before the `'created_datetime'` column: ```html สถานะตรวจสอบ รอตรวจสอบ ถูกส่งแก้ไข ตรวจสอบแล้ว ยังไม่ผ่านการอนุมัติ ``` - [ ] **Step 4: Build** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-web ng build ``` Expected: compiles with no new errors. - [ ] **Step 5: Manual browser walkthrough** Navigate to `/app/research-and-evaluation/evaluate-follow`, confirm the list now shows a status column, and that it matches each row's actual status (cross-check one row against its detail page from Task 14). - [ ] **Step 6: Production build (final verification for the whole frontend slice)** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-web npm run build ``` Expected: `Application bundle generation complete`, no errors (pre-existing bundle-size/CommonJS warnings are fine, per this session's earlier baseline build). - [ ] **Step 7: Commit** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-web git add src/app/feature/research-and-evaluation/evaluate-follow/index/evaluate-follow-index.component.ts src/app/feature/research-and-evaluation/evaluate-follow/index/evaluate-follow-index.component.html git commit -m "feat(evaluate-follow): show review status bucket on the list page" ``` --- ## Post-implementation Push both branches, open PRs (or merge directly per this project's usual flow — check with the user), and run the full end-to-end happy path one more time through the real UI (not curl) before considering this feature done: กำหนดผู้อนุมัติ → ส่งอนุมัติ → อนุมัติ (ลำดับ 1) → อนุมัติ (ลำดับ 2) → record appears on evaluate-follow → ส่งแก้ไข → ผู้อนุมัติลำดับสุดท้ายเห็นคิวเปิดใหม่ → อนุมัติอีกครั้ง → ตรวจสอบแล้ว.