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
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_050625at203.158.221.28:31620) viapsql.dotnet ef migrations addis 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,psqlquery,curl, or browser check) — run it and read the actual output before moving on. - All monetary fields end in
_amountor_priceso the project-wideOnModelCreatingconvention (rmutr-api/Databases/Features/Db.cs:91-99) automatically appliesnumeric(18,4)precision — do not set precision manually.income_budget_report_line.amount/original_amountare already plainnumericcolumns 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 havet_/v_write/read-view variants,HasOne/WithMany/HasForeignKeymust be configured on BOTH thet_andv_sides inDb.cs, or EF creates a broken shadow FK causing a runtime 500. This plan avoids the gotcha entirely by adding only plain scalarGuid?columns (no navigation properties) — do not add navigation properties forincome_budget_report.revenue_draft_committee_uid. - DI registration for every new service must go in a
Modules/<Module>/Configures/<Module>.csfile using thepublic static class XxxCollectionExtension { public static IServiceCollection AddXxxServices(this IServiceCollection services) { ... return services; } }pattern, invoked fromStartup.cs's service-registration block (around lines 155-183). This is how every other module in the codebase registers its services —IncomeBudgetReportServicecurrently 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 likeModules/IncomeBudgetReport/Config/ReportTreeNode.csalready does. - Frontend error handling convention for HTTP calls in this component:
catchError(err => { Swal.fire(err.error.description, '', 'error'); return throwError(err); }), matchingsaveComplete()inincome-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"})(externalSeventyOneDev.Utilitiespackage, no local source) returns the caller's agency uid(s) asList<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— addrevenue_draft_committee_uidscalar (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 onPUT 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)"
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
-- 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 byCreateDraftin 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 viaDbdirectly (_context.v_invest_asset_request_information,_context.v_invest_construct_request_information), filteredbudget_year_uid == statement.budget_year_uid(the report's own budget year — there is nofaculty_uidonpersonnel_statementto filter by, matching the already-approved Task-5 precedent of not having a faculty filter for this report; filtering bybudget_year_uidalone mirrorsBudgetProgressService.GetSummaryExpense's scoping intent as closely as this report's available data allows). -
Produces:
IncomeReportTreeSchema.OutputPrefixByLabel() : Dictionary<string, string>, consumed byCreateDraft. -
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:
UpdateLineAmountsnow returns400 BadRequestwith a description when a line is rejected, instead of silently succeeding. -
Step 1: Stamp
owner_agency_uidinCreateDraftandCloneNextRound
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
_dbloop
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 viaIOptions<application_setting>),t_revenue_draft_committee(existingDbSetonDb, confirmed atModules/Setting/Databases/Db.cs:71). -
Produces:
income_budget_report.revenue_draft_committee_uidis now always populated for type-1 reports afterCreateDraft. -
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
CreateDraftand 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
POST create_draftfor a type-1 personnel_statement → confirm the response'srevenue_draft_committee_uidis non-null.GET /api/setting/revenue_draft_committee/<that uid>(or however the existing controller exposes single-record GET) → confirmrevenue_draft_committee_fileis a real, non-null URL andtype_id == 1.- Download that URL and confirm it opens as a valid
.xlsx(same file Task 6 verified). PUT linesan amount change on that report → repeat step 2 and confirmrevenue_draft_committee_filechanged to a new URL (proving the mirror actually regenerates, not just points at a stale file).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
summaryDatanow carriesisLegacy: boolean, consumed by Task 9'sonEdit/ondelete. -
Step 1: Extend
mapType1Rowsto 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
- Create a type-1 draft, disconnect network (devtools offline mode), try saving an amount — confirm an error
Swalappears instead of silence. - Reconnect, edit an amount partially (type
"1234."and pause) — confirm the field doesn't reset to0mid-type. - 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.
- Visually inspect indentation at the deepest tree level (
out{N}-invest-equip, depth 5) — confirm it's not visually indented ~300px, just proportionally nested. - 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:
- Navigate to
/app/income-budget-report-income1/add, pick the personnel_statement. - Confirm the tree renders with personnel rows (locked) and the ค่าตอบแทน/เงินเพิ่มค่าครองชีพ/งบรายจ่ายอื่นๆ fields editable (not showing
0permanently) and, if the test data has ร.4/ร.5 rows, the ค่าครุภัณฑ์/ค่าที่ดินฯ tables populated. - Edit an unlocked amount (e.g. ค่าตอบแทน ค่าใช้สอยและค่าวัสดุ). Confirm the parent group totals update live in the browser.
- Click บันทึก. Confirm a success message appears.
- 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.
- Click ส่งออก Excel. Confirm the file downloads (not a 401) and opens with the structured layout from Task 6.
- 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 isolatedcurlchecks). - Go back to
/app/income-budget-report-income1, confirm the report appears exactly once (not duplicated against any legacy row). - 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.