# Income Budget Report — Group A (type 1-3) 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:** Replace the SyncFusion spreadsheet on `/app/income-budget-report-income1/2/3` with a native Angular tree component backed by a real data model, sourcing personnel line items read-only from ร.2 (`personnel_statement`) instead of free-edit Excel cells. **Architecture:** Backend owns the report tree schema (a static C# structure per `type_id`) and computes the fully-merged tree (labels + amounts + lock flags) server-side on every GET; the frontend is a single generic recursive component that renders whatever tree JSON it receives, with no per-type knowledge of its own. New tables `income_budget_report` / `income_budget_report_line` replace the `.xlsx`-blob storage (`revenue_draft_committee`) for this feature; `.xlsx` is generated on demand (EPPlus) only when the user clicks "export". **Tech Stack:** .NET 8 / EF Core 8 / Npgsql (backend), Angular (frontend), PostgreSQL, EPPlus 6.2.4 for export. ## Global Constraints - Backend module lives at `rmutr-api/Modules/IncomeBudgetReport/` (new module, mirrors `Modules/Setting/` structure). - Schema/table naming, `base_table` inheritance, `t_`/`v_` entity split, and `BaseUidController` usage follow the exact pattern already used for `announcement`/`announcement_faculty` (added this session — see `rmutr-api/Modules/Setting/Databases/Models/announcement.cs`). - DB migrations are **manual SQL files** in `rmutr-api/Migrations/*.sql`, applied directly to the production Postgres instance (`rmutr_budget_050625` at `203.158.221.28:31620`) via `psql` — `dotnet ef migrations add` is **not** used in this codebase; the EF migration history has pre-existing unrelated drift that makes it unsafe (confirmed this session — do not attempt `dotnet ef migrations add` for this feature). - When adding an EF relationship between two entities that both have `t_`/`v_` variants, configure `HasOne/WithMany/HasForeignKey` on **both** the `t_` and `v_` sides in `Db.cs` — omitting the `v_` side produces a broken shadow FK property and a 500 at runtime (root-caused and fixed for `announcement` this session; do not repeat the mistake). - Frontend service files follow `rmutr-web/src/app/core/service/**` + extend `BaseService` (`rmutr-web/src/app/core/base/base-service.ts`) — full URL is always `environment.rmutrApi + '/api' + endpoint`. - No automated test framework exists in either repo. Every task's verification step is a concrete manual check (`dotnet build`, `psql` query, `curl`, or browser check) — run it and read the actual output before moving on, per this session's established practice. - All monetary fields end in `_amount` or `_price` so the project-wide `OnModelCreating` convention (`rmutr-api/Databases/Features/Db.cs:91-99`) automatically applies `numeric(18,4)` — do not set precision manually. --- ## File Structure **Backend — new module `Modules/IncomeBudgetReport/`:** ``` Databases/Models/income_budget_report/income_budget_report.cs entity: income_budget_report, t_/v_ Databases/Models/income_budget_report/income_budget_report_line.cs entity: income_budget_report_line, t_/v_ Databases/Db.cs DbSets + OnIncomeBudgetReportModelCreating Config/IncomeReportTreeSchema.cs static tree definition for type_id 1/2/3 Services/IncomeBudgetReportService.cs CreateDraft, CloneNextRound, BuildTree, UpdateLineAmounts Controllers/IncomeBudgetReportController.cs BaseUidController + custom endpoints ``` **Backend — modified files:** ``` Databases/Db.cs (central registry) add OnIncomeBudgetReportModelCreating(builder, schema) call Migrations/add_income_budget_report_tables.sql new manual migration ``` **Frontend — new files:** ``` src/app/shared/components/budget-report-tree/budget-report-tree-node.component.ts src/app/shared/components/budget-report-tree/budget-report-tree-node.component.html src/app/shared/components/budget-report-tree/budget-report-tree-node.component.scss src/app/shared/components/budget-report-tree/budget-report-tree.module.ts src/app/core/service/setting/income-budget-report.service.ts ``` **Frontend — modified files:** ``` src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.ts rewrite src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.html rewrite src/app/feature/income/draft/income-budget-report1/income-budget-report.module.ts import BudgetReportTreeModule ``` --- ## Interfaces (the contract every task below must match exactly) **Tree JSON returned by `GET /api/setting/income_budget_report/{uid}/tree`:** ```ts interface ReportTreeNode { nodeKey: string; // stable id, e.g. "pb-temp-old" label: string; depth: number; // 0-based, drives indentation kind: 'group' | 'leaf-amount' | 'leaf-table'; amount: number; // computed sum for 'group', the value itself for 'leaf-amount'/'leaf-table' total locked: boolean; // true when sourced from ร.2 (source_uid != null) — label/qty read-only lineUid: string | null; // income_budget_report_line_uid, set for 'leaf-amount' nodes only children: ReportTreeNode[]; // populated for kind='group' rows: ReportTreeTableRow[]; // populated for kind='leaf-table' } interface ReportTreeTableRow { lineUid: string; label: string; qualification: string | null; // ร.2 "วุฒิ" — null for non-personnel rows qty: number | null; // "อัตรา" — null when not applicable amount: number; locked: boolean; } ``` **C# equivalent (`IncomeBudgetReportRollupService.BuildTree` return type) uses the same shape** — see Task 4/6. --- ### Task 1: Backend entity models **Files:** - Create: `rmutr-api/Modules/IncomeBudgetReport/Databases/Models/income_budget_report/income_budget_report.cs` - Create: `rmutr-api/Modules/IncomeBudgetReport/Databases/Models/income_budget_report/income_budget_report_line.cs` **Interfaces:** - Produces: `income_budget_report`, `t_income_budget_report`, `v_income_budget_report`, `income_budget_report_line`, `t_income_budget_report_line`, `v_income_budget_report_line` classes used by every later backend task. - [ ] **Step 1: Write `income_budget_report.cs`** ```csharp using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using SeventyOneDev.Utilities; using SeventyOneDev.Utilities.Attributes; namespace rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models { public class income_budget_report : base_table { [Key] public Guid? income_budget_report_uid { get; set; } public int? type_id { get; set; } public Guid? budget_year_uid { get; set; } public Guid? faculty_uid { get; set; } public Guid? budget_location_uid { get; set; } [Search] public string sector_name_th { get; set; } public Guid? parent_uid { get; set; } } public class t_income_budget_report : income_budget_report { [Include] public List income_budget_report_lines { get; set; } } public class v_income_budget_report : income_budget_report { [Include] public List income_budget_report_lines { get; set; } } } ``` - [ ] **Step 2: Write `income_budget_report_line.cs`** ```csharp using System; using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using SeventyOneDev.Utilities; using SeventyOneDev.Utilities.Attributes; namespace rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models { public class income_budget_report_line : base_table { [Key] public Guid? income_budget_report_line_uid { get; set; } public Guid? income_budget_report_uid { get; set; } [MaxLength(100)] public string node_key { get; set; } [MaxLength(50)] public string source_type { get; set; } // "personnel_detail" | "personnel_detail_2" | "manual" public Guid? source_uid { get; set; } public string label_th { get; set; } public string qualification_th { get; set; } public decimal? qty { get; set; } public decimal? amount { get; set; } public decimal? original_amount { get; set; } public int? sequence_no { get; set; } } public class t_income_budget_report_line : income_budget_report_line { [JsonIgnore] public t_income_budget_report income_budget_report { get; set; } } public class v_income_budget_report_line : income_budget_report_line { [JsonIgnore] public v_income_budget_report income_budget_report { get; set; } } } ``` - [ ] **Step 3: Verify — no build target yet, just confirm files exist and namespaces match the folder they're in** ```bash ls rmutr-api/Modules/IncomeBudgetReport/Databases/Models/income_budget_report/ ``` Expected: both files listed. (The project won't compile until Task 2 wires up `Db.cs` — that's expected, don't try to build yet.) - [ ] **Step 4: Commit** ```bash cd rmutr-api git add Modules/IncomeBudgetReport/Databases/Models/income_budget_report/ git commit -m "feat: add income_budget_report and income_budget_report_line entity models" ``` --- ### Task 2: Backend Db.cs registration **Files:** - Create: `rmutr-api/Modules/IncomeBudgetReport/Databases/Db.cs` - Modify: `rmutr-api/Databases/Db.cs` (central registry, currently ends at line 33 `}`) **Interfaces:** - Consumes: entity classes from Task 1. - Produces: `_context.t_income_budget_report`, `_context.v_income_budget_report`, `_context.t_income_budget_report_line`, `_context.v_income_budget_report_line` DbSets usable by every later backend task. - [ ] **Step 1: Write `Modules/IncomeBudgetReport/Databases/Db.cs`** ```csharp using Microsoft.EntityFrameworkCore; using rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models; namespace SeventyOneDev.Utilities { public partial class Db : DbContext { public DbSet t_income_budget_report { get; set; } public DbSet v_income_budget_report { get; set; } public DbSet t_income_budget_report_line { get; set; } public DbSet v_income_budget_report_line { get; set; } private void OnIncomeBudgetReportModelCreating(ModelBuilder builder, string schema) { builder.Entity().ToTable("income_budget_report", schema); builder.Entity().ToView("v_income_budget_report", schema); builder.Entity().ToTable("income_budget_report_line", schema); builder.Entity().ToView("v_income_budget_report_line", schema); builder.Entity().HasOne(p => p.income_budget_report) .WithMany(p => p.income_budget_report_lines) .HasForeignKey(p => p.income_budget_report_uid) .OnDelete(DeleteBehavior.Cascade); builder.Entity().HasOne(p => p.income_budget_report) .WithMany(p => p.income_budget_report_lines) .HasForeignKey(p => p.income_budget_report_uid) .OnDelete(DeleteBehavior.Cascade); } } } ``` - [ ] **Step 2: Register the call in the central registry** Read `rmutr-api/Databases/Db.cs` first (it's short, ~34 lines). Add the new call as the last line inside `OnApplicationModelCreating`, right after `OnPlanResultModelCreating(builder,schema);`: ```csharp OnPlanResultModelCreating(builder,schema); OnIncomeBudgetReportModelCreating(builder,schema); ``` - [ ] **Step 3: Build and verify it compiles** ```bash cd rmutr-api dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20 ``` Expected: `0 Error(s)`. (Run in background if it exceeds ~2 minutes — it's a large solution, this is normal.) - [ ] **Step 4: Commit** ```bash git add Modules/IncomeBudgetReport/Databases/Db.cs Databases/Db.cs git commit -m "feat: register income_budget_report entities with EF Db context" ``` --- ### Task 3: Manual SQL migration **Files:** - Create: `rmutr-api/Migrations/add_income_budget_report_tables.sql` **Interfaces:** - Consumes: table/column names from Task 1. - Produces: physical `"BUDGET".income_budget_report`, `"BUDGET".income_budget_report_line` tables and matching views, required by every later task that touches the database. - [ ] **Step 1: Write the migration file** ```sql -- Migration: Create income_budget_report + income_budget_report_line tables (Group A, type 1-3). -- Date: 2026-08-05 -- Description: Backs the native replacement for the SyncFusion income-budget-report page. -- Replaces the .xlsx-blob storage (revenue_draft_committee) for type_id 1-3 with -- structured rows. See docs/superpowers/specs/2026-08-05-income-budget-report-native-design.md -- Idempotent: safe to run again. CREATE TABLE IF NOT EXISTS "BUDGET".income_budget_report ( income_budget_report_uid uuid PRIMARY KEY, status_id smallint, created_by character varying(200), created_datetime timestamptz, updated_by character varying(200), updated_datetime timestamptz, owner_agency_uid uuid, type_id integer, budget_year_uid uuid, faculty_uid uuid, budget_location_uid uuid, sector_name_th text, parent_uid uuid REFERENCES "BUDGET".income_budget_report (income_budget_report_uid) ); CREATE TABLE IF NOT EXISTS "BUDGET".income_budget_report_line ( income_budget_report_line_uid uuid PRIMARY KEY, status_id smallint, created_by character varying(200), created_datetime timestamptz, updated_by character varying(200), updated_datetime timestamptz, owner_agency_uid uuid, income_budget_report_uid uuid REFERENCES "BUDGET".income_budget_report (income_budget_report_uid) ON DELETE CASCADE, node_key character varying(100), source_type character varying(50), source_uid uuid, label_th text, qualification_th text, qty numeric(18,4), amount numeric(18,4), original_amount numeric(18,4), sequence_no integer ); CREATE INDEX IF NOT EXISTS "IX_income_budget_report_line_income_budget_report_uid" ON "BUDGET".income_budget_report_line (income_budget_report_uid); CREATE INDEX IF NOT EXISTS "IX_income_budget_report_parent_uid" ON "BUDGET".income_budget_report (parent_uid); CREATE OR REPLACE VIEW "BUDGET".v_income_budget_report AS SELECT * FROM "BUDGET".income_budget_report; CREATE OR REPLACE VIEW "BUDGET".v_income_budget_report_line AS SELECT * FROM "BUDGET".income_budget_report_line; ``` - [ ] **Step 2: Apply to the production database and verify** Confirm VPN/DB connectivity first (this session repeatedly needed to re-check this): ```bash nc -zv -w 6 203.158.221.28 31620 ``` Expected: `succeeded`. If it times out, ask the user to check their VPN before continuing. Apply: ```bash export PGPASSWORD='vtwi,yo0tpkd-okfouh' /opt/homebrew/Cellar/postgresql@16/16.14/bin/psql -h 203.158.221.28 -p 31620 -U devadmin -d rmutr_budget_050625 \ -v ON_ERROR_STOP=1 -f rmutr-api/Migrations/add_income_budget_report_tables.sql ``` Expected output: `CREATE TABLE` ×2, `CREATE INDEX` ×2, `CREATE VIEW` ×2, no errors. Verify columns exist: ```bash /opt/homebrew/Cellar/postgresql@16/16.14/bin/psql -h 203.158.221.28 -p 31620 -U devadmin -d rmutr_budget_050625 \ -c "SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='BUDGET' AND table_name IN ('income_budget_report','income_budget_report_line') ORDER BY table_name, ordinal_position;" ``` Expected: every field from Task 1's entities listed. - [ ] **Step 3: Commit** ```bash cd rmutr-api git add Migrations/add_income_budget_report_tables.sql git commit -m "feat: add income_budget_report tables migration" ``` --- ### Task 4: Tree schema config (backend) **Files:** - Create: `rmutr-api/Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs` **Interfaces:** - Produces: `ReportTreeNodeDef` class and `IncomeReportTreeSchema.ForType1()` static method returning the type-1/2/3 tree definition, consumed by Task 5 (CreateDraft, to assign `node_key` per source row) and Task 6 (BuildTree rollup). This schema is transcribed directly from the real production file inspected this session (`type1.xlsx`, sheet "Page1", 153 rows) — see the spec's Data Model section for the source verification. - [ ] **Step 1: Write the schema definition** ```csharp using System.Collections.Generic; namespace rmutr_budget_api.Modules.IncomeBudgetReport.Config { public class ReportTreeNodeDef { public string NodeKey { get; set; } public string Label { get; set; } public string Kind { get; set; } // "group" | "leaf-amount" | "leaf-table" public string SourceType { get; set; } // null for group/manual nodes public List Children { get; set; } = new(); public static ReportTreeNodeDef Group(string key, string label, params ReportTreeNodeDef[] children) => new ReportTreeNodeDef { NodeKey = key, Label = label, Kind = "group", Children = new List(children) }; public static ReportTreeNodeDef LeafAmount(string key, string label) => new ReportTreeNodeDef { NodeKey = key, Label = label, Kind = "leaf-amount" }; public static ReportTreeNodeDef LeafTable(string key, string label, string sourceType) => new ReportTreeNodeDef { NodeKey = key, Label = label, Kind = "leaf-table", SourceType = sourceType }; } public static class IncomeReportTreeSchema { // Verified against the real type-1 production file (Page1, 153 rows) downloaded and // inspected 2026-08-05. Plan/output labels are the exact wording found in that file. public static ReportTreeNodeDef ForType1() { return ReportTreeNodeDef.Group("root", "รายการบุคลากร", ReportTreeNodeDef.Group("pb", "งบบุคลากร", ReportTreeNodeDef.Group("pb-temp", "ค่าจ้างชั่วคราว", ReportTreeNodeDef.LeafTable("pb-temp-old", "อัตราเดิม", "personnel_detail"), ReportTreeNodeDef.LeafTable("pb-temp-new", "อัตราใหม่", "personnel_detail_2") ), ReportTreeNodeDef.Group("pb-cola", "เงินเพิ่มค่าครองชีพชั่วคราว", ReportTreeNodeDef.LeafAmount("pb-cola-old", "อัตราเดิม"), ReportTreeNodeDef.LeafAmount("pb-cola-new", "อัตราใหม่") ) ), ReportTreeNodeDef.Group("op", "งบดำเนินงาน", ReportTreeNodeDef.LeafAmount("op-comp", "ค่าตอบแทน ค่าใช้สอยและค่าวัสดุ"), ReportTreeNodeDef.Group("plan1", "แผนงานยุทธศาสตร์พัฒนาศักยภาพคนตลอดช่วงชีวิต", BuildOutput("out1", "ผลผลิต ผู้สำเร็จการศึกษาด้านวิทยาศาสตร์และเทคโนโลยี"), BuildOutput("out2", "ผลผลิต ผู้สำเร็จการศึกษาด้านสังคมศาสตร์") ), ReportTreeNodeDef.Group("plan2", "แผนงานพื้นฐานด้านการพัฒนาและเสริมสร้างศักยภาพทรัพยากรมนุษย์", BuildOutput("out3", "ผลผลิต ผลงานการให้บริการวิชาการ"), BuildOutput("out4", "ผลผลิต ผลงานทำนุบำรุงศิลปวัฒนธรรม") ), ReportTreeNodeDef.Group("plan3", "แผนงานยุทธศาสตร์การวิจัยและพัฒนานวัตกรรม", BuildOutput("out5", "ผลผลิต ผลงานวิจัยเพื่อสร้างองค์ความรู้") ) ) ); } private static ReportTreeNodeDef BuildOutput(string prefix, string label) { return ReportTreeNodeDef.Group(prefix, label, ReportTreeNodeDef.Group($"{prefix}-grant", "งบเงินอุดหนุน", ReportTreeNodeDef.LeafAmount($"{prefix}-grant-proj", "เงินอุดหนุนค่าใช้จ่ายโครงการ") ), ReportTreeNodeDef.Group($"{prefix}-invest", "งบลงทุน", ReportTreeNodeDef.LeafTable($"{prefix}-invest-equip", "ค่าครุภัณฑ์", "invest_asset"), ReportTreeNodeDef.LeafTable($"{prefix}-invest-land", "ค่าที่ดินและสิ่งก่อสร้าง", "invest_construct") ), ReportTreeNodeDef.LeafAmount($"{prefix}-other", "งบรายจ่ายอื่นๆ") ); } } } ``` - [ ] **Step 2: Build and verify it compiles** ```bash cd rmutr-api dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20 ``` Expected: `0 Error(s)`. - [ ] **Step 3: Commit** ```bash git add Modules/IncomeBudgetReport/Config/ git commit -m "feat: add type-1 income budget report tree schema" ``` --- ### Task 5: IncomeBudgetReportService — CreateDraft **Files:** - Create: `rmutr-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs` **Interfaces:** - Consumes: `ReportTreeNodeDef`/`IncomeReportTreeSchema.ForType1()` (Task 4), `t_personnel_statement`/`personnel_statement_detail`/`personnel_statement_detail_2` (existing, `rmutr-api/Modules/ReportSalary/Databases/Models/`). - Produces: `IncomeBudgetReportService.CreateDraft(Guid facultyUid, Guid budgetYearUid, ClaimsIdentity identity) : Task`, consumed by Task 8 (controller). - [ ] **Step 1: Write the service (CreateDraft only — BuildTree and CloneNextRound come in Tasks 6/7)** ```csharp using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using SeventyOneDev.Utilities; using rmutr_budget_api.Modules.IncomeBudgetReport.Config; using rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models; using rmutr_budget_api.Modules.ReportSalary.Models; namespace rmutr_budget_api.Modules.IncomeBudgetReport.Services { public class IncomeBudgetReportService : EntityUidService { private readonly Db _context; public IncomeBudgetReportService(Db context) : base(context) { _context = context; } public override async Task GetEntity(Guid uid, List agencies = null, string userName = null) { return await _context.v_income_budget_report .Include(c => c.income_budget_report_lines) .AsNoTracking() .FirstOrDefaultAsync(c => c.income_budget_report_uid == uid); } public async Task CreateDraft(Guid facultyUid, Guid budgetYearUid, ClaimsIdentity identity) { var statement = await _context.t_personnel_statement .Include(c => c.personnel_statement_details) .Include(c => c.personnel_statement_details_2) .FirstOrDefaultAsync(c => c.faculty_uid == facultyUid && c.budget_year_uid == budgetYearUid); var reportUid = Guid.NewGuid(); var report = new t_income_budget_report { income_budget_report_uid = reportUid, type_id = 1, budget_year_uid = budgetYearUid, faculty_uid = facultyUid, status_id = 0, }; var lines = new List(); int seq = 0; if (statement != null) { foreach (var d in statement.personnel_statement_details ?? new List()) { var amount = (d.salary_rate ?? 0) * 12; lines.Add(new t_income_budget_report_line { income_budget_report_line_uid = Guid.NewGuid(), income_budget_report_uid = reportUid, node_key = "pb-temp-old", source_type = "personnel_detail", source_uid = d.personnel_statement_detail_uid, label_th = d.full_name, qualification_th = d.qualification, qty = 1, amount = amount, original_amount = amount, sequence_no = seq++, }); } foreach (var d in statement.personnel_statement_details_2 ?? new List()) { var amount = (d.salary_rate ?? 0) * 12; lines.Add(new t_income_budget_report_line { income_budget_report_line_uid = Guid.NewGuid(), income_budget_report_uid = reportUid, node_key = "pb-temp-new", source_type = "personnel_detail_2", source_uid = d.personnel_statement_detail_2_uid, label_th = d.full_name, qualification_th = d.qualification, qty = 1, amount = amount, original_amount = amount, sequence_no = seq++, }); } } await _context.t_income_budget_report.AddAsync(report); await _context.t_income_budget_report_line.AddRangeAsync(lines); await _context.SaveChangesAsync(identity); return await GetEntity(reportUid); } } } ``` **Note for the implementer:** confirm the exact property names on `personnel_statement_detail`/`personnel_statement_detail_2` (`full_name`, `qualification`, `salary_rate`, `personnel_statement_detail_uid`) by reading `rmutr-api/Modules/ReportSalary/Databases/Models/personnel_statement_detail.cs` and `personnel_statement_detail_2.cs` before writing this file — they were confirmed present during this session's investigation but re-read them directly rather than trusting this plan's transcription, since field lists that long are easy to mistype. - [ ] **Step 2: Build and verify it compiles** ```bash cd rmutr-api dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -30 ``` Expected: `0 Error(s)`. If `personnel_statement_detail`/`_2` field names don't match, fix them here based on the actual model file — don't guess. - [ ] **Step 3: Commit** ```bash git add Modules/IncomeBudgetReport/Services/ git commit -m "feat: add IncomeBudgetReportService.CreateDraft (pulls from ร.2)" ``` --- ### Task 6: IncomeBudgetReportService — BuildTree (rollup) **Files:** - Modify: `rmutr-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs` - Create: `rmutr-api/Modules/IncomeBudgetReport/Config/ReportTreeNode.cs` (the response DTOs matching the Interfaces section above) **Interfaces:** - Consumes: `v_income_budget_report` with `income_budget_report_lines` loaded (Task 5's `GetEntity`). - Produces: `IncomeBudgetReportService.BuildTree(v_income_budget_report report) : ReportTreeNode`, consumed by Task 8 (GET tree endpoint) and Task 9 (export). - [ ] **Step 1: Write the response DTOs** ```csharp using System.Collections.Generic; namespace rmutr_budget_api.Modules.IncomeBudgetReport.Config { public class ReportTreeNode { public string NodeKey { get; set; } public string Label { get; set; } public int Depth { get; set; } public string Kind { get; set; } public decimal Amount { get; set; } public bool Locked { get; set; } public string LineUid { get; set; } public List Children { get; set; } = new(); public List Rows { get; set; } = new(); } public class ReportTreeTableRow { public string LineUid { get; set; } public string Label { get; set; } public string Qualification { get; set; } public decimal? Qty { get; set; } public decimal Amount { get; set; } public bool Locked { get; set; } } } ``` - [ ] **Step 2: Add `BuildTree` to `IncomeBudgetReportService`** Append to the class body from Task 5: ```csharp public ReportTreeNode BuildTree(v_income_budget_report report) { var linesByNode = (report.income_budget_report_lines ?? new List()) .GroupBy(l => l.node_key) .ToDictionary(g => g.Key, g => g.OrderBy(l => l.sequence_no).ToList()); var schema = IncomeReportTreeSchema.ForType1(); return BuildNode(schema, 0, linesByNode); } private ReportTreeNode BuildNode(ReportTreeNodeDef def, int depth, Dictionary> linesByNode) { var node = new ReportTreeNode { NodeKey = def.NodeKey, Label = def.Label, Depth = depth, Kind = def.Kind, }; if (def.Kind == "group") { foreach (var child in def.Children) { var childNode = BuildNode(child, depth + 1, linesByNode); node.Children.Add(childNode); } node.Amount = node.Children.Sum(c => c.Amount); node.Locked = false; return node; } var lines = linesByNode.TryGetValue(def.NodeKey, out var l) ? l : new List(); if (def.Kind == "leaf-amount") { var line = lines.FirstOrDefault(); node.Amount = line?.amount ?? 0; node.Locked = line?.source_uid != null; node.LineUid = line?.income_budget_report_line_uid?.ToString(); return node; } // leaf-table foreach (var line in lines) { node.Rows.Add(new ReportTreeTableRow { LineUid = line.income_budget_report_line_uid.ToString(), Label = line.label_th, Qualification = line.qualification_th, Qty = line.qty, Amount = line.amount ?? 0, Locked = line.source_uid != null, }); } node.Amount = node.Rows.Sum(r => r.Amount); node.Locked = false; return node; } ``` Add `using System.Linq;` and `using rmutr_budget_api.Modules.IncomeBudgetReport.Config;` to the top of `IncomeBudgetReportService.cs` if not already present (the `Config` using is already there from Task 5). - [ ] **Step 3: Build and verify it compiles** ```bash cd rmutr-api dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20 ``` Expected: `0 Error(s)`. - [ ] **Step 4: Commit** ```bash git add Modules/IncomeBudgetReport/Config/ReportTreeNode.cs Modules/IncomeBudgetReport/Services/ git commit -m "feat: add tree rollup calculation (BuildTree)" ``` --- ### Task 7: IncomeBudgetReportService — CloneNextRound **Files:** - Modify: `rmutr-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs` **Interfaces:** - Produces: `IncomeBudgetReportService.CloneNextRound(Guid sourceUid, ClaimsIdentity identity) : Task`, consumed by Task 8. - [ ] **Step 1: Add `CloneNextRound` to the service** ```csharp public async Task CloneNextRound(Guid sourceUid, ClaimsIdentity identity) { var alreadyCloned = await _context.t_income_budget_report.AnyAsync(r => r.parent_uid == sourceUid); if (alreadyCloned) { throw new InvalidOperationException("มีรายงานรอบถัดไปที่สร้างจากรอบนี้อยู่แล้ว"); } var source = await _context.t_income_budget_report .Include(c => c.income_budget_report_lines) .AsNoTracking() .FirstOrDefaultAsync(c => c.income_budget_report_uid == sourceUid); if (source == null) throw new InvalidOperationException("ไม่พบรายงานต้นทาง"); if (source.type_id >= 3) throw new InvalidOperationException("รอบนี้เป็นรอบสุดท้ายแล้ว (type 3)"); var newUid = Guid.NewGuid(); var clone = new t_income_budget_report { income_budget_report_uid = newUid, type_id = source.type_id + 1, budget_year_uid = source.budget_year_uid, faculty_uid = source.faculty_uid, budget_location_uid = source.budget_location_uid, sector_name_th = source.sector_name_th, parent_uid = sourceUid, status_id = 0, }; var clonedLines = (source.income_budget_report_lines ?? new List()) .Select(l => new t_income_budget_report_line { income_budget_report_line_uid = Guid.NewGuid(), income_budget_report_uid = newUid, node_key = l.node_key, source_type = l.source_type, source_uid = l.source_uid, label_th = l.label_th, qualification_th = l.qualification_th, qty = l.qty, amount = l.amount, original_amount = l.amount, sequence_no = l.sequence_no, }).ToList(); await _context.t_income_budget_report.AddAsync(clone); await _context.t_income_budget_report_line.AddRangeAsync(clonedLines); await _context.SaveChangesAsync(identity); return await GetEntity(newUid); } ``` Add `using System;` at the top if not already present (needed for `InvalidOperationException`). - [ ] **Step 2: Build and verify it compiles** ```bash cd rmutr-api dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20 ``` Expected: `0 Error(s)`. - [ ] **Step 3: Commit** ```bash git add Modules/IncomeBudgetReport/Services/ git commit -m "feat: add CloneNextRound for round-to-round continuation (type 1->2->3)" ``` --- ### Task 8: IncomeBudgetReportController **Files:** - Create: `rmutr-api/Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs` **Interfaces:** - Consumes: `IncomeBudgetReportService` (Tasks 5-7). - Produces: the 5 HTTP endpoints listed below, consumed by Task 11 (frontend service). - [ ] **Step 1: Write the controller** ```csharp using System; using System.Collections.Generic; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models; using rmutr_budget_api.Modules.IncomeBudgetReport.Services; using SeventyOneDev.Utilities; namespace rmutr_budget_api.Modules.IncomeBudgetReport.Controllers { public class LineAmountUpdate { public Guid income_budget_report_line_uid { get; set; } public decimal amount { get; set; } } [Route("api/setting/income_budget_report", Name = "income_budget_report")] public class IncomeBudgetReportController : BaseUidController { private readonly Db _db; private readonly IncomeBudgetReportService _service; public IncomeBudgetReportController(IncomeBudgetReportService entityUidService, Db db) : base(entityUidService) { _db = db; _service = entityUidService; } [Authorize] [HttpPost("create_draft")] public async Task> CreateDraft( [FromQuery] Guid faculty_uid, [FromQuery] Guid budget_year_uid) { var result = await _service.CreateDraft(faculty_uid, budget_year_uid, User.Identity as ClaimsIdentity); return Ok(result); } [Authorize] [HttpPost("{uid:guid}/clone_next_round")] public async Task> CloneNextRound([FromRoute] Guid uid) { try { var result = await _service.CloneNextRound(uid, User.Identity as ClaimsIdentity); return Ok(result); } catch (InvalidOperationException ex) { return BadRequest(new { description = ex.Message }); } } [Authorize] [HttpGet("{uid:guid}/tree")] public async Task GetTree([FromRoute] Guid uid) { var report = await _service.GetEntity(uid); if (report == null) return NotFound(); return Ok(_service.BuildTree(report)); } [Authorize] [HttpPut("lines")] public async Task UpdateLineAmounts([FromBody] List updates) { foreach (var u in updates) { var line = await _db.t_income_budget_report_line.FindAsync(u.income_budget_report_line_uid); if (line != null) line.amount = u.amount; } await _db.SaveChangesAsync(User.Identity as ClaimsIdentity); return Ok(); } } } ``` - [ ] **Step 2: Build and verify it compiles** ```bash cd rmutr-api dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20 ``` Expected: `0 Error(s)`. - [ ] **Step 3: Verify endpoints exist via Swagger (dev server)** ```bash cd rmutr-api dotnet run --urls http://localhost:5050 & sleep 15 curl -s http://localhost:5050/swagger/v1/swagger.json | python3 -c "import json,sys; d=json.load(sys.stdin); print([k for k in d['paths'] if 'income_budget_report' in k])" kill %1 ``` Expected: a list containing `/api/setting/income_budget_report/create_draft`, `/api/setting/income_budget_report/{uid}/clone_next_round`, `/api/setting/income_budget_report/{uid}/tree`, `/api/setting/income_budget_report/lines`, and the base CRUD paths from `BaseUidController`. - [ ] **Step 4: Commit** ```bash git add Modules/IncomeBudgetReport/Controllers/ git commit -m "feat: add IncomeBudgetReportController with create_draft/clone_next_round/tree/lines endpoints" ``` --- ### Task 9: Export to Excel **Files:** - Modify: `rmutr-api/Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs` **Interfaces:** - Consumes: `BuildTree` (Task 6). - Produces: `GET /api/setting/income_budget_report/{uid}/export/xlsx`, consumed by the frontend "ส่งออก Excel" button (Task 12). - [ ] **Step 1: Add the export endpoint** ```csharp [Authorize] [HttpGet("{uid:guid}/export/xlsx")] public async Task ExportXlsx([FromRoute] Guid uid) { var report = await _service.GetEntity(uid); if (report == null) return NotFound(); var tree = _service.BuildTree(report); using var package = new OfficeOpenXml.ExcelPackage(); var ws = package.Workbook.Worksheets.Add("Page1"); ws.Cells[1, 1].Value = "รายการ"; ws.Cells[1, 2].Value = "จำนวนเงิน (บาท)"; int row = 2; WriteNode(tree, ws, ref row); ws.Column(1).Width = 60; ws.Column(2).Width = 20; var bytes = package.GetAsByteArray(); var fileName = $"income_budget_report_{uid}.xlsx"; return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName); } private static void WriteNode(Config.ReportTreeNode node, OfficeOpenXml.ExcelWorksheet ws, ref int row) { var indent = new string(' ', node.Depth * 4); ws.Cells[row, 1].Value = indent + node.Label; ws.Cells[row, 2].Value = node.Amount; row++; if (node.Kind == "leaf-table") { foreach (var r in node.Rows) { ws.Cells[row, 1].Value = indent + " - " + r.Label; ws.Cells[row, 2].Value = r.Amount; row++; } } foreach (var child in node.Children) { WriteNode(child, ws, ref row); } } ``` Add `using OfficeOpenXml;` to the top of the file if preferred over the fully-qualified names used above (either works — the fully-qualified form avoids a namespace collision check). - [ ] **Step 2: Build and verify it compiles** ```bash cd rmutr-api dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20 ``` Expected: `0 Error(s)`. - [ ] **Step 3: Commit** ```bash git add Modules/IncomeBudgetReport/Controllers/ git commit -m "feat: add xlsx export endpoint for income budget report" ``` --- ### Task 10: Frontend generic tree component **Files:** - Create: `rmutr-web/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.ts` - Create: `rmutr-web/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.html` - Create: `rmutr-web/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.scss` - Create: `rmutr-web/src/app/shared/components/budget-report-tree/budget-report-tree.module.ts` **Interfaces:** - Consumes: `ReportTreeNode`/`ReportTreeTableRow` JSON shape from the Interfaces section (Task 8's `/tree` endpoint). - Produces: `` component, consumed by Task 12. This ports the mockup's interaction design (recursive `
`, indentation via depth, live recalculation) into a real Angular component. Unlike the mockup, amounts come pre-computed from the backend on load; the component only needs to recompute a node's own displayed total when one of ITS OWN rows/leaf inputs changes (bubbling the recalculation up through `@Output` events, since Angular components can't share mutable DOM state across siblings the way the static mockup's global `recalc()` did). - [ ] **Step 1: Write the component class** ```typescript import { Component, EventEmitter, Input, Output } from '@angular/core'; export interface ReportTreeTableRow { lineUid: string; label: string; qualification: string | null; qty: number | null; amount: number; locked: boolean; } export interface ReportTreeNode { nodeKey: string; label: string; depth: number; kind: 'group' | 'leaf-amount' | 'leaf-table'; amount: number; locked: boolean; lineUid: string | null; children: ReportTreeNode[]; rows: ReportTreeTableRow[]; } export interface LineAmountChange { lineUid: string; amount: number; } @Component({ selector: 'app-budget-report-tree-node', templateUrl: './budget-report-tree-node.component.html', styleUrls: ['./budget-report-tree-node.component.scss'], }) export class BudgetReportTreeNodeComponent { @Input() node: ReportTreeNode; @Output() amountChange = new EventEmitter(); onLeafAmountInput(value: string): void { const amount = parseFloat(value) || 0; this.node.amount = amount; this.amountChange.emit({ lineUid: this.node.lineUid, amount }); } onRowAmountInput(row: ReportTreeTableRow, value: string): void { const amount = parseFloat(value) || 0; row.amount = amount; this.node.amount = this.node.rows.reduce((s, r) => s + (r.amount || 0), 0); this.amountChange.emit({ lineUid: row.lineUid, amount }); } onChildAmountChange(event: LineAmountChange): void { this.node.amount = this.node.children.reduce((s, c) => s + (c.amount || 0), 0); this.amountChange.emit(event); } trackByNodeKey(_i: number, n: ReportTreeNode): string { return n.nodeKey; } trackByLineUid(_i: number, r: ReportTreeTableRow): string { return r.lineUid; } } ``` - [ ] **Step 2: Write the template** ```html
{{ node.label }} {{ node.amount | number:'1.2-2' }} {{ node.amount | number:'1.2-2' }}
รายการ วุฒิ อัตรา จำนวนเงิน (บาท)
{{ row.label }} {{ row.qualification }} {{ row.qty }} {{ row.amount | number:'1.2-2' }}

