Files
rmutr/docs/superpowers/plans/2026-08-05-income-budget-report-native-group-a-remediation.md
T
Nut.ไปเรื่อยandClaude Sonnet 5 4471b224cf docs: add remaining implementation plans
Adds plan docs that were pending in the working tree, matching the
existing docs/superpowers/plans/ pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf6qFtiQoRuSh9zQSPF1wS
2026-09-24 19:32:52 +07:00

71 KiB

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/<Module>/Configures/<Module>.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<string>{"agency_uid"}) (external SeventyOneDev.Utilities package, no local source) returns the caller's agency uid(s) as List<Guid?>; 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

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<IncomeBudgetReportService>();
            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:

services.AddPlanResultServices();
services.AddIncomeBudgetReportServices();

Add the using statement at the top of Startup.cs alongside the other Modules.*.Configures usings:

using rmutr_budget_api.Modules.IncomeBudgetReport.Configures;
  • Step 3: Build and verify by actually calling the endpoint, not swagger
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:

curl -s -o /dev/null -w "%{http_code}\n" http://localhost:<port>/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
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)"

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

-- 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
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:

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):

        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
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
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()):

        /// 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:

            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
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:

curl -s -X POST "http://localhost:<port>/api/setting/income_budget_report/create_draft?personnel_statement_uid=<uid>" \
  -H "Authorization: Bearer <token>" | 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
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<string, string>, consumed by CreateDraft.

  • Step 1: Add an output-label-to-prefix lookup to the schema

In IncomeReportTreeSchema.cs, add (after CollectLeafAmountKeys):

        /// 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<string, string> OutputPrefixByLabel()
        {
            var result = new Dictionary<string, string>();
            CollectOutputPrefixesRecursive(ForType1(), result);
            return result;
        }

        private static void CollectOutputPrefixesRecursive(ReportTreeNodeDef def, Dictionary<string, string> result)
        {
            if (def.Kind == "group" && def.NodeKey.StartsWith("out") && def.NodeKey.Length <= 4)
            {
                result[def.Label.Trim()] = def.NodeKey;
            }
            foreach (var child in def.Children)
            {
                CollectOutputPrefixesRecursive(child, result);
            }
        }

(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:

            var outputPrefixByLabel = IncomeReportTreeSchema.OutputPrefixByLabel();
            var unmatchedInvestLabels = new List<string>();

            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:

            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
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<v_invest_asset_request_information> / DbSet<v_invest_construct_request_information> 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
curl -s -X POST "http://localhost:<port>/api/setting/income_budget_report/create_draft?personnel_statement_uid=<uid>" \
  -H "Authorization: Bearer <token>" > /tmp/draft.json
curl -s "http://localhost:<port>/api/setting/income_budget_report/$(jq -r .income_budget_report_uid /tmp/draft.json)/tree" \
  -H "Authorization: Bearer <token>" | 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
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<string>) (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,):

                status_id = 0,
                owner_agency_uid = Helper.GetDataFilter(identity, new List<string> { "agency_uid" }).Where(a => a != Guid.Empty).FirstOrDefault(),

Do the same in CloneNextRound's clone object initializer:

                status_id = 0,
                owner_agency_uid = Helper.GetDataFilter(identity, new List<string> { "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):

        public async Task<List<string>> UpdateLineAmounts(List<(Guid LineUid, decimal Amount)> updates, ClaimsIdentity identity)
        {
            var agencies = Helper.GetDataFilter(identity, new List<string> { "agency_uid" }).Where(a => a != Guid.Empty).ToList();
            var rejected = new List<string>();

            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:

        [Authorize]
        [HttpPut("lines")]
        public async Task<ActionResult> UpdateLineAmounts([FromBody] List<LineAmountUpdate> 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
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
curl -s -X PUT "http://localhost:<port>/api/setting/income_budget_report/lines" \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '[{"income_budget_report_line_uid":"<a locked personnel line uid from GET .../tree>","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
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

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:

        [Authorize]
        [HttpGet("{uid:guid}/export/xlsx")]
        public async Task<ActionResult> 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):

        public async Task<byte[]> 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<v_budget_year>\|DbSet<v_personnel_statement>") and use those.

  • Step 4: Build
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
curl -s "http://localhost:<port>/api/setting/income_budget_report/<uid>/export/xlsx" \
  -H "Authorization: Bearer <token>" -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
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<application_setting>), 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<application_setting> 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<application_setting> and stores .Value:

        private readonly Db _context;
        private readonly rmutp_mooc_api.Modules.Utilities.Models.application_setting _appSetting;

        public IncomeBudgetReportService(Db context, Microsoft.Extensions.Options.IOptions<rmutp_mooc_api.Modules.Utilities.Models.application_setting> appSetting) : base(context)
        {
            _context = context;
            _appSetting = appSetting.Value;
        }
  • Step 2: Add the mirror-sync method

In IncomeBudgetReportService.cs, add (after BuildXlsx):

        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:

            return await GetEntity(reportUid);

to:

            await SyncRevenueDraftCommitteeMirror(reportUid, identity);
            return await GetEntity(reportUid);

In UpdateLineAmounts (Task 5), change the end of the method — replace:

            await _context.SaveChangesAsync(identity);
            return rejected;

with:

            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
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/<that uid> (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
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:

  /** 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:

      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$:

    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
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
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 <input type="number"> 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:

  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:

  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:

        if (this.typeId == 1) {
          this.reportUid = this.uniquekey;
          this.loadTree();
        } else {

with:

        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:

  /** 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:

        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:

  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:

  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:

  /** 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:

  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):

      <input
        *ngIf="!node.locked; else lockedAmount"
        type="number"
        class="amount-input"
        [value]="node.amount"
        (change)="onLeafAmountInput($any($event.target).value)"
      />

and

          <input
            *ngIf="!row.locked; else lockedRowAmount"
            type="number"
            class="amount-input"
            [value]="row.amount"
            (change)="onRowAmountInput(row, $any($event.target).value)"
          />

(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-nodes 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):

.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
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
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.