# Income Budget Report Native (Group A) — Remediation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Fix the Critical/Important defects found by the final whole-branch review of `docs/superpowers/plans/2026-08-05-income-budget-report-native-group-a.md` (both the backend and frontend halves), and close the scope gaps the human explicitly approved fixing now rather than deferring: (1) seed *every* editable amount field so nothing is silently dropped, (2) source ร.4/ร.5 (`invest_asset_request_information`/`invest_construct_request_information`) into the native tree so the "ค่าครุภัณฑ์"/"ค่าที่ดินและสิ่งก่อสร้าง" leaf-tables aren't permanently empty, (3) generate a real government-form-styled `.xlsx` export instead of a flat 2-column dump, and (4) dual-write type-1 drafts into `revenue_draft_committee` so type-2's picker and the report list keep working end to end, with legacy pre-existing rows still reachable. **Architecture:** Continues directly on the two existing worktrees/branches from the Group A plan (do not create new worktrees — this is a fix-forward continuation of `worktree-income-budget-report-api` in both `rmutr-api` and `rmutr-web`). No new tables. Extends `income_budget_report` with one nullable link column, extends `IncomeBudgetReportService.CreateDraft` to seed every schema-defined line (not just personnel ones) and to mirror a `revenue_draft_committee` row, replaces the flat xlsx exporter with a structured one, and extends the frontend list/edit flow to union native + legacy data sources. **Tech Stack:** ASP.NET Core 8 / EF Core 8 / Npgsql (manual SQL migrations only — no `dotnet ef migrations add`), EPPlus 6.2.4, Angular / RxJS, PostgreSQL (`"BUDGET"` schema). ## Global Constraints - Manual SQL migrations only, in `rmutr-api/Migrations/*.sql`, applied directly to production Postgres (`rmutr_budget_050625` at `203.158.221.28:31620`) via `psql`. `dotnet ef migrations add` is forbidden in this codebase (pre-existing unrelated EF migration-history drift). - No automated test framework exists in either repo. Every task's verification step is a concrete manual check (`dotnet build`, `psql` query, `curl`, or browser check) — run it and read the actual output before moving on. - All monetary fields end in `_amount` or `_price` so the project-wide `OnModelCreating` convention (`rmutr-api/Databases/Features/Db.cs:91-99`) automatically applies `numeric(18,4)` precision — do not set precision manually. `income_budget_report_line.amount`/`original_amount` are already plain `numeric` columns per the approved Task-3 deviation from the original plan — do not change their SQL type. - **EF Core `t_`/`v_` FK gotcha:** when adding a relationship between two entities that have `t_`/`v_` write/read-view variants, `HasOne/WithMany/HasForeignKey` must be configured on BOTH the `t_` and `v_` sides in `Db.cs`, or EF creates a broken shadow FK causing a runtime 500. This plan avoids the gotcha entirely by adding only plain scalar `Guid?` columns (no navigation properties) — do not add navigation properties for `income_budget_report.revenue_draft_committee_uid`. - DI registration for every new service must go in a `Modules//Configures/.cs` file using the `public static class XxxCollectionExtension { public static IServiceCollection AddXxxServices(this IServiceCollection services) { ... return services; } }` pattern, invoked from `Startup.cs`'s service-registration block (around lines 155-183). This is how every other module in the codebase registers its services — `IncomeBudgetReportService` currently has no such file, which is Task 1 of this plan. - Newtonsoft.Json via `AddNewtonsoftJson()` with no camelCase policy — new DTO properties consumed by the frontend need explicit `[JsonProperty("camelCaseName")]` attributes, exactly like `Modules/IncomeBudgetReport/Config/ReportTreeNode.cs` already does. - Frontend error handling convention for HTTP calls in this component: `catchError(err => { Swal.fire(err.error.description, '', 'error'); return throwError(err); })`, matching `saveComplete()` in `income-budget-report.component.ts`. Use this pattern for every new `.subscribe()` this plan adds to that file. - `Helper.GetDataFilter(identity, new List{"agency_uid"})` (external `SeventyOneDev.Utilities` package, no local source) returns the caller's agency uid(s) as `List`; empty list = unrestricted. Existing convention: `.Where(a => a != Guid.Empty).ToList()` before using it as a filter. - Types 2-10 must remain byte-for-byte behaviorally unchanged by this plan — every task that touches shared files (`income-budget-report.component.ts`, `income-budget-report-list.component.ts`) must be additive/branching only, never modifying the existing type 2-10 code paths. --- ## File Map **Backend (`rmutr-api/.claude/worktrees/income-budget-report-api`):** - Create: `Modules/IncomeBudgetReport/Configures/IncomeBudgetReport.cs` — DI registration (Task 1) - Create: `Migrations/add_income_budget_report_remediation.sql` — unique index, link column (Task 2) - Modify: `Modules/IncomeBudgetReport/Databases/Models/income_budget_report/income_budget_report.cs` — add `revenue_draft_committee_uid` scalar (Task 2) - Modify: `Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs` — seed all lines, owner_agency_uid stamping, dual-write (Tasks 3, 4, 5, 8) - Modify: `Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs` — leaf-amount key list + output-label matcher (Tasks 3, 4) - Modify: `Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs` — ownership guard on `PUT lines`, new exporter (Tasks 6, 7) - Create: `Modules/IncomeBudgetReport/Services/IncomeBudgetReportXlsxExporter.cs` — structured export (Task 7) **Frontend (`rmutr-web/.claude/worktrees/income-budget-report-api`):** - Modify: `src/app/feature/income/draft/income-budget-report1/income-budget-report1-list/income-budget-report-list.component.ts` — union legacy rows (Task 9) - Modify: `src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.ts` — legacy-edit branch, error/loading handling, pendingChanges reset (Task 10) - Modify: `src/app/shared/components/budget-report-tree/budget-report-tree-node.component.ts`/`.html`/`.scss` — decimal-input fix, indentation fix (Task 10) --- ### Task 1: Register `IncomeBudgetReportService` in DI **Files:** - Create: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Configures/IncomeBudgetReport.cs` - Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Startup.cs` **Why this is Critical:** `IncomeBudgetReportController`'s constructor takes `IncomeBudgetReportService` by concrete type. No `Configures/*.cs` file exists for this module and `Startup.cs` never calls an `AddIncomeBudgetReportServices()`. Every request to any endpoint on this controller — including the inherited `BaseUidController` CRUD/list routes the frontend list page depends on — throws `InvalidOperationException: Unable to resolve service for type 'IncomeBudgetReportService'` and 500s. This was never caught because the plan had no DI-registration verification step and Task 8's swagger-based check only enumerates routes, never activates the controller. **Interfaces:** - Consumes: `IncomeBudgetReportService` (existing, `Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs`) - Produces: nothing new consumed by later tasks — this just makes the existing controller constructible. - [ ] **Step 1: Create the Configures file** ```csharp using Microsoft.Extensions.DependencyInjection; using rmutr_budget_api.Modules.IncomeBudgetReport.Services; namespace rmutr_budget_api.Modules.IncomeBudgetReport.Configures { public static class IncomeBudgetReportCollectionExtension { public static IServiceCollection AddIncomeBudgetReportServices(this IServiceCollection services) { services.AddScoped(); return services; } } } ``` - [ ] **Step 2: Register it in Startup.cs** Open `Startup.cs`, find the block of `services.AddXxxServices();` calls (around line 155-183, ends with `services.AddPlanResultServices();`). Add a new line immediately after it: ```csharp services.AddPlanResultServices(); services.AddIncomeBudgetReportServices(); ``` Add the using statement at the top of `Startup.cs` alongside the other `Modules.*.Configures` usings: ```csharp using rmutr_budget_api.Modules.IncomeBudgetReport.Configures; ``` - [ ] **Step 3: Build and verify by actually calling the endpoint, not swagger** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api dotnet build ``` Expected: `Build succeeded. 0 Error(s)`. Then start the API locally (or use whatever run command the rest of this worktree's task reports used — check `.superpowers/sdd/2026-08-05-income-budget-report-native-group-a/task-8-report.md` for the exact local-run command) and hit the endpoint directly: ```bash curl -s -o /dev/null -w "%{http_code}\n" http://localhost:/api/setting/income_budget_report/agency?type_id=1 ``` Expected: `401` (Unauthorized — no token supplied) or `200`, **not** `500`. A `500` here means DI is still broken; a `401` proves the controller was constructed successfully (DI resolved) and only the `[Authorize]` check rejected the anonymous request, which is correct. - [ ] **Step 4: Commit** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api git add Modules/IncomeBudgetReport/Configures/IncomeBudgetReport.cs Startup.cs git commit -m "fix: register IncomeBudgetReportService in DI (every endpoint was 500ing)" ``` --- ### Task 2: Migration — duplicate-draft unique index + dual-write link column **Files:** - Create: `rmutr-api/.claude/worktrees/income-budget-report-api/Migrations/add_income_budget_report_remediation.sql` - Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Databases/Models/income_budget_report/income_budget_report.cs` **Why:** (1) `CreateDraft`'s existing check-then-insert duplicate-draft guard has no backing constraint — a double-click still creates two drafts (this is the exact bug that triggered a Task-5 reopen during the original plan; a partial unique index closes it for good and also gives the lookup an index it currently lacks). (2) Task 8 (dual-write) needs a place to record which `revenue_draft_committee` row mirrors a given `income_budget_report` row. **Interfaces:** - Produces: `income_budget_report.revenue_draft_committee_uid : Guid?`, consumed by Task 8 (dual-write) and Task 9 (frontend list union, to dedupe legacy rows). - [ ] **Step 1: Write the migration** ```sql -- Migration: Remediation for income_budget_report (Group A native report). -- Date: 2026-08-05 -- Description: (1) Partial unique index preventing duplicate type-1 drafts per personnel_statement_uid -- (closes a known TOCTOU race in CreateDraft's check-then-insert guard). -- (2) revenue_draft_committee_uid link column for the dual-write mirror -- (see IncomeBudgetReportService.CreateDraft / SyncMirror). -- Idempotent: safe to run again. CREATE UNIQUE INDEX IF NOT EXISTS "UX_income_budget_report_personnel_statement_uid_type1" ON "BUDGET".income_budget_report (personnel_statement_uid) WHERE type_id = 1; ALTER TABLE "BUDGET".income_budget_report ADD COLUMN IF NOT EXISTS revenue_draft_committee_uid uuid; CREATE OR REPLACE VIEW "BUDGET".v_income_budget_report AS SELECT * FROM "BUDGET".income_budget_report; ``` - [ ] **Step 2: Apply it to production** ```bash PGPASSWORD='vtwi,yo0tpkd-okfouh' psql -h 203.158.221.28 -p 31620 -U devadmin -d rmutr_budget_050625 \ -f /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api/Migrations/add_income_budget_report_remediation.sql ``` If the VPN to `203.158.221.28` is down, ask the human to reconnect before running this (established pattern from the rest of this session). Verify: ```bash PGPASSWORD='vtwi,yo0tpkd-okfouh' psql -h 203.158.221.28 -p 31620 -U devadmin -d rmutr_budget_050625 \ -c "\d \"BUDGET\".income_budget_report" ``` Expected: `revenue_draft_committee_uid` listed as a column, and `UX_income_budget_report_personnel_statement_uid_type1` listed as an index. - [ ] **Step 3: Add the matching C# property** In `income_budget_report.cs`, add the field to the base class (so both `t_income_budget_report` and `v_income_budget_report` inherit it — no navigation property, plain scalar, no FK gotcha): ```csharp public Guid? parent_uid { get; set; } public Guid? personnel_statement_uid { get; set; } public Guid? revenue_draft_committee_uid { get; set; } ``` (insert the new line directly after the existing `personnel_statement_uid` line) - [ ] **Step 4: Build** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api dotnet build ``` Expected: `Build succeeded. 0 Error(s)`. - [ ] **Step 5: Commit** ```bash git add Migrations/add_income_budget_report_remediation.sql Modules/IncomeBudgetReport/Databases/Models/income_budget_report/income_budget_report.cs git commit -m "feat: add duplicate-draft unique index and revenue_draft_committee link column" ``` --- ### Task 3: Seed every leaf-amount line (fixes the silent data-loss bug) **Files:** - Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs` - Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs` **Why this is Critical:** `CreateDraft` only ever creates lines for `node_key = "pb-temp-old"`/`"pb-temp-new"` (personnel sources). The other 13 `leaf-amount` nodes in the schema (`pb-cola-old`, `pb-cola-new`, `op-comp`, and `{out1..out5}-grant-proj`/`{out1..out5}-other`) have no backing line at all. `BuildNode` renders them as editable (`Locked = false`, `LineUid = null`) because there's no line to lock, but every edit collides on the same `null` key client-side and the backend silently skips a `null`-keyed line-uid update. **Not a single one of these 13 fields can currently be saved.** The design spec (`docs/superpowers/specs/2026-08-05-income-budget-report-native-design.md:77`) explicitly describes this case — "รายการที่ `source_uid = null`... แก้ไขได้ทั้งชื่อและจำนวนเงิน" (source_uid=null items are editable) — meaning these rows were always meant to exist with `source_uid = null`, `source_type = "manual"`; `CreateDraft` simply never created them. **Interfaces:** - Produces: `IncomeReportTreeSchema.CollectLeafAmountKeys() : List<(string NodeKey, string Label)>`, consumed by `CreateDraft` in this task. - [ ] **Step 1: Add a leaf-amount key collector to the schema** In `IncomeReportTreeSchema.cs`, add this method to the `IncomeReportTreeSchema` static class (after `ForType1()`): ```csharp /// Every "leaf-amount" node key + label in the type-1 tree, in document order. /// Used by CreateDraft to seed a zero-amount manual line for each one, so every /// editable field has a real income_budget_report_line row to save against. public static List<(string NodeKey, string Label)> CollectLeafAmountKeys() { var result = new List<(string, string)>(); CollectLeafAmountKeysRecursive(ForType1(), result); return result; } private static void CollectLeafAmountKeysRecursive(ReportTreeNodeDef def, List<(string, string)> result) { if (def.Kind == "leaf-amount") { result.Add((def.NodeKey, def.Label)); return; } foreach (var child in def.Children) { CollectLeafAmountKeysRecursive(child, result); } } ``` - [ ] **Step 2: Seed a manual line for every leaf-amount key in `CreateDraft`** In `IncomeBudgetReportService.cs`, inside `CreateDraft`, after the existing `foreach (var d in statement.personnel_statement_details_2 ...)` block and before `await _context.t_income_budget_report.AddAsync(report);`, add: ```csharp foreach (var (nodeKey, label) in IncomeReportTreeSchema.CollectLeafAmountKeys()) { lines.Add(new t_income_budget_report_line { income_budget_report_line_uid = Guid.NewGuid(), income_budget_report_uid = reportUid, node_key = nodeKey, source_type = "manual", source_uid = null, label_th = label, qualification_th = null, qty = null, amount = 0, original_amount = 0, sequence_no = seq++, }); } ``` This must run unconditionally (not gated on `statement != null`) — the leaf-amount fields aren't personnel-sourced, so a report should always get them. - [ ] **Step 3: Build** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api dotnet build ``` Expected: `Build succeeded. 0 Error(s)`. - [ ] **Step 4: Verify against a real draft** Pick a real `personnel_statement_uid` from production (or ask the human which one is safe to use for testing), call: ```bash curl -s -X POST "http://localhost:/api/setting/income_budget_report/create_draft?personnel_statement_uid=" \ -H "Authorization: Bearer " | jq '.income_budget_report_lines | length, ([.[] | select(.source_type == "manual")] | length)' ``` Expected: the manual-line count is exactly `13`. Then `GET .../{uid}/tree` and confirm `pb-cola-old`, `pb-cola-new`, `op-comp`, and every `out{N}-grant-proj`/`out{N}-other` node has `"locked": false` and a non-null `"lineUid"`. - [ ] **Step 5: Commit** ```bash git add Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs git commit -m "fix: seed manual lines for every leaf-amount node (13 fields could not be saved before)" ``` --- ### Task 4: Wire ร.4/ร.5 (invest_asset/invest_construct) into the leaf-table nodes **Files:** - Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs` - Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs` **Why:** The type-1 schema has 10 `leaf-table` nodes (`{out1..out5}-invest-equip`/`{out1..out5}-invest-land`) meant to hold ร.4 (`invest_asset_request_information`)/ร.5 (`invest_construct_request_information`) rows for that output ("ผลผลิต"). `CreateDraft` never queries either table, so these always render `"ยังไม่มีรายการ"`. The human explicitly approved wiring this in now rather than deferring it. **Grouping approach:** `invest_asset_request_information`/`invest_construct_request_information` don't carry a `node_key` — they're matched to an output node by their `budget_project_name_th` (the "ผลผลิต" name), compared against the schema's own `out1`..`out5` group labels (which are the real ผลผลิต names, verified against production — see `IncomeReportTreeSchema.cs:43-51`). A row whose `budget_project_name_th` doesn't match any of the 5 output labels is skipped (logged, not silently discarded — see Step 3) rather than guessed at, since there is no existing convention in this codebase for approval-status filtering on these two tables (confirmed: `BudgetProgressService.GetSummaryExpense` doesn't filter on `is_approve` either) and inventing one here would be a new, undiscussed policy. **Interfaces:** - Consumes: `invest_asset_request_information`/`invest_construct_request_information` — read via `Db` directly (`_context.v_invest_asset_request_information`, `_context.v_invest_construct_request_information`), filtered `budget_year_uid == statement.budget_year_uid` (the report's own budget year — there is no `faculty_uid` on `personnel_statement` to filter by, matching the already-approved Task-5 precedent of not having a faculty filter for this report; filtering by `budget_year_uid` alone mirrors `BudgetProgressService.GetSummaryExpense`'s scoping intent as closely as this report's available data allows). - Produces: `IncomeReportTreeSchema.OutputPrefixByLabel() : Dictionary`, consumed by `CreateDraft`. - [ ] **Step 1: Add an output-label-to-prefix lookup to the schema** In `IncomeReportTreeSchema.cs`, add (after `CollectLeafAmountKeys`): ```csharp /// Maps each "ผลผลิต" (output) label to its node-key prefix ("out1".."out5"), so /// CreateDraft can match invest_asset/invest_construct rows (which carry a /// budget_project_name_th, not a node_key) to the correct leaf-table node. public static Dictionary OutputPrefixByLabel() { var result = new Dictionary(); CollectOutputPrefixesRecursive(ForType1(), result); return result; } private static void CollectOutputPrefixesRecursive(ReportTreeNodeDef def, Dictionary result) { if (def.Kind == "group" && def.NodeKey.StartsWith("out") && def.NodeKey.Length <= 4) { result[def.Label.Trim()] = def.NodeKey; } foreach (var child in def.Children) { CollectOutputPrefixesRecursive(child, result); } } ``` (`def.NodeKey.Length <= 4` excludes `out1-grant`/`out1-invest` etc., matching only the top-level `out1`..`out5` group nodes themselves.) - [ ] **Step 2: Query and map invest rows in `CreateDraft`** In `IncomeBudgetReportService.cs`, `CreateDraft`, immediately after the leaf-amount seeding loop added in Task 3 and still before `await _context.t_income_budget_report.AddAsync(report);`, add: ```csharp var outputPrefixByLabel = IncomeReportTreeSchema.OutputPrefixByLabel(); var unmatchedInvestLabels = new List(); if (statement?.budget_year_uid != null) { var assetRows = await _context.v_invest_asset_request_information .Where(c => c.budget_year_uid == statement.budget_year_uid) .ToListAsync(); foreach (var a in assetRows) { var label = a.budget_project_name_th?.Trim(); if (label == null || !outputPrefixByLabel.TryGetValue(label, out var prefix)) { if (label != null) unmatchedInvestLabels.Add($"invest_asset:{label}"); continue; } lines.Add(new t_income_budget_report_line { income_budget_report_line_uid = Guid.NewGuid(), income_budget_report_uid = reportUid, node_key = $"{prefix}-invest-equip", source_type = "invest_asset", source_uid = a.invest_asset_request_information_uid, label_th = a.invest_asset_request_information_name_th, qualification_th = null, qty = a.amount_unit, amount = a.total_unit_price ?? 0, original_amount = a.total_unit_price ?? 0, sequence_no = seq++, }); } var constructRows = await _context.v_invest_construct_request_information .Where(c => c.budget_year_uid == statement.budget_year_uid) .ToListAsync(); foreach (var c in constructRows) { var label = c.budget_project_name_th?.Trim(); if (label == null || !outputPrefixByLabel.TryGetValue(label, out var prefix)) { if (label != null) unmatchedInvestLabels.Add($"invest_construct:{label}"); continue; } lines.Add(new t_income_budget_report_line { income_budget_report_line_uid = Guid.NewGuid(), income_budget_report_uid = reportUid, node_key = $"{prefix}-invest-land", source_type = "invest_construct", source_uid = c.invest_construct_request_information_uid, label_th = c.invest_construct_request_information_name_th, qualification_th = null, qty = null, amount = c.budget ?? 0, original_amount = c.budget ?? 0, sequence_no = seq++, }); } } ``` - [ ] **Step 3: Surface unmatched rows instead of silently dropping them** Still in `CreateDraft`, after building `report` but before `return await GetEntity(reportUid);`, log unmatched labels so a mismatch (e.g. a ผลผลิต name that drifted since the schema was verified against production) is visible in logs rather than invisible: ```csharp if (unmatchedInvestLabels.Any()) { Console.WriteLine($"[IncomeBudgetReportService.CreateDraft] {unmatchedInvestLabels.Count} invest row(s) did not match any output label and were skipped: {string.Join(", ", unmatchedInvestLabels.Distinct())}"); } ``` - [ ] **Step 4: Build** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api dotnet build ``` Expected: `Build succeeded. 0 Error(s)`. If `v_invest_asset_request_information`/`v_invest_construct_request_information` aren't already `DbSet`-exposed on `Db`, this step will fail with a compile error naming the missing property — if that happens, find the exact `DbSet` / `DbSet` property names already declared in `Modules/InvestInformations/Databases/Db.cs` (or wherever they're registered) and use those exact names/casing. - [ ] **Step 5: Verify against a real budget year with known invest rows** ```bash curl -s -X POST "http://localhost:/api/setting/income_budget_report/create_draft?personnel_statement_uid=" \ -H "Authorization: Bearer " > /tmp/draft.json curl -s "http://localhost:/api/setting/income_budget_report/$(jq -r .income_budget_report_uid /tmp/draft.json)/tree" \ -H "Authorization: Bearer " | jq '.. | objects | select(.nodeKey? | test("invest")) | {nodeKey, rows: (.rows | length)}' ``` Expected: at least one `*-invest-equip`/`*-invest-land` node has `rows > 0` for a faculty/year known to have ร.4/ร.5 data (ask the human which `budget_year_uid` has real data if unsure, or check `check-pa-status.md`/similar files from earlier investigation in the outer repo for a known-good example). - [ ] **Step 6: Commit** ```bash git add Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs git commit -m "feat: source invest_asset/invest_construct (ร.4/ร.5) into the leaf-table nodes" ``` --- ### Task 5: Agency-ownership guard on `PUT lines`, and reject writes to locked lines **Files:** - Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs` - Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs` **Why:** `PUT .../lines` currently looks up each line by bare uid and overwrites it with no check that the line's report belongs to the caller's agency, and no check on `source_uid` — the UI-only `locked` flag can be bypassed entirely via direct API call, letting any authenticated user rewrite any faculty's report, including authoritative ร.2/ร.4/ร.5-sourced figures. There is no existing precedent in this codebase for a write-time ownership guard (confirmed — every `Helper.GetDataFilter` usage is either a read-list filter or a create-time stamp), so this task also has to make `CreateDraft`/`CloneNextRound` actually stamp `owner_agency_uid` (currently unset on both), since the guard needs something real to check. **Interfaces:** - Consumes: `Helper.GetDataFilter(ClaimsIdentity, List)` (external package, existing convention). - Produces: `UpdateLineAmounts` now returns `400 BadRequest` with a description when a line is rejected, instead of silently succeeding. - [ ] **Step 1: Stamp `owner_agency_uid` in `CreateDraft` and `CloneNextRound`** In `IncomeBudgetReportService.cs`, `CreateDraft`, add to the `report` object initializer (right after `status_id = 0,`): ```csharp status_id = 0, owner_agency_uid = Helper.GetDataFilter(identity, new List { "agency_uid" }).Where(a => a != Guid.Empty).FirstOrDefault(), ``` Do the same in `CloneNextRound`'s `clone` object initializer: ```csharp status_id = 0, owner_agency_uid = Helper.GetDataFilter(identity, new List { "agency_uid" }).Where(a => a != Guid.Empty).FirstOrDefault(), ``` (`Helper` is already in scope via `using SeventyOneDev.Utilities;` at the top of the file.) - [ ] **Step 2: Add a guarded update method to the service** In `IncomeBudgetReportService.cs`, add a new public method (after `CloneNextRound`, before `BuildTree`): ```csharp public async Task> UpdateLineAmounts(List<(Guid LineUid, decimal Amount)> updates, ClaimsIdentity identity) { var agencies = Helper.GetDataFilter(identity, new List { "agency_uid" }).Where(a => a != Guid.Empty).ToList(); var rejected = new List(); foreach (var (lineUid, amount) in updates) { var line = await _context.t_income_budget_report_line.FindAsync(lineUid); if (line == null) { rejected.Add($"{lineUid}: ไม่พบรายการ"); continue; } if (line.source_uid != null) { rejected.Add($"{lineUid}: รายการนี้ล็อกไว้ แก้ไขไม่ได้"); continue; } var report = await _context.t_income_budget_report.FindAsync(line.income_budget_report_uid); if (report == null || (agencies.Any() && !agencies.Contains(report.owner_agency_uid))) { rejected.Add($"{lineUid}: ไม่มีสิทธิ์แก้ไขรายงานนี้"); continue; } line.amount = amount; } await _context.SaveChangesAsync(identity); return rejected; } ``` - [ ] **Step 3: Use it from the controller instead of the inline `_db` loop** In `IncomeBudgetReportController.cs`, replace the entire `UpdateLineAmounts` action body: ```csharp [Authorize] [HttpPut("lines")] public async Task UpdateLineAmounts([FromBody] List updates) { var rejected = await _service.UpdateLineAmounts( updates.Select(u => (u.income_budget_report_line_uid, u.amount)).ToList(), User.Identity as ClaimsIdentity); if (rejected.Any()) { return BadRequest(new { description = string.Join("; ", rejected) }); } return Ok(); } ``` Add `using System.Linq;` to the top of the controller file if not already present (it is not, per the file read earlier). The `_db` field and its constructor parameter are no longer used by this action, but leave them in place — `Db db` may still be needed if other actions in this controller are added later, and removing it isn't required by this fix. - [ ] **Step 4: Build** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api dotnet build ``` Expected: `Build succeeded. 0 Error(s)`. - [ ] **Step 5: Verify the guard rejects a locked-line write** ```bash curl -s -X PUT "http://localhost:/api/setting/income_budget_report/lines" \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '[{"income_budget_report_line_uid":"","amount":999999}]' ``` Expected: `400` with a description containing "ล็อกไว้". Then repeat with a real unlocked manual line's uid (from Task 3's seeded lines) and confirm `200 OK` and that the amount actually changed on a follow-up `GET .../tree`. - [ ] **Step 6: Commit** ```bash git add Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs git commit -m "fix: guard PUT lines against cross-agency writes and locked-line edits" ``` --- ### Task 6: Structured xlsx export matching the official form's shape **Files:** - Create: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportXlsxExporter.cs` - Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs` **Why:** The current export (`WriteNode`) is a flat 2-column `label`/`amount` dump with space-indentation — nothing like the real government form. **No literal `.xlsx` template file exists anywhere reachable from this codebase** (confirmed: the real form is rendered by a separate paid FastReport microservice, `rmutr_report`, using `.frx` templates that cannot be loaded by EPPlus; the production blank-template URL the old SyncFusion UI used is currently returning `503`). Per the human's explicit decision, this task builds a **new, EPPlus-constructed workbook that replicates the government form's structure** (title block, indented tree with real merged/bordered cells, right-aligned currency, signature block) using the schema's own hierarchy and labels — not a pixel-identical reproduction of the FastReport output. This must be reviewed against the real form by a human before this feature is considered fully production-ready for actual government submission (flag this explicitly in the task's completion report). **Interfaces:** - Consumes: `ReportTreeNode` (existing, `Modules/IncomeBudgetReport/Config/ReportTreeNode.cs`), `v_income_budget_report` (for header fields: `sector_name_th`, `budget_year_uid` — resolve the year's display name via `_context.v_budget_year`). - Produces: `IncomeBudgetReportXlsxExporter.Build(v_income_budget_report report, ReportTreeNode tree, string budgetYearNameTh, string personnelStatementDisplayName) : byte[]`, consumed by the controller (this task) and by Task 8 (dual-write, server-side generation). - [ ] **Step 1: Write the exporter** ```csharp using System.Linq; using OfficeOpenXml; using OfficeOpenXml.Style; using rmutr_budget_api.Modules.IncomeBudgetReport.Config; using rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models; namespace rmutr_budget_api.Modules.IncomeBudgetReport.Services { public static class IncomeBudgetReportXlsxExporter { public static byte[] Build(v_income_budget_report report, ReportTreeNode tree, string budgetYearNameTh, string personnelStatementDisplayName) { ExcelPackage.LicenseContext = LicenseContext.NonCommercial; using var package = new ExcelPackage(); var ws = package.Workbook.Worksheets.Add("Page1"); ws.Column(1).Width = 55; ws.Column(2).Width = 20; ws.Column(3).Width = 12; ws.Column(4).Width = 18; ws.Cells["A1:D1"].Merge = true; ws.Cells["A1"].Value = "ร่างเงินรายได้ เพื่อประชุมคณะกรรมการร่างเงินรายได้"; ws.Cells["A1"].Style.Font.Bold = true; ws.Cells["A1"].Style.Font.Size = 14; ws.Cells["A1"].Style.HorizontalAlignment = ExcelHorizontalAlignment.Center; ws.Cells["A2:D2"].Merge = true; ws.Cells["A2"].Value = $"หน่วยงาน: {personnelStatementDisplayName} ปีงบประมาณ: {budgetYearNameTh}"; ws.Cells["A2"].Style.HorizontalAlignment = ExcelHorizontalAlignment.Center; int headerRow = 4; ws.Cells[headerRow, 1].Value = "รายการ"; ws.Cells[headerRow, 2].Value = "อัตรา/วุฒิ"; ws.Cells[headerRow, 3].Value = "จำนวน"; ws.Cells[headerRow, 4].Value = "จำนวนเงิน (บาท)"; using (var headerRange = ws.Cells[headerRow, 1, headerRow, 4]) { headerRange.Style.Font.Bold = true; headerRange.Style.Fill.PatternType = ExcelFillStyle.Solid; headerRange.Style.Fill.BackgroundColor.SetColor(System.Drawing.Color.LightGray); headerRange.Style.Border.BorderAround(ExcelBorderStyle.Thin); } int row = headerRow + 1; WriteNode(tree, ws, ref row); using (var dataRange = ws.Cells[headerRow, 1, row - 1, 4]) { dataRange.Style.Border.Top.Style = ExcelBorderStyle.Thin; dataRange.Style.Border.Bottom.Style = ExcelBorderStyle.Thin; dataRange.Style.Border.Left.Style = ExcelBorderStyle.Thin; dataRange.Style.Border.Right.Style = ExcelBorderStyle.Thin; } row += 3; ws.Cells[row, 1].Value = "ลงชื่อ .............................................. ผู้จัดทำ"; row += 2; ws.Cells[row, 1].Value = "ลงชื่อ .............................................. ประธานคณะกรรมการร่างเงินรายได้"; row += 2; ws.Cells[row, 1].Value = "วันที่ .............../.............../..............."; ws.View.FreezePanes(headerRow + 1, 1); return package.GetAsByteArray(); } private static void WriteNode(ReportTreeNode node, ExcelWorksheet ws, ref int row) { var cell = ws.Cells[row, 1]; cell.Value = node.Label; cell.Style.Indent = node.Depth; if (node.Kind == "group") { cell.Style.Font.Bold = true; } var amountCell = ws.Cells[row, 4]; amountCell.Value = node.Amount; amountCell.Style.Numberformat.Format = "#,##0.00"; amountCell.Style.HorizontalAlignment = ExcelHorizontalAlignment.Right; row++; if (node.Kind == "leaf-table") { foreach (var r in node.Rows) { var rowCell = ws.Cells[row, 1]; rowCell.Value = "- " + r.Label; rowCell.Style.Indent = node.Depth + 1; ws.Cells[row, 2].Value = r.Qualification; ws.Cells[row, 3].Value = r.Qty; var rowAmountCell = ws.Cells[row, 4]; rowAmountCell.Value = r.Amount; rowAmountCell.Style.Numberformat.Format = "#,##0.00"; rowAmountCell.Style.HorizontalAlignment = ExcelHorizontalAlignment.Right; row++; } } foreach (var child in node.Children) { WriteNode(child, ws, ref row); } } } } ``` - [ ] **Step 2: Use it from the controller** In `IncomeBudgetReportController.cs`, replace the `ExportXlsx` action and delete the old private `WriteNode` method entirely: ```csharp [Authorize] [HttpGet("{uid:guid}/export/xlsx")] public async Task ExportXlsx([FromRoute] Guid uid) { var report = await _service.GetEntity(uid); if (report == null) return NotFound(); var tree = _service.BuildTree(report); var bytes = await _service.BuildXlsx(report, tree); var fileName = $"income_budget_report_{uid}.xlsx"; return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName); } ``` - [ ] **Step 3: Add the wrapper method the controller now calls** In `IncomeBudgetReportService.cs`, add (after `BuildTree`/`BuildNode`, this resolves the display strings the exporter needs): ```csharp public async Task BuildXlsx(v_income_budget_report report, ReportTreeNode tree) { var budgetYear = report.budget_year_uid != null ? await _context.v_budget_year.FirstOrDefaultAsync(y => y.budget_year_uid == report.budget_year_uid) : null; var personnel = report.personnel_statement_uid != null ? await _context.v_personnel_statement.FirstOrDefaultAsync(p => p.personnel_statement_uid == report.personnel_statement_uid) : null; var displayName = personnel?.display_name ?? personnel?.agency_name_th ?? report.sector_name_th ?? ""; return IncomeBudgetReportXlsxExporter.Build(report, tree, budgetYear?.budget_year_name_th ?? "", displayName); } ``` If `v_budget_year`/`v_personnel_statement` aren't the exact `DbSet` property names on `Db`, grep for the actual names (`grep -rn "DbSet\|DbSet"`) and use those. - [ ] **Step 4: Build** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api dotnet build ``` Expected: `Build succeeded. 0 Error(s)`. - [ ] **Step 5: Verify by downloading and opening the file** ```bash curl -s "http://localhost:/api/setting/income_budget_report//export/xlsx" \ -H "Authorization: Bearer " -o /tmp/export-test.xlsx ``` Open `/tmp/export-test.xlsx` and confirm: title block at top, indented tree rows (not space-prefixed text — actual cell indent), bordered table, currency right-aligned with thousands separators, signature block at the bottom. - [ ] **Step 6: Flag for human review** In the task completion report, state explicitly: *"This export is a structural approximation built from the tree schema, not a reproduction of the real FastReport-generated government form (no template file was obtainable — see remediation plan Task 6 rationale). Before this is used for an actual government submission, compare it side-by-side against a real production `.xlsx` (e.g. once `rmutr.71dev.com/budget-api/...` is back up) and confirm with the human whether further layout changes are needed."* - [ ] **Step 7: Commit** ```bash git add Modules/IncomeBudgetReport/Services/IncomeBudgetReportXlsxExporter.cs Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs git commit -m "feat: replace flat xlsx export with a structured, form-styled workbook" ``` --- ### Task 7: Dual-write — mirror type-1 drafts into `revenue_draft_committee` **Files:** - Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs` - Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs` **Why:** Per the human's explicit "เปิดใช้เต็มรูปแบบเลย ทำ dual-write/union ข้อมูล" decision: type-2's picker (`income-budget-report.component.ts:132`, `RevenueDraftCommitteeSV.queryWithAgency('?type_id=1')`) and `change()` (reads `res.revenue_draft_committee_file`) still only read from the old `revenue_draft_committee` table. Rather than rewire type-2 (out of scope — it's still the untouched SyncFusion flow), `CreateDraft` and `UpdateLineAmounts` mirror a `revenue_draft_committee` row for every type-1 report, keeping its `revenue_draft_committee_file` populated with a freshly generated xlsx (via Task 6's exporter) using the exact same on-disk storage convention `DocumentUploadController` already uses (`Modules/Utilities/Controller/Upload.cs:23-60`) — writing directly to `application_setting.document_path` server-side, no client round-trip needed. **Interfaces:** - Consumes: `IncomeBudgetReportXlsxExporter.Build` (Task 6), `application_setting` (`Modules/Utilities/Models/application_setting.cs`, injected via `IOptions`), `t_revenue_draft_committee` (existing `DbSet` on `Db`, confirmed at `Modules/Setting/Databases/Db.cs:71`). - Produces: `income_budget_report.revenue_draft_committee_uid` is now always populated for type-1 reports after `CreateDraft`. - [ ] **Step 1: Inject `IOptions` into the service** In `IncomeBudgetReportService.cs`, update the constructor. This mirrors `DocumentUploadController`'s exact pattern (`Modules/Utilities/Controller/Upload.cs:16-21`), which injects `IOptions` and stores `.Value`: ```csharp private readonly Db _context; private readonly rmutp_mooc_api.Modules.Utilities.Models.application_setting _appSetting; public IncomeBudgetReportService(Db context, Microsoft.Extensions.Options.IOptions appSetting) : base(context) { _context = context; _appSetting = appSetting.Value; } ``` - [ ] **Step 2: Add the mirror-sync method** In `IncomeBudgetReportService.cs`, add (after `BuildXlsx`): ```csharp private async Task SyncRevenueDraftCommitteeMirror(Guid reportUid, ClaimsIdentity identity) { var report = await GetEntity(reportUid); if (report == null || report.type_id != 1) return; var tree = BuildTree(report); var bytes = await BuildXlsx(report, tree); if (!Directory.Exists(_appSetting.document_path)) { Directory.CreateDirectory(_appSetting.document_path); } var fileName = Guid.NewGuid() + ".xlsx"; await System.IO.File.WriteAllBytesAsync(Path.Combine(_appSetting.document_path, fileName), bytes); var fileUrl = _appSetting.document_url + fileName; var personnel = report.personnel_statement_uid != null ? await _context.v_personnel_statement.FirstOrDefaultAsync(p => p.personnel_statement_uid == report.personnel_statement_uid) : null; var displayName = personnel?.display_name ?? personnel?.agency_name_th ?? report.sector_name_th ?? ""; var mirror = report.revenue_draft_committee_uid != null ? await _context.t_revenue_draft_committee.FindAsync(report.revenue_draft_committee_uid) : null; if (mirror == null) { mirror = new t_revenue_draft_committee { revenue_draft_committee_uid = Guid.NewGuid(), type_id = 1, personnel_statement_uid = report.personnel_statement_uid, budget_year_uid = report.budget_year_uid, status_id = 1, owner_agency_uid = report.owner_agency_uid, }; await _context.t_revenue_draft_committee.AddAsync(mirror); var trackedReport = await _context.t_income_budget_report.FindAsync(reportUid); trackedReport.revenue_draft_committee_uid = mirror.revenue_draft_committee_uid; } mirror.revenue_draft_committee_name_th = displayName; mirror.revenue_draft_committee_file = fileUrl; await _context.SaveChangesAsync(identity); } ``` Add `using System.IO;` to the top of the file if not already present. - [ ] **Step 3: Call it from `CreateDraft` and after line updates** In `CreateDraft`, change the final two lines from: ```csharp return await GetEntity(reportUid); ``` to: ```csharp await SyncRevenueDraftCommitteeMirror(reportUid, identity); return await GetEntity(reportUid); ``` In `UpdateLineAmounts` (Task 5), change the end of the method — replace: ```csharp await _context.SaveChangesAsync(identity); return rejected; ``` with: ```csharp await _context.SaveChangesAsync(identity); var reportUids = updates.Select(u => u.LineUid).Distinct().ToList(); var affectedReportUids = await _context.t_income_budget_report_line .Where(l => reportUids.Contains(l.income_budget_report_line_uid.Value)) .Select(l => l.income_budget_report_uid.Value) .Distinct() .ToListAsync(); foreach (var reportUid in affectedReportUids) { await SyncRevenueDraftCommitteeMirror(reportUid, identity); } return rejected; ``` - [ ] **Step 4: Build** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api dotnet build ``` Expected: `Build succeeded. 0 Error(s)`. Fix any namespace mismatches against what `Upload.cs` actually uses (the exact `application_setting` namespace/using was copied from the earlier read of that file — verify it still matches). - [ ] **Step 5: Verify end to end** 1. `POST create_draft` for a type-1 personnel_statement → confirm the response's `revenue_draft_committee_uid` is non-null. 2. `GET /api/setting/revenue_draft_committee/` (or however the existing controller exposes single-record GET) → confirm `revenue_draft_committee_file` is a real, non-null URL and `type_id == 1`. 3. Download that URL and confirm it opens as a valid `.xlsx` (same file Task 6 verified). 4. `PUT lines` an amount change on that report → repeat step 2 and confirm `revenue_draft_committee_file` changed to a **new** URL (proving the mirror actually regenerates, not just points at a stale file). 5. `GET /api/setting/revenue_draft_committee/agency?type_id=1` (the exact call type-2's picker makes) → confirm the new draft appears in the list. - [ ] **Step 6: Commit** ```bash git add Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs git commit -m "feat: dual-write type-1 drafts into revenue_draft_committee so type-2's picker keeps working" ``` --- ### Task 8: Frontend — union legacy `revenue_draft_committee` rows into the type-1 list **Files:** - Modify: `rmutr-web/.claude/worktrees/income-budget-report-api/src/app/feature/income/draft/income-budget-report1/income-budget-report1-list/income-budget-report-list.component.ts` **Why:** After Task 7, every *new* type-1 draft is mirrored into `revenue_draft_committee`, so it would actually show up fine if the list just queried `revenue_draft_committee` directly (like it did before the original Group A plan). But **historical rows that predate this whole feature** only exist in `revenue_draft_committee` and have no matching `income_budget_report` row — those must still be reachable (view/export via the old download, since they have no tree/lines to edit natively), and must not be shown twice once a legacy row eventually gets superseded. Union both sources, dedupe by the link column from Task 2, and flag legacy-only rows so Task 9 can route their edit action correctly. **Interfaces:** - Consumes: `income_budget_report.revenue_draft_committee_uid` (Task 2), the already-dual-written mirror rows (Task 7). - Produces: each row in `summaryData` now carries `isLegacy: boolean`, consumed by Task 9's `onEdit`/`ondelete`. - [ ] **Step 1: Extend `mapType1Rows` to also carry the link field, and add a legacy-union helper** Replace the existing `mapType1Rows` method with: ```typescript /** type_id 1 drafts live in the new income_budget_report table (v_income_budget_report shape), * but this list's template renders revenue_draft_committee_* fields. Alias the display fields * onto the row so the existing template works unmodified, without stripping the real fields. */ private mapType1Rows(rows: any[], personnelList: any[], budgetYearList: any[]): any[] { return (rows || []).map((row: any) => { const personnel = (personnelList || []).find( (p: any) => p.personnel_statement_uid == row.personnel_statement_uid ) const budgetYear = (budgetYearList || []).find( (b: any) => b.budget_year_uid == row.budget_year_uid ) const displayName = personnel?.display_name || personnel?.agency_name_th || '' return { ...row, revenue_draft_committee_uid: row.income_budget_report_uid, revenue_draft_committee_name_th: displayName, budget_year_name_th: budgetYear?.budget_year_name_th, isLegacy: false, } }) } /** Historical type-1 rows that predate the native report table — they only exist in * revenue_draft_committee and have no income_budget_report counterpart. Included so * they stay reachable; excluded once a native row's dual-write has mirrored them * (matched via income_budget_report.revenue_draft_committee_uid, aliased to * revenue_draft_committee_uid on the mapped native rows above). */ private unionLegacyRows(nativeRows: any[], legacyRows: any[]): any[] { const mirroredUids = new Set((nativeRows || []).map((r: any) => r.revenue_draft_committee_uid).filter(Boolean)) const legacyOnly = (legacyRows || []) .filter((r: any) => !mirroredUids.has(r.revenue_draft_committee_uid)) .map((r: any) => ({ ...r, isLegacy: true })) return [...nativeRows, ...legacyOnly] } ``` - [ ] **Step 2: Fetch both sources and union them in `ngOnInit`** Replace the `concatMap` branch for `x.type == 1`: ```typescript concatMap(x=>{ if (x.type == 1) { return combineLatest([ this.IncomeBudgetReportSV.queryWithAgency('?type_id=1'), this.RevenueDraftCommitteeSV.queryWithAgency('?type_id=1'), this.personnelStatement$, this.budgetYear$, ]).pipe( map(([nativeRows, legacyRows, personnelList, budgetYearList]: [any[], any[], any[], any[]]) => this.unionLegacyRows(this.mapType1Rows(nativeRows, personnelList, budgetYearList), legacyRows) ) ) } return this.RevenueDraftCommitteeSV.queryWithAgency(`?type_id=${x.type}`) }), ``` - [ ] **Step 3: Same union in `search()`** Replace the `typeUrl == 1` branch of `source$`: ```typescript const source$ = this.typeUrl == 1 ? combineLatest([ this.IncomeBudgetReportSV.queryWithAgency(`?${queryStr}`), this.RevenueDraftCommitteeSV.queryWithAgency(`?${queryStr}`), this.personnelStatement$, this.budgetYear$, ]).pipe( map(([nativeRows, legacyRows, personnelList, budgetYearList]: [any[], any[], any[], any[]]) => this.unionLegacyRows(this.mapType1Rows(nativeRows, personnelList, budgetYearList), legacyRows) ) ) : this.RevenueDraftCommitteeSV.queryWithAgency(`?${queryStr}`) ``` - [ ] **Step 4: Build** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-web/.claude/worktrees/income-budget-report-api npx tsc --noEmit ng build --configuration production ``` Expected: zero new errors (ignore the pre-existing unrelated `form8.1.component.spec.ts` errors already noted in the Group A ledger). - [ ] **Step 5: Verify in the browser** Load `/app/income-budget-report-income1`. Confirm: rows created before this remediation (legacy, `revenue_draft_committee`-only) still appear; a freshly created draft (via `create_draft`, now dual-written) appears exactly once, not twice. - [ ] **Step 6: Commit** ```bash git add src/app/feature/income/draft/income-budget-report1/income-budget-report1-list/income-budget-report-list.component.ts git commit -m "feat: union legacy revenue_draft_committee rows into the type-1 list, deduped against dual-written drafts" ``` --- ### Task 9: Frontend — legacy-row edit routing, error/loading handling, decimal-input and indentation fixes **Files:** - Modify: `rmutr-web/.claude/worktrees/income-budget-report-api/src/app/feature/income/draft/income-budget-report1/income-budget-report1-list/income-budget-report-list.component.ts` - Modify: `rmutr-web/.claude/worktrees/income-budget-report-api/src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.ts` - Modify: `rmutr-web/.claude/worktrees/income-budget-report-api/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.ts` - Modify: `rmutr-web/.claude/worktrees/income-budget-report-api/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.html` - Modify: `rmutr-web/.claude/worktrees/income-budget-report-api/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.scss` **Why:** Four Important findings from the final review, all in scope for this remediation: (1) legacy rows (Task 8) have no `income_budget_report_uid` to edit natively — routing them into the tree UI would 404; (2) the whole native tree flow has zero error/loading handling, so any backend failure looks like nothing happened; (3) `pendingChanges`/`tree` aren't cleared when a user switches which personnel_statement they're viewing, so edits can leak across selections; (4) the amount `` bound via `[value]`+`(input)` loses a user's in-progress decimal typing (e.g. typing `"50."` resets to `0`), and the tree's CSS indentation compounds quadratically by depth. **Interfaces:** - Consumes: `row.isLegacy` (Task 8). - Produces: none consumed by later tasks (this is the last frontend task before verification). - [ ] **Step 1: Route legacy rows to the old edit flow via a query param** In `income-budget-report-list.component.ts`, replace `onEdit`: ```typescript onEdit(row: any){ const uid = row.revenue_draft_committee_uid const legacyQuery = row.isLegacy ? { queryParams: { legacy: '1' } } : {} if(this.typeUrl == 1){ this.router.navigate(['/app/income-budget-report-income1/edit', uid], legacyQuery) } else if(this.typeUrl == 2){ this.router.navigate(['/app/income-budget-report-income2/edit',uid]) } else if(this.typeUrl == 3){ this.router.navigate(['/app/income-budget-report-income3/edit',uid]) } else if(this.typeUrl == 4){ this.router.navigate(['/app/allocate-income-budget/edit',uid]) } else if(this.typeUrl == 5){ this.router.navigate(['/app/income-expense-estimates/edit',uid]) } else if(this.typeUrl == 6){ this.router.navigate(['/app/income-expense-estimates-02/edit',uid]) } else if(this.typeUrl == 10){ this.router.navigate(['/app/educational-materials/edit',uid]) } } ``` (signature changed from `onEdit(uid)` to `onEdit(row: any)` — update the template call site.) In `income-budget-report-list.component.html`, change the edit icon's click handler from `(click)="onEdit(x.revenue_draft_committee_uid)"` to `(click)="onEdit(x)"`. Also update `ondelete` to branch on legacy the same way — replace its body: ```typescript ondelete(value){ console.log(value) this.swSV.confirm().pipe( filter(x => x.isConfirmed), concatMap(x => { const delete$ = (this.typeUrl == 1 && !value.isLegacy) ? this.IncomeBudgetReportSV.deleteData(value.revenue_draft_committee_uid) : this.RevenueDraftCommitteeSV.deleteData(value.revenue_draft_committee_uid) return delete$.pipe( tap(x => this.swSV.removeSuccess()), tap(x => this.ngOnInit()), tap(x => this.cdRef.detectChanges()) ) }) ).subscribe() } ``` and in the template, change `(click)="ondelete(x.revenue_draft_committee_uid)"` to `(click)="ondelete(x)"`. - [ ] **Step 2: Make the edit-mode branch respect the legacy flag** In `income-budget-report.component.ts`, `ngOnInit`'s `case 'edit':` block, replace: ```typescript if (this.typeId == 1) { this.reportUid = this.uniquekey; this.loadTree(); } else { ``` with: ```typescript const isLegacyEdit = this.activatedRoute.snapshot.queryParams?.legacy === '1'; if (this.typeId == 1 && !isLegacyEdit) { this.reportUid = this.uniquekey; this.loadTree(); } else { ``` Add a field near the other type-1 state fields: ```typescript /** true when editing a legacy revenue_draft_committee-only row (no income_budget_report * counterpart) via the ?legacy=1 route — falls back to the old SyncFusion edit UI even * though typeId === 1. */ isLegacyEdit = false ``` and set it alongside the check above: ```typescript this.isLegacyEdit = this.activatedRoute.snapshot.queryParams?.legacy === '1'; if (this.typeId == 1 && !this.isLegacyEdit) { ``` (remove the local `const isLegacyEdit` and use `this.isLegacyEdit` in the `if` instead, to also expose it to the template.) In `income-budget-report.component.html`, change the top-level template gate from `*ngIf="typeId === 1; else oldReportUi"` to `*ngIf="typeId === 1 && !isLegacyEdit; else oldReportUi"`. This is the only change to that template's structure — everything inside `#oldReportUi` stays byte-identical, per the Global Constraint. - [ ] **Step 3: Add error/loading handling to the four native-flow service calls** In `income-budget-report.component.ts`, replace `onPersonnelStatementChange`: ```typescript onPersonnelStatementChange(personnelStatementUid: string): void { if (!personnelStatementUid) return; if (this.state === 'edit') return; this.pendingChanges.clear(); this.tree = null; this.loading(); this.incomeBudgetReportSV.createDraft(personnelStatementUid).pipe( tap((report: any) => { this.reportUid = report.income_budget_report_uid; this.loadTree(); }), catchError(err => { this.closeLoading(); Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error'); return throwError(err); }), ).subscribe(); } ``` Replace `loadTree`: ```typescript loadTree(): void { if (!this.reportUid) return; this.incomeBudgetReportSV.getTree(this.reportUid).pipe( tap((tree) => { this.tree = tree; this.closeLoading(); this.cdRef.detectChanges(); }), catchError(err => { this.closeLoading(); Swal.fire(err.error?.description || 'ไม่พบข้อมูลรายงาน', '', 'error'); return throwError(err); }), ).subscribe(); } ``` Replace `saveTree`: ```typescript /** Saves the edited line amounts of the native tree report. * Named apart from save() above, which saves the SyncFusion workbook for types 2..10. */ saveTree(): void { if (!this.reportUid || this.pendingChanges.size === 0) return; const lines = Array.from(this.pendingChanges.entries()).map(([lineUid, amount]) => ({ income_budget_report_line_uid: lineUid, amount, })); this.loading(); this.incomeBudgetReportSV.updateLineAmounts(lines).pipe( tap(() => { this.pendingChanges.clear(); this.closeLoading(); this.AppSV.swaltAlert('บันทึกสำเร็จ'); this.loadTree(); }), catchError(err => { this.closeLoading(); Swal.fire(err.error?.description || 'บันทึกไม่สำเร็จ', '', 'error'); return throwError(err); }), ).subscribe(); } ``` Replace `exportXlsx`: ```typescript exportXlsx(): void { if (!this.reportUid) return; const url = this.incomeBudgetReportSV.exportXlsxUrl(this.reportUid); this.loading(); this.incomeBudgetReportSV.getExcelReport(url).pipe( tap((fileUrl: any) => { this.closeLoading(); window.open(fileUrl); }), catchError(err => { this.closeLoading(); Swal.fire(err.error?.description || 'ส่งออกไม่สำเร็จ', '', 'error'); return throwError(err); }), ).subscribe(); } ``` `catchError`, `throwError`, and `Swal` are already imported in this file (confirmed: `catchError` is in the existing `rxjs/operators` import, `throwError` in the `rxjs` import, `Swal` via `import Swal from 'sweetalert2';`). - [ ] **Step 4: Fix the decimal-input round-trip bug** In `budget-report-tree-node.component.html`, change both amount inputs from `(input)` to `(change)` (matches user intent — commit on blur/enter, not on every keystroke, so a partial `"50."` isn't clobbered mid-type): ```html ``` and ```html ``` (`onLeafAmountInput`/`onRowAmountInput` in the `.ts` file don't need to change — they already parse the string value correctly; the bug was purely the `(input)` event firing on every keystroke against a `[value]` binding that immediately re-renders the parsed number.) - [ ] **Step 5: Fix the quadratic indentation** In `budget-report-tree-node.component.scss`, find the rule using `--depth` for `padding-left` (it's applied per-nesting-level, so nested `.tree-node`s compound it). Replace it so indentation is a flat per-level amount applied only once per node, not inherited from ancestors. Read the current file first to get the exact existing selector, then change the padding calculation from `calc(var(--depth,0) * 20px)` to a fixed `20px` applied unconditionally to `.tree-node .tree-node` (i.e. indent by one level relative to its immediate parent, not by absolute depth): ```scss .tree-node { .tree-node { padding-left: 20px; } } ``` Remove the `[style.--depth]` binding usage for padding purposes if the `.scss` no longer references `--depth` anywhere after this change — check `budget-report-tree-node.component.html:1`'s `[style.--depth]="node.depth"` binding and leave it in place only if something else in the `.scss` still uses it (e.g. for a `lvl-N` class-based rule); otherwise it's harmless to leave unused. - [ ] **Step 6: Build** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-web/.claude/worktrees/income-budget-report-api npx tsc --noEmit ng build --configuration production ``` Expected: zero new errors. - [ ] **Step 7: Verify in the browser** 1. Create a type-1 draft, disconnect network (devtools offline mode), try saving an amount — confirm an error `Swal` appears instead of silence. 2. Reconnect, edit an amount partially (type `"1234."` and pause) — confirm the field doesn't reset to `0` mid-type. 3. Pick personnel A, edit an amount, then pick personnel B without saving — confirm B's tree loads clean with no leftover pending change from A. 4. Visually inspect indentation at the deepest tree level (`out{N}-invest-equip`, depth 5) — confirm it's not visually indented ~300px, just proportionally nested. 5. Click edit on a legacy-flagged row from the list (Task 8) — confirm it opens the old SyncFusion UI (not a blank tree page). - [ ] **Step 8: Commit** ```bash git add src/app/feature/income/draft/income-budget-report1/income-budget-report1-list/income-budget-report-list.component.ts src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.ts src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.html src/app/shared/components/budget-report-tree/budget-report-tree-node.component.html src/app/shared/components/budget-report-tree/budget-report-tree-node.component.scss git commit -m "fix: legacy-row edit routing, error/loading handling, decimal input, and indentation" ``` --- ### Task 10: End-to-end verification against real data (both repos) **Files:** none (verification only — this is the equivalent of the original Group A plan's Task 13, which was never executed; that gap is why every defect this remediation plan fixes survived 12 clean task-level reviews) - [ ] **Step 1: Full round trip with real production-shaped data** Using a real (or realistic test) `personnel_statement_uid` with known ร.2 detail rows and, ideally, a `budget_year_uid` that also has ร.4/ร.5 rows: 1. Navigate to `/app/income-budget-report-income1/add`, pick the personnel_statement. 2. Confirm the tree renders with personnel rows (locked) **and** the ค่าตอบแทน/เงินเพิ่มค่าครองชีพ/งบรายจ่ายอื่นๆ fields editable (not showing `0` permanently) **and**, if the test data has ร.4/ร.5 rows, the ค่าครุภัณฑ์/ค่าที่ดินฯ tables populated. 3. Edit an unlocked amount (e.g. ค่าตอบแทน ค่าใช้สอยและค่าวัสดุ). Confirm the parent group totals update live in the browser. 4. Click บันทึก. Confirm a success message appears. 5. **Reload the page** (full browser refresh, not SPA navigation) and re-open the same report via the list's edit action. Confirm the edited amount survived — this is the check that would have caught the original silent-save-loss bug. 6. Click ส่งออก Excel. Confirm the file downloads (not a 401) and opens with the structured layout from Task 6. 7. Go to `/app/income-budget-report-income2/add`, open the picker. Confirm the type-1 report just created/edited appears in the list (proves the Task 7 dual-write worked end to end, not just in isolated `curl` checks). 8. Go back to `/app/income-budget-report-income1`, confirm the report appears exactly once (not duplicated against any legacy row). 9. If a genuine legacy row exists in this environment (predating this feature), click its edit action and confirm it opens the old SyncFusion UI correctly, and that types 2-10's own add/edit flows are visually and functionally unchanged from before this whole feature branch existed. - [ ] **Step 2: Record the result in the ledger** Append to `/Users/nut.looknut/Project/rmutr/.superpowers/sdd/2026-08-05-income-budget-report-native-group-a/progress.md`: ``` === Remediation plan (2026-08-05-income-budget-report-native-group-a-remediation.md) Tasks 1-10 complete. === Task 10 (end-to-end verification): [PASS/FAIL per step above — fill in actual results, do not mark complete without having actually run every step against a real environment] ``` - [ ] **Step 3: Request final whole-branch re-review** Once Task 10 passes for real, dispatch the same final whole-branch review process used for the original Group A plan (`superpowers:requesting-code-review`'s template, on the most capable model, `scripts/review-package` diffed from each worktree's original Group A merge-base through the new remediation HEAD) before merge — do not skip this because the individual remediation tasks were reviewed; the whole point of Group A's own final review was that whole-branch issues aren't visible task-by-task, and this remediation branch is now big enough to have the same risk.