ยังไม่มีรายการ

``` - [ ] **Step 3: Write minimal styles** ```scss .tree-node { padding-left: calc(var(--depth, 0) * 20px); } .row { display: flex; justify-content: space-between; align-items: center; padding: 6px 8px; gap: 12px; } .row-label { font-weight: 500; } .amount-input { width: 120px; text-align: right; font-variant-numeric: tabular-nums; } .amount-readonly, .amount-computed { font-variant-numeric: tabular-nums; min-width: 120px; text-align: right; display: inline-block; } .leaf-table { width: 100%; border-collapse: collapse; margin: 4px 0 8px; th, td { padding: 4px 8px; border-bottom: 1px solid #e2e6ed; text-align: left; } th.num, td.num { text-align: right; } } .empty-hint { color: #8993a8; font-size: 12.5px; padding-left: 8px; } ``` - [ ] **Step 4: Write the module** ```typescript import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { BudgetReportTreeNodeComponent } from './budget-report-tree-node.component'; @NgModule({ declarations: [BudgetReportTreeNodeComponent], imports: [CommonModule, FormsModule], exports: [BudgetReportTreeNodeComponent], }) export class BudgetReportTreeModule {} ``` - [ ] **Step 5: Verify — type-check the whole frontend** ```bash cd rmutr-web npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -i "budget-report-tree" | head -30 ``` Expected: no output (no errors referencing the new files). Note: this only catches type errors, not template errors — Task 12's `ng build` will catch template binding mistakes. - [ ] **Step 6: Commit** ```bash git add src/app/shared/components/budget-report-tree/ git commit -m "feat: add generic recursive budget report tree component" ``` --- ### Task 11: Frontend API service **Files:** - Create: `rmutr-web/src/app/core/service/setting/income-budget-report.service.ts` **Interfaces:** - Consumes: endpoints from Tasks 8-9. - Produces: `IncomeBudgetReportService.createDraft/cloneNextRound/getTree/exportXlsxUrl/updateLineAmounts`, used by Task 12. - [ ] **Step 1: Write the service** ```typescript import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { BaseService } from 'src/app/core/base/base-service'; import { environment } from 'src/environments/environment'; import { ReportTreeNode } from 'src/app/shared/components/budget-report-tree/budget-report-tree-node.component'; @Injectable({ providedIn: 'root' }) export class IncomeBudgetReportService extends BaseService { constructor(http: HttpClient, private httpClient: HttpClient) { super('/setting/income_budget_report', http); } createDraft(facultyUid: string, budgetYearUid: string): Observable { const url = `${environment.rmutrApi}/api/setting/income_budget_report/create_draft?faculty_uid=${facultyUid}&budget_year_uid=${budgetYearUid}`; return this.httpClient.post(url, {}); } cloneNextRound(uid: string): Observable { const url = `${environment.rmutrApi}/api/setting/income_budget_report/${uid}/clone_next_round`; return this.httpClient.post(url, {}); } getTree(uid: string): Observable { const url = `${environment.rmutrApi}/api/setting/income_budget_report/${uid}/tree`; return this.httpClient.get(url); } exportXlsxUrl(uid: string): string { return `${environment.rmutrApi}/api/setting/income_budget_report/${uid}/export/xlsx`; } updateLineAmounts(updates: { income_budget_report_line_uid: string; amount: number }[]): Observable { const url = `${environment.rmutrApi}/api/setting/income_budget_report/lines`; return this.httpClient.put(url, updates); } } ``` Check `rmutr-web/src/app/core/base/base-service.ts`'s constructor signature before writing this file — confirm it takes `(endpoint: string, http: HttpClient)` in that order (matches every other service seen this session, e.g. `announcement.service.ts`, but verify directly). - [ ] **Step 2: Verify — type-check** ```bash cd rmutr-web npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -i "income-budget-report.service" | head -30 ``` Expected: no output. - [ ] **Step 3: Commit** ```bash git add src/app/core/service/setting/income-budget-report.service.ts git commit -m "feat: add IncomeBudgetReportService frontend client" ``` --- ### Task 12: Rewrite the page component **Files:** - Modify: `rmutr-web/src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.ts` (full rewrite) - Modify: `rmutr-web/src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.html` (full rewrite) - Modify: `rmutr-web/src/app/feature/income/draft/income-budget-report1/income-budget-report.module.ts` (add `BudgetReportTreeModule` import) **Interfaces:** - Consumes: `IncomeBudgetReportService` (Task 11), `BudgetReportTreeModule`/`BudgetReportTreeNodeComponent` (Task 10). - Scope: this task only rewires **`type_id == 1`** behavior (the `/income-budget-report-income1/add` route this whole plan targets). Leave the `type == 2..10` branches in the existing `ngOnInit` switch alone for now — they still reference the old SyncFusion members, which is fine because Task 13's verification only exercises type 1. Wiring type 2/3 into this rewritten component is 1-2 lines each (call `cloneNextRound` instead of `createDraft`) but is explicitly **out of scope** for this plan per the Group A/B split agreed during brainstorming — leave the `NOTE(group-b-followup)` comment shown in Step 2 at the relevant branch rather than implementing it now, so the next plan has an obvious anchor. - [ ] **Step 1: Read the current component fully before rewriting** ```bash cat rmutr-web/src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.ts ``` Note the `budgetYearSV`, `FacultySV`, and route param handling (`this.form.get('type_id')`) — the rewrite keeps the faculty/budget-year selector UI but drops everything SyncFusion-related. - [ ] **Step 2: Write the new component (type_id==1 path only)** ```typescript import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; import { FacultyService } from 'src/app/core/service/general/faculty.service'; import { BudgetYearService } from 'src/app/core/service/budget/budget-year.service'; import { IncomeBudgetReportService } from 'src/app/core/service/setting/income-budget-report.service'; import { ReportTreeNode, LineAmountChange } from 'src/app/shared/components/budget-report-tree/budget-report-tree-node.component'; @Component({ selector: 'app-income-budget-report', templateUrl: './income-budget-report.component.html', styleUrls: ['./income-budget-report.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class IncomeBudgetReportComponent implements OnInit { typeId: number; reportUid: string | null = null; tree: ReportTreeNode | null = null; facultyUid: string | null = null; budgetYearUid: string | null = null; pendingChanges = new Map(); faculty$ = new Observable(); budgetYear$ = new Observable(); constructor( private activatedRoute: ActivatedRoute, private router: Router, private cdRef: ChangeDetectorRef, private facultySV: FacultyService, private budgetYearSV: BudgetYearService, private incomeBudgetReportSV: IncomeBudgetReportService, ) {} ngOnInit(): void { this.faculty$ = this.facultySV.getAll(); this.budgetYear$ = this.budgetYearSV.getAll(); this.activatedRoute.data.pipe( tap((x: any) => this.typeId = x.type), ).subscribe(); } onFacultyChange(facultyUid: string): void { this.facultyUid = facultyUid; this.tryCreateDraft(); } onBudgetYearChange(budgetYearUid: string): void { this.budgetYearUid = budgetYearUid; this.tryCreateDraft(); } private tryCreateDraft(): void { if (!this.facultyUid || !this.budgetYearUid) return; this.incomeBudgetReportSV.createDraft(this.facultyUid, this.budgetYearUid).pipe( tap((report: any) => { this.reportUid = report.income_budget_report_uid; this.loadTree(); }), ).subscribe(); } loadTree(): void { if (!this.reportUid) return; this.incomeBudgetReportSV.getTree(this.reportUid).pipe( tap((tree) => { this.tree = tree; this.cdRef.detectChanges(); }), ).subscribe(); } hasNoPersonnelData(): boolean { if (!this.tree) return false; const pb = this.tree.children.find(c => c.nodeKey === 'pb'); const temp = pb?.children.find(c => c.nodeKey === 'pb-temp'); const oldRates = temp?.children.find(c => c.nodeKey === 'pb-temp-old'); const newRates = temp?.children.find(c => c.nodeKey === 'pb-temp-new'); return !oldRates?.rows.length && !newRates?.rows.length; } onAmountChange(change: LineAmountChange): void { this.pendingChanges.set(change.lineUid, change.amount); } save(): void { if (!this.reportUid || this.pendingChanges.size === 0) return; const lines = Array.from(this.pendingChanges.entries()).map(([lineUid, amount]) => ({ income_budget_report_line_uid: lineUid, amount, })); this.incomeBudgetReportSV.updateLineAmounts(lines).pipe( tap(() => { this.pendingChanges.clear(); this.loadTree(); }), ).subscribe(); } exportXlsx(): void { if (!this.reportUid) return; window.open(this.incomeBudgetReportSV.exportXlsxUrl(this.reportUid), '_blank'); } // NOTE(group-b-followup): wire cloneNextRound() here instead of createDraft() when this // component is extended to handle income-budget-report-income2/3 — tracked as a follow-up // plan, not implemented here. See docs/superpowers/plans/2026-08-05-income-budget-report-native-group-a.md Task 12. } ``` `save()` calls `IncomeBudgetReportService.updateLineAmounts()` (Task 11), which PUTs to `/api/setting/income_budget_report/lines` — that endpoint was added in Task 8 Step 1 (`IncomeBudgetReportController.UpdateLineAmounts`). No backend change needed in this task. - [ ] **Step 3: Write the new template** ```html
หน่วยงาน {{ f.faculty_name_th }} ปีงบประมาณ {{ y.budget_year_name_th }}
ยังไม่มีข้อมูลตำแหน่ง/อัตรากำลังจาก ร.2 สำหรับหน่วยงานและปีงบประมาณนี้ กรอกฟอร์ม ร.2 ก่อน
รวมทั้งสิ้น: {{ tree.amount | number:'1.2-2' }} บาท
``` - [ ] **Step 4: Wire `BudgetReportTreeModule` into the feature module** Read `rmutr-web/src/app/feature/income/draft/income-budget-report1/income-budget-report.module.ts` and add to its `imports` array: ```typescript import { BudgetReportTreeModule } from 'src/app/shared/components/budget-report-tree/budget-report-tree.module'; // ... imports: [ // ...existing imports... BudgetReportTreeModule, ], ``` - [ ] **Step 5: Build and verify** ```bash cd rmutr-web node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -60 ``` Expected: build succeeds (warnings OK, 0 errors). This is the first point template binding errors would surface — read the output carefully. - [ ] **Step 6: Commit** ```bash git add src/app/feature/income/draft/income-budget-report1/ git commit -m "feat: replace SyncFusion spreadsheet with native tree component (type 1)" ``` --- ### Task 13: End-to-end verification against real data **Files:** none (verification only) - [ ] **Step 1: Deploy backend and frontend to the dev/staging environment** (follow this session's established deploy steps: `dotnet publish -c Release -r linux-x64`, scp the DLL, restart the systemd service; `ng build --configuration production`, rsync to the web server) - [ ] **Step 2: Pick a faculty+year that already has a real ร.2 record** (this session confirmed `personnel_statement` records exist for `faculty_uid` matching "บพิตรพิมุข จักรวรรดิ / คณะศิลปศาสตร์" for `budget_year_name_th = '2569'`) — verify via: ```bash export PGPASSWORD='vtwi,yo0tpkd-okfouh' /opt/homebrew/Cellar/postgresql@16/16.14/bin/psql -h 203.158.221.28 -p 31620 -U devadmin -d rmutr_budget_050625 \ -c "SELECT personnel_statement_uid, faculty_uid, agency_name_th, budget_year_uid FROM \"BUDGET\".personnel_statement LIMIT 5;" ``` - [ ] **Step 3: In the browser, open `/app/income-budget-report-income1/add`, select that faculty+year, and confirm:** - The tree renders with "รายการบุคลากร" at the top and the same category labels seen in the real downloaded `type1.xlsx` (`งบบุคลากร`, `งบดำเนินงาน`, `แผนงานยุทธศาสตร์พัฒนาศักยภาพคนตลอดช่วงชีวิต`, ...) - Position rows under "อัตราเดิม" are **read-only** (no input box, just text) and match names/amounts in `personnel_statement_detail` for that faculty+year - Editing an unlocked amount (e.g. "ค่าตอบแทน ค่าใช้สอยและค่าวัสดุ") updates the parent totals live - "บันทึก" persists the change — reload the page and confirm the edited value survived - "ส่งออก Excel" downloads a `.xlsx` file that opens and shows the same numbers - [ ] **Step 4: Compare the grand total** against the real downloaded file for the same faculty (`/tmp/mockup_fonts/type2.xlsx` from this session's investigation, or re-download the latest `revenue_draft_committee` record for that faculty) — the personnel-derived subtotal should match `personnel_statement_detail` records summed × 12 exactly, since Task 5's `CreateDraft` uses the same `salary_rate * 12` formula the old system used (`Personnel.cs:2345`) - [ ] **Step 5: Document any mismatch found as a follow-up task** — do not silently patch numbers to make them match without understanding why they differed first. --- ## Explicitly out of scope for this plan (tracked for the Group B follow-up plan) - `type_id` 2, 3 UI wiring (backend `CloneNextRound` supports it; frontend component's faculty/year picker → clone flow is not built) - `type_id` 4, 5, 6 (Group B — multi-tab workbook, different tree schema, different source tables ร.4/ร.5/ร.6) - `type_id` 10 (Group C — matrix report, entirely different data model) - Deprecating/removing the old `revenue_draft_committee` table and SyncFusion dependency (leave both in place until Group B ships too, per the phased/pilot rollout agreed in the spec)