22c3c84f55
Bite-sized task breakdown implementing the design in docs/superpowers/specs/2026-08-05-income-budget-report-native-design.md, scoped to Group A only (income-budget-report-income1, type_id 1) per the writing-plans skill's guidance to split independently-testable subsystems into separate plans. Group B (type 4-6) and Group C (type 10) are explicitly out of scope, tracked as follow-up plans. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011R9j5gaw2qsoh9ypWQeXxi
1512 lines
63 KiB
Markdown
1512 lines
63 KiB
Markdown
# Income Budget Report — Group A (type 1-3) Implementation 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:** Replace the SyncFusion spreadsheet on `/app/income-budget-report-income1/2/3` with a native Angular tree component backed by a real data model, sourcing personnel line items read-only from ร.2 (`personnel_statement`) instead of free-edit Excel cells.
|
||
|
||
**Architecture:** Backend owns the report tree schema (a static C# structure per `type_id`) and computes the fully-merged tree (labels + amounts + lock flags) server-side on every GET; the frontend is a single generic recursive component that renders whatever tree JSON it receives, with no per-type knowledge of its own. New tables `income_budget_report` / `income_budget_report_line` replace the `.xlsx`-blob storage (`revenue_draft_committee`) for this feature; `.xlsx` is generated on demand (EPPlus) only when the user clicks "export".
|
||
|
||
**Tech Stack:** .NET 8 / EF Core 8 / Npgsql (backend), Angular (frontend), PostgreSQL, EPPlus 6.2.4 for export.
|
||
|
||
## Global Constraints
|
||
|
||
- Backend module lives at `rmutr-api/Modules/IncomeBudgetReport/` (new module, mirrors `Modules/Setting/` structure).
|
||
- Schema/table naming, `base_table` inheritance, `t_`/`v_` entity split, and `BaseUidController<T,TV>` usage follow the exact pattern already used for `announcement`/`announcement_faculty` (added this session — see `rmutr-api/Modules/Setting/Databases/Models/announcement.cs`).
|
||
- DB migrations are **manual SQL files** in `rmutr-api/Migrations/*.sql`, applied directly to the production Postgres instance (`rmutr_budget_050625` at `203.158.221.28:31620`) via `psql` — `dotnet ef migrations add` is **not** used in this codebase; the EF migration history has pre-existing unrelated drift that makes it unsafe (confirmed this session — do not attempt `dotnet ef migrations add` for this feature).
|
||
- When adding an EF relationship between two entities that both have `t_`/`v_` variants, configure `HasOne/WithMany/HasForeignKey` on **both** the `t_` and `v_` sides in `Db.cs` — omitting the `v_` side produces a broken shadow FK property and a 500 at runtime (root-caused and fixed for `announcement` this session; do not repeat the mistake).
|
||
- Frontend service files follow `rmutr-web/src/app/core/service/**` + extend `BaseService` (`rmutr-web/src/app/core/base/base-service.ts`) — full URL is always `environment.rmutrApi + '/api' + endpoint`.
|
||
- 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, per this session's established practice.
|
||
- 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)` — do not set precision manually.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**Backend — new module `Modules/IncomeBudgetReport/`:**
|
||
```
|
||
Databases/Models/income_budget_report/income_budget_report.cs entity: income_budget_report, t_/v_
|
||
Databases/Models/income_budget_report/income_budget_report_line.cs entity: income_budget_report_line, t_/v_
|
||
Databases/Db.cs DbSets + OnIncomeBudgetReportModelCreating
|
||
Config/IncomeReportTreeSchema.cs static tree definition for type_id 1/2/3
|
||
Services/IncomeBudgetReportService.cs CreateDraft, CloneNextRound, BuildTree, UpdateLineAmounts
|
||
Controllers/IncomeBudgetReportController.cs BaseUidController + custom endpoints
|
||
```
|
||
|
||
**Backend — modified files:**
|
||
```
|
||
Databases/Db.cs (central registry) add OnIncomeBudgetReportModelCreating(builder, schema) call
|
||
Migrations/add_income_budget_report_tables.sql new manual migration
|
||
```
|
||
|
||
**Frontend — new files:**
|
||
```
|
||
src/app/shared/components/budget-report-tree/budget-report-tree-node.component.ts
|
||
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
|
||
src/app/shared/components/budget-report-tree/budget-report-tree.module.ts
|
||
src/app/core/service/setting/income-budget-report.service.ts
|
||
```
|
||
|
||
**Frontend — modified files:**
|
||
```
|
||
src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.ts rewrite
|
||
src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.html rewrite
|
||
src/app/feature/income/draft/income-budget-report1/income-budget-report.module.ts import BudgetReportTreeModule
|
||
```
|
||
|
||
---
|
||
|
||
## Interfaces (the contract every task below must match exactly)
|
||
|
||
**Tree JSON returned by `GET /api/setting/income_budget_report/{uid}/tree`:**
|
||
|
||
```ts
|
||
interface ReportTreeNode {
|
||
nodeKey: string; // stable id, e.g. "pb-temp-old"
|
||
label: string;
|
||
depth: number; // 0-based, drives indentation
|
||
kind: 'group' | 'leaf-amount' | 'leaf-table';
|
||
amount: number; // computed sum for 'group', the value itself for 'leaf-amount'/'leaf-table' total
|
||
locked: boolean; // true when sourced from ร.2 (source_uid != null) — label/qty read-only
|
||
lineUid: string | null; // income_budget_report_line_uid, set for 'leaf-amount' nodes only
|
||
children: ReportTreeNode[]; // populated for kind='group'
|
||
rows: ReportTreeTableRow[]; // populated for kind='leaf-table'
|
||
}
|
||
interface ReportTreeTableRow {
|
||
lineUid: string;
|
||
label: string;
|
||
qualification: string | null; // ร.2 "วุฒิ" — null for non-personnel rows
|
||
qty: number | null; // "อัตรา" — null when not applicable
|
||
amount: number;
|
||
locked: boolean;
|
||
}
|
||
```
|
||
|
||
**C# equivalent (`IncomeBudgetReportRollupService.BuildTree` return type) uses the same shape** — see Task 4/6.
|
||
|
||
---
|
||
|
||
### Task 1: Backend entity models
|
||
|
||
**Files:**
|
||
- Create: `rmutr-api/Modules/IncomeBudgetReport/Databases/Models/income_budget_report/income_budget_report.cs`
|
||
- Create: `rmutr-api/Modules/IncomeBudgetReport/Databases/Models/income_budget_report/income_budget_report_line.cs`
|
||
|
||
**Interfaces:**
|
||
- Produces: `income_budget_report`, `t_income_budget_report`, `v_income_budget_report`, `income_budget_report_line`, `t_income_budget_report_line`, `v_income_budget_report_line` classes used by every later backend task.
|
||
|
||
- [ ] **Step 1: Write `income_budget_report.cs`**
|
||
|
||
```csharp
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.ComponentModel.DataAnnotations;
|
||
using System.Text.Json.Serialization;
|
||
using SeventyOneDev.Utilities;
|
||
using SeventyOneDev.Utilities.Attributes;
|
||
|
||
namespace rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models
|
||
{
|
||
public class income_budget_report : base_table
|
||
{
|
||
[Key] public Guid? income_budget_report_uid { get; set; }
|
||
public int? type_id { get; set; }
|
||
public Guid? budget_year_uid { get; set; }
|
||
public Guid? faculty_uid { get; set; }
|
||
public Guid? budget_location_uid { get; set; }
|
||
[Search]
|
||
public string sector_name_th { get; set; }
|
||
public Guid? parent_uid { get; set; }
|
||
}
|
||
|
||
public class t_income_budget_report : income_budget_report
|
||
{
|
||
[Include]
|
||
public List<t_income_budget_report_line> income_budget_report_lines { get; set; }
|
||
}
|
||
|
||
public class v_income_budget_report : income_budget_report
|
||
{
|
||
[Include]
|
||
public List<v_income_budget_report_line> income_budget_report_lines { get; set; }
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Write `income_budget_report_line.cs`**
|
||
|
||
```csharp
|
||
using System;
|
||
using System.ComponentModel.DataAnnotations;
|
||
using System.Text.Json.Serialization;
|
||
using SeventyOneDev.Utilities;
|
||
using SeventyOneDev.Utilities.Attributes;
|
||
|
||
namespace rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models
|
||
{
|
||
public class income_budget_report_line : base_table
|
||
{
|
||
[Key] public Guid? income_budget_report_line_uid { get; set; }
|
||
public Guid? income_budget_report_uid { get; set; }
|
||
[MaxLength(100)]
|
||
public string node_key { get; set; }
|
||
[MaxLength(50)]
|
||
public string source_type { get; set; } // "personnel_detail" | "personnel_detail_2" | "manual"
|
||
public Guid? source_uid { get; set; }
|
||
public string label_th { get; set; }
|
||
public string qualification_th { get; set; }
|
||
public decimal? qty { get; set; }
|
||
public decimal? amount { get; set; }
|
||
public decimal? original_amount { get; set; }
|
||
public int? sequence_no { get; set; }
|
||
}
|
||
|
||
public class t_income_budget_report_line : income_budget_report_line
|
||
{
|
||
[JsonIgnore]
|
||
public t_income_budget_report income_budget_report { get; set; }
|
||
}
|
||
|
||
public class v_income_budget_report_line : income_budget_report_line
|
||
{
|
||
[JsonIgnore]
|
||
public v_income_budget_report income_budget_report { get; set; }
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify — no build target yet, just confirm files exist and namespaces match the folder they're in**
|
||
|
||
```bash
|
||
ls rmutr-api/Modules/IncomeBudgetReport/Databases/Models/income_budget_report/
|
||
```
|
||
Expected: both files listed. (The project won't compile until Task 2 wires up `Db.cs` — that's expected, don't try to build yet.)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd rmutr-api
|
||
git add Modules/IncomeBudgetReport/Databases/Models/income_budget_report/
|
||
git commit -m "feat: add income_budget_report and income_budget_report_line entity models"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Backend Db.cs registration
|
||
|
||
**Files:**
|
||
- Create: `rmutr-api/Modules/IncomeBudgetReport/Databases/Db.cs`
|
||
- Modify: `rmutr-api/Databases/Db.cs` (central registry, currently ends at line 33 `}`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: entity classes from Task 1.
|
||
- Produces: `_context.t_income_budget_report`, `_context.v_income_budget_report`, `_context.t_income_budget_report_line`, `_context.v_income_budget_report_line` DbSets usable by every later backend task.
|
||
|
||
- [ ] **Step 1: Write `Modules/IncomeBudgetReport/Databases/Db.cs`**
|
||
|
||
```csharp
|
||
using Microsoft.EntityFrameworkCore;
|
||
using rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models;
|
||
|
||
namespace SeventyOneDev.Utilities
|
||
{
|
||
public partial class Db : DbContext
|
||
{
|
||
public DbSet<t_income_budget_report> t_income_budget_report { get; set; }
|
||
public DbSet<v_income_budget_report> v_income_budget_report { get; set; }
|
||
public DbSet<t_income_budget_report_line> t_income_budget_report_line { get; set; }
|
||
public DbSet<v_income_budget_report_line> v_income_budget_report_line { get; set; }
|
||
|
||
private void OnIncomeBudgetReportModelCreating(ModelBuilder builder, string schema)
|
||
{
|
||
builder.Entity<t_income_budget_report>().ToTable("income_budget_report", schema);
|
||
builder.Entity<v_income_budget_report>().ToView("v_income_budget_report", schema);
|
||
|
||
builder.Entity<t_income_budget_report_line>().ToTable("income_budget_report_line", schema);
|
||
builder.Entity<v_income_budget_report_line>().ToView("v_income_budget_report_line", schema);
|
||
|
||
builder.Entity<t_income_budget_report_line>().HasOne(p => p.income_budget_report)
|
||
.WithMany(p => p.income_budget_report_lines)
|
||
.HasForeignKey(p => p.income_budget_report_uid)
|
||
.OnDelete(DeleteBehavior.Cascade);
|
||
builder.Entity<v_income_budget_report_line>().HasOne(p => p.income_budget_report)
|
||
.WithMany(p => p.income_budget_report_lines)
|
||
.HasForeignKey(p => p.income_budget_report_uid)
|
||
.OnDelete(DeleteBehavior.Cascade);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Register the call in the central registry**
|
||
|
||
Read `rmutr-api/Databases/Db.cs` first (it's short, ~34 lines). Add the new call as the last line inside `OnApplicationModelCreating`, right after `OnPlanResultModelCreating(builder,schema);`:
|
||
|
||
```csharp
|
||
OnPlanResultModelCreating(builder,schema);
|
||
OnIncomeBudgetReportModelCreating(builder,schema);
|
||
```
|
||
|
||
- [ ] **Step 3: Build and verify it compiles**
|
||
|
||
```bash
|
||
cd rmutr-api
|
||
dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20
|
||
```
|
||
Expected: `0 Error(s)`. (Run in background if it exceeds ~2 minutes — it's a large solution, this is normal.)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add Modules/IncomeBudgetReport/Databases/Db.cs Databases/Db.cs
|
||
git commit -m "feat: register income_budget_report entities with EF Db context"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Manual SQL migration
|
||
|
||
**Files:**
|
||
- Create: `rmutr-api/Migrations/add_income_budget_report_tables.sql`
|
||
|
||
**Interfaces:**
|
||
- Consumes: table/column names from Task 1.
|
||
- Produces: physical `"BUDGET".income_budget_report`, `"BUDGET".income_budget_report_line` tables and matching views, required by every later task that touches the database.
|
||
|
||
- [ ] **Step 1: Write the migration file**
|
||
|
||
```sql
|
||
-- Migration: Create income_budget_report + income_budget_report_line tables (Group A, type 1-3).
|
||
-- Date: 2026-08-05
|
||
-- Description: Backs the native replacement for the SyncFusion income-budget-report page.
|
||
-- Replaces the .xlsx-blob storage (revenue_draft_committee) for type_id 1-3 with
|
||
-- structured rows. See docs/superpowers/specs/2026-08-05-income-budget-report-native-design.md
|
||
-- Idempotent: safe to run again.
|
||
|
||
CREATE TABLE IF NOT EXISTS "BUDGET".income_budget_report (
|
||
income_budget_report_uid uuid PRIMARY KEY,
|
||
status_id smallint,
|
||
created_by character varying(200),
|
||
created_datetime timestamptz,
|
||
updated_by character varying(200),
|
||
updated_datetime timestamptz,
|
||
owner_agency_uid uuid,
|
||
type_id integer,
|
||
budget_year_uid uuid,
|
||
faculty_uid uuid,
|
||
budget_location_uid uuid,
|
||
sector_name_th text,
|
||
parent_uid uuid REFERENCES "BUDGET".income_budget_report (income_budget_report_uid)
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS "BUDGET".income_budget_report_line (
|
||
income_budget_report_line_uid uuid PRIMARY KEY,
|
||
status_id smallint,
|
||
created_by character varying(200),
|
||
created_datetime timestamptz,
|
||
updated_by character varying(200),
|
||
updated_datetime timestamptz,
|
||
owner_agency_uid uuid,
|
||
income_budget_report_uid uuid REFERENCES "BUDGET".income_budget_report (income_budget_report_uid) ON DELETE CASCADE,
|
||
node_key character varying(100),
|
||
source_type character varying(50),
|
||
source_uid uuid,
|
||
label_th text,
|
||
qualification_th text,
|
||
qty numeric(18,4),
|
||
amount numeric(18,4),
|
||
original_amount numeric(18,4),
|
||
sequence_no integer
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS "IX_income_budget_report_line_income_budget_report_uid"
|
||
ON "BUDGET".income_budget_report_line (income_budget_report_uid);
|
||
|
||
CREATE INDEX IF NOT EXISTS "IX_income_budget_report_parent_uid"
|
||
ON "BUDGET".income_budget_report (parent_uid);
|
||
|
||
CREATE OR REPLACE VIEW "BUDGET".v_income_budget_report AS
|
||
SELECT * FROM "BUDGET".income_budget_report;
|
||
|
||
CREATE OR REPLACE VIEW "BUDGET".v_income_budget_report_line AS
|
||
SELECT * FROM "BUDGET".income_budget_report_line;
|
||
```
|
||
|
||
- [ ] **Step 2: Apply to the production database and verify**
|
||
|
||
Confirm VPN/DB connectivity first (this session repeatedly needed to re-check this):
|
||
```bash
|
||
nc -zv -w 6 203.158.221.28 31620
|
||
```
|
||
Expected: `succeeded`. If it times out, ask the user to check their VPN before continuing.
|
||
|
||
Apply:
|
||
```bash
|
||
export PGPASSWORD='vtwi,yo0tpkd-okfouh'
|
||
/opt/homebrew/Cellar/postgresql@16/16.14/bin/psql -h 203.158.221.28 -p 31620 -U devadmin -d rmutr_budget_050625 \
|
||
-v ON_ERROR_STOP=1 -f rmutr-api/Migrations/add_income_budget_report_tables.sql
|
||
```
|
||
Expected output: `CREATE TABLE` ×2, `CREATE INDEX` ×2, `CREATE VIEW` ×2, no errors.
|
||
|
||
Verify columns exist:
|
||
```bash
|
||
/opt/homebrew/Cellar/postgresql@16/16.14/bin/psql -h 203.158.221.28 -p 31620 -U devadmin -d rmutr_budget_050625 \
|
||
-c "SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='BUDGET' AND table_name IN ('income_budget_report','income_budget_report_line') ORDER BY table_name, ordinal_position;"
|
||
```
|
||
Expected: every field from Task 1's entities listed.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd rmutr-api
|
||
git add Migrations/add_income_budget_report_tables.sql
|
||
git commit -m "feat: add income_budget_report tables migration"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Tree schema config (backend)
|
||
|
||
**Files:**
|
||
- Create: `rmutr-api/Modules/IncomeBudgetReport/Config/IncomeReportTreeSchema.cs`
|
||
|
||
**Interfaces:**
|
||
- Produces: `ReportTreeNodeDef` class and `IncomeReportTreeSchema.ForType1()` static method returning the type-1/2/3 tree definition, consumed by Task 5 (CreateDraft, to assign `node_key` per source row) and Task 6 (BuildTree rollup).
|
||
|
||
This schema is transcribed directly from the real production file inspected this session (`type1.xlsx`, sheet "Page1", 153 rows) — see the spec's Data Model section for the source verification.
|
||
|
||
- [ ] **Step 1: Write the schema definition**
|
||
|
||
```csharp
|
||
using System.Collections.Generic;
|
||
|
||
namespace rmutr_budget_api.Modules.IncomeBudgetReport.Config
|
||
{
|
||
public class ReportTreeNodeDef
|
||
{
|
||
public string NodeKey { get; set; }
|
||
public string Label { get; set; }
|
||
public string Kind { get; set; } // "group" | "leaf-amount" | "leaf-table"
|
||
public string SourceType { get; set; } // null for group/manual nodes
|
||
public List<ReportTreeNodeDef> Children { get; set; } = new();
|
||
|
||
public static ReportTreeNodeDef Group(string key, string label, params ReportTreeNodeDef[] children)
|
||
=> new ReportTreeNodeDef { NodeKey = key, Label = label, Kind = "group", Children = new List<ReportTreeNodeDef>(children) };
|
||
|
||
public static ReportTreeNodeDef LeafAmount(string key, string label)
|
||
=> new ReportTreeNodeDef { NodeKey = key, Label = label, Kind = "leaf-amount" };
|
||
|
||
public static ReportTreeNodeDef LeafTable(string key, string label, string sourceType)
|
||
=> new ReportTreeNodeDef { NodeKey = key, Label = label, Kind = "leaf-table", SourceType = sourceType };
|
||
}
|
||
|
||
public static class IncomeReportTreeSchema
|
||
{
|
||
// Verified against the real type-1 production file (Page1, 153 rows) downloaded and
|
||
// inspected 2026-08-05. Plan/output labels are the exact wording found in that file.
|
||
public static ReportTreeNodeDef ForType1()
|
||
{
|
||
return ReportTreeNodeDef.Group("root", "รายการบุคลากร",
|
||
ReportTreeNodeDef.Group("pb", "งบบุคลากร",
|
||
ReportTreeNodeDef.Group("pb-temp", "ค่าจ้างชั่วคราว",
|
||
ReportTreeNodeDef.LeafTable("pb-temp-old", "อัตราเดิม", "personnel_detail"),
|
||
ReportTreeNodeDef.LeafTable("pb-temp-new", "อัตราใหม่", "personnel_detail_2")
|
||
),
|
||
ReportTreeNodeDef.Group("pb-cola", "เงินเพิ่มค่าครองชีพชั่วคราว",
|
||
ReportTreeNodeDef.LeafAmount("pb-cola-old", "อัตราเดิม"),
|
||
ReportTreeNodeDef.LeafAmount("pb-cola-new", "อัตราใหม่")
|
||
)
|
||
),
|
||
ReportTreeNodeDef.Group("op", "งบดำเนินงาน",
|
||
ReportTreeNodeDef.LeafAmount("op-comp", "ค่าตอบแทน ค่าใช้สอยและค่าวัสดุ"),
|
||
ReportTreeNodeDef.Group("plan1", "แผนงานยุทธศาสตร์พัฒนาศักยภาพคนตลอดช่วงชีวิต",
|
||
BuildOutput("out1", "ผลผลิต ผู้สำเร็จการศึกษาด้านวิทยาศาสตร์และเทคโนโลยี"),
|
||
BuildOutput("out2", "ผลผลิต ผู้สำเร็จการศึกษาด้านสังคมศาสตร์")
|
||
),
|
||
ReportTreeNodeDef.Group("plan2", "แผนงานพื้นฐานด้านการพัฒนาและเสริมสร้างศักยภาพทรัพยากรมนุษย์",
|
||
BuildOutput("out3", "ผลผลิต ผลงานการให้บริการวิชาการ"),
|
||
BuildOutput("out4", "ผลผลิต ผลงานทำนุบำรุงศิลปวัฒนธรรม")
|
||
),
|
||
ReportTreeNodeDef.Group("plan3", "แผนงานยุทธศาสตร์การวิจัยและพัฒนานวัตกรรม",
|
||
BuildOutput("out5", "ผลผลิต ผลงานวิจัยเพื่อสร้างองค์ความรู้")
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
private static ReportTreeNodeDef BuildOutput(string prefix, string label)
|
||
{
|
||
return ReportTreeNodeDef.Group(prefix, label,
|
||
ReportTreeNodeDef.Group($"{prefix}-grant", "งบเงินอุดหนุน",
|
||
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", "งบรายจ่ายอื่นๆ")
|
||
);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build and verify it compiles**
|
||
|
||
```bash
|
||
cd rmutr-api
|
||
dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20
|
||
```
|
||
Expected: `0 Error(s)`.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add Modules/IncomeBudgetReport/Config/
|
||
git commit -m "feat: add type-1 income budget report tree schema"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: IncomeBudgetReportService — CreateDraft
|
||
|
||
**Files:**
|
||
- Create: `rmutr-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ReportTreeNodeDef`/`IncomeReportTreeSchema.ForType1()` (Task 4), `t_personnel_statement`/`personnel_statement_detail`/`personnel_statement_detail_2` (existing, `rmutr-api/Modules/ReportSalary/Databases/Models/`).
|
||
- Produces: `IncomeBudgetReportService.CreateDraft(Guid facultyUid, Guid budgetYearUid, ClaimsIdentity identity) : Task<v_income_budget_report>`, consumed by Task 8 (controller).
|
||
|
||
- [ ] **Step 1: Write the service (CreateDraft only — BuildTree and CloneNextRound come in Tasks 6/7)**
|
||
|
||
```csharp
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using System.Security.Claims;
|
||
using System.Threading.Tasks;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using SeventyOneDev.Utilities;
|
||
using rmutr_budget_api.Modules.IncomeBudgetReport.Config;
|
||
using rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models;
|
||
using rmutr_budget_api.Modules.ReportSalary.Models;
|
||
|
||
namespace rmutr_budget_api.Modules.IncomeBudgetReport.Services
|
||
{
|
||
public class IncomeBudgetReportService : EntityUidService<t_income_budget_report, v_income_budget_report>
|
||
{
|
||
private readonly Db _context;
|
||
|
||
public IncomeBudgetReportService(Db context) : base(context)
|
||
{
|
||
_context = context;
|
||
}
|
||
|
||
public override async Task<v_income_budget_report> GetEntity(Guid uid, List<Guid?> agencies = null, string userName = null)
|
||
{
|
||
return await _context.v_income_budget_report
|
||
.Include(c => c.income_budget_report_lines)
|
||
.AsNoTracking()
|
||
.FirstOrDefaultAsync(c => c.income_budget_report_uid == uid);
|
||
}
|
||
|
||
public async Task<v_income_budget_report> CreateDraft(Guid facultyUid, Guid budgetYearUid, ClaimsIdentity identity)
|
||
{
|
||
var statement = await _context.t_personnel_statement
|
||
.Include(c => c.personnel_statement_details)
|
||
.Include(c => c.personnel_statement_details_2)
|
||
.FirstOrDefaultAsync(c => c.faculty_uid == facultyUid && c.budget_year_uid == budgetYearUid);
|
||
|
||
var reportUid = Guid.NewGuid();
|
||
var report = new t_income_budget_report
|
||
{
|
||
income_budget_report_uid = reportUid,
|
||
type_id = 1,
|
||
budget_year_uid = budgetYearUid,
|
||
faculty_uid = facultyUid,
|
||
status_id = 0,
|
||
};
|
||
|
||
var lines = new List<t_income_budget_report_line>();
|
||
int seq = 0;
|
||
if (statement != null)
|
||
{
|
||
foreach (var d in statement.personnel_statement_details ?? new List<personnel_statement_detail>())
|
||
{
|
||
var amount = (d.salary_rate ?? 0) * 12;
|
||
lines.Add(new t_income_budget_report_line
|
||
{
|
||
income_budget_report_line_uid = Guid.NewGuid(),
|
||
income_budget_report_uid = reportUid,
|
||
node_key = "pb-temp-old",
|
||
source_type = "personnel_detail",
|
||
source_uid = d.personnel_statement_detail_uid,
|
||
label_th = d.full_name,
|
||
qualification_th = d.qualification,
|
||
qty = 1,
|
||
amount = amount,
|
||
original_amount = amount,
|
||
sequence_no = seq++,
|
||
});
|
||
}
|
||
foreach (var d in statement.personnel_statement_details_2 ?? new List<personnel_statement_detail_2>())
|
||
{
|
||
var amount = (d.salary_rate ?? 0) * 12;
|
||
lines.Add(new t_income_budget_report_line
|
||
{
|
||
income_budget_report_line_uid = Guid.NewGuid(),
|
||
income_budget_report_uid = reportUid,
|
||
node_key = "pb-temp-new",
|
||
source_type = "personnel_detail_2",
|
||
source_uid = d.personnel_statement_detail_2_uid,
|
||
label_th = d.full_name,
|
||
qualification_th = d.qualification,
|
||
qty = 1,
|
||
amount = amount,
|
||
original_amount = amount,
|
||
sequence_no = seq++,
|
||
});
|
||
}
|
||
}
|
||
|
||
await _context.t_income_budget_report.AddAsync(report);
|
||
await _context.t_income_budget_report_line.AddRangeAsync(lines);
|
||
await _context.SaveChangesAsync(identity);
|
||
|
||
return await GetEntity(reportUid);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Note for the implementer:** confirm the exact property names on `personnel_statement_detail`/`personnel_statement_detail_2` (`full_name`, `qualification`, `salary_rate`, `personnel_statement_detail_uid`) by reading `rmutr-api/Modules/ReportSalary/Databases/Models/personnel_statement_detail.cs` and `personnel_statement_detail_2.cs` before writing this file — they were confirmed present during this session's investigation but re-read them directly rather than trusting this plan's transcription, since field lists that long are easy to mistype.
|
||
|
||
- [ ] **Step 2: Build and verify it compiles**
|
||
|
||
```bash
|
||
cd rmutr-api
|
||
dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -30
|
||
```
|
||
Expected: `0 Error(s)`. If `personnel_statement_detail`/`_2` field names don't match, fix them here based on the actual model file — don't guess.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add Modules/IncomeBudgetReport/Services/
|
||
git commit -m "feat: add IncomeBudgetReportService.CreateDraft (pulls from ร.2)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: IncomeBudgetReportService — BuildTree (rollup)
|
||
|
||
**Files:**
|
||
- Modify: `rmutr-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs`
|
||
- Create: `rmutr-api/Modules/IncomeBudgetReport/Config/ReportTreeNode.cs` (the response DTOs matching the Interfaces section above)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `v_income_budget_report` with `income_budget_report_lines` loaded (Task 5's `GetEntity`).
|
||
- Produces: `IncomeBudgetReportService.BuildTree(v_income_budget_report report) : ReportTreeNode`, consumed by Task 8 (GET tree endpoint) and Task 9 (export).
|
||
|
||
- [ ] **Step 1: Write the response DTOs**
|
||
|
||
```csharp
|
||
using System.Collections.Generic;
|
||
|
||
namespace rmutr_budget_api.Modules.IncomeBudgetReport.Config
|
||
{
|
||
public class ReportTreeNode
|
||
{
|
||
public string NodeKey { get; set; }
|
||
public string Label { get; set; }
|
||
public int Depth { get; set; }
|
||
public string Kind { get; set; }
|
||
public decimal Amount { get; set; }
|
||
public bool Locked { get; set; }
|
||
public string LineUid { get; set; }
|
||
public List<ReportTreeNode> Children { get; set; } = new();
|
||
public List<ReportTreeTableRow> Rows { get; set; } = new();
|
||
}
|
||
|
||
public class ReportTreeTableRow
|
||
{
|
||
public string LineUid { get; set; }
|
||
public string Label { get; set; }
|
||
public string Qualification { get; set; }
|
||
public decimal? Qty { get; set; }
|
||
public decimal Amount { get; set; }
|
||
public bool Locked { get; set; }
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add `BuildTree` to `IncomeBudgetReportService`**
|
||
|
||
Append to the class body from Task 5:
|
||
|
||
```csharp
|
||
public ReportTreeNode BuildTree(v_income_budget_report report)
|
||
{
|
||
var linesByNode = (report.income_budget_report_lines ?? new List<v_income_budget_report_line>())
|
||
.GroupBy(l => l.node_key)
|
||
.ToDictionary(g => g.Key, g => g.OrderBy(l => l.sequence_no).ToList());
|
||
|
||
var schema = IncomeReportTreeSchema.ForType1();
|
||
return BuildNode(schema, 0, linesByNode);
|
||
}
|
||
|
||
private ReportTreeNode BuildNode(ReportTreeNodeDef def, int depth, Dictionary<string, List<v_income_budget_report_line>> linesByNode)
|
||
{
|
||
var node = new ReportTreeNode
|
||
{
|
||
NodeKey = def.NodeKey,
|
||
Label = def.Label,
|
||
Depth = depth,
|
||
Kind = def.Kind,
|
||
};
|
||
|
||
if (def.Kind == "group")
|
||
{
|
||
foreach (var child in def.Children)
|
||
{
|
||
var childNode = BuildNode(child, depth + 1, linesByNode);
|
||
node.Children.Add(childNode);
|
||
}
|
||
node.Amount = node.Children.Sum(c => c.Amount);
|
||
node.Locked = false;
|
||
return node;
|
||
}
|
||
|
||
var lines = linesByNode.TryGetValue(def.NodeKey, out var l) ? l : new List<v_income_budget_report_line>();
|
||
|
||
if (def.Kind == "leaf-amount")
|
||
{
|
||
var line = lines.FirstOrDefault();
|
||
node.Amount = line?.amount ?? 0;
|
||
node.Locked = line?.source_uid != null;
|
||
node.LineUid = line?.income_budget_report_line_uid?.ToString();
|
||
return node;
|
||
}
|
||
|
||
// leaf-table
|
||
foreach (var line in lines)
|
||
{
|
||
node.Rows.Add(new ReportTreeTableRow
|
||
{
|
||
LineUid = line.income_budget_report_line_uid.ToString(),
|
||
Label = line.label_th,
|
||
Qualification = line.qualification_th,
|
||
Qty = line.qty,
|
||
Amount = line.amount ?? 0,
|
||
Locked = line.source_uid != null,
|
||
});
|
||
}
|
||
node.Amount = node.Rows.Sum(r => r.Amount);
|
||
node.Locked = false;
|
||
return node;
|
||
}
|
||
```
|
||
|
||
Add `using System.Linq;` and `using rmutr_budget_api.Modules.IncomeBudgetReport.Config;` to the top of `IncomeBudgetReportService.cs` if not already present (the `Config` using is already there from Task 5).
|
||
|
||
- [ ] **Step 3: Build and verify it compiles**
|
||
|
||
```bash
|
||
cd rmutr-api
|
||
dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20
|
||
```
|
||
Expected: `0 Error(s)`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add Modules/IncomeBudgetReport/Config/ReportTreeNode.cs Modules/IncomeBudgetReport/Services/
|
||
git commit -m "feat: add tree rollup calculation (BuildTree)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: IncomeBudgetReportService — CloneNextRound
|
||
|
||
**Files:**
|
||
- Modify: `rmutr-api/Modules/IncomeBudgetReport/Services/IncomeBudgetReportService.cs`
|
||
|
||
**Interfaces:**
|
||
- Produces: `IncomeBudgetReportService.CloneNextRound(Guid sourceUid, ClaimsIdentity identity) : Task<v_income_budget_report>`, consumed by Task 8.
|
||
|
||
- [ ] **Step 1: Add `CloneNextRound` to the service**
|
||
|
||
```csharp
|
||
public async Task<v_income_budget_report> CloneNextRound(Guid sourceUid, ClaimsIdentity identity)
|
||
{
|
||
var alreadyCloned = await _context.t_income_budget_report.AnyAsync(r => r.parent_uid == sourceUid);
|
||
if (alreadyCloned)
|
||
{
|
||
throw new InvalidOperationException("มีรายงานรอบถัดไปที่สร้างจากรอบนี้อยู่แล้ว");
|
||
}
|
||
|
||
var source = await _context.t_income_budget_report
|
||
.Include(c => c.income_budget_report_lines)
|
||
.AsNoTracking()
|
||
.FirstOrDefaultAsync(c => c.income_budget_report_uid == sourceUid);
|
||
if (source == null) throw new InvalidOperationException("ไม่พบรายงานต้นทาง");
|
||
if (source.type_id >= 3) throw new InvalidOperationException("รอบนี้เป็นรอบสุดท้ายแล้ว (type 3)");
|
||
|
||
var newUid = Guid.NewGuid();
|
||
var clone = new t_income_budget_report
|
||
{
|
||
income_budget_report_uid = newUid,
|
||
type_id = source.type_id + 1,
|
||
budget_year_uid = source.budget_year_uid,
|
||
faculty_uid = source.faculty_uid,
|
||
budget_location_uid = source.budget_location_uid,
|
||
sector_name_th = source.sector_name_th,
|
||
parent_uid = sourceUid,
|
||
status_id = 0,
|
||
};
|
||
|
||
var clonedLines = (source.income_budget_report_lines ?? new List<t_income_budget_report_line>())
|
||
.Select(l => new t_income_budget_report_line
|
||
{
|
||
income_budget_report_line_uid = Guid.NewGuid(),
|
||
income_budget_report_uid = newUid,
|
||
node_key = l.node_key,
|
||
source_type = l.source_type,
|
||
source_uid = l.source_uid,
|
||
label_th = l.label_th,
|
||
qualification_th = l.qualification_th,
|
||
qty = l.qty,
|
||
amount = l.amount,
|
||
original_amount = l.amount,
|
||
sequence_no = l.sequence_no,
|
||
}).ToList();
|
||
|
||
await _context.t_income_budget_report.AddAsync(clone);
|
||
await _context.t_income_budget_report_line.AddRangeAsync(clonedLines);
|
||
await _context.SaveChangesAsync(identity);
|
||
|
||
return await GetEntity(newUid);
|
||
}
|
||
```
|
||
|
||
Add `using System;` at the top if not already present (needed for `InvalidOperationException`).
|
||
|
||
- [ ] **Step 2: Build and verify it compiles**
|
||
|
||
```bash
|
||
cd rmutr-api
|
||
dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20
|
||
```
|
||
Expected: `0 Error(s)`.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add Modules/IncomeBudgetReport/Services/
|
||
git commit -m "feat: add CloneNextRound for round-to-round continuation (type 1->2->3)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: IncomeBudgetReportController
|
||
|
||
**Files:**
|
||
- Create: `rmutr-api/Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `IncomeBudgetReportService` (Tasks 5-7).
|
||
- Produces: the 5 HTTP endpoints listed below, consumed by Task 11 (frontend service).
|
||
|
||
- [ ] **Step 1: Write the controller**
|
||
|
||
```csharp
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Security.Claims;
|
||
using System.Threading.Tasks;
|
||
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using rmutr_budget_api.Modules.IncomeBudgetReport.Databases.Models;
|
||
using rmutr_budget_api.Modules.IncomeBudgetReport.Services;
|
||
using SeventyOneDev.Utilities;
|
||
|
||
namespace rmutr_budget_api.Modules.IncomeBudgetReport.Controllers
|
||
{
|
||
public class LineAmountUpdate
|
||
{
|
||
public Guid income_budget_report_line_uid { get; set; }
|
||
public decimal amount { get; set; }
|
||
}
|
||
|
||
[Route("api/setting/income_budget_report", Name = "income_budget_report")]
|
||
public class IncomeBudgetReportController : BaseUidController<t_income_budget_report, v_income_budget_report>
|
||
{
|
||
private readonly Db _db;
|
||
private readonly IncomeBudgetReportService _service;
|
||
|
||
public IncomeBudgetReportController(IncomeBudgetReportService entityUidService, Db db) : base(entityUidService)
|
||
{
|
||
_db = db;
|
||
_service = entityUidService;
|
||
}
|
||
|
||
[Authorize]
|
||
[HttpPost("create_draft")]
|
||
public async Task<ActionResult<v_income_budget_report>> CreateDraft(
|
||
[FromQuery] Guid faculty_uid, [FromQuery] Guid budget_year_uid)
|
||
{
|
||
var result = await _service.CreateDraft(faculty_uid, budget_year_uid, User.Identity as ClaimsIdentity);
|
||
return Ok(result);
|
||
}
|
||
|
||
[Authorize]
|
||
[HttpPost("{uid:guid}/clone_next_round")]
|
||
public async Task<ActionResult<v_income_budget_report>> CloneNextRound([FromRoute] Guid uid)
|
||
{
|
||
try
|
||
{
|
||
var result = await _service.CloneNextRound(uid, User.Identity as ClaimsIdentity);
|
||
return Ok(result);
|
||
}
|
||
catch (InvalidOperationException ex)
|
||
{
|
||
return BadRequest(new { description = ex.Message });
|
||
}
|
||
}
|
||
|
||
[Authorize]
|
||
[HttpGet("{uid:guid}/tree")]
|
||
public async Task<ActionResult> GetTree([FromRoute] Guid uid)
|
||
{
|
||
var report = await _service.GetEntity(uid);
|
||
if (report == null) return NotFound();
|
||
return Ok(_service.BuildTree(report));
|
||
}
|
||
|
||
[Authorize]
|
||
[HttpPut("lines")]
|
||
public async Task<ActionResult> UpdateLineAmounts([FromBody] List<LineAmountUpdate> updates)
|
||
{
|
||
foreach (var u in updates)
|
||
{
|
||
var line = await _db.t_income_budget_report_line.FindAsync(u.income_budget_report_line_uid);
|
||
if (line != null) line.amount = u.amount;
|
||
}
|
||
await _db.SaveChangesAsync(User.Identity as ClaimsIdentity);
|
||
return Ok();
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build and verify it compiles**
|
||
|
||
```bash
|
||
cd rmutr-api
|
||
dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20
|
||
```
|
||
Expected: `0 Error(s)`.
|
||
|
||
- [ ] **Step 3: Verify endpoints exist via Swagger (dev server)**
|
||
|
||
```bash
|
||
cd rmutr-api
|
||
dotnet run --urls http://localhost:5050 &
|
||
sleep 15
|
||
curl -s http://localhost:5050/swagger/v1/swagger.json | python3 -c "import json,sys; d=json.load(sys.stdin); print([k for k in d['paths'] if 'income_budget_report' in k])"
|
||
kill %1
|
||
```
|
||
Expected: a list containing `/api/setting/income_budget_report/create_draft`, `/api/setting/income_budget_report/{uid}/clone_next_round`, `/api/setting/income_budget_report/{uid}/tree`, `/api/setting/income_budget_report/lines`, and the base CRUD paths from `BaseUidController`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add Modules/IncomeBudgetReport/Controllers/
|
||
git commit -m "feat: add IncomeBudgetReportController with create_draft/clone_next_round/tree/lines endpoints"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Export to Excel
|
||
|
||
**Files:**
|
||
- Modify: `rmutr-api/Modules/IncomeBudgetReport/Controllers/IncomeBudgetReportController.cs`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `BuildTree` (Task 6).
|
||
- Produces: `GET /api/setting/income_budget_report/{uid}/export/xlsx`, consumed by the frontend "ส่งออก Excel" button (Task 12).
|
||
|
||
- [ ] **Step 1: Add the export endpoint**
|
||
|
||
```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);
|
||
|
||
using var package = new OfficeOpenXml.ExcelPackage();
|
||
var ws = package.Workbook.Worksheets.Add("Page1");
|
||
ws.Cells[1, 1].Value = "รายการ";
|
||
ws.Cells[1, 2].Value = "จำนวนเงิน (บาท)";
|
||
int row = 2;
|
||
WriteNode(tree, ws, ref row);
|
||
ws.Column(1).Width = 60;
|
||
ws.Column(2).Width = 20;
|
||
|
||
var bytes = package.GetAsByteArray();
|
||
var fileName = $"income_budget_report_{uid}.xlsx";
|
||
return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName);
|
||
}
|
||
|
||
private static void WriteNode(Config.ReportTreeNode node, OfficeOpenXml.ExcelWorksheet ws, ref int row)
|
||
{
|
||
var indent = new string(' ', node.Depth * 4);
|
||
ws.Cells[row, 1].Value = indent + node.Label;
|
||
ws.Cells[row, 2].Value = node.Amount;
|
||
row++;
|
||
if (node.Kind == "leaf-table")
|
||
{
|
||
foreach (var r in node.Rows)
|
||
{
|
||
ws.Cells[row, 1].Value = indent + " - " + r.Label;
|
||
ws.Cells[row, 2].Value = r.Amount;
|
||
row++;
|
||
}
|
||
}
|
||
foreach (var child in node.Children)
|
||
{
|
||
WriteNode(child, ws, ref row);
|
||
}
|
||
}
|
||
```
|
||
|
||
Add `using OfficeOpenXml;` to the top of the file if preferred over the fully-qualified names used above (either works — the fully-qualified form avoids a namespace collision check).
|
||
|
||
- [ ] **Step 2: Build and verify it compiles**
|
||
|
||
```bash
|
||
cd rmutr-api
|
||
dotnet build rmutr-budget-api.csproj -c Debug 2>&1 | tail -20
|
||
```
|
||
Expected: `0 Error(s)`.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add Modules/IncomeBudgetReport/Controllers/
|
||
git commit -m "feat: add xlsx export endpoint for income budget report"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Frontend generic tree component
|
||
|
||
**Files:**
|
||
- Create: `rmutr-web/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.ts`
|
||
- Create: `rmutr-web/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.html`
|
||
- Create: `rmutr-web/src/app/shared/components/budget-report-tree/budget-report-tree-node.component.scss`
|
||
- Create: `rmutr-web/src/app/shared/components/budget-report-tree/budget-report-tree.module.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ReportTreeNode`/`ReportTreeTableRow` JSON shape from the Interfaces section (Task 8's `/tree` endpoint).
|
||
- Produces: `<app-budget-report-tree-node [node]="node">` component, consumed by Task 12.
|
||
|
||
This ports the mockup's interaction design (recursive `<details>`, indentation via depth, live recalculation) into a real Angular component. Unlike the mockup, amounts come pre-computed from the backend on load; the component only needs to recompute a node's own displayed total when one of ITS OWN rows/leaf inputs changes (bubbling the recalculation up through `@Output` events, since Angular components can't share mutable DOM state across siblings the way the static mockup's global `recalc()` did).
|
||
|
||
- [ ] **Step 1: Write the component class**
|
||
|
||
```typescript
|
||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||
|
||
export interface ReportTreeTableRow {
|
||
lineUid: string;
|
||
label: string;
|
||
qualification: string | null;
|
||
qty: number | null;
|
||
amount: number;
|
||
locked: boolean;
|
||
}
|
||
|
||
export interface ReportTreeNode {
|
||
nodeKey: string;
|
||
label: string;
|
||
depth: number;
|
||
kind: 'group' | 'leaf-amount' | 'leaf-table';
|
||
amount: number;
|
||
locked: boolean;
|
||
lineUid: string | null;
|
||
children: ReportTreeNode[];
|
||
rows: ReportTreeTableRow[];
|
||
}
|
||
|
||
export interface LineAmountChange {
|
||
lineUid: string;
|
||
amount: number;
|
||
}
|
||
|
||
@Component({
|
||
selector: 'app-budget-report-tree-node',
|
||
templateUrl: './budget-report-tree-node.component.html',
|
||
styleUrls: ['./budget-report-tree-node.component.scss'],
|
||
})
|
||
export class BudgetReportTreeNodeComponent {
|
||
@Input() node: ReportTreeNode;
|
||
@Output() amountChange = new EventEmitter<LineAmountChange>();
|
||
|
||
onLeafAmountInput(value: string): void {
|
||
const amount = parseFloat(value) || 0;
|
||
this.node.amount = amount;
|
||
this.amountChange.emit({ lineUid: this.node.lineUid, amount });
|
||
}
|
||
|
||
onRowAmountInput(row: ReportTreeTableRow, value: string): void {
|
||
const amount = parseFloat(value) || 0;
|
||
row.amount = amount;
|
||
this.node.amount = this.node.rows.reduce((s, r) => s + (r.amount || 0), 0);
|
||
this.amountChange.emit({ lineUid: row.lineUid, amount });
|
||
}
|
||
|
||
onChildAmountChange(event: LineAmountChange): void {
|
||
this.node.amount = this.node.children.reduce((s, c) => s + (c.amount || 0), 0);
|
||
this.amountChange.emit(event);
|
||
}
|
||
|
||
trackByNodeKey(_i: number, n: ReportTreeNode): string { return n.nodeKey; }
|
||
trackByLineUid(_i: number, r: ReportTreeTableRow): string { return r.lineUid; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Write the template**
|
||
|
||
```html
|
||
<div class="tree-node lvl-{{ node.depth }}" [style.--depth]="node.depth">
|
||
<div class="row">
|
||
<span class="row-label">{{ node.label }}</span>
|
||
<ng-container *ngIf="node.kind === 'leaf-amount'; else groupAmount">
|
||
<input
|
||
*ngIf="!node.locked; else lockedAmount"
|
||
type="number"
|
||
class="amount-input"
|
||
[value]="node.amount"
|
||
(input)="onLeafAmountInput($any($event.target).value)"
|
||
/>
|
||
<ng-template #lockedAmount>
|
||
<span class="amount-readonly">{{ node.amount | number:'1.2-2' }}</span>
|
||
</ng-template>
|
||
</ng-container>
|
||
<ng-template #groupAmount>
|
||
<span class="amount-computed">{{ node.amount | number:'1.2-2' }}</span>
|
||
</ng-template>
|
||
</div>
|
||
|
||
<table class="leaf-table" *ngIf="node.kind === 'leaf-table' && node.rows.length">
|
||
<thead>
|
||
<tr>
|
||
<th>รายการ</th>
|
||
<th>วุฒิ</th>
|
||
<th class="num">อัตรา</th>
|
||
<th class="num">จำนวนเงิน (บาท)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr *ngFor="let row of node.rows; trackBy: trackByLineUid">
|
||
<td>{{ row.label }}</td>
|
||
<td>{{ row.qualification }}</td>
|
||
<td class="num">{{ row.qty }}</td>
|
||
<td class="num">
|
||
<input
|
||
*ngIf="!row.locked; else lockedRowAmount"
|
||
type="number"
|
||
class="amount-input"
|
||
[value]="row.amount"
|
||
(input)="onRowAmountInput(row, $any($event.target).value)"
|
||
/>
|
||
<ng-template #lockedRowAmount>
|
||
<span class="amount-readonly">{{ row.amount | number:'1.2-2' }}</span>
|
||
</ng-template>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<p class="empty-hint" *ngIf="node.kind === 'leaf-table' && !node.rows.length">ยังไม่มีรายการ</p>
|
||
|
||
<div class="children" *ngIf="node.kind === 'group'">
|
||
<app-budget-report-tree-node
|
||
*ngFor="let child of node.children; trackBy: trackByNodeKey"
|
||
[node]="child"
|
||
(amountChange)="onChildAmountChange($event)"
|
||
></app-budget-report-tree-node>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 3: Write minimal styles**
|
||
|
||
```scss
|
||
.tree-node {
|
||
padding-left: calc(var(--depth, 0) * 20px);
|
||
}
|
||
.row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 6px 8px;
|
||
gap: 12px;
|
||
}
|
||
.row-label { font-weight: 500; }
|
||
.amount-input {
|
||
width: 120px;
|
||
text-align: right;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.amount-readonly, .amount-computed {
|
||
font-variant-numeric: tabular-nums;
|
||
min-width: 120px;
|
||
text-align: right;
|
||
display: inline-block;
|
||
}
|
||
.leaf-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin: 4px 0 8px;
|
||
th, td { padding: 4px 8px; border-bottom: 1px solid #e2e6ed; text-align: left; }
|
||
th.num, td.num { text-align: right; }
|
||
}
|
||
.empty-hint { color: #8993a8; font-size: 12.5px; padding-left: 8px; }
|
||
```
|
||
|
||
- [ ] **Step 4: Write the module**
|
||
|
||
```typescript
|
||
import { NgModule } from '@angular/core';
|
||
import { CommonModule } from '@angular/common';
|
||
import { FormsModule } from '@angular/forms';
|
||
import { BudgetReportTreeNodeComponent } from './budget-report-tree-node.component';
|
||
|
||
@NgModule({
|
||
declarations: [BudgetReportTreeNodeComponent],
|
||
imports: [CommonModule, FormsModule],
|
||
exports: [BudgetReportTreeNodeComponent],
|
||
})
|
||
export class BudgetReportTreeModule {}
|
||
```
|
||
|
||
- [ ] **Step 5: Verify — type-check the whole frontend**
|
||
|
||
```bash
|
||
cd rmutr-web
|
||
npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -i "budget-report-tree" | head -30
|
||
```
|
||
Expected: no output (no errors referencing the new files). Note: this only catches type errors, not template errors — Task 12's `ng build` will catch template binding mistakes.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/app/shared/components/budget-report-tree/
|
||
git commit -m "feat: add generic recursive budget report tree component"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Frontend API service
|
||
|
||
**Files:**
|
||
- Create: `rmutr-web/src/app/core/service/setting/income-budget-report.service.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: endpoints from Tasks 8-9.
|
||
- Produces: `IncomeBudgetReportService.createDraft/cloneNextRound/getTree/exportXlsxUrl/updateLineAmounts`, used by Task 12.
|
||
|
||
- [ ] **Step 1: Write the service**
|
||
|
||
```typescript
|
||
import { Injectable } from '@angular/core';
|
||
import { HttpClient } from '@angular/common/http';
|
||
import { Observable } from 'rxjs';
|
||
import { BaseService } from 'src/app/core/base/base-service';
|
||
import { environment } from 'src/environments/environment';
|
||
import { ReportTreeNode } from 'src/app/shared/components/budget-report-tree/budget-report-tree-node.component';
|
||
|
||
@Injectable({ providedIn: 'root' })
|
||
export class IncomeBudgetReportService extends BaseService<any> {
|
||
constructor(http: HttpClient, private httpClient: HttpClient) {
|
||
super('/setting/income_budget_report', http);
|
||
}
|
||
|
||
createDraft(facultyUid: string, budgetYearUid: string): Observable<any> {
|
||
const url = `${environment.rmutrApi}/api/setting/income_budget_report/create_draft?faculty_uid=${facultyUid}&budget_year_uid=${budgetYearUid}`;
|
||
return this.httpClient.post(url, {});
|
||
}
|
||
|
||
cloneNextRound(uid: string): Observable<any> {
|
||
const url = `${environment.rmutrApi}/api/setting/income_budget_report/${uid}/clone_next_round`;
|
||
return this.httpClient.post(url, {});
|
||
}
|
||
|
||
getTree(uid: string): Observable<ReportTreeNode> {
|
||
const url = `${environment.rmutrApi}/api/setting/income_budget_report/${uid}/tree`;
|
||
return this.httpClient.get<ReportTreeNode>(url);
|
||
}
|
||
|
||
exportXlsxUrl(uid: string): string {
|
||
return `${environment.rmutrApi}/api/setting/income_budget_report/${uid}/export/xlsx`;
|
||
}
|
||
|
||
updateLineAmounts(updates: { income_budget_report_line_uid: string; amount: number }[]): Observable<any> {
|
||
const url = `${environment.rmutrApi}/api/setting/income_budget_report/lines`;
|
||
return this.httpClient.put(url, updates);
|
||
}
|
||
}
|
||
```
|
||
|
||
Check `rmutr-web/src/app/core/base/base-service.ts`'s constructor signature before writing this file — confirm it takes `(endpoint: string, http: HttpClient)` in that order (matches every other service seen this session, e.g. `announcement.service.ts`, but verify directly).
|
||
|
||
- [ ] **Step 2: Verify — type-check**
|
||
|
||
```bash
|
||
cd rmutr-web
|
||
npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -i "income-budget-report.service" | head -30
|
||
```
|
||
Expected: no output.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/app/core/service/setting/income-budget-report.service.ts
|
||
git commit -m "feat: add IncomeBudgetReportService frontend client"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Rewrite the page component
|
||
|
||
**Files:**
|
||
- Modify: `rmutr-web/src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.ts` (full rewrite)
|
||
- Modify: `rmutr-web/src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.html` (full rewrite)
|
||
- Modify: `rmutr-web/src/app/feature/income/draft/income-budget-report1/income-budget-report.module.ts` (add `BudgetReportTreeModule` import)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `IncomeBudgetReportService` (Task 11), `BudgetReportTreeModule`/`BudgetReportTreeNodeComponent` (Task 10).
|
||
- Scope: this task only rewires **`type_id == 1`** behavior (the `/income-budget-report-income1/add` route this whole plan targets). Leave the `type == 2..10` branches in the existing `ngOnInit` switch alone for now — they still reference the old SyncFusion members, which is fine because Task 13's verification only exercises type 1. Wiring type 2/3 into this rewritten component is 1-2 lines each (call `cloneNextRound` instead of `createDraft`) but is explicitly **out of scope** for this plan per the Group A/B split agreed during brainstorming — leave the `NOTE(group-b-followup)` comment shown in Step 2 at the relevant branch rather than implementing it now, so the next plan has an obvious anchor.
|
||
|
||
- [ ] **Step 1: Read the current component fully before rewriting**
|
||
|
||
```bash
|
||
cat rmutr-web/src/app/feature/income/draft/income-budget-report1/income-budget-report1/income-budget-report.component.ts
|
||
```
|
||
Note the `budgetYearSV`, `FacultySV`, and route param handling (`this.form.get('type_id')`) — the rewrite keeps the faculty/budget-year selector UI but drops everything SyncFusion-related.
|
||
|
||
- [ ] **Step 2: Write the new component (type_id==1 path only)**
|
||
|
||
```typescript
|
||
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
|
||
import { ActivatedRoute, Router } from '@angular/router';
|
||
import { Observable } from 'rxjs';
|
||
import { tap } from 'rxjs/operators';
|
||
import { FacultyService } from 'src/app/core/service/general/faculty.service';
|
||
import { BudgetYearService } from 'src/app/core/service/budget/budget-year.service';
|
||
import { IncomeBudgetReportService } from 'src/app/core/service/setting/income-budget-report.service';
|
||
import { ReportTreeNode, LineAmountChange } from 'src/app/shared/components/budget-report-tree/budget-report-tree-node.component';
|
||
|
||
@Component({
|
||
selector: 'app-income-budget-report',
|
||
templateUrl: './income-budget-report.component.html',
|
||
styleUrls: ['./income-budget-report.component.scss'],
|
||
changeDetection: ChangeDetectionStrategy.OnPush
|
||
})
|
||
export class IncomeBudgetReportComponent implements OnInit {
|
||
typeId: number;
|
||
reportUid: string | null = null;
|
||
tree: ReportTreeNode | null = null;
|
||
facultyUid: string | null = null;
|
||
budgetYearUid: string | null = null;
|
||
pendingChanges = new Map<string, number>();
|
||
|
||
faculty$ = new Observable<any>();
|
||
budgetYear$ = new Observable<any>();
|
||
|
||
constructor(
|
||
private activatedRoute: ActivatedRoute,
|
||
private router: Router,
|
||
private cdRef: ChangeDetectorRef,
|
||
private facultySV: FacultyService,
|
||
private budgetYearSV: BudgetYearService,
|
||
private incomeBudgetReportSV: IncomeBudgetReportService,
|
||
) {}
|
||
|
||
ngOnInit(): void {
|
||
this.faculty$ = this.facultySV.getAll();
|
||
this.budgetYear$ = this.budgetYearSV.getAll();
|
||
this.activatedRoute.data.pipe(
|
||
tap((x: any) => this.typeId = x.type),
|
||
).subscribe();
|
||
}
|
||
|
||
onFacultyChange(facultyUid: string): void {
|
||
this.facultyUid = facultyUid;
|
||
this.tryCreateDraft();
|
||
}
|
||
|
||
onBudgetYearChange(budgetYearUid: string): void {
|
||
this.budgetYearUid = budgetYearUid;
|
||
this.tryCreateDraft();
|
||
}
|
||
|
||
private tryCreateDraft(): void {
|
||
if (!this.facultyUid || !this.budgetYearUid) return;
|
||
this.incomeBudgetReportSV.createDraft(this.facultyUid, this.budgetYearUid).pipe(
|
||
tap((report: any) => {
|
||
this.reportUid = report.income_budget_report_uid;
|
||
this.loadTree();
|
||
}),
|
||
).subscribe();
|
||
}
|
||
|
||
loadTree(): void {
|
||
if (!this.reportUid) return;
|
||
this.incomeBudgetReportSV.getTree(this.reportUid).pipe(
|
||
tap((tree) => { this.tree = tree; this.cdRef.detectChanges(); }),
|
||
).subscribe();
|
||
}
|
||
|
||
hasNoPersonnelData(): boolean {
|
||
if (!this.tree) return false;
|
||
const pb = this.tree.children.find(c => c.nodeKey === 'pb');
|
||
const temp = pb?.children.find(c => c.nodeKey === 'pb-temp');
|
||
const oldRates = temp?.children.find(c => c.nodeKey === 'pb-temp-old');
|
||
const newRates = temp?.children.find(c => c.nodeKey === 'pb-temp-new');
|
||
return !oldRates?.rows.length && !newRates?.rows.length;
|
||
}
|
||
|
||
onAmountChange(change: LineAmountChange): void {
|
||
this.pendingChanges.set(change.lineUid, change.amount);
|
||
}
|
||
|
||
save(): 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.incomeBudgetReportSV.updateLineAmounts(lines).pipe(
|
||
tap(() => { this.pendingChanges.clear(); this.loadTree(); }),
|
||
).subscribe();
|
||
}
|
||
|
||
exportXlsx(): void {
|
||
if (!this.reportUid) return;
|
||
window.open(this.incomeBudgetReportSV.exportXlsxUrl(this.reportUid), '_blank');
|
||
}
|
||
|
||
// NOTE(group-b-followup): wire cloneNextRound() here instead of createDraft() when this
|
||
// component is extended to handle income-budget-report-income2/3 — tracked as a follow-up
|
||
// plan, not implemented here. See docs/superpowers/plans/2026-08-05-income-budget-report-native-group-a.md Task 12.
|
||
}
|
||
```
|
||
|
||
`save()` calls `IncomeBudgetReportService.updateLineAmounts()` (Task 11), which PUTs to `/api/setting/income_budget_report/lines` — that endpoint was added in Task 8 Step 1 (`IncomeBudgetReportController.UpdateLineAmounts`). No backend change needed in this task.
|
||
|
||
- [ ] **Step 3: Write the new template**
|
||
|
||
```html
|
||
<div class="report-page">
|
||
<div class="header-fields">
|
||
<mat-form-field>
|
||
<mat-label>หน่วยงาน</mat-label>
|
||
<mat-select (selectionChange)="onFacultyChange($event.value)">
|
||
<mat-option *ngFor="let f of (faculty$ | async)" [value]="f.faculty_uid">{{ f.faculty_name_th }}</mat-option>
|
||
</mat-select>
|
||
</mat-form-field>
|
||
<mat-form-field>
|
||
<mat-label>ปีงบประมาณ</mat-label>
|
||
<mat-select (selectionChange)="onBudgetYearChange($event.value)">
|
||
<mat-option *ngFor="let y of (budgetYear$ | async)" [value]="y.budget_year_uid">{{ y.budget_year_name_th }}</mat-option>
|
||
</mat-select>
|
||
</mat-form-field>
|
||
</div>
|
||
|
||
<div class="empty-source-banner" *ngIf="tree && hasNoPersonnelData()">
|
||
ยังไม่มีข้อมูลตำแหน่ง/อัตรากำลังจาก ร.2 สำหรับหน่วยงานและปีงบประมาณนี้
|
||
<a routerLink="/app/estimated-income">กรอกฟอร์ม ร.2 ก่อน</a>
|
||
</div>
|
||
|
||
<div class="toolbar" *ngIf="tree">
|
||
<span class="grand-total">รวมทั้งสิ้น: {{ tree.amount | number:'1.2-2' }} บาท</span>
|
||
<button mat-raised-button color="primary" (click)="save()">บันทึก</button>
|
||
<button mat-raised-button (click)="exportXlsx()">ส่งออก Excel</button>
|
||
</div>
|
||
|
||
<app-budget-report-tree-node
|
||
*ngIf="tree"
|
||
[node]="tree"
|
||
(amountChange)="onAmountChange($event)"
|
||
></app-budget-report-tree-node>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 4: Wire `BudgetReportTreeModule` into the feature module**
|
||
|
||
Read `rmutr-web/src/app/feature/income/draft/income-budget-report1/income-budget-report.module.ts` and add to its `imports` array:
|
||
```typescript
|
||
import { BudgetReportTreeModule } from 'src/app/shared/components/budget-report-tree/budget-report-tree.module';
|
||
// ...
|
||
imports: [
|
||
// ...existing imports...
|
||
BudgetReportTreeModule,
|
||
],
|
||
```
|
||
|
||
- [ ] **Step 5: Build and verify**
|
||
|
||
```bash
|
||
cd rmutr-web
|
||
node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -60
|
||
```
|
||
Expected: build succeeds (warnings OK, 0 errors). This is the first point template binding errors would surface — read the output carefully.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/app/feature/income/draft/income-budget-report1/
|
||
git commit -m "feat: replace SyncFusion spreadsheet with native tree component (type 1)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: End-to-end verification against real data
|
||
|
||
**Files:** none (verification only)
|
||
|
||
- [ ] **Step 1: Deploy backend and frontend to the dev/staging environment** (follow this session's established deploy steps: `dotnet publish -c Release -r linux-x64`, scp the DLL, restart the systemd service; `ng build --configuration production`, rsync to the web server)
|
||
|
||
- [ ] **Step 2: Pick a faculty+year that already has a real ร.2 record** (this session confirmed `personnel_statement` records exist for `faculty_uid` matching "บพิตรพิมุข จักรวรรดิ / คณะศิลปศาสตร์" for `budget_year_name_th = '2569'`) — verify via:
|
||
|
||
```bash
|
||
export PGPASSWORD='vtwi,yo0tpkd-okfouh'
|
||
/opt/homebrew/Cellar/postgresql@16/16.14/bin/psql -h 203.158.221.28 -p 31620 -U devadmin -d rmutr_budget_050625 \
|
||
-c "SELECT personnel_statement_uid, faculty_uid, agency_name_th, budget_year_uid FROM \"BUDGET\".personnel_statement LIMIT 5;"
|
||
```
|
||
|
||
- [ ] **Step 3: In the browser, open `/app/income-budget-report-income1/add`, select that faculty+year, and confirm:**
|
||
- The tree renders with "รายการบุคลากร" at the top and the same category labels seen in the real downloaded `type1.xlsx` (`งบบุคลากร`, `งบดำเนินงาน`, `แผนงานยุทธศาสตร์พัฒนาศักยภาพคนตลอดช่วงชีวิต`, ...)
|
||
- Position rows under "อัตราเดิม" are **read-only** (no input box, just text) and match names/amounts in `personnel_statement_detail` for that faculty+year
|
||
- Editing an unlocked amount (e.g. "ค่าตอบแทน ค่าใช้สอยและค่าวัสดุ") updates the parent totals live
|
||
- "บันทึก" persists the change — reload the page and confirm the edited value survived
|
||
- "ส่งออก Excel" downloads a `.xlsx` file that opens and shows the same numbers
|
||
|
||
- [ ] **Step 4: Compare the grand total** against the real downloaded file for the same faculty (`/tmp/mockup_fonts/type2.xlsx` from this session's investigation, or re-download the latest `revenue_draft_committee` record for that faculty) — the personnel-derived subtotal should match `personnel_statement_detail` records summed × 12 exactly, since Task 5's `CreateDraft` uses the same `salary_rate * 12` formula the old system used (`Personnel.cs:2345`)
|
||
|
||
- [ ] **Step 5: Document any mismatch found as a follow-up task** — do not silently patch numbers to make them match without understanding why they differed first.
|
||
|
||
---
|
||
|
||
## Explicitly out of scope for this plan (tracked for the Group B follow-up plan)
|
||
|
||
- `type_id` 2, 3 UI wiring (backend `CloneNextRound` supports it; frontend component's faculty/year picker → clone flow is not built)
|
||
- `type_id` 4, 5, 6 (Group B — multi-tab workbook, different tree schema, different source tables ร.4/ร.5/ร.6)
|
||
- `type_id` 10 (Group C — matrix report, entirely different data model)
|
||
- Deprecating/removing the old `revenue_draft_committee` table and SyncFusion dependency (leave both in place until Group B ships too, per the phased/pilot rollout agreed in the spec)
|