diff --git a/docs/superpowers/plans/2026-09-07-income-budget-review-stage.md b/docs/superpowers/plans/2026-09-07-income-budget-review-stage.md new file mode 100644 index 0000000..dfe84ff --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-income-budget-review-stage.md @@ -0,0 +1,1308 @@ +# ขั้นตอนตรวจสอบส่วนกลาง (income-budget-review) 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:** เมื่อเชนอนุมัติ "ส่งอนุมัติคำขอเงินรายได้" (income-report, `request_expense`/`request_approve_type="04"`) +อนุมัติผ่านครบทุกลำดับ ให้ระบบขึ้นสถานะ "รอตรวจสอบ" ให้อัตโนมัติที่เมนู `income-budget-plan-02` (ผู้ตรวจสอบ +ส่วนกลาง กด "ส่งแก้ไข"/"ตรวจสอบแล้ว" พร้อม dashboard + ตารางประวัติ) และ `income-budget-plan-01` (ฝั่งคณะ +เห็นสถานะ) — "ส่งแก้ไข" จะเปิดคิวผู้อนุมัติคนสุดท้ายของเชนเดิมกลับมาใหม่ ไหลกลับเข้าสู่กลไก "ส่งแก้ไข"/ +"อนุมัติ" ที่ income-report มีอยู่แล้วทุกประการ + +**Architecture:** ตารางใหม่ `income_budget_review` เก็บเป็น **log/ประวัติเท่านั้น** (ไม่ใช่ state machine ของ +ตัวเอง) — สถานะจริงยังคงมาจาก `request_expense.is_draft`/`is_approve` เดิม backend เพิ่ม trigger 1 จุดใน +`ApproveState.cs` (insert log ตอนเชนอนุมัติผ่านครบ) + endpoint ใหม่ 4 ตัวใน module `IncomeBudgetReview` +frontend เพิ่ม dashboard component (reuse pattern จาก `income-report-approve-dashboard`) + status component +ใหม่ 1 ตัว (ใช้ร่วมกันทั้ง plan-01/plan-02 ต่างกันที่ปุ่ม) เสียบเข้า `RequestBudgetContainer` ที่มีอยู่แล้ว + +**Tech Stack:** ASP.NET Core 8 / EF Core / Npgsql (backend), Angular (frontend), PostgreSQL + +**Spec:** `docs/superpowers/specs/2026-09-07-income-budget-review-stage-design.md` +(อ้างอิงเพิ่ม: `docs/superpowers/specs/2026-09-07-approve-state-workflow-reference.md`) + +## Global Constraints + +- Trigger การ log "รอตรวจสอบ" ต้องยิงเฉพาะ `request_approve_type == "04"` เท่านั้น ห้ามกระทบ + `invest_asset`/`invest_construct`/`request_expense` type "01"/"02"/"03" ที่ใช้ engine เดียวกัน +- `income_budget_review` เป็นตารางประวัติ (insert-only) — ห้าม update แถวเก่า, สถานะปัจจุบันคำนวณจาก + `request_expense.is_draft`/`is_approve` + แถวล่าสุดของ log เท่านั้น (ไม่มี endpoint update) +- ผู้ตรวจสอบไม่ผูกกับคณะ/พื้นที่ — endpoint `/dashboard` และ `/status/{uid}` ไม่กรองตามผู้ใช้ที่ล็อกอิน + (`[Authorize]` เฉยๆ พอ — สิทธิ์เข้าเมนูควบคุมที่ระดับเมนู/route อยู่แล้วตามระบบเดิม) +- โค้ดเบสนี้ไม่มี backend unit test project (`.Tests.csproj`) และ frontend spec ที่มีอยู่เป็น boilerplate + ที่ไม่เคยรันจริง — ทุก task ใช้วิธี verify แบบเดียวกับที่ใช้ได้ผลจริงตลอด session นี้: `dotnet build` + (backend) / `ng build` (frontend) ให้ผ่านไม่มี error ก่อน แล้ว verify ด้วย `curl` ยิง endpoint จริงเทียบกับ + ข้อมูลจริงใน DB (query ตรงด้วย `psycopg2`) แทนการเขียน unit test ใหม่ — ตรงตามรูปแบบที่ยืนยันได้ผลมาแล้ว + ทุก feature ก่อนหน้าในเซสชันนี้ +- Deploy จริง: backend = `dotnet publish` แล้ว `scp` ไป `k8-node-1` (`/var/www/rmutr-budget-api/`) restart + `systemctl restart rmutr-budget-api`; frontend = `ng build --configuration production --base-href /budget-web/` + แล้ว `rsync` ไป `k8-node-1:/var/www/html/budget-web/` — ทั้งคู่ผ่าน VPN, credentials ใน `rmutr-api/script.sh` + และ memory `project-rmutr-budget-web` + +--- + +## Task 1: Backend — โมเดล + ตาราง + migration `income_budget_review` + +**Files:** +- Create: `rmutr-api/Modules/IncomeBudgetReview/Databases/Models/income_budget_review.cs` +- Create: `rmutr-api/Modules/IncomeBudgetReview/Databases/Db.cs` +- Modify: `rmutr-api/Databases/Db.cs` (เพิ่มเรียก `OnIncomeBudgetReviewModelCreating`) +- Create: `rmutr-api/Migrations/add_income_budget_review.sql` +- Modify: `rmutr-api/Startup.cs` (เพิ่ม `ApplyRawSqlMigration` เรียก migration ใหม่) + +**Interfaces:** +- Produces: `t_income_budget_review` (EF entity, `DbSet` บน `Db`) — fields: + `income_budget_review_uid: Guid?`, `request_expense_uid: Guid?`, `review_status_id: short` + (1=รอตรวจสอบ, 2=ส่งแก้ไข, 3=ตรวจสอบแล้ว), `remark: string`, บวก field จาก `base_table` + (`created_by`, `created_datetime`, `updated_by`, `updated_datetime`, `owner_agency_uid`, `status_id`) + +- [ ] **Step 1: สร้างโมเดล C#** + +```csharp +// rmutr-api/Modules/IncomeBudgetReview/Databases/Models/income_budget_review.cs +using System; +using System.ComponentModel.DataAnnotations; +using SeventyOneDev.Utilities; + +namespace rmutr_budget_api.Modules.IncomeBudgetReview.Databases.Models +{ + public class income_budget_review : base_table + { + [Key] public Guid? income_budget_review_uid { get; set; } + public Guid? request_expense_uid { get; set; } + /// 1 = รอตรวจสอบ, 2 = ส่งแก้ไข, 3 = ตรวจสอบแล้ว — ดูรายละเอียดที่ + /// docs/superpowers/specs/2026-09-07-income-budget-review-stage-design.md ข้อ 3 + public short review_status_id { get; set; } + public string remark { get; set; } + } + + public class t_income_budget_review : income_budget_review + { + } +} +``` + +- [ ] **Step 2: สร้าง `Db.cs` ของโมดูลนี้ (DbSet + ToTable)** + +```csharp +// rmutr-api/Modules/IncomeBudgetReview/Databases/Db.cs +using Microsoft.EntityFrameworkCore; +using rmutr_budget_api.Modules.IncomeBudgetReview.Databases.Models; + +namespace SeventyOneDev.Utilities +{ + public partial class Db : DbContext + { + public DbSet t_income_budget_review { get; set; } + + private void OnIncomeBudgetReviewModelCreating(ModelBuilder builder, string schema) + { + builder.Entity().ToTable("income_budget_review", schema); + } + } +} +``` + +- [ ] **Step 3: เรียก `OnIncomeBudgetReviewModelCreating` จากจุดกลาง** + +แก้ `rmutr-api/Databases/Db.cs` — ในเมธอด `OnApplicationModelCreating` เพิ่มบรรทัดสุดท้าย: + +```csharp + OnIncomeBudgetReportModelCreating(builder,schema); + OnIncomeBudgetReviewModelCreating(builder,schema); // เพิ่มบรรทัดนี้ + } +``` + +- [ ] **Step 4: เขียน migration SQL (idempotent เหมือนไฟล์อื่นในโฟลเดอร์เดียวกัน)** + +```sql +-- rmutr-api/Migrations/add_income_budget_review.sql +-- Migration: สร้างตาราง income_budget_review — เก็บ log การตรวจสอบส่วนกลางของคำขอเงินรายได้ +-- (request_expense, request_approve_type="04") หลังเชนอนุมัติผ่านครบ ดู +-- docs/superpowers/specs/2026-09-07-income-budget-review-stage-design.md +-- Schema placeholder {0} replaced at runtime by default_schema (e.g. BUDGET). +-- Idempotent: safe to run on every startup. + +CREATE TABLE IF NOT EXISTS "{0}".income_budget_review ( + income_budget_review_uid uuid PRIMARY KEY, + request_expense_uid uuid, + review_status_id smallint NOT NULL, + remark text, + status_id smallint, + created_by text, + created_datetime timestamptz, + updated_by text, + updated_datetime timestamptz, + owner_agency_uid uuid +); + +CREATE INDEX IF NOT EXISTS ix_income_budget_review_request_expense_uid + ON "{0}".income_budget_review (request_expense_uid); +``` + +- [ ] **Step 5: ลงทะเบียน migration ใน `Startup.cs`** + +แก้ `rmutr-api/Startup.cs` — เพิ่มบรรทัดต่อจาก `add_faculty_uid_to_budget_income.sql` (ประมาณบรรทัด 486): + +```csharp + ApplyRawSqlMigration(dbContext, setting.Value.default_schema, "add_faculty_uid_to_budget_income.sql"); + ApplyRawSqlMigration(dbContext, setting.Value.default_schema, "add_income_budget_review.sql"); +``` + +- [ ] **Step 6: Build ตรวจสอบว่าไม่มี compile error** + +Run: `cd rmutr-api && dotnet build -v q` +Expected: `0 Error(s)` (มี warning เดิมได้ ไม่เกี่ยวกับ task นี้) + +- [ ] **Step 7: Commit** + +```bash +cd rmutr-api +git add Modules/IncomeBudgetReview/Databases/Models/income_budget_review.cs \ + Modules/IncomeBudgetReview/Databases/Db.cs Databases/Db.cs \ + Migrations/add_income_budget_review.sql Startup.cs +git commit -m "feat: add income_budget_review table for central review log + +Co-Authored-By: Claude Sonnet 5 " +``` + +--- + +## Task 2: Backend — Auto-trigger "รอตรวจสอบ" ตอนเชนอนุมัติผ่านครบ + +**Files:** +- Modify: `rmutr-api/Modules/Setting/Controllers/ApproveState.cs` + +**Interfaces:** +- Consumes: `t_income_budget_review` (Task 1) +- Produces: ทุกครั้งที่ `request_expense.is_approve` ถูกตั้งเป็น `true` (ทั้งครั้งแรกและหลังถูกส่งแก้ไขจาก + ส่วนกลางแล้วอนุมัติผ่านใหม่) และ `request_approve_type == "04"` จะมี `t_income_budget_review` แถวใหม่ + `review_status_id = 1` เกิดขึ้นเสมอ + +- [ ] **Step 1: หาจุด insert ใน `ApproveGetEntity` (branch `request_expense`)** + +เปิด `rmutr-api/Modules/Setting/Controllers/ApproveState.cs` หา block นี้ (อยู่ในเมธอด +`ApproveGetEntity`, branch `if (request_expense != null)`): + +```csharp + 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); + } +``` + +- [ ] **Step 2: แก้ไข block นี้ให้ log "รอตรวจสอบ" เมื่อ type เป็น "04"** + +แทนที่ block ด้านบนด้วย: + +```csharp + 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); + + // เชนอนุมัติผ่านครบแล้ว (ทั้งครั้งแรกและหลังถูกส่งแก้ไขจากการตรวจสอบส่วนกลาง + // แล้วอนุมัติผ่านใหม่) — ส่งเข้าสู่ขั้นตอนตรวจสอบส่วนกลางโดยอัตโนมัติ เฉพาะ code "04" + // (สรุปคำของบประมาณรายได้) เท่านั้น ดู + // docs/superpowers/specs/2026-09-07-income-budget-review-stage-design.md ข้อ 3 + if (getResult.request_approve_type == "04") + { + await _db.t_income_budget_review.AddAsync(new t_income_budget_review + { + income_budget_review_uid = Guid.NewGuid(), + request_expense_uid = uid, + review_status_id = 1, + }); + await _db.SaveChangesAsync(User.Identity as ClaimsIdentity); + } + } +``` + +- [ ] **Step 3: เพิ่ม `using` ที่หัวไฟล์** + +เปิด `rmutr-api/Modules/Setting/Controllers/ApproveState.cs` เพิ่มบรรทัด using (หลัง using เดิมบรรทัดที่ 18): + +```csharp +using rmutr_budget_api.Modules.IncomeBudgetReview.Databases.Models; +``` + +- [ ] **Step 4: Build** + +Run: `cd rmutr-api && dotnet build -v q` +Expected: `0 Error(s)` + +- [ ] **Step 5: Commit** + +```bash +cd rmutr-api +git add Modules/Setting/Controllers/ApproveState.cs +git commit -m "feat: auto-log income_budget_review pending row on chain completion + +Co-Authored-By: Claude Sonnet 5 " +``` + +--- + +## Task 3: Backend — Service + Controller (search/dashboard/return_for_edit/mark_reviewed) + +**Files:** +- Create: `rmutr-api/Modules/IncomeBudgetReview/Services/IncomeBudgetReview.cs` +- Create: `rmutr-api/Modules/IncomeBudgetReview/Controllers/IncomeBudgetReview.cs` +- Create: `rmutr-api/Modules/IncomeBudgetReview/Configures/IncomeBudgetReview.cs` +- Modify: `rmutr-api/Startup.cs` (ลงทะเบียน service) + +**Interfaces:** +- Consumes: `t_income_budget_review`/`Db` (Task 1), `t_request_expense`, `t_approve_state`, + `t_remark_history` (มีอยู่แล้ว — ดู [[2026-09-07-approve-state-workflow-reference]]) +- Produces: + - `GET /api/income_budget_review/status/{request_expense_uid}` → `income_budget_review_status_response` + `{ request_expense_uid, current_status: "not_ready"|"pending"|"returned"|"reviewed", history: [{ datetime, by, detail }] }` + - `GET /api/income_budget_review/dashboard` → `income_budget_review_dashboard` + `{ pending: [...], returned: [...], reviewed: [...] }` แต่ละรายการเป็น + `{ request_expense_uid, budget_year_uid, budget_year_name_th, faculty_uid, faculty_name_th, + budget_location_uid, budget_location_name_th, updated_datetime }` + - `POST /api/income_budget_review/return_for_edit/{request_expense_uid}` body `{ remark: string }` + → `200 OK` หรือ `400 { description }` + - `POST /api/income_budget_review/mark_reviewed/{request_expense_uid}` → `200 OK` หรือ `400 { description }` + +- [ ] **Step 1: เขียน DTO + Service** + +```csharp +// rmutr-api/Modules/IncomeBudgetReview/Services/IncomeBudgetReview.cs +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.IncomeBudgetReview.Databases.Models; +using rmutr_budget_api.Modules.Request.Databases.Models; +using SeventyOneDev.Utilities; + +namespace rmutr_budget_api.Modules.IncomeBudgetReview.Services +{ + public class income_budget_review_history_item + { + public DateTime? datetime { get; set; } + public string by { get; set; } + public string detail { get; set; } + } + + public class income_budget_review_status_response + { + public Guid? request_expense_uid { get; set; } + public string current_status { get; set; } + public List history { get; set; } = new(); + } + + public class income_budget_review_dashboard_item + { + public Guid? request_expense_uid { get; set; } + public Guid? budget_year_uid { get; set; } + public string budget_year_name_th { get; set; } + public Guid? faculty_uid { get; set; } + public string faculty_name_th { get; set; } + public Guid? budget_location_uid { get; set; } + public string budget_location_name_th { get; set; } + public DateTime? updated_datetime { get; set; } + } + + public class income_budget_review_dashboard + { + public List pending { get; set; } = new(); + public List returned { get; set; } = new(); + public List reviewed { get; set; } = new(); + } + + public class IncomeBudgetReviewService + { + private readonly Db _context; + + public IncomeBudgetReviewService(Db context) + { + _context = context; + } + + /// + /// สถานะปัจจุบันของเรื่องหนึ่งๆ ตามกฎใน design doc ข้อ 3: + /// is_draft+is_approve==false ของ request_expense เองเป็นความจริงเสมอ (ไม่ต้องพึ่ง log) — + /// ใช้ log income_budget_review ล่าสุดแค่ตอน is_approve==true เพื่อแยก "รอตรวจสอบ"/"ตรวจสอบแล้ว" + /// + private string ComputeStatus(t_request_expense entity, t_income_budget_review latestLog) + { + if (entity.is_draft == true && entity.is_approve == false) return "returned"; + if (entity.is_approve == true) + { + if (latestLog != null && latestLog.review_status_id == 3) return "reviewed"; + return "pending"; + } + return "not_ready"; // ยังไม่ผ่านเชนอนุมัติภายในครบทุกลำดับ + } + + public async Task GetStatus(Guid requestExpenseUid) + { + var entity = await _context.t_request_expense + .FirstOrDefaultAsync(c => c.request_expense_uid == requestExpenseUid); + if (entity == null) return null; + + var logs = await _context.t_income_budget_review.AsNoTracking() + .Where(c => c.request_expense_uid == requestExpenseUid) + .OrderByDescending(c => c.created_datetime) + .ToListAsync(); + var latestLog = logs.FirstOrDefault(); + + var remarks = await _context.t_remark_history.AsNoTracking() + .Where(c => c.request_expense_uid == requestExpenseUid) + .OrderByDescending(c => c.updated_date) + .ToListAsync(); + + var history = remarks + .Select(r => new income_budget_review_history_item + { + datetime = r.updated_date, + by = r.updated_name, + detail = r.remark_history_name_th, + }) + .OrderByDescending(h => h.datetime) + .ToList(); + + return new income_budget_review_status_response + { + request_expense_uid = requestExpenseUid, + current_status = ComputeStatus(entity, latestLog), + history = history, + }; + } + + public async Task GetDashboard() + { + var result = new income_budget_review_dashboard(); + + var reviewedOnceIds = await _context.t_income_budget_review.AsNoTracking() + .Select(r => r.request_expense_uid).Distinct().ToListAsync(); + if (!reviewedOnceIds.Any()) return result; + + var candidates = await _context.v_request_expense.AsNoTracking() + .Where(e => e.type_id == 2 && reviewedOnceIds.Contains(e.request_expense_uid)) + .ToListAsync(); + var allLogs = await _context.t_income_budget_review.AsNoTracking() + .Where(r => reviewedOnceIds.Contains(r.request_expense_uid)) + .ToListAsync(); + var latestLogByRequest = allLogs + .GroupBy(r => r.request_expense_uid) + .ToDictionary(g => g.Key, g => g.OrderByDescending(r => r.created_datetime).First()); + + var years = await _context.v_budget_year.AsNoTracking() + .ToDictionaryAsync(c => c.budget_year_uid, c => c.budget_year_name_th); + var locations = await _context.v_budget_location.AsNoTracking() + .ToDictionaryAsync(c => c.budget_location_uid, c => c.budget_location_name_th); + var faculties = await _context.v_faculty.AsNoTracking() + .ToDictionaryAsync(c => c.faculty_uid, c => c.faculty_name_th); + + income_budget_review_dashboard_item ToItem(v_request_expense e) => new() + { + request_expense_uid = e.request_expense_uid, + budget_year_uid = e.budget_year_uid, + budget_year_name_th = e.budget_year_uid.HasValue && years.TryGetValue(e.budget_year_uid, out var y) ? y : null, + faculty_uid = e.faculty_uid, + faculty_name_th = e.faculty_uid.HasValue && faculties.TryGetValue(e.faculty_uid, out var f) ? f : e.faculty_name_th, + budget_location_uid = e.budget_location_uid, + budget_location_name_th = e.budget_location_uid.HasValue && locations.TryGetValue(e.budget_location_uid, out var l) ? l : null, + updated_datetime = e.updated_datetime, + }; + + foreach (var e in candidates) + { + latestLogByRequest.TryGetValue(e.request_expense_uid, out var latestLog); + var status = ComputeStatus(new t_request_expense + { + is_draft = e.is_draft, + is_approve = e.is_approve, + }, latestLog); + var item = ToItem(e); + if (status == "pending") result.pending.Add(item); + else if (status == "returned") result.returned.Add(item); + else if (status == "reviewed") result.reviewed.Add(item); + } + + return result; + } + + public async Task<(bool ok, string error)> ReturnForEdit(Guid requestExpenseUid, string remark, + ClaimsIdentity claimsIdentity) + { + var entity = await _context.t_request_expense + .FirstOrDefaultAsync(c => c.request_expense_uid == requestExpenseUid); + if (entity == null) return (false, "ไม่พบรายการ"); + if (entity.is_approve != true) + return (false, "รายการนี้ยังไม่ผ่านการอนุมัติครบทุกลำดับ ไม่สามารถส่งแก้ไขจากการตรวจสอบได้"); + if (string.IsNullOrWhiteSpace(remark)) + return (false, "กรุณาระบุรายละเอียดที่ส่งแก้ไข"); + + var currentRound = await _context.t_approve_state + .Where(c => c.request_expense_uid == requestExpenseUid) + .Select(c => (int?)c.round_no).MaxAsync() ?? 1; + var lastRow = await _context.t_approve_state + .Where(c => c.request_expense_uid == requestExpenseUid && c.round_no == currentRound) + .OrderByDescending(c => c.row_order) + .FirstOrDefaultAsync(); + if (lastRow == null) return (false, "ไม่พบผู้อนุมัติของรายการนี้"); + + lastRow.is_state = true; + lastRow.is_approve = null; + _context.t_approve_state.Update(lastRow); + + entity.is_draft = true; + entity.is_approve = false; + entity.current_approve_name = lastRow.display_name_th; + if (lastRow.row_order == 1) entity.is_sent = false; + _context.t_request_expense.Update(entity); + + await _context.t_remark_history.AddAsync(new t_remark_history + { + remark_history_uid = Guid.NewGuid(), + request_expense_uid = requestExpenseUid, + remark_history_name_th = remark, + }); + + await _context.t_income_budget_review.AddAsync(new t_income_budget_review + { + income_budget_review_uid = Guid.NewGuid(), + request_expense_uid = requestExpenseUid, + review_status_id = 2, + remark = remark, + }); + + await _context.SaveChangesAsync(claimsIdentity); + return (true, null); + } + + public async Task<(bool ok, string error)> MarkReviewed(Guid requestExpenseUid, ClaimsIdentity claimsIdentity) + { + var entity = await _context.t_request_expense + .FirstOrDefaultAsync(c => c.request_expense_uid == requestExpenseUid); + if (entity == null) return (false, "ไม่พบรายการ"); + if (entity.is_approve != true) + return (false, "รายการนี้ยังไม่ผ่านการอนุมัติครบทุกลำดับ"); + + var latestLog = await _context.t_income_budget_review.AsNoTracking() + .Where(c => c.request_expense_uid == requestExpenseUid) + .OrderByDescending(c => c.created_datetime) + .FirstOrDefaultAsync(); + if (latestLog == null || latestLog.review_status_id != 1) + return (false, "รายการนี้ไม่ได้อยู่ในสถานะรอตรวจสอบ"); + + await _context.t_income_budget_review.AddAsync(new t_income_budget_review + { + income_budget_review_uid = Guid.NewGuid(), + request_expense_uid = requestExpenseUid, + review_status_id = 3, + }); + await _context.SaveChangesAsync(claimsIdentity); + return (true, null); + } + } +} +``` + +- [ ] **Step 2: เขียน Controller** + +```csharp +// rmutr-api/Modules/IncomeBudgetReview/Controllers/IncomeBudgetReview.cs +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.IncomeBudgetReview.Services; + +namespace rmutr_budget_api.Modules.IncomeBudgetReview.Controllers +{ + public class return_for_edit_input + { + public string remark { get; set; } + } + + [Route("api/income_budget_review", Name = "income_budget_review")] + public class IncomeBudgetReviewController : Controller + { + private readonly IncomeBudgetReviewService _service; + + public IncomeBudgetReviewController(IncomeBudgetReviewService service) + { + _service = service; + } + + [Authorize] + [HttpGet("status/{request_expense_uid:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetStatus( + [FromRoute] Guid request_expense_uid) + { + var result = await _service.GetStatus(request_expense_uid); + return result == null ? NotFound() : Ok(result); + } + + [Authorize] + [HttpGet("dashboard")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetDashboard() + { + return Ok(await _service.GetDashboard()); + } + + [Authorize] + [HttpPost("return_for_edit/{request_expense_uid:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task ReturnForEdit([FromRoute] Guid request_expense_uid, + [FromBody] return_for_edit_input input) + { + var result = await _service.ReturnForEdit(request_expense_uid, input?.remark, + User.Identity as ClaimsIdentity); + if (!result.ok) return BadRequest(new { description = result.error }); + return Ok(); + } + + [Authorize] + [HttpPost("mark_reviewed/{request_expense_uid:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task MarkReviewed([FromRoute] Guid request_expense_uid) + { + var result = await _service.MarkReviewed(request_expense_uid, User.Identity as ClaimsIdentity); + if (!result.ok) return BadRequest(new { description = result.error }); + return Ok(); + } + } +} +``` + +- [ ] **Step 3: เขียน Configure (DI registration) แล้วเรียกจาก `Startup.cs`** + +```csharp +// rmutr-api/Modules/IncomeBudgetReview/Configures/IncomeBudgetReview.cs +using Microsoft.Extensions.DependencyInjection; +using rmutr_budget_api.Modules.IncomeBudgetReview.Services; + +namespace rmutr_budget_api.Modules.IncomeBudgetReview.Configures +{ + public static class IncomeBudgetReviewCollectionExtension + { + public static IServiceCollection AddIncomeBudgetReviewServices(this IServiceCollection services) + { + services.AddScoped(); + return services; + } + } +} +``` + +แก้ `rmutr-api/Startup.cs` เพิ่ม `using rmutr_budget_api.Modules.IncomeBudgetReview.Configures;` ที่หัวไฟล์ +แล้วเพิ่มบรรทัดต่อจาก `services.AddRequestServices();` (ประมาณบรรทัด 168): + +```csharp + services.AddRequestServices(); + services.AddIncomeBudgetReviewServices(); +``` + +- [ ] **Step 4: Build** + +Run: `cd rmutr-api && dotnet build -v q` +Expected: `0 Error(s)` + +- [ ] **Step 5: Deploy ไปเซิร์ฟเวอร์จริงเพื่อทดสอบ** + +```bash +cd rmutr-api +dotnet publish -c Release -r linux-x64 --self-contained false -o ./bin/Release/net8.0/linux-x64 +sshpass -p '@Dmin2241' scp -o StrictHostKeyChecking=no \ + ./bin/Release/net8.0/linux-x64/rmutr-budget-api.dll \ + k8-node-1@172.27.30.135:/var/www/rmutr-budget-api/rmutr-budget-api.dll +sshpass -p '@Dmin2241' ssh -o StrictHostKeyChecking=no k8-node-1@172.27.30.135 \ + "echo '@Dmin2241' | sudo -S systemctl restart rmutr-budget-api" +sleep 20 +curl -sS -o /dev/null -w "HTTP %{http_code}\n" https://rmis.rmutr.ac.th/budget-api/api/security/app_tokens_v2 +``` + +Expected: `HTTP 401` (service ขึ้นปกติ, endpoint ต้อง auth) + +- [ ] **Step 6: ทดสอบ endpoint จริงด้วย token ที่ mint เอง (วิธีเดียวกับที่ใช้ทดสอบ my_dashboard ก่อนหน้านี้)** + +```bash +python3 - <<'EOF' > /tmp/token.txt +import jwt, datetime +secret = "1234567890123456789012345678901234567890" +now = datetime.datetime.utcnow() +payload = { + "sub": "test", "user_uid": "test", "login_name": "test", "display_name": "test", + "iss": "https://rmis.rmutr.ac.th", "aud": "https://rmis.rmutr.ac.th", + "iat": now, "exp": now + datetime.timedelta(minutes=10), +} +print(jwt.encode(payload, secret, algorithm="HS256")) +EOF +TOKEN=$(cat /tmp/token.txt) +curl -sS -H "Authorization: Bearer $TOKEN" \ + "https://rmis.rmutr.ac.th/budget-api/api/income_budget_review/dashboard" -w "\nHTTP:%{http_code}\n" +``` + +Expected: `HTTP:200` กับ `{"pending":[],"returned":[],"reviewed":[]}` (ว่างเปล่าเพราะยังไม่มีเรื่องไหนผ่าน +Task 2's trigger เลยตอนนี้ — ถูกต้องแล้ว จะมีข้อมูลจริงหลัง Task 2 ถูก exercise จากการอนุมัติจริงในระบบ) + +- [ ] **Step 7: Commit** + +```bash +cd rmutr-api +git add Modules/IncomeBudgetReview/Services Modules/IncomeBudgetReview/Controllers \ + Modules/IncomeBudgetReview/Configures Startup.cs +git commit -m "feat: add income_budget_review search/dashboard/action endpoints + +Co-Authored-By: Claude Sonnet 5 " +git push origin master +``` + +--- + +## Task 4: Frontend — Angular service `IncomeBudgetReviewService` + +**Files:** +- Create: `rmutr-web/src/app/core/service/income-budget-review/income-budget-review.service.ts` + +**Interfaces:** +- Produces: `IncomeBudgetReviewService` with methods `getStatus(uid)`, `getDashboard()`, + `returnForEdit(uid, remark)`, `markReviewed(uid)` — เรียก endpoint จาก Task 3 + +- [ ] **Step 1: เขียน service** + +```typescript +// rmutr-web/src/app/core/service/income-budget-review/income-budget-review.service.ts +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root' +}) +export class IncomeBudgetReviewService { + + private base = `${environment.rmutrApi}/api/income_budget_review`; + + constructor(private http: HttpClient) { } + + getStatus(requestExpenseUid: string) { + return this.http.get(`${this.base}/status/${requestExpenseUid}`); + } + + getDashboard() { + return this.http.get(`${this.base}/dashboard`); + } + + returnForEdit(requestExpenseUid: string, remark: string) { + return this.http.post(`${this.base}/return_for_edit/${requestExpenseUid}`, { remark }); + } + + markReviewed(requestExpenseUid: string) { + return this.http.post(`${this.base}/mark_reviewed/${requestExpenseUid}`, {}); + } +} +``` + +- [ ] **Step 2: Build ตรวจสอบว่า Angular compile ผ่าน** + +Run: `cd rmutr-web && node --max_old_space_size=14336 node_modules/@angular/cli/bin/ng build --configuration production --base-href /budget-web/ 2>&1 | tail -20` +Expected: build จบโดยไม่มี error (มี warning เดิมได้) + +- [ ] **Step 3: Commit** + +```bash +cd rmutr-web +git add src/app/core/service/income-budget-review/income-budget-review.service.ts +git commit -m "feat: add IncomeBudgetReviewService for review-stage endpoints + +Co-Authored-By: Claude Sonnet 5 " +``` + +--- + +## Task 5: Frontend — Dashboard component สำหรับ `income-budget-plan-02` + +**Files:** +- Create: `rmutr-web/src/app/feature/budget-request/request-budget-plan/presenter/dashboard/income-budget-review-dashboard/income-budget-review-dashboard.component.ts` +- Create: `.../income-budget-review-dashboard.component.html` +- Create: `.../income-budget-review-dashboard.component.scss` +- Modify: `rmutr-web/src/app/feature/budget-request/request-budget-plan/request-budget-plan.module.ts` + (ประกาศ component ใหม่) + +**Interfaces:** +- Consumes: `IncomeBudgetReviewService.getDashboard()` (Task 4) +- Produces: `` emit item + `{ request_expense_uid, budget_year_uid, budget_year_name_th, faculty_uid, faculty_name_th, + budget_location_uid, budget_location_name_th }` + +- [ ] **Step 1: สร้างโฟลเดอร์และ component (โครงเดียวกับ `income-report-approve-dashboard` ที่มีอยู่แล้ว)** + +```typescript +// income-budget-review-dashboard.component.ts +import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef, EventEmitter, Output } from '@angular/core'; +import { IncomeBudgetReviewService } from 'src/app/core/service/income-budget-review/income-budget-review.service'; + +export interface ReviewDashboardItem { + request_expense_uid: string; + budget_year_uid: string; + budget_year_name_th: string; + faculty_uid: string; + faculty_name_th: string; + budget_location_uid: string; + budget_location_name_th: string; + updated_datetime: string; +} + +interface ReviewDashboardData { + pending: ReviewDashboardItem[]; + returned: ReviewDashboardItem[]; + reviewed: ReviewDashboardItem[]; +} + +const EMPTY_DATA: ReviewDashboardData = { pending: [], returned: [], reviewed: [] }; + +@Component({ + selector: 'app-income-budget-review-dashboard', + templateUrl: './income-budget-review-dashboard.component.html', + styleUrls: ['./income-budget-review-dashboard.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class IncomeBudgetReviewDashboardComponent implements OnInit { + + @Output() onselectitem = new EventEmitter(); + + isLoading = true; + data: ReviewDashboardData = EMPTY_DATA; + expandedKey: keyof ReviewDashboardData | null = null; + + constructor( + private reviewSV: IncomeBudgetReviewService, + private cd: ChangeDetectorRef, + ) { } + + ngOnInit(): void { + this.reviewSV.getDashboard().subscribe({ + next: (res: any) => { + this.data = { ...EMPTY_DATA, ...res }; + this.isLoading = false; + this.cd.markForCheck(); + }, + error: () => { + this.isLoading = false; + this.cd.markForCheck(); + } + }); + } + + get hasData(): boolean { + return this.data.pending.length > 0 || this.data.returned.length > 0 || this.data.reviewed.length > 0; + } + + toggle(key: keyof ReviewDashboardData) { + this.expandedKey = this.expandedKey === key ? null : key; + } + + selectItem(item: ReviewDashboardItem) { + this.expandedKey = null; + this.onselectitem.emit(item); + } + +} +``` + +- [ ] **Step 2: เขียน template** + +```html + +
+
+ + + +
+ +
+
+ {{item.faculty_name_th}} + {{item.budget_location_name_th}} + ปีงบ {{item.budget_year_name_th}} + {{item.updated_datetime | thaiDate}} +
+
ไม่มีรายการ
+
+
+``` + +- [ ] **Step 3: เขียน style (คัดลอกจาก `income-report-approve-dashboard.component.scss` ทั้งไฟล์)** + +```bash +cp rmutr-web/src/app/feature/income-report/presenter/dashboard/income-report-approve-dashboard/income-report-approve-dashboard.component.scss \ + rmutr-web/src/app/feature/budget-request/request-budget-plan/presenter/dashboard/income-budget-review-dashboard/income-budget-review-dashboard.component.scss +``` + +(เนื้อหา scss เดิมใช้ได้เลย — class names `.dashboard-card`, `.chip`, `.chip-warn`, `.chip-success`, +`.item-list`, `.item-row`, `.item-empty` ตรงกันกับ template ด้านบนทุกตัว) + +- [ ] **Step 4: ประกาศ component ใน module** + +แก้ `rmutr-web/src/app/feature/budget-request/request-budget-plan/request-budget-plan.module.ts` +เพิ่ม import และใส่เข้า `declarations`: + +```typescript +import { IncomeBudgetReviewDashboardComponent } from './presenter/dashboard/income-budget-review-dashboard/income-budget-review-dashboard.component'; +``` + +ใน `declarations: [...]` เพิ่ม `IncomeBudgetReviewDashboardComponent,` ต่อจาก `RequestBudgetListComponent,` + +- [ ] **Step 5: Build** + +Run: `cd rmutr-web && node --max_old_space_size=14336 node_modules/@angular/cli/bin/ng build --configuration production --base-href /budget-web/ 2>&1 | tail -20` +Expected: build ผ่าน ไม่มี error + +- [ ] **Step 6: Commit** + +```bash +cd rmutr-web +git add src/app/feature/budget-request/request-budget-plan/presenter/dashboard \ + src/app/feature/budget-request/request-budget-plan/request-budget-plan.module.ts +git commit -m "feat: add income-budget-review-dashboard component + +Co-Authored-By: Claude Sonnet 5 " +``` + +--- + +## Task 6: Frontend — Status component (badge + ตารางประวัติ + ปุ่ม) ใช้ร่วมกัน plan-01/plan-02 + +**Files:** +- Create: `rmutr-web/src/app/feature/budget-request/request-budget-plan/presenter/status/income-budget-review-status/income-budget-review-status.component.ts` +- Create: `.../income-budget-review-status.component.html` +- Create: `.../income-budget-review-status.component.scss` +- Modify: `rmutr-web/src/app/feature/budget-request/request-budget-plan/request-budget-plan.module.ts` + +**Interfaces:** +- Consumes: `IncomeBudgetReviewService.getStatus/returnForEdit/markReviewed` (Task 4) +- Produces: `` + — `showActions=true` ที่ plan-02 (โชว์ปุ่ม), `false` ที่ plan-01 (โชว์อย่างเดียว) + `(onchanged)` emit เมื่อกดปุ่มสำเร็จ ให้ container โหลดข้อมูลใหม่ + +- [ ] **Step 1: เขียน component** + +```typescript +// income-budget-review-status.component.ts +import { Component, Input, OnChanges, SimpleChanges, ChangeDetectionStrategy, ChangeDetectorRef, EventEmitter, Output } from '@angular/core'; +import { catchError, tap } from 'rxjs/operators'; +import { throwError } from 'rxjs'; +import { IncomeBudgetReviewService } from 'src/app/core/service/income-budget-review/income-budget-review.service'; +import Swal from 'sweetalert2'; + +@Component({ + selector: 'app-income-budget-review-status', + templateUrl: './income-budget-review-status.component.html', + styleUrls: ['./income-budget-review-status.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class IncomeBudgetReviewStatusComponent implements OnChanges { + + @Input() requestExpenseUid: string | null = null; + @Input() showActions = false; + @Output() onchanged = new EventEmitter(); + + isLoading = false; + status: any = null; + + statusLabel: { [key: string]: string } = { + not_ready: 'ยังไม่ผ่านการอนุมัติภายในคณะครบทุกลำดับ', + pending: 'รอตรวจสอบ', + returned: 'ถูกส่งแก้ไข รอคณะแก้ไข', + reviewed: 'ตรวจสอบแล้ว', + }; + + constructor( + private reviewSV: IncomeBudgetReviewService, + private cd: ChangeDetectorRef, + ) { } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.requestExpenseUid) { + this.load(); + } + } + + load() { + this.status = null; + if (!this.requestExpenseUid) { + this.cd.markForCheck(); + return; + } + this.isLoading = true; + this.reviewSV.getStatus(this.requestExpenseUid).pipe( + tap((res: any) => { + this.status = res; + this.isLoading = false; + this.cd.markForCheck(); + }), + catchError(err => { + this.isLoading = false; + this.cd.markForCheck(); + return throwError(err); + }) + ).subscribe(); + } + + async onReturnForEdit() { + const { value: remark } = await Swal.fire({ + input: 'textarea', + inputLabel: 'รายละเอียดที่ส่งแก้ไข', + inputPlaceholder: 'กรอกรายละเอียดที่ส่งแก้ไข...', + width: 1200, + showCancelButton: true, + confirmButtonText: 'ส่งแก้ไข', + cancelButtonText: 'ยกเลิก', + }); + if (!remark) return; + + this.reviewSV.returnForEdit(this.requestExpenseUid, remark).pipe( + catchError(err => { + Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error'); + return throwError(err); + }) + ).subscribe(() => { + Swal.fire({ title: 'ส่งแก้ไขสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง' }).then(() => { + this.load(); + this.onchanged.emit(); + }); + }); + } + + onMarkReviewed() { + Swal.fire({ + icon: 'warning', + title: 'ยืนยันว่าตรวจสอบแล้วใช่หรือไม่', + showCancelButton: true, + confirmButtonText: 'ยืนยัน', + cancelButtonText: 'ยกเลิก', + }).then((result) => { + if (!result.isConfirmed) return; + this.reviewSV.markReviewed(this.requestExpenseUid).pipe( + catchError(err => { + Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error'); + return throwError(err); + }) + ).subscribe(() => { + Swal.fire({ title: 'บันทึกสำเร็จ', icon: 'success', confirmButtonText: 'ตกลง' }).then(() => { + this.load(); + this.onchanged.emit(); + }); + }); + }); + } + +} +``` + +- [ ] **Step 2: เขียน template** + +```html + +
+
+ สถานะการตรวจสอบ: + + {{statusLabel[status.current_status]}} + +
+ +
+ + + + + + + + + + + + + + + +
วันที่รายละเอียดผู้บันทึก
{{h.datetime | thaiDate}}{{h.detail}}{{h.by}}
+
+ +
+ + +
+
+``` + +- [ ] **Step 3: เขียน style** + +```scss +// income-budget-review-status.component.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 { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th, td { + padding: 6px 8px; + border-bottom: 1px solid #eceff1; + text-align: left; + } +} + +.status-actions { + display: flex; + gap: 8px; +} +``` + +- [ ] **Step 4: ประกาศ component ใน module** + +แก้ `rmutr-web/src/app/feature/budget-request/request-budget-plan/request-budget-plan.module.ts` +เพิ่ม import และใส่เข้า `declarations`: + +```typescript +import { IncomeBudgetReviewStatusComponent } from './presenter/status/income-budget-review-status/income-budget-review-status.component'; +``` + +ใน `declarations: [...]` เพิ่ม `IncomeBudgetReviewStatusComponent,` + +- [ ] **Step 5: Build** + +Run: `cd rmutr-web && node --max_old_space_size=14336 node_modules/@angular/cli/bin/ng build --configuration production --base-href /budget-web/ 2>&1 | tail -20` +Expected: build ผ่าน ไม่มี error + +- [ ] **Step 6: Commit** + +```bash +cd rmutr-web +git add src/app/feature/budget-request/request-budget-plan/presenter/status \ + src/app/feature/budget-request/request-budget-plan/request-budget-plan.module.ts +git commit -m "feat: add income-budget-review-status component + +Co-Authored-By: Claude Sonnet 5 " +``` + +--- + +## Task 7: Frontend — เสียบเข้า `RequestBudgetContainer` (plan-01 / plan-02) + +**Files:** +- Modify: `rmutr-web/src/app/feature/budget-request/request-budget-plan/container/request-budget/request-budget.container.html` +- Modify: `rmutr-web/src/app/feature/budget-request/request-budget-plan/container/request-budget/request-budget.container.ts` + +**Interfaces:** +- Consumes: `IncomeBudgetReviewDashboardComponent` (Task 5), `IncomeBudgetReviewStatusComponent` (Task 6) +- Produces: ปุ่ม/สถานะแสดงผลจริงที่ `income-budget-plan-01` (`keyUrl==201`) และ `income-budget-plan-02` + (`keyUrl==202`) — dashboard บน plan-02 เท่านั้น, status badge ทั้งสองหน้า + +- [ ] **Step 1: เพิ่ม dashboard (เฉพาะ plan-02) เหนือฟอร์มค้นหา** + +แก้ `request-budget.container.html` เพิ่มก่อนบรรทัดแรก (`
+``` + +- [ ] **Step 2: เพิ่ม status component (ทั้งสองหน้า) หลังฟอร์มค้นหา ก่อน list** + +แก้ `request-budget.container.html` เพิ่มหลัง `` (บรรทัด 14) ก่อน +` +``` + +- [ ] **Step 3: เพิ่มเมธอด `onSelectReviewDashboardItem` ใน container** + +`RequestBudgetContainer.onClickSearch(query)` (มีอยู่แล้ว) รับ query object อิสระได้เลยโดยไม่ต้องมาจาก +`RequestBudgetSearchComponent` เสมอไป (แค่ต้องมี `budget_year_uid`/`faculty_uid`/`budget_location_uid` +เป็นอย่างน้อยตามที่ keyUrl==202 branch ต้องการ) — เรียกตรงได้เลยแบบเดียวกับที่ income-report ทำกับ +dashboard ของตัวเอง (`IncomeReportContainer.onSelectDashboardItem`) เพียงแต่รอบนี้**ไม่ต้อง sync ค่า +กลับเข้าฟอร์มค้นหา** (`RequestBudgetSearchComponent` ไม่มี mechanism รับค่าจากภายนอกแบบ +`presetQuery` ในตอนนี้ — ฟอร์มค้นหาจะยังว่างอยู่ แต่ผลลัพธ์ด้านล่างจะแสดงเรื่องที่ถูกต้อง เป็นเรื่องเข้าใจได้ +ไม่ต้องแก้ component นั้นเพิ่มในรอบนี้): + +แก้ `request-budget.container.ts` เพิ่มเมธอดใหม่ต่อจาก `getApprove()` (หรือจุดใดก็ได้ในคลาส): + +```typescript + /** กดรายการใน dashboard การตรวจสอบ -> ค้นหาเรื่องนั้นให้ทันที (เหมือนพฤติกรรม keyUrl==202 ปกติ) */ + onSelectReviewDashboardItem(item: any) { + const query = { + budget_year_uid: item.budget_year_uid, + budget_year_name_th: item.budget_year_name_th, + faculty_uid: item.faculty_uid, + faculty_name_th: item.faculty_name_th, + budget_location_uid: item.budget_location_uid, + } + this.onClickSearch(query) + } +``` + +- [ ] **Step 4: Build** + +Run: `cd rmutr-web && node --max_old_space_size=14336 node_modules/@angular/cli/bin/ng build --configuration production --base-href /budget-web/ 2>&1 | tail -20` +Expected: build ผ่าน ไม่มี error + +- [ ] **Step 5: Deploy ไปเซิร์ฟเวอร์จริง** + +```bash +cd rmutr-web +sshpass -p '@Dmin2241' rsync -avz --delete dist/rmutr-web/browser/ k8-node-1@172.27.30.135:/var/www/html/budget-web/ +``` + +- [ ] **Step 6: ทดสอบ end-to-end ในเบราว์เซอร์จริง** + +เปิด `https://rmis.rmutr.ac.th/budget-web/app/income-report` → หาเรื่องที่เชนอนุมัติใกล้จะครบ (หรือใช้ +เรื่องที่มีผู้อนุมัติคนเดียวเพื่อทดสอบง่าย) → อนุมัติจนครบทุกลำดับ → เปิด +`https://rmis.rmutr.ac.th/budget-web/app/income-budget-plan-02` → ค้นหาด้วยปีงบ/คณะ/พื้นที่เดียวกัน → +ต้องเห็น dashboard ขึ้น "รอตรวจสอบ 1" และหลังค้นหาเจอเรื่องต้องเห็น badge "รอตรวจสอบ" + ปุ่ม "ส่งแก้ไข"/ +"ตรวจสอบแล้ว" → กด "ส่งแก้ไข" พิมพ์ remark → กลับไปที่ income-report ต้องเห็นเรื่องนี้ "ถูกส่งแก้ไข" พร้อม +remark ในตาราง "รายละเอียดสำหรับการส่งแก้ไข" เดิม → เปิด +`https://rmis.rmutr.ac.th/budget-web/app/income-budget-plan-01` ค้นหาเรื่องเดียวกัน ต้องเห็น badge +"ถูกส่งแก้ไข" เช่นกัน (ไม่มีปุ่ม) + +- [ ] **Step 7: Commit** + +```bash +cd rmutr-web +git add src/app/feature/budget-request/request-budget-plan/container/request-budget +git commit -m "feat: wire income-budget-review dashboard/status into plan-01/plan-02 + +Co-Authored-By: Claude Sonnet 5 " +git push origin master +``` + +--- + +## Task 8: Verification สุดท้ายด้วยข้อมูลจริง + +**Files:** ไม่มีไฟล์ใหม่ — task นี้คือ verify ผ่าน SQL + curl ตรงกับ production ตามรูปแบบที่ใช้ตลอด session + +- [ ] **Step 1: หาเรื่องจริงในระบบที่ใกล้จะอนุมัติผ่านครบ (หรือสร้างเคสทดสอบด้วย SQL)** + +```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=10) +cur = conn.cursor() +cur.execute(""" + select request_expense_uid, is_sent, is_draft, is_approve + from "BUDGET".request_expense + where type_id = 2 and is_sent = true and is_approve is distinct from true + limit 5 +""") +for r in cur.fetchall(): print(r) +EOF +``` + +- [ ] **Step 2: ยืนยันว่า trigger (Task 2) ทำงานจริงหลังมีเรื่องอนุมัติผ่านครบในระบบจริง** + +```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=10) +cur = conn.cursor() +cur.execute('select count(*) from "BUDGET".income_budget_review') +print("income_budget_review rows:", cur.fetchone()[0]) +EOF +``` + +Expected: มากกว่า 0 หลังมีการอนุมัติจริงผ่านระบบครบอย่างน้อย 1 เรื่องนับจากตอน deploy Task 2 + +- [ ] **Step 3: ยืนยัน dashboard endpoint คืนค่าตรงกับ SQL** + +เทียบ `GET /api/income_budget_review/dashboard` (ด้วย token จาก Task 3 Step 6) กับผลจาก +`SELECT` ตรงในขั้นตอนที่ 1-2 ว่าจำนวนตรงกัน + +- [ ] **Step 4: สรุปผลให้ผู้ใช้** + +รายงานว่าทดสอบผ่านจริงกี่เคส (รอตรวจสอบ → ส่งแก้ไข → กลับไป income-report เห็น remark → อนุมัติใหม่ → +กลับมา "รอตรวจสอบ" อีกครั้ง → กด "ตรวจสอบแล้ว" จบ) ก่อนถือว่าเสร็จงาน