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
44 KiB
Income Budget Report Native (type 1) — Auto-pull ร.3/ร.4/ร.5/ร.6
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: The type-1 native report (docs/superpowers/specs/2026-08-05-income-budget-report-native-design.md) currently only auto-pulls ร.2 (personnel) data — every other section (op-comp = งบดำเนินงาน/ร.3, {out}-grant-proj/{out}-other = เงินอุดหนุน/งบรายจ่ายอื่นๆ from ร.6, {out}-invest-equip/{out}-invest-land = ร.4/ร.5) is a $0 manual placeholder, seeded by an earlier remediation round that explicitly deferred wiring real data into them (see ledger .superpowers/sdd/2026-08-05-income-budget-report-native-group-a/progress.md line 53/63). The design spec always intended these to auto-pull (ขอบเขต/สถาปัตยกรรม sections) — this plan wires the four remaining data sources so create_draft produces a report whose numbers match a real downloaded reference report (a full งบประมาณรายจ่ายจากเงินรายได้ PDF for คณะอุตสาหกรรมและเทคโนโลยี / วิทยาเขตวังไกลกังวล / ปีงบประมาณ 2569, provided by the human — kept at /Users/nut.looknut/Downloads/download.pdf at plan-writing time) node for node, not just the personnel section.
Architecture: Continues directly on the existing worktrees/branches (rmutr-api/.worktrees/income-budget-report-group-a, rmutr-web/.worktrees/income-budget-report-group-a) — do not create new worktrees. No new tables; income_budget_report_line already supports arbitrary node_key/source_type/source_uid rows. Extends IncomeReportTreeSchema.ForType1() so op-comp becomes a group of 4 leaf-tables (was a single leaf-amount) and {out}-grant-proj/{out}-other become leaf-tables (were leaf-amounts), matching how {out}-invest-equip/{out}-invest-land already work. Extends IncomeBudgetReportService.CreateDraft to query four more EF-mapped views and generate t_income_budget_report_line rows for them, keyed the same way the rest of CreateDraft already is (by personnel_statement's own fields — see "Matching key" below).
Tech Stack: ASP.NET Core 8 / EF Core 8, Angular / RxJS. No automated tests in either repo — every task's verification step is dotnet build + a real curl/browser check against production data, comparing to the reference PDF.
Matching key (read this before touching any task)
⚠️ Corrected 2026-08-06/07 after Task 1 ran against production — the budget_year_uid part of this section was wrong. Keeping the wrong version below struck through so later tasks' briefs (already extracted before this correction) can be cross-checked against what actually changed; every task in this plan now uses the corrected key.
personnel_statement (ร.2, what CreateDraft already has in hand) carries no faculty_uid/budget_location_uid GUIDs, and — confirmed by direct query, SELECT count(*), count(budget_year_uid) FROM "BUDGET".personnel_statement → 21, 0 — its budget_year_uid column is NULL for every row in production, so it cannot be used as a join key at all. What it does reliably carry: academic_year_name_th (a string year number, e.g. "2568"), parent_agency_name (e.g. "คณะอุตสาหกรรมและเทคโนโลยี" — a faculty/department-level value, confirmed by direct query against a real record), agency_name_th (e.g. "วิทยาเขตวังไกลกังวล" — a campus/location-level value), sector (e.g. "ปริญญาตรี ภาคปกติ"). Every other source table's EF view (v_invest_asset_request_information, v_invest_construct_request_information, v_request_budget_income, v_budget_progress/v_budget_progress_list) denormalizes the matching strings as faculty_name_th/budget_location_name_th/budget_year_number (int, not the useless budget_year_uid) — v_budget_progress additionally carries sector_name_th, required for that source specifically (see Task 2). So every new query in this plan filters by, at minimum:
var yearNumber = int.TryParse(statement.academic_year_name_th, out var parsedYear) ? (int?)parsedYear : null;
// yearNumber == null short-circuits to an empty result (logged), never throws — see Task 1's BuildInvestLines for the exact pattern.
...
c.budget_year_number == yearNumber
&& c.faculty_name_th == statement.parent_agency_name
&& c.budget_location_name_th == statement.agency_name_th
// + c.sector_name_th == statement.sector, for v_budget_progress only (Task 2) — confirmed required there
// via two real production rows for the same faculty/location/year/output that differ ONLY by sector
// (6,719,700 for "ปริญญาตรี ภาคปกติ" vs 736,600 for "ภาคปกติ ฤดูร้อน") and would otherwise be
// ambiguous. Not required for invest_asset/invest_construct/request_budget_income — confirmed via
// Task 1's live verification that faculty+location+year alone was sufficient and unambiguous there.
c.budget_year_uid == statement.budget_year_uid ← do not use this, it is always comparing against NULL and will silently match zero real rows.
This is exact-match (not .Contains()/fuzzy, unlike the legacy Personnel.cs V3 report) — these are picklist-sourced values on both sides, not free text. Confirmed working end-to-end by Task 1's live verification against production (zero-data and real-data cases both checked, see the plan's ledger for the exact numbers).
Global Constraints
- Manual SQL migrations only if a migration turns out to be needed (it shouldn't — no schema change, only new queries and a new nullable DTO field). If you find yourself needing one, stop and confirm with the human first — this plan was scoped assuming none are required.
dotnet buildafter every task;Build succeeded. 0 Error(s)is the bar, same as every other plan in this repo.- Frontend error handling convention:
catchError(err => { Swal.fire(err.error.description, '', 'error'); return throwError(err); }). - Every new field added to
ReportTreeNode/ReportTreeTableRowneeds[JsonProperty("camelCaseName")](Newtonsoft, no camelCase policy globally). - Do not touch
CloneNextRound's own logic — it clones lines generically by copying every existingt_income_budget_report_linerow regardless ofnode_key, so it needs no changes for this plan's new node keys to flow through correctly on a round-2/3 clone. Task 4 just verifies this is actually true post-schema-change; it does not modifyCloneNextRound. - Types 2–10 (
income-budget-report.component.ts, the old SyncFusion paths) must remain byte-for-byte unchanged.
File Map
Backend (rmutr-api/.worktrees/income-budget-report-group-a):
- Modify:
Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs— restructureop-comp,{out}-grant-proj,{out}-other(Tasks 1–3) - Modify:
Modules/IncomeBudgetReport/Config/ReportTreeNode.cs— addPricePerUnittoReportTreeTableRow(Task 1) - Modify:
Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs— new query/seed logic inCreateDraft,BuildNoderow-mapping for the new fields (Tasks 1–3)
Frontend (rmutr-web/.worktrees/income-budget-report-group-a):
- Modify:
src/app/shared/components/budget-report-tree/budget-report-tree-node.component.ts/.html/.scss— renderpricePerUnitcolumn when present, generalize the leaf-table column headers (Task 5)
Task 1: Wire ร.4/ร.5 (invest_asset/invest_construct) into the existing leaf-table nodes
Files:
- Modify:
Modules/IncomeBudgetReport/Config/ReportTreeNode.cs - Modify:
Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs
Why: {out1..out5}-invest-equip/{out1..out5}-invest-land are already leaf-table nodes in the schema (IncomeReportTreeSchema.cs:64-65), tagged SourceType = "invest_asset"/"invest_construct", but CreateDraft never queries either table — they always render "ยังไม่มีรายการ". This task is the most mechanical of the four (both tables are already flat, already leaf-tables — no schema restructuring needed, just query + row mapping), so do it first to validate the matching-key approach in "Matching key" above before the harder Tasks 2–3.
Interfaces:
-
Consumes:
_context.v_invest_asset_request_information,_context.v_invest_construct_request_information(both already registeredDbSets — confirmed viaModules/InvestInformations/Databases/Models/...). -
Produces:
IncomeReportTreeSchema.OutputPrefixByLabel() : Dictionary<string,string>(maps a ผลผลิต label to itsout1..out5node-key prefix), consumed by this task and Task 3. -
Step 1: Add
PricePerUnittoReportTreeTableRow
In ReportTreeNode.cs, add one field so the frontend can render the PDF's full รายการ/จำนวน/หน่วยนับ/ราคาต่อหน่วย/รวมเงิน table for ค่าครุภัณฑ์ rows (construct rows will leave it null):
public class ReportTreeTableRow
{
[JsonProperty("lineUid")] public string LineUid { get; set; }
[JsonProperty("label")] public string Label { get; set; }
[JsonProperty("qualification")] public string Qualification { get; set; }
[JsonProperty("qty")] public decimal? Qty { get; set; }
[JsonProperty("pricePerUnit")] public decimal? PricePerUnit { get; set; }
[JsonProperty("amount")] public decimal Amount { get; set; }
[JsonProperty("locked")] public bool Locked { get; set; }
}
t_income_budget_report_line already has no price_per_unit column — check Modules/IncomeBudgetReport/Databases/Models/income_budget_report_line/income_budget_report_line.cs for the exact column list. If it's missing, add a plain nullable decimal? price_per_unit scalar column via a manual SQL migration (ALTER TABLE "BUDGET".income_budget_report_line ADD COLUMN IF NOT EXISTS price_per_unit numeric;) and the matching C# property + v_income_budget_report_line mirror — otherwise skip straight to Step 2. (This is the one place this plan might need a migration — everything else reads from existing views without writing new columns.)
- Step 2: Add the output-label lookup to the schema
In IncomeReportTreeSchema.cs, add (the docstring explains why prefix matching, not node_key, is needed — invest rows carry a budget_project_name_th, which is the ผลผลิต's label, not any node_key):
/// Maps each "ผลผลิต" (output) label to its node-key prefix ("out1".."out5"), so
/// CreateDraft can match invest_asset/invest_construct/request_budget_income rows
/// (which carry a budget_project_name_th, not a node_key) to the right output 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);
}
}
- Step 3: Query and seed invest rows in
CreateDraft
In IncomeBudgetReportService.cs, inside CreateDraft, after the BuildMissingManualLines/personnel-line-building block and before await _context.t_income_budget_report.AddAsync(report);, add a block that:
- Queries
v_invest_asset_request_information/v_invest_construct_request_informationfiltered by the three-field match key from "Matching key" above. - For each row, looks up
OutputPrefixByLabel()[row.budget_project_name_th.Trim()]to get theout{N}prefix; rows whose label doesn't match any of the 5 known ผลผลิต labels are skipped and logged (Console.WriteLine, not silently dropped) — this schema was verified against one real production file, a label drift for a different faculty is possible and must be visible, not invisible. - Builds
t_income_budget_report_linerows:node_key = "{prefix}-invest-equip"/source_type = "invest_asset"/source_uid = <uid>/label_th = invest_asset_request_information_name_th/qualification_th = budget_unit_name_th(unit label) /qty = amount_unit/amount = original_amount = total_unit_price ?? 0; and for construct:node_key = "{prefix}-invest-land"/source_type = "invest_construct"/label_th = invest_construct_request_information_name_th/qty = null/qualification_th = null/amount = original_amount = budget ?? 0. price_per_unit(if you added the column in Step 1) is set froma.price_per_unitfor invest_asset rows only.
- Step 4: Map the new row fields in
BuildNode
In BuildNode's leaf-table branch, add PricePerUnit = line.price_per_unit (or however Step 1 named the column) to the ReportTreeTableRow construction — only if Step 1 added the column.
- Step 5: Build
cd /Users/nut.looknut/Project/rmutr/rmutr-api/.worktrees/income-budget-report-group-a
dotnet build
Expected: Build succeeded. 0 Error(s).
- Step 6: Verify against the reference PDF
Create (or re-create — CreateDraft is idempotent and self-heals missing lines on an existing draft, see its existing != null branch) a draft for the personnel_statement matching พื้นที่ = คณะอุตสาหกรรมและเทคโนโลยี / หน่วยงาน = วิทยาเขตวังไกลกังวล / ปี 2569 (ask the human for the exact personnel_statement_uid if it's not obvious from queryWithAgency), then GET .../tree and confirm every {out}-invest-equip/{out}-invest-land node's row count and amounts match the reference PDF's ค่าครุภัณฑ์/ค่าที่ดินและสิ่งก่อสร้าง tables for that output (the sample PDF shows these as empty/0 for this particular faculty/year — if that's still true, confirm the query ran and found zero matching rows, not that it silently errored; check the unmatchedInvestLabels-style log line is empty too).
- Step 7: Commit
git add Modules/IncomeBudgetReport/Config/ReportTreeNode.cs 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 2: Wire ร.3 (v_budget_progress) into op-comp AND a new per-output "ค่าใช้จ่ายดำเนินงาน" section
⚠️ Revised after direct production-DB verification (2026-08-06/07), before this task was ever dispatched — the original version of this task only covered the top-level op-comp node. Verifying against the real reference agency's data (คณะอุตสาหกรรมและเทคโนโลยี/วิทยาเขตวังไกลกังวล/2569) revealed v_budget_progress also backs a SECOND, per-output section the first draft of this plan missed entirely — see "Why" below for the arithmetic proof. This version supersedes the original; there is no other copy to reconcile against.
Files:
- Modify:
Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs - Modify:
Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs
Why: op-comp ("ค่าตอบแทน ค่าใช้สอยและค่าวัสดุ", top-level, sibling of plan1/plan2/plan3) is currently a single leaf-amount, manually seeded at $0. The reference PDF shows it fully itemized: ค่าตอบแทน / ค่าใช้สอย / ค่าวัสดุ / ค่าสาธารณูปโภค, each with named line items and its own subtotal — these 4 category names are budget_progress_type_name values on v_budget_progress's nested budget_progress_types; each type's line items are its nested budget_progress_lists (label = budget_progress_list_name, amount = price).
But direct production queries against the reference agency/year found 4 v_budget_progress rows, not 1:
budget_progress_uid |
budget_progress_id |
budget_project_name_th |
sector_name_th |
total_price |
|---|---|---|---|---|
831453fb-... |
1 | ผลผลิต ผู้สำเร็จการศึกษาด้านวิทยาศาสตร์และเทคโนโลยี | ปริญญาตรี ภาคปกติ | 12,758,064 |
03a08b8b-... |
2 | รายการงบบุคลากร | ปริญญาตรี ภาคปกติ | 118,700 ← matches the PDF's top-level งบดำเนินงาน exactly |
8bfddd85-... |
2 | ผลผลิต ผู้สำเร็จการศึกษาด้านวิทยาศาสตร์และเทคโนโลยี | ปริญญาตรี ภาคปกติ | 6,719,700 ← matches the PDF's out1 "ค่าใช้จ่ายดำเนินงาน" exactly |
32783f05-... |
2 | ผลผลิต ผู้สำเร็จการศึกษาด้านวิทยาศาสตร์และเทคโนโลยี | ภาคปกติ ฤดูร้อน | 736,600 |
This proves three things at once: (1) budget_progress_id == 2 is confirmed correct (matches the one existing precedent, BudgetProgressService.GetSummaryExpense's budget_progress_id == 2 filter) — row 831453fb (id=1) is excluded correctly by this filter and its huge total (12.7M) does NOT belong in this report; (2) the top-level op-comp section is matched by a literal constant budget_project_name_th == "รายการงบบุคลากร", not any output label — do not run it through OutputPrefixByLabel(); (3) v_budget_progress has a second, per-output row (budget_project_name_th == the output's own label, e.g. out1's "ผลผลิต ผู้สำเร็จการศึกษาด้านวิทยาศาสตร์และเทคโนโลยี") that must ALSO be matched by sector_name_th == statement.sector — without the sector filter, 8bfddd85 (6,719,700, the right one — sector_name_th = "ปริญญาตรี ภาคปกติ", matching the reference personnel_statement.sector) is ambiguous against 32783f05 (736,600, wrong one — sector_name_th = "ภาคปกติ ฤดูร้อน", a summer-term row for the same output/faculty/year/plan that does not belong in this report).
Arithmetic proof this per-output row is real and its PDF placement is "ค่าใช้จ่ายดำเนินงาน" under "งบเงินอุดหนุน", a SIBLING of "เงินอุดหนุนค่าใช้จ่ายโครงการ" (not its parent or child): the reference PDF's out1 (ผลผลิต ผู้สำเร็จการศึกษาด้านวิทยาศาสตร์และเทคโนโลยี) totals 6,885,900. Its printed children are: งบเงินอุดหนุน = 6,719,700 (= exactly this 8bfddd85 row's total_price, with NO addition for grant projects — proving the projects list is not nested inside it), เงินอุดหนุนค่าใช้จ่ายโครงการ = 166,200, งบลงทุน = 0, งบรายจ่ายอื่นๆ = 0. 6,719,700 + 166,200 + 0 + 0 = 6,885,900 — matches the ผลผลิต total exactly, confirming เงินอุดหนุนค่าใช้จ่ายโครงการ (Task 3's {prefix}-grant-proj) is a sibling of งบเงินอุดหนุน at the output level, not a child of it as the original (now superseded) schema draft had it. This task restructures the schema accordingly — Task 3, when it runs, must build on this task's shape ({prefix}-grant now only contains {prefix}-grant-opex; {prefix}-grant-proj moves out to be {prefix}'s own direct child, alongside {prefix}-grant/{prefix}-invest/{prefix}-other).
Interfaces:
-
Consumes:
_context.v_budget_progress.Include(c => c.budget_progress_types).ThenInclude(t => t.budget_progress_lists). -
Produces: the corrected
BuildOutputshape (see Step 1) that Task 3's brief already assumes. -
Step 1: Restructure the schema — both the top-level
op-compANDBuildOutput
In IncomeReportTreeSchema.cs, replace:
ReportTreeNodeDef.LeafAmount("op-comp", "ค่าตอบแทน ค่าใช้สอยและค่าวัสดุ"),
with:
BuildCompCategories("op-comp", "ค่าตอบแทน ค่าใช้สอยและค่าวัสดุ"),
And replace BuildOutput entirely:
private static ReportTreeNodeDef BuildOutput(string prefix, string label)
{
return ReportTreeNodeDef.Group(prefix, label,
ReportTreeNodeDef.Group($"{prefix}-grant", "งบเงินอุดหนุน",
BuildCompCategories($"{prefix}-grant-opex", "ค่าใช้จ่ายดำเนินงาน")
),
ReportTreeNodeDef.LeafAmount($"{prefix}-grant-proj", "เงินอุดหนุนค่าใช้จ่ายโครงการ"),
ReportTreeNodeDef.Group($"{prefix}-invest", "งบลงทุน",
ReportTreeNodeDef.LeafTable($"{prefix}-invest-equip", "ค่าครุภัณฑ์", "invest_asset"),
ReportTreeNodeDef.LeafTable($"{prefix}-invest-land", "ค่าที่ดินและสิ่งก่อสร้าง", "invest_construct")
),
ReportTreeNodeDef.LeafAmount($"{prefix}-other", "งบรายจ่ายอื่นๆ")
);
}
private static ReportTreeNodeDef BuildCompCategories(string prefix, string label)
{
return ReportTreeNodeDef.Group(prefix, label,
ReportTreeNodeDef.LeafTable($"{prefix}-comp", "ค่าตอบแทน", "budget_progress"),
ReportTreeNodeDef.LeafTable($"{prefix}-serv", "ค่าใช้สอย", "budget_progress"),
ReportTreeNodeDef.LeafTable($"{prefix}-mat", "ค่าวัสดุ", "budget_progress"),
ReportTreeNodeDef.LeafTable($"{prefix}-util", "ค่าสาธารณูปโภค", "budget_progress")
);
}
({prefix}-grant-proj/{prefix}-other stay LeafAmount for now — Task 3 converts them to LeafTable, don't do that here, it's out of this task's scope.) Both op-comp and {prefix}-grant-opex's own amounts now auto-sum from their 4 children for free via BuildNode's existing group branch. The old node_key = "op-comp" manual $0 line seeded by an earlier task becomes inert (no longer looked up by any leaf node in the new schema) — expected and harmless, do not attempt to delete/migrate it.
- Step 2: Category names — already confirmed, use verbatim
Already confirmed against production (SELECT DISTINCT budget_progress_type_name FROM "BUDGET".v_budget_progress_type): exactly ค่าตอบแทน, ค่าใช้สอย, ค่าวัสดุ, ค่าสาธารณูปโภค — no further check needed, use these 4 literal strings verbatim in Step 3's mapping.
- Step 3: Query and seed budget_progress rows in
CreateDraft— two call sites, one shared helper
Add a shared private method (same file as BuildInvestLines from Task 1 — follow that method's exact structure/placement/error-logging conventions):
private List<t_income_budget_report_line> BuildBudgetProgressCategoryLines(
Guid reportUid, string nodePrefix, string budgetProjectNameTh, t_personnel_statement statement, ref int seq)
{
var categoryNodeKeys = new Dictionary<string, string>
{
["ค่าตอบแทน"] = $"{nodePrefix}-comp",
["ค่าใช้สอย"] = $"{nodePrefix}-serv",
["ค่าวัสดุ"] = $"{nodePrefix}-mat",
["ค่าสาธารณูปโภค"] = $"{nodePrefix}-util",
};
var lines = new List<t_income_budget_report_line>();
var yearNumber = int.TryParse(statement.academic_year_name_th, out var parsedYear) ? (int?)parsedYear : null;
if (yearNumber == null)
{
Console.WriteLine($"[IncomeBudgetReportService] academic_year_name_th '{statement.academic_year_name_th}' is not numeric, skipping budget_progress lookup for {nodePrefix}");
return lines;
}
var budgetProgress = _context.v_budget_progress
.Include(c => c.budget_progress_types).ThenInclude(t => t.budget_progress_lists)
.Where(c => c.budget_year_number == yearNumber
&& c.faculty_name_th == statement.parent_agency_name
&& c.budget_location_name_th == statement.agency_name_th
&& c.sector_name_th == statement.sector
&& c.budget_progress_id == 2
&& c.budget_project_name_th == budgetProjectNameTh)
.ToList();
foreach (var bp in budgetProgress)
{
foreach (var type in bp.budget_progress_types ?? new List<v_budget_progress_type>())
{
var typeName = type.budget_progress_type_name?.Trim();
if (typeName == null || !categoryNodeKeys.TryGetValue(typeName, out var nodeKey))
{
if (typeName != null) Console.WriteLine($"[IncomeBudgetReportService] budget_progress_type_name '{typeName}' did not match any of the 4 known categories for {nodePrefix}, skipped");
continue;
}
foreach (var list in type.budget_progress_lists ?? new List<v_budget_progress_list>())
{
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 = "budget_progress",
source_uid = list.budget_progress_list_uid,
label_th = list.budget_progress_list_name,
qualification_th = null,
qty = null,
amount = list.price ?? 0,
original_amount = list.price ?? 0,
sequence_no = seq++,
});
}
}
}
return lines;
}
Then in CreateDraft, in the same block/placement as Task 1's invest-line seeding (before AddAsync(report)), call it twice:
lines.AddRange(BuildBudgetProgressCategoryLines(reportUid, "op-comp", "รายการงบบุคลากร", statement, ref seq));
foreach (var (outputLabel, prefix) in outputPrefixByLabel) // reuse the dictionary from Task 1 if already computed in this method — don't call OutputPrefixByLabel() twice
{
lines.AddRange(BuildBudgetProgressCategoryLines(reportUid, $"{prefix}-grant-opex", outputLabel, statement, ref seq));
}
Apply the SAME self-heal treatment Task 1 gave the invest lines: also call this from the existing != null branch (deduped by source_uid, same pattern Task 1 already established there) so re-running CreateDraft on an existing draft backfills these lines too.
.Include(...).ThenInclude(...) navigation property names (budget_progress_types/budget_progress_lists) were confirmed directly against the model files during plan-writing — if dotnet build disagrees, trust the compiler error over this text.
-
Step 4: (resolved, no action needed)
budget_progress_id == 2andsector_name_thare both now confirmed-required filters, applied in Step 3's query. No further investigation needed here. -
Step 5: Build
cd /Users/nut.looknut/Project/rmutr/rmutr-api/.worktrees/income-budget-report-group-a
dotnet build
Expected: Build succeeded. 0 Error(s).
- Step 6: Verify against the reference PDF
GET .../tree for a draft on the reference agency (คณะอุตสาหกรรมและเทคโนโลยี/วิทยาเขตวังไกลกังวล/2569):
-
op-comp(top-level): total must be exactly 118,700, withop-comp-comp= 96,000 (rows: เงินสมทบ... none — this category's real rows are 0 for this agency per the PDF, all its named items are 0),op-comp-serv= 22,700 (rows: เงินสมทบกองทุนประกันสังคม = 21,800, เงินสมทบกองทุนเงินทดแทน = 900),op-comp-mat/op-comp-util= 0. -
out1-grant-opex(out1 = ผลผลิต ผู้สำเร็จการศึกษาด้านวิทยาศาสตร์และเทคโนโลยี): total must be exactly 6,719,700, matching the PDF's "ค่าใช้จ่ายดำเนินงาน" under out1's งบเงินอุดหนุน (ค่าตอบแทน 3,595,000 / ค่าใช้สอย 636,800 / ค่าวัสดุ 2,475,900 / ค่าสาธารณูปโภค 12,000 — check each category's own subtotal too, not just the 6,719,700 grand total, since a mismatched category split with a coincidentally-correct total would still be wrong). -
Confirm
out2–out5's-grant-opexnodes also run cleanly (whether they find real data or legitimately zero — check for skip/error logs either way, same standard as Task 1's verification). -
Step 7: Commit
git add Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs
git commit -m "feat: source v_budget_progress (ร.3) into op-comp and per-output ค่าใช้จ่ายดำเนินงาน"
Task 3: Wire ร.6 (v_request_budget_income) into {out}-grant-proj/{out}-other, restructured as leaf-tables
⚠️ Depends on Task 2's schema restructuring — read Task 2 in full before starting, especially its "Why" section's arithmetic proof. Task 2 already moved {prefix}-grant-proj OUT of {prefix}-grant (which now only contains {prefix}-grant-opex) to be {prefix}'s own direct sibling child, confirmed correct via 6,719,700 (grant-opex) + 166,200 (grant-proj) + 0 (invest) + 0 (other) = 6,885,900 matching the PDF's output-level total exactly. This task does NOT redo that move — it only converts {prefix}-grant-proj/{prefix}-other from LeafAmount to LeafTable in place, and wires the query. Also uses Task 1's confirmed budget_year_number matching-key fix (budget_year_uid is NULL for every personnel_statement row in production — do not use it).
Files:
- Modify:
Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs - Modify:
Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs
Why: {out1..out5}-grant-proj/{out1..out5}-other are currently leaf-amount nodes seeded at $0. The reference PDF shows เงินอุดหนุนค่าใช้จ่ายโครงการ as a numbered list of named projects (e.g. "โครงการเรียนรู้บทบาทของผู้ประกอบอาชีพด้านอุตสาหกรรมและเทคโนโลยีในรูปแบบ Pre-Course experience" = 25,500), grouped under one more level in the PDF — "1 เงินอุดหนุน ค่าใช้จ่ายโครงการ" then "1 ด้านการพัฒนาคุณภาพการศึกษา" then the numbered project list — verify in Step 1a whether this "ด้านการพัฒนาคุณภาพการศึกษา" grouping is always a single fixed value or can vary (check budget_topic_name_th/budget_faculty_topic or similar grouping field on v_request_budget_income for the reference agency's rows) before deciding whether the schema needs one more nesting level or whether a flat LeafTable (this plan's assumption) is sufficient. v_request_budget_income.type_id distinguishes the two top-level sections: type_id == 1 = เงินอุดหนุนค่าใช้จ่ายโครงการ (confirmed convention — Modules/Report/Controller/Request.cs:651), type_id == 2 = งบรายจ่ายอื่นๆ (confirmed — legacy Personnel.cs V3 report filters the same way for this exact section, per investigation).
Interfaces:
-
Consumes:
_context.v_request_budget_income,IncomeReportTreeSchema.OutputPrefixByLabel()(from Task 1). -
Step 1a: Check the "ด้านการพัฒนาคุณภาพการศึกษา" grouping before writing the schema
SELECT budget_topic_name_th, project_name_th, total_budget, type_id
FROM "BUDGET".v_request_budget_income
WHERE faculty_name_th = 'คณะอุตสาหกรรมและเทคโนโลยี' AND budget_location_name_th = 'วิทยาเขตวังไกลกังวล'
AND budget_year_number = 2569 AND budget_project_name_th = 'ผลผลิต ผู้สำเร็จการศึกษาด้านวิทยาศาสตร์และเทคโนโลยี';
If every row's grouping field is the same single value for a given output (matching the PDF's single "ด้านการพัฒนาคุณภาพการศึกษา" subheading), a flat LeafTable per Step 1's schema is correct — the grouping label itself doesn't need to be its own tree node, just confirm it's not silently hiding multiple distinct groups that the PDF would show as separate subheadings. If you find 2+ distinct grouping values for the same output, stop and report this as a finding rather than guessing — it changes Step 1's schema shape (would need a LeafTable per distinct group instead of one per output).
- Step 1b: Convert
{prefix}-grant-proj/{prefix}-otherfromLeafAmounttoLeafTable
In IncomeReportTreeSchema.cs, BuildOutput (as left by Task 2 — do not reintroduce the old nested {prefix}-grant > {prefix}-grant-proj shape), change only these two lines:
ReportTreeNodeDef.LeafAmount($"{prefix}-grant-proj", "เงินอุดหนุนค่าใช้จ่ายโครงการ"),
→
ReportTreeNodeDef.LeafTable($"{prefix}-grant-proj", "เงินอุดหนุนค่าใช้จ่ายโครงการ", "request_budget_income"),
and
ReportTreeNodeDef.LeafAmount($"{prefix}-other", "งบรายจ่ายอื่นๆ")
→
ReportTreeNodeDef.LeafTable($"{prefix}-other", "งบรายจ่ายอื่นๆ", "request_budget_income")
- Step 2: Query and seed request_budget_income rows in
CreateDraft
Add a block (reuse the yearNumber parse from Task 1/2 if it's already computed earlier in the same method — don't reparse):
var requestBudgetIncomes = await _context.v_request_budget_income
.Where(c => c.budget_year_number == yearNumber
&& c.faculty_name_th == statement.parent_agency_name
&& c.budget_location_name_th == statement.agency_name_th
&& (c.type_id == 1 || c.type_id == 2))
.ToListAsync();
foreach (var rbi in requestBudgetIncomes)
{
var label = rbi.budget_project_name_th?.Trim();
if (label == null || !outputPrefixByLabel.TryGetValue(label, out var prefix))
{
if (label != null) Console.WriteLine($"[IncomeBudgetReportService] request_budget_income budget_project_name_th '{label}' did not match any output, skipped");
continue;
}
var nodeKey = rbi.type_id == 1 ? $"{prefix}-grant-proj" : $"{prefix}-other";
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 = "request_budget_income",
source_uid = rbi.request_budget_income_uid,
label_th = rbi.project_name_th,
qualification_th = null,
qty = null,
amount = rbi.total_budget ?? 0,
original_amount = rbi.total_budget ?? 0,
sequence_no = seq++,
});
}
outputPrefixByLabel was already computed once for Task 1's invest-line seeding earlier in the same CreateDraft method — reuse that variable, don't call OutputPrefixByLabel() again. Apply the same existing-draft self-heal treatment Tasks 1/2 established (dedupe by source_uid).
- Step 3: Build
cd /Users/nut.looknut/Project/rmutr/rmutr-api/.worktrees/income-budget-report-group-a
dotnet build
Expected: Build succeeded. 0 Error(s).
- Step 4: Verify against the reference PDF
GET .../tree, confirm each output's -grant-proj node has rows matching the PDF's named โครงการ list under that ผลผลิต (e.g. under ผลผลิต ผู้สำเร็จการศึกษาด้านวิทยาศาสตร์และเทคโนโลยี: "โครงการเรียนรู้บทบาท..." = 25,500, "โครงการอบรมเชิงปฏิบัติการ...ขั้นปีที่ 2" = 48,900, etc., total เงินอุดหนุนค่าใช้จ่ายโครงการ = 166,200) and -other matches the PDF's งบรายจ่ายอื่นๆ line (0 in the sample — confirm the query ran and found zero type_id == 2 rows, not that it errored). Also confirm out1's total (งบเงินอุดหนุน + เงินอุดหนุนค่าใช้จ่ายโครงการ + งบลงทุน + งบรายจ่ายอื่นๆ) now sums to exactly 6,885,900, matching the PDF's ผลผลิต-level total — this is the end-to-end check that Task 2's schema restructuring and this task's data both landed correctly together.
- Step 5: Commit
git add Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs
git commit -m "feat: source v_request_budget_income (ร.6) into grant-proj/other leaf-tables"
Task 4: Confirm CloneNextRound still works unmodified against the new schema
Files: none (verification-only task; a real code change here would mean Tasks 1-3 broke an invariant they were supposed to preserve).
Why: CloneNextRound copies every existing t_income_budget_report_line row by value regardless of node_key (IncomeBudgetReportService.cs:274-288) — it has no schema awareness at all, so Tasks 1-3's node_key changes (leaf-amount → leaf-table on 6 node keys) should require zero changes here. This task exists to actually prove that, not assume it.
- Step 1: Create a draft, clone it, compare
curl -s -X POST ".../create_draft?personnel_statement_uid=<uid>" -H "Authorization: Bearer <token>" > /tmp/draft1.json
curl -s -X POST ".../$(jq -r .income_budget_report_uid /tmp/draft1.json)/clone_next_round" -H "Authorization: Bearer <token>" > /tmp/draft2.json
diff <(curl -s ".../$(jq -r .income_budget_report_uid /tmp/draft1.json)/tree" -H "Authorization: Bearer <token>" | jq 'del(.. | .lineUid?)') \
<(curl -s ".../$(jq -r .income_budget_report_uid /tmp/draft2.json)/tree" -H "Authorization: Bearer <token>" | jq 'del(.. | .lineUid?)')
Expected: no diff (line uids differ by design, everything else — labels, amounts, row counts, node structure — must be identical between round 1 and its clone).
- Step 2: Record the result in the task report
No commit for this task — just document in the report that the diff was empty (or, if not, treat that as a real finding to fix, which would then require reopening this task with an actual code change).
Task 5: Frontend — render the new leaf-table columns
Files:
- Modify:
rmutr-web/.worktrees/income-budget-report-group-a/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.ts - Modify:
.../budget-report-tree-node.component.html - Modify:
.../budget-report-tree-node.component.scss
Why: budget-report-tree-node.component.html's leaf-table branch currently hardcodes 4 columns titled "รายการ / วุฒิ / อัตรา / จำนวนเงิน (บาท)" — correct for personnel rows (Qualification = degree, Qty = headcount) but wrong header wording for the new invest/budget_progress/request_budget_income rows this plan adds (Qualification now sometimes holds a unit label, not a degree; Qty sometimes holds a unit count, not headcount; a new pricePerUnit field needs a column for invest_asset rows specifically). Since ReportTreeNode already carries the leaf-table's SourceType is NOT currently exposed to the frontend (only used server-side to tag the schema) — check whether it needs adding to the DTO, or whether column headers can just be generalized to fit every source type without needing to branch on it.
- Step 1: Decide: generalize headers, or branch by source type
Read the current budget-report-tree-node.component.html's leaf-table <thead> (columns: รายการ/วุฒิ/อัตรา/จำนวนเงิน (บาท)). Two options — pick whichever is a smaller diff once you see the four real row shapes side by side in a live draft (personnel / invest_asset / invest_construct / budget_progress / request_budget_income):
-
(a) Generalize the header wording to something source-agnostic ("รายการ / หน่วยนับ / จำนวน / จำนวนเงิน (บาท)") that reads correctly for all five row sources, add a 5th "ราคาต่อหน่วย" column that's simply blank when
pricePerUnitis null (most rows). -
(b) Expose
SourceTypeonReportTreeNode(mirror the[JsonProperty]pattern already used for other fields) and switch the header/column set per table based on it. Prefer (a) unless the row shapes turn out too different to share one column set cleanly — check with a real draft before deciding, don't guess from the schema alone. -
Step 2: Implement the chosen option, add the
pricePerUnitbinding
Add pricePerUnit?: number to the ReportTreeTableRow interface in budget-report-tree-node.component.ts (matching backend Task 1's new field), and render it (right-aligned, number:'1.2-2', matching the existing amount column's formatting) in the table.
- Step 3: Build
cd /Users/nut.looknut/Project/rmutr/rmutr-web/.worktrees/income-budget-report-group-a
node --max_old_space_size=14336 node_modules/@angular/cli/bin/ng build --configuration production --base-href /budget-web/
Expected: build succeeds, no new errors.
- Step 4: Verify visually
Load a draft for the reference agency/year in a browser, confirm every new section (งบดำเนินงาน's 4 categories, เงินอุดหนุนโครงการ, งบรายจ่ายอื่นๆ, ค่าครุภัณฑ์/ค่าที่ดินและสิ่งก่อสร้าง) renders its rows with sensible column headers and correct-looking amounts — not just that it doesn't crash.
- Step 5: Commit
git add src/app/shared/components/budget-report-tree/
git commit -m "feat: render invest/budget_progress/request_budget_income leaf-table rows"
Explicitly out of scope for this plan
- Group B (type_id 4,5,6 — the separate "จัดสรร/ปรับแผนเงินรายได้" 5-tab workbook report) — different report entirely, still deferred.
- The government-form-styled
.xlsxexport, dual-write intorevenue_draft_committee, and legacy-list union — tracked separately, not touched here. - Any UI to edit ร.1-3/ร.4/ร.5/ร.6 data at its source — this plan is read-only against those tables, same as the rest of this feature.