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
1273 lines
71 KiB
Markdown
1273 lines
71 KiB
Markdown
# 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**
|
|
|
|
```csharp
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using rmutr_budget_api.Modules.IncomeBudgetReport.Services;
|
|
|
|
namespace rmutr_budget_api.Modules.IncomeBudgetReport.Configures
|
|
{
|
|
public static class IncomeBudgetReportCollectionExtension
|
|
{
|
|
public static IServiceCollection AddIncomeBudgetReportServices(this IServiceCollection services)
|
|
{
|
|
services.AddScoped<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:
|
|
|
|
```csharp
|
|
services.AddPlanResultServices();
|
|
services.AddIncomeBudgetReportServices();
|
|
```
|
|
|
|
Add the using statement at the top of `Startup.cs` alongside the other `Modules.*.Configures` usings:
|
|
|
|
```csharp
|
|
using rmutr_budget_api.Modules.IncomeBudgetReport.Configures;
|
|
```
|
|
|
|
- [ ] **Step 3: Build and verify by actually calling the endpoint, not swagger**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api
|
|
dotnet build
|
|
```
|
|
Expected: `Build succeeded. 0 Error(s)`.
|
|
|
|
Then start the API locally (or use whatever run command the rest of this worktree's task reports used — check `.superpowers/sdd/2026-08-05-income-budget-report-native-group-a/task-8-report.md` for the exact local-run command) and hit the endpoint directly:
|
|
```bash
|
|
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:<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**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api
|
|
git add Modules/IncomeBudgetReport/Configures/IncomeBudgetReport.cs Startup.cs
|
|
git commit -m "fix: register IncomeBudgetReportService in DI (every endpoint was 500ing)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Migration — duplicate-draft unique index + dual-write link column
|
|
|
|
**Files:**
|
|
- Create: `rmutr-api/.claude/worktrees/income-budget-report-api/Migrations/add_income_budget_report_remediation.sql`
|
|
- Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Databases/Models/income_budget_report/income_budget_report.cs`
|
|
|
|
**Why:** (1) `CreateDraft`'s existing check-then-insert duplicate-draft guard has no backing constraint — a double-click still creates two drafts (this is the exact bug that triggered a Task-5 reopen during the original plan; a partial unique index closes it for good and also gives the lookup an index it currently lacks). (2) Task 8 (dual-write) needs a place to record which `revenue_draft_committee` row mirrors a given `income_budget_report` row.
|
|
|
|
**Interfaces:**
|
|
- Produces: `income_budget_report.revenue_draft_committee_uid : Guid?`, consumed by Task 8 (dual-write) and Task 9 (frontend list union, to dedupe legacy rows).
|
|
|
|
- [ ] **Step 1: Write the migration**
|
|
|
|
```sql
|
|
-- Migration: Remediation for income_budget_report (Group A native report).
|
|
-- Date: 2026-08-05
|
|
-- Description: (1) Partial unique index preventing duplicate type-1 drafts per personnel_statement_uid
|
|
-- (closes a known TOCTOU race in CreateDraft's check-then-insert guard).
|
|
-- (2) revenue_draft_committee_uid link column for the dual-write mirror
|
|
-- (see IncomeBudgetReportService.CreateDraft / SyncMirror).
|
|
-- Idempotent: safe to run again.
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS "UX_income_budget_report_personnel_statement_uid_type1"
|
|
ON "BUDGET".income_budget_report (personnel_statement_uid)
|
|
WHERE type_id = 1;
|
|
|
|
ALTER TABLE "BUDGET".income_budget_report
|
|
ADD COLUMN IF NOT EXISTS revenue_draft_committee_uid uuid;
|
|
|
|
CREATE OR REPLACE VIEW "BUDGET".v_income_budget_report AS
|
|
SELECT * FROM "BUDGET".income_budget_report;
|
|
```
|
|
|
|
- [ ] **Step 2: Apply it to production**
|
|
|
|
```bash
|
|
PGPASSWORD='vtwi,yo0tpkd-okfouh' psql -h 203.158.221.28 -p 31620 -U devadmin -d rmutr_budget_050625 \
|
|
-f /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api/Migrations/add_income_budget_report_remediation.sql
|
|
```
|
|
If the VPN to `203.158.221.28` is down, ask the human to reconnect before running this (established pattern from the rest of this session).
|
|
|
|
Verify:
|
|
```bash
|
|
PGPASSWORD='vtwi,yo0tpkd-okfouh' psql -h 203.158.221.28 -p 31620 -U devadmin -d rmutr_budget_050625 \
|
|
-c "\d \"BUDGET\".income_budget_report"
|
|
```
|
|
Expected: `revenue_draft_committee_uid` listed as a column, and `UX_income_budget_report_personnel_statement_uid_type1` listed as an index.
|
|
|
|
- [ ] **Step 3: Add the matching C# property**
|
|
|
|
In `income_budget_report.cs`, add the field to the base class (so both `t_income_budget_report` and `v_income_budget_report` inherit it — no navigation property, plain scalar, no FK gotcha):
|
|
|
|
```csharp
|
|
public Guid? parent_uid { get; set; }
|
|
public Guid? personnel_statement_uid { get; set; }
|
|
public Guid? revenue_draft_committee_uid { get; set; }
|
|
```
|
|
(insert the new line directly after the existing `personnel_statement_uid` line)
|
|
|
|
- [ ] **Step 4: Build**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api
|
|
dotnet build
|
|
```
|
|
Expected: `Build succeeded. 0 Error(s)`.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add Migrations/add_income_budget_report_remediation.sql Modules/IncomeBudgetReport/Databases/Models/income_budget_report/income_budget_report.cs
|
|
git commit -m "feat: add duplicate-draft unique index and revenue_draft_committee link column"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Seed every leaf-amount line (fixes the silent data-loss bug)
|
|
|
|
**Files:**
|
|
- Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs`
|
|
- Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs`
|
|
|
|
**Why this is Critical:** `CreateDraft` only ever creates lines for `node_key = "pb-temp-old"`/`"pb-temp-new"` (personnel sources). The other 13 `leaf-amount` nodes in the schema (`pb-cola-old`, `pb-cola-new`, `op-comp`, and `{out1..out5}-grant-proj`/`{out1..out5}-other`) have no backing line at all. `BuildNode` renders them as editable (`Locked = false`, `LineUid = null`) because there's no line to lock, but every edit collides on the same `null` key client-side and the backend silently skips a `null`-keyed line-uid update. **Not a single one of these 13 fields can currently be saved.** The design spec (`docs/superpowers/specs/2026-08-05-income-budget-report-native-design.md:77`) explicitly describes this case — "รายการที่ `source_uid = null`... แก้ไขได้ทั้งชื่อและจำนวนเงิน" (source_uid=null items are editable) — meaning these rows were always meant to exist with `source_uid = null`, `source_type = "manual"`; `CreateDraft` simply never created them.
|
|
|
|
**Interfaces:**
|
|
- Produces: `IncomeReportTreeSchema.CollectLeafAmountKeys() : List<(string NodeKey, string Label)>`, consumed by `CreateDraft` in this task.
|
|
|
|
- [ ] **Step 1: Add a leaf-amount key collector to the schema**
|
|
|
|
In `IncomeReportTreeSchema.cs`, add this method to the `IncomeReportTreeSchema` static class (after `ForType1()`):
|
|
|
|
```csharp
|
|
/// Every "leaf-amount" node key + label in the type-1 tree, in document order.
|
|
/// Used by CreateDraft to seed a zero-amount manual line for each one, so every
|
|
/// editable field has a real income_budget_report_line row to save against.
|
|
public static List<(string NodeKey, string Label)> CollectLeafAmountKeys()
|
|
{
|
|
var result = new List<(string, string)>();
|
|
CollectLeafAmountKeysRecursive(ForType1(), result);
|
|
return result;
|
|
}
|
|
|
|
private static void CollectLeafAmountKeysRecursive(ReportTreeNodeDef def, List<(string, string)> result)
|
|
{
|
|
if (def.Kind == "leaf-amount")
|
|
{
|
|
result.Add((def.NodeKey, def.Label));
|
|
return;
|
|
}
|
|
foreach (var child in def.Children)
|
|
{
|
|
CollectLeafAmountKeysRecursive(child, result);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Seed a manual line for every leaf-amount key in `CreateDraft`**
|
|
|
|
In `IncomeBudgetReportService.cs`, inside `CreateDraft`, after the existing `foreach (var d in statement.personnel_statement_details_2 ...)` block and before `await _context.t_income_budget_report.AddAsync(report);`, add:
|
|
|
|
```csharp
|
|
foreach (var (nodeKey, label) in IncomeReportTreeSchema.CollectLeafAmountKeys())
|
|
{
|
|
lines.Add(new t_income_budget_report_line
|
|
{
|
|
income_budget_report_line_uid = Guid.NewGuid(),
|
|
income_budget_report_uid = reportUid,
|
|
node_key = nodeKey,
|
|
source_type = "manual",
|
|
source_uid = null,
|
|
label_th = label,
|
|
qualification_th = null,
|
|
qty = null,
|
|
amount = 0,
|
|
original_amount = 0,
|
|
sequence_no = seq++,
|
|
});
|
|
}
|
|
```
|
|
|
|
This must run unconditionally (not gated on `statement != null`) — the leaf-amount fields aren't personnel-sourced, so a report should always get them.
|
|
|
|
- [ ] **Step 3: Build**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api
|
|
dotnet build
|
|
```
|
|
Expected: `Build succeeded. 0 Error(s)`.
|
|
|
|
- [ ] **Step 4: Verify against a real draft**
|
|
|
|
Pick a real `personnel_statement_uid` from production (or ask the human which one is safe to use for testing), call:
|
|
```bash
|
|
curl -s -X POST "http://localhost:<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**
|
|
|
|
```bash
|
|
git add Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs
|
|
git commit -m "fix: seed manual lines for every leaf-amount node (13 fields could not be saved before)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Wire ร.4/ร.5 (invest_asset/invest_construct) into the leaf-table nodes
|
|
|
|
**Files:**
|
|
- Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs`
|
|
- Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs`
|
|
|
|
**Why:** The type-1 schema has 10 `leaf-table` nodes (`{out1..out5}-invest-equip`/`{out1..out5}-invest-land`) meant to hold ร.4 (`invest_asset_request_information`)/ร.5 (`invest_construct_request_information`) rows for that output ("ผลผลิต"). `CreateDraft` never queries either table, so these always render `"ยังไม่มีรายการ"`. The human explicitly approved wiring this in now rather than deferring it.
|
|
|
|
**Grouping approach:** `invest_asset_request_information`/`invest_construct_request_information` don't carry a `node_key` — they're matched to an output node by their `budget_project_name_th` (the "ผลผลิต" name), compared against the schema's own `out1`..`out5` group labels (which are the real ผลผลิต names, verified against production — see `IncomeReportTreeSchema.cs:43-51`). A row whose `budget_project_name_th` doesn't match any of the 5 output labels is skipped (logged, not silently discarded — see Step 3) rather than guessed at, since there is no existing convention in this codebase for approval-status filtering on these two tables (confirmed: `BudgetProgressService.GetSummaryExpense` doesn't filter on `is_approve` either) and inventing one here would be a new, undiscussed policy.
|
|
|
|
**Interfaces:**
|
|
- Consumes: `invest_asset_request_information`/`invest_construct_request_information` — read via `Db` directly (`_context.v_invest_asset_request_information`, `_context.v_invest_construct_request_information`), filtered `budget_year_uid == statement.budget_year_uid` (the report's own budget year — there is no `faculty_uid` on `personnel_statement` to filter by, matching the already-approved Task-5 precedent of not having a faculty filter for this report; filtering by `budget_year_uid` alone mirrors `BudgetProgressService.GetSummaryExpense`'s scoping intent as closely as this report's available data allows).
|
|
- Produces: `IncomeReportTreeSchema.OutputPrefixByLabel() : Dictionary<string, string>`, consumed by `CreateDraft`.
|
|
|
|
- [ ] **Step 1: Add an output-label-to-prefix lookup to the schema**
|
|
|
|
In `IncomeReportTreeSchema.cs`, add (after `CollectLeafAmountKeys`):
|
|
|
|
```csharp
|
|
/// Maps each "ผลผลิต" (output) label to its node-key prefix ("out1".."out5"), so
|
|
/// CreateDraft can match invest_asset/invest_construct rows (which carry a
|
|
/// budget_project_name_th, not a node_key) to the correct leaf-table node.
|
|
public static Dictionary<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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
if (unmatchedInvestLabels.Any())
|
|
{
|
|
Console.WriteLine($"[IncomeBudgetReportService.CreateDraft] {unmatchedInvestLabels.Count} invest row(s) did not match any output label and were skipped: {string.Join(", ", unmatchedInvestLabels.Distinct())}");
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Build**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api
|
|
dotnet build
|
|
```
|
|
Expected: `Build succeeded. 0 Error(s)`. If `v_invest_asset_request_information`/`v_invest_construct_request_information` aren't already `DbSet`-exposed on `Db`, this step will fail with a compile error naming the missing property — if that happens, find the exact `DbSet<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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
git add Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs
|
|
git commit -m "feat: source invest_asset/invest_construct (ร.4/ร.5) into the leaf-table nodes"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Agency-ownership guard on `PUT lines`, and reject writes to locked lines
|
|
|
|
**Files:**
|
|
- Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs`
|
|
- Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs`
|
|
|
|
**Why:** `PUT .../lines` currently looks up each line by bare uid and overwrites it with no check that the line's report belongs to the caller's agency, and no check on `source_uid` — the UI-only `locked` flag can be bypassed entirely via direct API call, letting any authenticated user rewrite any faculty's report, including authoritative ร.2/ร.4/ร.5-sourced figures. There is no existing precedent in this codebase for a write-time ownership guard (confirmed — every `Helper.GetDataFilter` usage is either a read-list filter or a create-time stamp), so this task also has to make `CreateDraft`/`CloneNextRound` actually stamp `owner_agency_uid` (currently unset on both), since the guard needs something real to check.
|
|
|
|
**Interfaces:**
|
|
- Consumes: `Helper.GetDataFilter(ClaimsIdentity, List<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,`):
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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`):
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
[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**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api
|
|
dotnet build
|
|
```
|
|
Expected: `Build succeeded. 0 Error(s)`.
|
|
|
|
- [ ] **Step 5: Verify the guard rejects a locked-line write**
|
|
|
|
```bash
|
|
curl -s -X PUT "http://localhost:<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**
|
|
|
|
```bash
|
|
git add Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs
|
|
git commit -m "fix: guard PUT lines against cross-agency writes and locked-line edits"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Structured xlsx export matching the official form's shape
|
|
|
|
**Files:**
|
|
- Create: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportXlsxExporter.cs`
|
|
- Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs`
|
|
|
|
**Why:** The current export (`WriteNode`) is a flat 2-column `label`/`amount` dump with space-indentation — nothing like the real government form. **No literal `.xlsx` template file exists anywhere reachable from this codebase** (confirmed: the real form is rendered by a separate paid FastReport microservice, `rmutr_report`, using `.frx` templates that cannot be loaded by EPPlus; the production blank-template URL the old SyncFusion UI used is currently returning `503`). Per the human's explicit decision, this task builds a **new, EPPlus-constructed workbook that replicates the government form's structure** (title block, indented tree with real merged/bordered cells, right-aligned currency, signature block) using the schema's own hierarchy and labels — not a pixel-identical reproduction of the FastReport output. This must be reviewed against the real form by a human before this feature is considered fully production-ready for actual government submission (flag this explicitly in the task's completion report).
|
|
|
|
**Interfaces:**
|
|
- Consumes: `ReportTreeNode` (existing, `Modules/IncomeBudgetReport/Config/ReportTreeNode.cs`), `v_income_budget_report` (for header fields: `sector_name_th`, `budget_year_uid` — resolve the year's display name via `_context.v_budget_year`).
|
|
- Produces: `IncomeBudgetReportXlsxExporter.Build(v_income_budget_report report, ReportTreeNode tree, string budgetYearNameTh, string personnelStatementDisplayName) : byte[]`, consumed by the controller (this task) and by Task 8 (dual-write, server-side generation).
|
|
|
|
- [ ] **Step 1: Write the exporter**
|
|
|
|
```csharp
|
|
using System.Linq;
|
|
using OfficeOpenXml;
|
|
using OfficeOpenXml.Style;
|
|
using rmutr_budget_api.Modules.IncomeBudgetReport.Config;
|
|
using rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models;
|
|
|
|
namespace rmutr_budget_api.Modules.IncomeBudgetReport.Services
|
|
{
|
|
public static class IncomeBudgetReportXlsxExporter
|
|
{
|
|
public static byte[] Build(v_income_budget_report report, ReportTreeNode tree, string budgetYearNameTh, string personnelStatementDisplayName)
|
|
{
|
|
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
|
|
using var package = new ExcelPackage();
|
|
var ws = package.Workbook.Worksheets.Add("Page1");
|
|
|
|
ws.Column(1).Width = 55;
|
|
ws.Column(2).Width = 20;
|
|
ws.Column(3).Width = 12;
|
|
ws.Column(4).Width = 18;
|
|
|
|
ws.Cells["A1:D1"].Merge = true;
|
|
ws.Cells["A1"].Value = "ร่างเงินรายได้ เพื่อประชุมคณะกรรมการร่างเงินรายได้";
|
|
ws.Cells["A1"].Style.Font.Bold = true;
|
|
ws.Cells["A1"].Style.Font.Size = 14;
|
|
ws.Cells["A1"].Style.HorizontalAlignment = ExcelHorizontalAlignment.Center;
|
|
|
|
ws.Cells["A2:D2"].Merge = true;
|
|
ws.Cells["A2"].Value = $"หน่วยงาน: {personnelStatementDisplayName} ปีงบประมาณ: {budgetYearNameTh}";
|
|
ws.Cells["A2"].Style.HorizontalAlignment = ExcelHorizontalAlignment.Center;
|
|
|
|
int headerRow = 4;
|
|
ws.Cells[headerRow, 1].Value = "รายการ";
|
|
ws.Cells[headerRow, 2].Value = "อัตรา/วุฒิ";
|
|
ws.Cells[headerRow, 3].Value = "จำนวน";
|
|
ws.Cells[headerRow, 4].Value = "จำนวนเงิน (บาท)";
|
|
using (var headerRange = ws.Cells[headerRow, 1, headerRow, 4])
|
|
{
|
|
headerRange.Style.Font.Bold = true;
|
|
headerRange.Style.Fill.PatternType = ExcelFillStyle.Solid;
|
|
headerRange.Style.Fill.BackgroundColor.SetColor(System.Drawing.Color.LightGray);
|
|
headerRange.Style.Border.BorderAround(ExcelBorderStyle.Thin);
|
|
}
|
|
|
|
int row = headerRow + 1;
|
|
WriteNode(tree, ws, ref row);
|
|
|
|
using (var dataRange = ws.Cells[headerRow, 1, row - 1, 4])
|
|
{
|
|
dataRange.Style.Border.Top.Style = ExcelBorderStyle.Thin;
|
|
dataRange.Style.Border.Bottom.Style = ExcelBorderStyle.Thin;
|
|
dataRange.Style.Border.Left.Style = ExcelBorderStyle.Thin;
|
|
dataRange.Style.Border.Right.Style = ExcelBorderStyle.Thin;
|
|
}
|
|
|
|
row += 3;
|
|
ws.Cells[row, 1].Value = "ลงชื่อ .............................................. ผู้จัดทำ";
|
|
row += 2;
|
|
ws.Cells[row, 1].Value = "ลงชื่อ .............................................. ประธานคณะกรรมการร่างเงินรายได้";
|
|
row += 2;
|
|
ws.Cells[row, 1].Value = "วันที่ .............../.............../...............";
|
|
|
|
ws.View.FreezePanes(headerRow + 1, 1);
|
|
|
|
return package.GetAsByteArray();
|
|
}
|
|
|
|
private static void WriteNode(ReportTreeNode node, ExcelWorksheet ws, ref int row)
|
|
{
|
|
var cell = ws.Cells[row, 1];
|
|
cell.Value = node.Label;
|
|
cell.Style.Indent = node.Depth;
|
|
if (node.Kind == "group")
|
|
{
|
|
cell.Style.Font.Bold = true;
|
|
}
|
|
var amountCell = ws.Cells[row, 4];
|
|
amountCell.Value = node.Amount;
|
|
amountCell.Style.Numberformat.Format = "#,##0.00";
|
|
amountCell.Style.HorizontalAlignment = ExcelHorizontalAlignment.Right;
|
|
row++;
|
|
|
|
if (node.Kind == "leaf-table")
|
|
{
|
|
foreach (var r in node.Rows)
|
|
{
|
|
var rowCell = ws.Cells[row, 1];
|
|
rowCell.Value = "- " + r.Label;
|
|
rowCell.Style.Indent = node.Depth + 1;
|
|
ws.Cells[row, 2].Value = r.Qualification;
|
|
ws.Cells[row, 3].Value = r.Qty;
|
|
var rowAmountCell = ws.Cells[row, 4];
|
|
rowAmountCell.Value = r.Amount;
|
|
rowAmountCell.Style.Numberformat.Format = "#,##0.00";
|
|
rowAmountCell.Style.HorizontalAlignment = ExcelHorizontalAlignment.Right;
|
|
row++;
|
|
}
|
|
}
|
|
|
|
foreach (var child in node.Children)
|
|
{
|
|
WriteNode(child, ws, ref row);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Use it from the controller**
|
|
|
|
In `IncomeBudgetReportController.cs`, replace the `ExportXlsx` action and delete the old private `WriteNode` method entirely:
|
|
|
|
```csharp
|
|
[Authorize]
|
|
[HttpGet("{uid:guid}/export/xlsx")]
|
|
public async Task<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):
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api
|
|
dotnet build
|
|
```
|
|
Expected: `Build succeeded. 0 Error(s)`.
|
|
|
|
- [ ] **Step 5: Verify by downloading and opening the file**
|
|
|
|
```bash
|
|
curl -s "http://localhost:<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**
|
|
|
|
```bash
|
|
git add Modules/IncomeBudgetReport/Services/IncomeBudgetReportXlsxExporter.cs Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs
|
|
git commit -m "feat: replace flat xlsx export with a structured, form-styled workbook"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Dual-write — mirror type-1 drafts into `revenue_draft_committee`
|
|
|
|
**Files:**
|
|
- Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs`
|
|
- Modify: `rmutr-api/.claude/worktrees/income-budget-report-api/Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs`
|
|
|
|
**Why:** Per the human's explicit "เปิดใช้เต็มรูปแบบเลย ทำ dual-write/union ข้อมูล" decision: type-2's picker (`income-budget-report.component.ts:132`, `RevenueDraftCommitteeSV.queryWithAgency('?type_id=1')`) and `change()` (reads `res.revenue_draft_committee_file`) still only read from the old `revenue_draft_committee` table. Rather than rewire type-2 (out of scope — it's still the untouched SyncFusion flow), `CreateDraft` and `UpdateLineAmounts` mirror a `revenue_draft_committee` row for every type-1 report, keeping its `revenue_draft_committee_file` populated with a freshly generated xlsx (via Task 6's exporter) using the exact same on-disk storage convention `DocumentUploadController` already uses (`Modules/Utilities/Controller/Upload.cs:23-60`) — writing directly to `application_setting.document_path` server-side, no client round-trip needed.
|
|
|
|
**Interfaces:**
|
|
- Consumes: `IncomeBudgetReportXlsxExporter.Build` (Task 6), `application_setting` (`Modules/Utilities/Models/application_setting.cs`, injected via `IOptions<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`:
|
|
|
|
```csharp
|
|
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`):
|
|
|
|
```csharp
|
|
private async Task SyncRevenueDraftCommitteeMirror(Guid reportUid, ClaimsIdentity identity)
|
|
{
|
|
var report = await GetEntity(reportUid);
|
|
if (report == null || report.type_id != 1) return;
|
|
|
|
var tree = BuildTree(report);
|
|
var bytes = await BuildXlsx(report, tree);
|
|
|
|
if (!Directory.Exists(_appSetting.document_path))
|
|
{
|
|
Directory.CreateDirectory(_appSetting.document_path);
|
|
}
|
|
var fileName = Guid.NewGuid() + ".xlsx";
|
|
await System.IO.File.WriteAllBytesAsync(Path.Combine(_appSetting.document_path, fileName), bytes);
|
|
var fileUrl = _appSetting.document_url + fileName;
|
|
|
|
var personnel = report.personnel_statement_uid != null
|
|
? await _context.v_personnel_statement.FirstOrDefaultAsync(p => p.personnel_statement_uid == report.personnel_statement_uid)
|
|
: null;
|
|
var displayName = personnel?.display_name ?? personnel?.agency_name_th ?? report.sector_name_th ?? "";
|
|
|
|
var mirror = report.revenue_draft_committee_uid != null
|
|
? await _context.t_revenue_draft_committee.FindAsync(report.revenue_draft_committee_uid)
|
|
: null;
|
|
|
|
if (mirror == null)
|
|
{
|
|
mirror = new t_revenue_draft_committee
|
|
{
|
|
revenue_draft_committee_uid = Guid.NewGuid(),
|
|
type_id = 1,
|
|
personnel_statement_uid = report.personnel_statement_uid,
|
|
budget_year_uid = report.budget_year_uid,
|
|
status_id = 1,
|
|
owner_agency_uid = report.owner_agency_uid,
|
|
};
|
|
await _context.t_revenue_draft_committee.AddAsync(mirror);
|
|
var trackedReport = await _context.t_income_budget_report.FindAsync(reportUid);
|
|
trackedReport.revenue_draft_committee_uid = mirror.revenue_draft_committee_uid;
|
|
}
|
|
|
|
mirror.revenue_draft_committee_name_th = displayName;
|
|
mirror.revenue_draft_committee_file = fileUrl;
|
|
|
|
await _context.SaveChangesAsync(identity);
|
|
}
|
|
```
|
|
|
|
Add `using System.IO;` to the top of the file if not already present.
|
|
|
|
- [ ] **Step 3: Call it from `CreateDraft` and after line updates**
|
|
|
|
In `CreateDraft`, change the final two lines from:
|
|
```csharp
|
|
return await GetEntity(reportUid);
|
|
```
|
|
to:
|
|
```csharp
|
|
await SyncRevenueDraftCommitteeMirror(reportUid, identity);
|
|
return await GetEntity(reportUid);
|
|
```
|
|
|
|
In `UpdateLineAmounts` (Task 5), change the end of the method — replace:
|
|
```csharp
|
|
await _context.SaveChangesAsync(identity);
|
|
return rejected;
|
|
```
|
|
with:
|
|
```csharp
|
|
await _context.SaveChangesAsync(identity);
|
|
|
|
var reportUids = updates.Select(u => u.LineUid).Distinct().ToList();
|
|
var affectedReportUids = await _context.t_income_budget_report_line
|
|
.Where(l => reportUids.Contains(l.income_budget_report_line_uid.Value))
|
|
.Select(l => l.income_budget_report_uid.Value)
|
|
.Distinct()
|
|
.ToListAsync();
|
|
foreach (var reportUid in affectedReportUids)
|
|
{
|
|
await SyncRevenueDraftCommitteeMirror(reportUid, identity);
|
|
}
|
|
|
|
return rejected;
|
|
```
|
|
|
|
- [ ] **Step 4: Build**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-api/.claude/worktrees/income-budget-report-api
|
|
dotnet build
|
|
```
|
|
Expected: `Build succeeded. 0 Error(s)`. Fix any namespace mismatches against what `Upload.cs` actually uses (the exact `application_setting` namespace/using was copied from the earlier read of that file — verify it still matches).
|
|
|
|
- [ ] **Step 5: Verify end to end**
|
|
|
|
1. `POST create_draft` for a type-1 personnel_statement → confirm the response's `revenue_draft_committee_uid` is non-null.
|
|
2. `GET /api/setting/revenue_draft_committee/<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**
|
|
|
|
```bash
|
|
git add Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs
|
|
git commit -m "feat: dual-write type-1 drafts into revenue_draft_committee so type-2's picker keeps working"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Frontend — union legacy `revenue_draft_committee` rows into the type-1 list
|
|
|
|
**Files:**
|
|
- Modify: `rmutr-web/.claude/worktrees/income-budget-report-api/src/app/feature/income/draft/income-budget-report1/income-budget-report1-list/income-budget-report-list.component.ts`
|
|
|
|
**Why:** After Task 7, every *new* type-1 draft is mirrored into `revenue_draft_committee`, so it would actually show up fine if the list just queried `revenue_draft_committee` directly (like it did before the original Group A plan). But **historical rows that predate this whole feature** only exist in `revenue_draft_committee` and have no matching `income_budget_report` row — those must still be reachable (view/export via the old download, since they have no tree/lines to edit natively), and must not be shown twice once a legacy row eventually gets superseded. Union both sources, dedupe by the link column from Task 2, and flag legacy-only rows so Task 9 can route their edit action correctly.
|
|
|
|
**Interfaces:**
|
|
- Consumes: `income_budget_report.revenue_draft_committee_uid` (Task 2), the already-dual-written mirror rows (Task 7).
|
|
- Produces: each row in `summaryData` now carries `isLegacy: boolean`, consumed by Task 9's `onEdit`/`ondelete`.
|
|
|
|
- [ ] **Step 1: Extend `mapType1Rows` to also carry the link field, and add a legacy-union helper**
|
|
|
|
Replace the existing `mapType1Rows` method with:
|
|
|
|
```typescript
|
|
/** type_id 1 drafts live in the new income_budget_report table (v_income_budget_report shape),
|
|
* but this list's template renders revenue_draft_committee_* fields. Alias the display fields
|
|
* onto the row so the existing template works unmodified, without stripping the real fields. */
|
|
private mapType1Rows(rows: any[], personnelList: any[], budgetYearList: any[]): any[] {
|
|
return (rows || []).map((row: any) => {
|
|
const personnel = (personnelList || []).find(
|
|
(p: any) => p.personnel_statement_uid == row.personnel_statement_uid
|
|
)
|
|
const budgetYear = (budgetYearList || []).find(
|
|
(b: any) => b.budget_year_uid == row.budget_year_uid
|
|
)
|
|
const displayName = personnel?.display_name || personnel?.agency_name_th || ''
|
|
return {
|
|
...row,
|
|
revenue_draft_committee_uid: row.income_budget_report_uid,
|
|
revenue_draft_committee_name_th: displayName,
|
|
budget_year_name_th: budgetYear?.budget_year_name_th,
|
|
isLegacy: false,
|
|
}
|
|
})
|
|
}
|
|
|
|
/** Historical type-1 rows that predate the native report table — they only exist in
|
|
* revenue_draft_committee and have no income_budget_report counterpart. Included so
|
|
* they stay reachable; excluded once a native row's dual-write has mirrored them
|
|
* (matched via income_budget_report.revenue_draft_committee_uid, aliased to
|
|
* revenue_draft_committee_uid on the mapped native rows above). */
|
|
private unionLegacyRows(nativeRows: any[], legacyRows: any[]): any[] {
|
|
const mirroredUids = new Set((nativeRows || []).map((r: any) => r.revenue_draft_committee_uid).filter(Boolean))
|
|
const legacyOnly = (legacyRows || [])
|
|
.filter((r: any) => !mirroredUids.has(r.revenue_draft_committee_uid))
|
|
.map((r: any) => ({ ...r, isLegacy: true }))
|
|
return [...nativeRows, ...legacyOnly]
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Fetch both sources and union them in `ngOnInit`**
|
|
|
|
Replace the `concatMap` branch for `x.type == 1`:
|
|
|
|
```typescript
|
|
concatMap(x=>{
|
|
if (x.type == 1) {
|
|
return combineLatest([
|
|
this.IncomeBudgetReportSV.queryWithAgency('?type_id=1'),
|
|
this.RevenueDraftCommitteeSV.queryWithAgency('?type_id=1'),
|
|
this.personnelStatement$,
|
|
this.budgetYear$,
|
|
]).pipe(
|
|
map(([nativeRows, legacyRows, personnelList, budgetYearList]: [any[], any[], any[], any[]]) =>
|
|
this.unionLegacyRows(this.mapType1Rows(nativeRows, personnelList, budgetYearList), legacyRows)
|
|
)
|
|
)
|
|
}
|
|
return this.RevenueDraftCommitteeSV.queryWithAgency(`?type_id=${x.type}`)
|
|
}),
|
|
```
|
|
|
|
- [ ] **Step 3: Same union in `search()`**
|
|
|
|
Replace the `typeUrl == 1` branch of `source$`:
|
|
|
|
```typescript
|
|
const source$ = this.typeUrl == 1
|
|
? combineLatest([
|
|
this.IncomeBudgetReportSV.queryWithAgency(`?${queryStr}`),
|
|
this.RevenueDraftCommitteeSV.queryWithAgency(`?${queryStr}`),
|
|
this.personnelStatement$,
|
|
this.budgetYear$,
|
|
]).pipe(
|
|
map(([nativeRows, legacyRows, personnelList, budgetYearList]: [any[], any[], any[], any[]]) =>
|
|
this.unionLegacyRows(this.mapType1Rows(nativeRows, personnelList, budgetYearList), legacyRows)
|
|
)
|
|
)
|
|
: this.RevenueDraftCommitteeSV.queryWithAgency(`?${queryStr}`)
|
|
```
|
|
|
|
- [ ] **Step 4: Build**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-web/.claude/worktrees/income-budget-report-api
|
|
npx tsc --noEmit
|
|
ng build --configuration production
|
|
```
|
|
Expected: zero new errors (ignore the pre-existing unrelated `form8.1.component.spec.ts` errors already noted in the Group A ledger).
|
|
|
|
- [ ] **Step 5: Verify in the browser**
|
|
|
|
Load `/app/income-budget-report-income1`. Confirm: rows created before this remediation (legacy, `revenue_draft_committee`-only) still appear; a freshly created draft (via `create_draft`, now dual-written) appears exactly once, not twice.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/app/feature/income/draft/income-budget-report1/income-budget-report1-list/income-budget-report-list.component.ts
|
|
git commit -m "feat: union legacy revenue_draft_committee rows into the type-1 list, deduped against dual-written drafts"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Frontend — legacy-row edit routing, error/loading handling, decimal-input and indentation fixes
|
|
|
|
**Files:**
|
|
- Modify: `rmutr-web/.claude/worktrees/income-budget-report-api/src/app/feature/income/draft/income-budget-report1/income-budget-report1-list/income-budget-report-list.component.ts`
|
|
- Modify: `rmutr-web/.claude/worktrees/income-budget-report-api/src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.ts`
|
|
- Modify: `rmutr-web/.claude/worktrees/income-budget-report-api/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.ts`
|
|
- Modify: `rmutr-web/.claude/worktrees/income-budget-report-api/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.html`
|
|
- Modify: `rmutr-web/.claude/worktrees/income-budget-report-api/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.scss`
|
|
|
|
**Why:** Four Important findings from the final review, all in scope for this remediation: (1) legacy rows (Task 8) have no `income_budget_report_uid` to edit natively — routing them into the tree UI would 404; (2) the whole native tree flow has zero error/loading handling, so any backend failure looks like nothing happened; (3) `pendingChanges`/`tree` aren't cleared when a user switches which personnel_statement they're viewing, so edits can leak across selections; (4) the amount `<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`:
|
|
|
|
```typescript
|
|
onEdit(row: any){
|
|
const uid = row.revenue_draft_committee_uid
|
|
const legacyQuery = row.isLegacy ? { queryParams: { legacy: '1' } } : {}
|
|
if(this.typeUrl == 1){
|
|
this.router.navigate(['/app/income-budget-report-income1/edit', uid], legacyQuery)
|
|
}
|
|
else if(this.typeUrl == 2){
|
|
this.router.navigate(['/app/income-budget-report-income2/edit',uid])
|
|
}
|
|
else if(this.typeUrl == 3){
|
|
this.router.navigate(['/app/income-budget-report-income3/edit',uid])
|
|
}
|
|
else if(this.typeUrl == 4){
|
|
this.router.navigate(['/app/allocate-income-budget/edit',uid])
|
|
}
|
|
else if(this.typeUrl == 5){
|
|
this.router.navigate(['/app/income-expense-estimates/edit',uid])
|
|
}
|
|
else if(this.typeUrl == 6){
|
|
this.router.navigate(['/app/income-expense-estimates-02/edit',uid])
|
|
}
|
|
else if(this.typeUrl == 10){
|
|
this.router.navigate(['/app/educational-materials/edit',uid])
|
|
}
|
|
}
|
|
```
|
|
(signature changed from `onEdit(uid)` to `onEdit(row: any)` — update the template call site.)
|
|
|
|
In `income-budget-report-list.component.html`, change the edit icon's click handler from `(click)="onEdit(x.revenue_draft_committee_uid)"` to `(click)="onEdit(x)"`.
|
|
|
|
Also update `ondelete` to branch on legacy the same way — replace its body:
|
|
```typescript
|
|
ondelete(value){
|
|
console.log(value)
|
|
this.swSV.confirm().pipe(
|
|
filter(x => x.isConfirmed),
|
|
concatMap(x => {
|
|
const delete$ = (this.typeUrl == 1 && !value.isLegacy)
|
|
? this.IncomeBudgetReportSV.deleteData(value.revenue_draft_committee_uid)
|
|
: this.RevenueDraftCommitteeSV.deleteData(value.revenue_draft_committee_uid)
|
|
return delete$.pipe(
|
|
tap(x => this.swSV.removeSuccess()),
|
|
tap(x => this.ngOnInit()),
|
|
tap(x => this.cdRef.detectChanges())
|
|
)
|
|
})
|
|
).subscribe()
|
|
}
|
|
```
|
|
and in the template, change `(click)="ondelete(x.revenue_draft_committee_uid)"` to `(click)="ondelete(x)"`.
|
|
|
|
- [ ] **Step 2: Make the edit-mode branch respect the legacy flag**
|
|
|
|
In `income-budget-report.component.ts`, `ngOnInit`'s `case 'edit':` block, replace:
|
|
```typescript
|
|
if (this.typeId == 1) {
|
|
this.reportUid = this.uniquekey;
|
|
this.loadTree();
|
|
} else {
|
|
```
|
|
with:
|
|
```typescript
|
|
const isLegacyEdit = this.activatedRoute.snapshot.queryParams?.legacy === '1';
|
|
if (this.typeId == 1 && !isLegacyEdit) {
|
|
this.reportUid = this.uniquekey;
|
|
this.loadTree();
|
|
} else {
|
|
```
|
|
Add a field near the other type-1 state fields:
|
|
```typescript
|
|
/** true when editing a legacy revenue_draft_committee-only row (no income_budget_report
|
|
* counterpart) via the ?legacy=1 route — falls back to the old SyncFusion edit UI even
|
|
* though typeId === 1. */
|
|
isLegacyEdit = false
|
|
```
|
|
and set it alongside the check above:
|
|
```typescript
|
|
this.isLegacyEdit = this.activatedRoute.snapshot.queryParams?.legacy === '1';
|
|
if (this.typeId == 1 && !this.isLegacyEdit) {
|
|
```
|
|
(remove the local `const isLegacyEdit` and use `this.isLegacyEdit` in the `if` instead, to also expose it to the template.)
|
|
|
|
In `income-budget-report.component.html`, change the top-level template gate from `*ngIf="typeId === 1; else oldReportUi"` to `*ngIf="typeId === 1 && !isLegacyEdit; else oldReportUi"`. This is the only change to that template's structure — everything inside `#oldReportUi` stays byte-identical, per the Global Constraint.
|
|
|
|
- [ ] **Step 3: Add error/loading handling to the four native-flow service calls**
|
|
|
|
In `income-budget-report.component.ts`, replace `onPersonnelStatementChange`:
|
|
```typescript
|
|
onPersonnelStatementChange(personnelStatementUid: string): void {
|
|
if (!personnelStatementUid) return;
|
|
if (this.state === 'edit') return;
|
|
this.pendingChanges.clear();
|
|
this.tree = null;
|
|
this.loading();
|
|
this.incomeBudgetReportSV.createDraft(personnelStatementUid).pipe(
|
|
tap((report: any) => {
|
|
this.reportUid = report.income_budget_report_uid;
|
|
this.loadTree();
|
|
}),
|
|
catchError(err => {
|
|
this.closeLoading();
|
|
Swal.fire(err.error?.description || 'เกิดข้อผิดพลาด', '', 'error');
|
|
return throwError(err);
|
|
}),
|
|
).subscribe();
|
|
}
|
|
```
|
|
|
|
Replace `loadTree`:
|
|
```typescript
|
|
loadTree(): void {
|
|
if (!this.reportUid) return;
|
|
this.incomeBudgetReportSV.getTree(this.reportUid).pipe(
|
|
tap((tree) => { this.tree = tree; this.closeLoading(); this.cdRef.detectChanges(); }),
|
|
catchError(err => {
|
|
this.closeLoading();
|
|
Swal.fire(err.error?.description || 'ไม่พบข้อมูลรายงาน', '', 'error');
|
|
return throwError(err);
|
|
}),
|
|
).subscribe();
|
|
}
|
|
```
|
|
|
|
Replace `saveTree`:
|
|
```typescript
|
|
/** Saves the edited line amounts of the native tree report.
|
|
* Named apart from save() above, which saves the SyncFusion workbook for types 2..10. */
|
|
saveTree(): void {
|
|
if (!this.reportUid || this.pendingChanges.size === 0) return;
|
|
const lines = Array.from(this.pendingChanges.entries()).map(([lineUid, amount]) => ({
|
|
income_budget_report_line_uid: lineUid,
|
|
amount,
|
|
}));
|
|
this.loading();
|
|
this.incomeBudgetReportSV.updateLineAmounts(lines).pipe(
|
|
tap(() => {
|
|
this.pendingChanges.clear();
|
|
this.closeLoading();
|
|
this.AppSV.swaltAlert('บันทึกสำเร็จ');
|
|
this.loadTree();
|
|
}),
|
|
catchError(err => {
|
|
this.closeLoading();
|
|
Swal.fire(err.error?.description || 'บันทึกไม่สำเร็จ', '', 'error');
|
|
return throwError(err);
|
|
}),
|
|
).subscribe();
|
|
}
|
|
```
|
|
|
|
Replace `exportXlsx`:
|
|
```typescript
|
|
exportXlsx(): void {
|
|
if (!this.reportUid) return;
|
|
const url = this.incomeBudgetReportSV.exportXlsxUrl(this.reportUid);
|
|
this.loading();
|
|
this.incomeBudgetReportSV.getExcelReport(url).pipe(
|
|
tap((fileUrl: any) => { this.closeLoading(); window.open(fileUrl); }),
|
|
catchError(err => {
|
|
this.closeLoading();
|
|
Swal.fire(err.error?.description || 'ส่งออกไม่สำเร็จ', '', 'error');
|
|
return throwError(err);
|
|
}),
|
|
).subscribe();
|
|
}
|
|
```
|
|
|
|
`catchError`, `throwError`, and `Swal` are already imported in this file (confirmed: `catchError` is in the existing `rxjs/operators` import, `throwError` in the `rxjs` import, `Swal` via `import Swal from 'sweetalert2';`).
|
|
|
|
- [ ] **Step 4: Fix the decimal-input round-trip bug**
|
|
|
|
In `budget-report-tree-node.component.html`, change both amount inputs from `(input)` to `(change)` (matches user intent — commit on blur/enter, not on every keystroke, so a partial `"50."` isn't clobbered mid-type):
|
|
|
|
```html
|
|
<input
|
|
*ngIf="!node.locked; else lockedAmount"
|
|
type="number"
|
|
class="amount-input"
|
|
[value]="node.amount"
|
|
(change)="onLeafAmountInput($any($event.target).value)"
|
|
/>
|
|
```
|
|
and
|
|
```html
|
|
<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-node`s compound it). Replace it so indentation is a flat per-level amount applied only once per node, not inherited from ancestors. Read the current file first to get the exact existing selector, then change the padding calculation from `calc(var(--depth,0) * 20px)` to a fixed `20px` applied unconditionally to `.tree-node .tree-node` (i.e. indent by one level relative to its immediate parent, not by absolute depth):
|
|
|
|
```scss
|
|
.tree-node {
|
|
.tree-node {
|
|
padding-left: 20px;
|
|
}
|
|
}
|
|
```
|
|
Remove the `[style.--depth]` binding usage for padding purposes if the `.scss` no longer references `--depth` anywhere after this change — check `budget-report-tree-node.component.html:1`'s `[style.--depth]="node.depth"` binding and leave it in place only if something else in the `.scss` still uses it (e.g. for a `lvl-N` class-based rule); otherwise it's harmless to leave unused.
|
|
|
|
- [ ] **Step 6: Build**
|
|
|
|
```bash
|
|
cd /Users/nut.looknut/Project/rmutr/rmutr-web/.claude/worktrees/income-budget-report-api
|
|
npx tsc --noEmit
|
|
ng build --configuration production
|
|
```
|
|
Expected: zero new errors.
|
|
|
|
- [ ] **Step 7: Verify in the browser**
|
|
|
|
1. Create a type-1 draft, disconnect network (devtools offline mode), try saving an amount — confirm an error `Swal` appears instead of silence.
|
|
2. Reconnect, edit an amount partially (type `"1234."` and pause) — confirm the field doesn't reset to `0` mid-type.
|
|
3. Pick personnel A, edit an amount, then pick personnel B without saving — confirm B's tree loads clean with no leftover pending change from A.
|
|
4. Visually inspect indentation at the deepest tree level (`out{N}-invest-equip`, depth 5) — confirm it's not visually indented ~300px, just proportionally nested.
|
|
5. Click edit on a legacy-flagged row from the list (Task 8) — confirm it opens the old SyncFusion UI (not a blank tree page).
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add src/app/feature/income/draft/income-budget-report1/income-budget-report1-list/income-budget-report-list.component.ts src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.ts src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.html src/app/shared/components/budget-report-tree/budget-report-tree-node.component.html src/app/shared/components/budget-report-tree/budget-report-tree-node.component.scss
|
|
git commit -m "fix: legacy-row edit routing, error/loading handling, decimal input, and indentation"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: End-to-end verification against real data (both repos)
|
|
|
|
**Files:** none (verification only — this is the equivalent of the original Group A plan's Task 13, which was never executed; that gap is why every defect this remediation plan fixes survived 12 clean task-level reviews)
|
|
|
|
- [ ] **Step 1: Full round trip with real production-shaped data**
|
|
|
|
Using a real (or realistic test) `personnel_statement_uid` with known ร.2 detail rows and, ideally, a `budget_year_uid` that also has ร.4/ร.5 rows:
|
|
1. Navigate to `/app/income-budget-report-income1/add`, pick the personnel_statement.
|
|
2. Confirm the tree renders with personnel rows (locked) **and** the ค่าตอบแทน/เงินเพิ่มค่าครองชีพ/งบรายจ่ายอื่นๆ fields editable (not showing `0` permanently) **and**, if the test data has ร.4/ร.5 rows, the ค่าครุภัณฑ์/ค่าที่ดินฯ tables populated.
|
|
3. Edit an unlocked amount (e.g. ค่าตอบแทน ค่าใช้สอยและค่าวัสดุ). Confirm the parent group totals update live in the browser.
|
|
4. Click บันทึก. Confirm a success message appears.
|
|
5. **Reload the page** (full browser refresh, not SPA navigation) and re-open the same report via the list's edit action. Confirm the edited amount survived — this is the check that would have caught the original silent-save-loss bug.
|
|
6. Click ส่งออก Excel. Confirm the file downloads (not a 401) and opens with the structured layout from Task 6.
|
|
7. Go to `/app/income-budget-report-income2/add`, open the picker. Confirm the type-1 report just created/edited appears in the list (proves the Task 7 dual-write worked end to end, not just in isolated `curl` checks).
|
|
8. Go back to `/app/income-budget-report-income1`, confirm the report appears exactly once (not duplicated against any legacy row).
|
|
9. If a genuine legacy row exists in this environment (predating this feature), click its edit action and confirm it opens the old SyncFusion UI correctly, and that types 2-10's own add/edit flows are visually and functionally unchanged from before this whole feature branch existed.
|
|
|
|
- [ ] **Step 2: Record the result in the ledger**
|
|
|
|
Append to `/Users/nut.looknut/Project/rmutr/.superpowers/sdd/2026-08-05-income-budget-report-native-group-a/progress.md`:
|
|
```
|
|
=== Remediation plan (2026-08-05-income-budget-report-native-group-a-remediation.md) Tasks 1-10 complete. ===
|
|
Task 10 (end-to-end verification): [PASS/FAIL per step above — fill in actual results, do not mark complete without having actually run every step against a real environment]
|
|
```
|
|
|
|
- [ ] **Step 3: Request final whole-branch re-review**
|
|
|
|
Once Task 10 passes for real, dispatch the same final whole-branch review process used for the original Group A plan (`superpowers:requesting-code-review`'s template, on the most capable model, `scripts/review-package` diffed from each worktree's original Group A merge-base through the new remediation HEAD) before merge — do not skip this because the individual remediation tasks were reviewed; the whole point of Group A's own final review was that whole-branch issues aren't visible task-by-task, and this remediation branch is now big enough to have the same risk.
|