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
493 lines
44 KiB
Markdown
493 lines
44 KiB
Markdown
# 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:
|
||
|
||
```csharp
|
||
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 build` after 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`/`ReportTreeTableRow` needs `[JsonProperty("camelCaseName")]` (Newtonsoft, no camelCase policy globally).
|
||
- Do not touch `CloneNextRound`'s own logic — it clones lines generically by copying every existing `t_income_budget_report_line` row regardless of `node_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 modify `CloneNextRound`.
|
||
- 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` — restructure `op-comp`, `{out}-grant-proj`, `{out}-other` (Tasks 1–3)
|
||
- Modify: `Modules/IncomeBudgetReport/Config/ReportTreeNode.cs` — add `PricePerUnit` to `ReportTreeTableRow` (Task 1)
|
||
- Modify: `Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs` — new query/seed logic in `CreateDraft`, `BuildNode` row-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` — render `pricePerUnit` column 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 registered `DbSet`s — confirmed via `Modules/InvestInformations/Databases/Models/...`).
|
||
- Produces: `IncomeReportTreeSchema.OutputPrefixByLabel() : Dictionary<string,string>` (maps a ผลผลิต label to its `out1`..`out5` node-key prefix), consumed by this task and Task 3.
|
||
|
||
- [ ] **Step 1: Add `PricePerUnit` to `ReportTreeTableRow`**
|
||
|
||
In `ReportTreeNode.cs`, add one field so the frontend can render the PDF's full รายการ/จำนวน/หน่วยนับ/ราคาต่อหน่วย/รวมเงิน table for ค่าครุภัณฑ์ rows (construct rows will leave it null):
|
||
|
||
```csharp
|
||
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):
|
||
|
||
```csharp
|
||
/// 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:
|
||
1. Queries `v_invest_asset_request_information` / `v_invest_construct_request_information` filtered by the three-field match key from "Matching key" above.
|
||
2. For each row, looks up `OutputPrefixByLabel()[row.budget_project_name_th.Trim()]` to get the `out{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.
|
||
3. Builds `t_income_budget_report_line` rows: `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`.
|
||
4. `price_per_unit` (if you added the column in Step 1) is set from `a.price_per_unit` for 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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 `BuildOutput` shape (see Step 1) that Task 3's brief already assumes.
|
||
|
||
- [ ] **Step 1: Restructure the schema — both the top-level `op-comp` AND `BuildOutput`**
|
||
|
||
In `IncomeReportTreeSchema.cs`, replace:
|
||
```csharp
|
||
ReportTreeNodeDef.LeafAmount("op-comp", "ค่าตอบแทน ค่าใช้สอยและค่าวัสดุ"),
|
||
```
|
||
with:
|
||
```csharp
|
||
BuildCompCategories("op-comp", "ค่าตอบแทน ค่าใช้สอยและค่าวัสดุ"),
|
||
```
|
||
|
||
And replace `BuildOutput` entirely:
|
||
```csharp
|
||
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):
|
||
```csharp
|
||
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:
|
||
```csharp
|
||
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 == 2` and `sector_name_th` are both now confirmed-required filters, applied in Step 3's query. No further investigation needed here.
|
||
|
||
- [ ] **Step 5: Build**
|
||
|
||
```bash
|
||
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**, with `op-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-opex` nodes 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```sql
|
||
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}-other` from `LeafAmount` to `LeafTable`**
|
||
|
||
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:
|
||
```csharp
|
||
ReportTreeNodeDef.LeafAmount($"{prefix}-grant-proj", "เงินอุดหนุนค่าใช้จ่ายโครงการ"),
|
||
```
|
||
→
|
||
```csharp
|
||
ReportTreeNodeDef.LeafTable($"{prefix}-grant-proj", "เงินอุดหนุนค่าใช้จ่ายโครงการ", "request_budget_income"),
|
||
```
|
||
and
|
||
```csharp
|
||
ReportTreeNodeDef.LeafAmount($"{prefix}-other", "งบรายจ่ายอื่นๆ")
|
||
```
|
||
→
|
||
```csharp
|
||
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):
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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 `pricePerUnit` is null (most rows).
|
||
- (b) Expose `SourceType` on `ReportTreeNode` (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 `pricePerUnit` binding**
|
||
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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 `.xlsx` export, dual-write into `revenue_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.
|