Files
rmutr/docs/superpowers/specs/2026-08-05-income-budget-report-native-design.md
Nut.ไปเรื่อย da58e1aac8 docs: add design spec for native income-budget-report (replacing SyncFusion)
Design for replacing the SyncFusion spreadsheet on income-budget-report-income1
and its 6 sibling menus with a native Angular tree component. Covers the 3
structurally-distinct document groups found in the real production files
(draft rounds 1-3, allocation/adjustment 4-6, material-cost matrix type 10),
the shift from source-locked-vs-editable data (ร.2/ร.4/ร.5/ร.6 as read-only
source, only committee-adjusted amounts editable), and the new
income_budget_report/income_budget_report_line data model replacing the
.xlsx-blob storage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011R9j5gaw2qsoh9ypWQeXxi
2026-08-05 11:43:08 +07:00

22 KiB

แทนที่ SyncFusion Spreadsheet ในรายงานงบประมาณเงินรายได้ ด้วยหน้าเว็บ native

บริบท

หน้า /app/income-budget-report-income1/add (และเมนูพี่น้องอีก 6 เมนู) ใช้ SyncFusion Spreadsheet (@syncfusion/ej2-angular-spreadsheet) เปิดไฟล์ .xlsx ที่ backend generate มาให้ ผู้ใช้แก้ไขในสเปรดชีตที่มองเห็นได้ทุกเซลล์อิสระ แล้ว "บันทึก" กลับเป็นไฟล์ .xlsx ใหม่เก็บไว้เป็นหลักฐาน (revenue_draft_committee_file)

ปัญหาที่ต้องแก้:

  1. UX สเปรดชีตไม่เหมาะกับผู้ใช้ที่ไม่คุ้น Excel, formula/สูตรผูกกับ "สีเซลล์" ที่ซ่อนอยู่ในไฟล์ ตรวจสอบ/แก้ไขยาก
  2. ไม่มีการล็อกข้อมูล — ตำแหน่ง/จำนวนอัตรา/รายการครุภัณฑ์ที่ควรมาจากฟอร์มต้นทาง (ร.2/ร.4/ร.5/ร.6) กลับแก้ไขได้อิสระในหน้านี้เหมือนตัวเลขเงิน เพราะสเปรดชีตไม่แยกเซลล์ locked/unlocked

7 เมนูที่ใช้ component เดียวกัน (income-budget-report.component.ts, แยกพฤติกรรมด้วย route data type) แท้จริงแบ่งเป็น 3 กลุ่มเอกสารที่โครงสร้างต่างกัน (ยืนยันด้วยการดาวน์โหลดไฟล์จริงจาก production มาตรวจ ไม่ใช่การเดา):

กลุ่ม type_id ชื่อเมนู โครงสร้างไฟล์จริง
A — ร่างเงินรายได้ 1→2→3 ร่างเงินรายได้ เพื่อประชุม คกก.ร่างเงินรายได้ / คกก.การเงิน / สภา 1 ชีท ("Page1"), tree เดียว รายการบุคลากร→งบบุคลากร/งบดำเนินงาน→แผนงาน→ผลผลิต→งบเงินอุดหนุน/งบลงทุน/งบรายจ่ายอื่นๆ ยืนยันไฟล์จริงทั้ง 3 type ตรงกัน
B — จัดสรร/ปรับแผนเงินรายได้ 4→5→6 จัดสรรงบประมาณเงินรายได้ / ปรับแผนเงินรายได้ (ประมาณการรายจ่าย) / ปรับแผนเงินรายได้ workbook 5 ชีท แยกตาม "ผลผลิต" + สรุป มีช่องกรอกอิสระ ("ตำแหน่ง......(ระบุ)") แต่ ใช้ logic คำนวณผลรวมแบบเดียวกับกลุ่ม A (สีเซลล์ + สูตร SUM รูปแบบเดียวกัน) ยืนยันไฟล์จริง type 4,5 (type 6 ไม่มีข้อมูลจริงในระบบ อนุมานจาก chain ต่อจาก 5)
C — วิเคราะห์ค่าวัสดุการศึกษา 10 ตารางและรายงานคำนวณค่าวัสดุการศึกษา matrix แยกตามพื้นที่วิทยาเขต ไม่ใช่ tree เลย

Root cause ของปัญหาที่ 2: ตำแหน่ง/รายการที่ควรมาจากฟอร์มต้นทางจริง ๆ ก็ดึงมาจากต้นทางอยู่แล้วในทางเทคนิค

  • ร.2 (คำชี้แจงงบบุคลากร) เก็บใน personnel_statement + personnel_statement_detail/_2 (rmutr-api/Modules/ReportSalary/Databases/Models/) ดึงผ่าน GET /api/report/personnel/hr/budget_expenditure_report_from_revenue_v3/{view} (Personnel.cs:2195)
  • ร.4 (งบครุภัณฑ์) = invest_asset_request_information, ร.5 (งบที่ดิน/สิ่งก่อสร้าง) = invest_construct_request_information, ร.6 (เงินอุดหนุน) = request_budget_income — ทั้งสามดึงรวมกันแล้วผ่าน GET /api/budget_progress/budget_progress/summary_expense/{budget_year_uid}/{faculty_uid} (BudgetProgress.cs:409) ซึ่งมี tree renderer ต้นแบบอยู่แล้วที่ manage-budget-request-expense-list.component.html

แต่เพราะ SyncFusion เปิดให้แก้ทุกเซลล์อิสระ ข้อมูลที่ดึงมาจึง ไม่ถูกล็อก อีกต่อไปหลังโหลดเข้าสเปรดชีต — นี่คือรากของปัญหา ไม่ใช่การขาดต้นทางข้อมูล

ขอบเขต

  • แทนที่ SyncFusion Spreadsheet ด้วย native Angular tree component สำหรับ กลุ่ม A และ B (ใช้ "เครื่องยนต์" เดียวกัน — ดูเหตุผลในหัวข้อสถาปัตยกรรม)
  • กลุ่ม C (type 10) แยก component matrix ต่างหาก ใช้ style/token ชุดเดียวกันเพื่อความสม่ำเสมอ แต่ไม่ใช้ tree schema/engine เดียวกับ A/B
  • เปลี่ยนการเก็บข้อมูลจาก "ไฟล์ .xlsx เป็นหลักฐาน" → "ข้อมูลโครงสร้างใน DB เป็นหลัก, .xlsx generate ตอนดาวน์โหลดเท่านั้น"
  • Routing เดิมทั้ง 7 เมนูไม่เปลี่ยน (income-budget-report-income1/2/3, allocate-income-budget, income-expense-estimates(-02), educational-materials) — เปลี่ยนแค่เนื้อในของ component ที่ render
  • ไม่แตะ ฟอร์มต้นทาง ร.2/ร.4/ร.5/ร.6 เอง (estimated-income-form, statement-invest-asset, statement-invest-construct, statement-request) — หน้านี้อ่านข้อมูลจากตารางเหล่านั้นเท่านั้น ไม่เขียนกลับ

สถาปัตยกรรม

ต้นทางข้อมูล (อ่านอย่างเดียว)              หน้ารายงานใหม่ (native web)
─────────────────────────                ──────────────────────────
ร.2 personnel_statement           ──┐
ร.4 invest_asset_request_info     ──┤     Backend: endpoint รวบรวมใหม่
ร.5 invest_construct_request_info ──┼───▶  (ต่อยอด summary_expense เดิม
ร.6 request_budget_income         ──┘      + เพิ่ม ร.2)
                                                   │
                                                   ▼
                                         income_budget_report (ใหม่)
                                         + income_budget_report_line
                                                   │
                                                   ▼
                                         Angular: tree component ใช้ร่วม
                                         กลุ่ม A (1 ต้นไม้ใหญ่) + กลุ่ม B (5 แท็บ)

หน้ารายงานนี้เปลี่ยนบทบาทจาก "ฟอร์มกรอกข้อมูล" เป็น "หน้ารวบรวม+ทบทวนตัวเลข" — ตำแหน่ง/รายการครุภัณฑ์/โครงการ แสดง read-only จากฟอร์มต้นทาง ส่วนที่แก้ไขได้จริงคือ "จำนวนเงินที่คณะกรรมการปรับ" ต่อรายการ เก็บแยกเป็น snapshot ของตัวเอง ไม่เขียนทับข้อมูลต้นทาง

โครงสร้าง tree (รายการบุคลากร→งบบุคลากร→...) เป็น config คงที่ต่อ type_id (ไฟล์ TypeScript ฝั่ง frontend + ค่าคู่กันฝั่ง backend สำหรับคำนวณตอน export) ไม่ใช่ตารางใน DB — เพราะเป็นแบบฟอร์มราชการที่แทบไม่เปลี่ยนโครงสร้าง ถ้าในอนาคตต้องการให้แก้ schema ผ่าน UI ได้ ค่อยย้ายเป็น DB-driven ทีหลัง (YAGNI)

Data Model

income_budget_report                          income_budget_report_line
──────────────────────                        ──────────────────────────
income_budget_report_uid (PK)                 income_budget_report_line_uid (PK)
type_id (1-6, 10)                             income_budget_report_uid (FK)
budget_year_uid                               node_key  เช่น "pb-temp-old", "out1-invest-equip"
faculty_uid, budget_location_uid              source_type: personnel_detail | personnel_detail_2 |
sector_name_th                                             invest_asset | invest_construct |
parent_uid  ← report รอบก่อนหน้า (1→2→3, 4→5→6)              request_budget_income | manual
status_id                                     source_uid  (uid ต้นทาง, null = รายการพิมพ์เอง)
+ audit fields มาตรฐาน (base_table)            label_th (snapshot ชื่อรายการตอนดึงมา)
                                               amount        ← แก้ไขได้ที่นี่เท่านั้น
                                               original_amount (ค่าตอนดึงมา, ไว้เทียบ/audit)
                                               sequence_no

การสร้าง/ต่อรอบ:

  • รอบแรก (type 1, 4): backend query ร.2/4/5/6 ตาม faculty_uid + budget_year_uid (ต่อยอด summary_expense + budget_expenditure_report_from_revenue_v3) → generate income_budget_report_line ให้อัตโนมัติ, amount = original_amount = ค่าจากต้นทาง, จับคู่ node_key ตาม config ของ type นั้น
  • ต่อรอบ (type 2,3 / 5,6): clone header+lines จากรอบก่อนหน้าตรงๆ (ตามที่ยืนยัน) — ไม่ query ต้นทางซ้ำ เพราะรอบถัดไปคือ "แก้ต่อจากมติที่ประชุมรอบก่อน" ไม่ใช่ดึงข้อมูลสดใหม่ ถ้าต้นทาง (ร.2/4/5/6) ถูกแก้ไขหลังจาก snapshot ไปแล้ว จะไม่ไหลย้อนกลับมาอัตโนมัติ — เป็นการตัดสินใจโดยตั้งใจเพื่อความนิ่งของมติที่ประชุมแต่ละรอบ ต้องมี UI แจ้งผู้ใช้ให้ชัดเจน
  • รายการที่ source_uid = null (เช่น "อัตราใหม่" ที่ยังไม่มีใน ร.2 จริง, หรือช่องกรอกอิสระ "ระบุ..." ในกลุ่ม B) → แก้ไขได้ทั้งชื่อและจำนวนเงิน
  • รายการที่มี source_uid → ชื่อ/ตำแหน่ง/จำนวนอัตรา ล็อกเป็น read-only แก้ได้แค่ amount

กลุ่ม C (type 10) โครงสร้างเป็น matrix ไม่ใช่ tree — ใช้ตารางแยก material_cost_report เรียบง่ายกว่า ไม่ผูกกับ schema ข้างต้น (รายละเอียดโครงสร้างกลุ่ม C จะออกแบบในรอบถัดไปเมื่อเริ่มลงมือ เพราะเป็น sub-project ที่ independent จาก A/B)

Backend API (เพิ่มใหม่)

ต่อยอด pattern BaseUidController<T,V> มาตรฐานของระบบ:

GET    /api/setting/income_budget_report/{uid}                  → โหลด report + lines (BaseUidController มาตรฐาน)
POST   /api/setting/income_budget_report/create_draft           → สร้างรอบแรก (type 1,4): query ร.2/4/5/6
                                                                    ตาม faculty+ปี → generate lines
POST   /api/setting/income_budget_report/{uid}/clone_next_round → สร้างรอบถัดไป (type 2,3,5,6): clone
                                                                    header+lines จาก uid เดิม, type_id+1
PUT    /api/setting/income_budget_report/lines                  → บันทึกจำนวนเงินที่แก้ (batch update amount)
GET    /api/setting/income_budget_report/{uid}/export/xlsx      → generate .xlsx ตอนดาวน์โหลด (EPPlus/
                                                                    ClosedXML จาก tree config + ข้อมูลจริงใน DB)

IncomeBudgetReportRollupService (C# ใหม่) เดินตาม tree config เดียวกับฝั่ง frontend สรุปยอดแต่ละ node จาก children — ใช้ทั้งตอน export Excel และตอนส่งข้อมูลกลับให้ Angular แสดง แทนที่ logic เดิมที่ไล่หาสีเซลล์ (Personnel.cs:6089-6363, endpoint /report/personnel/calcurate) ทั้งหมด

Frontend Component Structure

shared/components/budget-report-tree/          generic recursive tree (ต่อยอดจาก mockup ที่อนุมัติแล้ว)
  budget-report-tree-node.component.ts            รับ @Input node config + data, render เอง + เรียกตัวเองซ้ำ
                                                    คำนวณ sum ฝั่ง client แบบ real-time เหมือน mockup

feature/income/report-config/                  config คงที่ต่อ type (TypeScript const)
  income-report-type1.config.ts                   tree schema กลุ่ม A
  income-report-type4.config.ts                   tree schema กลุ่ม B (5 sub-tree ตามผลผลิต)

feature/income/draft/income-budget-report1/    แทนที่เนื้อในของ component เดิมทั้งไฟล์
  income-budget-report.component.ts                โหลด config ตาม type_id (route data), โหลด/สร้าง/clone
                                                    report, bind เข้า budget-report-tree, ปุ่มบันทึก/ส่งออก
                                                    (กลุ่ม B ครอบด้วย mat-tab-group 5 แท็บตามผลผลิต)

feature/income/material-cost-report/           กลุ่ม C แยกต่างหาก (matrix component ใหม่ทั้งหมด)

Routing เดิมไม่เปลี่ยน — component เดิมยังถูกเรียกจากทั้ง 7 เมนูเหมือนเดิม แค่เนื้อในเปลี่ยนจาก SyncFusion เป็น tree component (กลุ่ม A/B) หรือ matrix component ใหม่ (กลุ่ม C, educational-materials type=10)

Data Flow

  1. เปิดหน้า /add → เช็คว่ามี report ของ หน่วยงาน+ปี+type นี้อยู่แล้วหรือยัง ถ้ายัง → กด "สร้างรายงาน" → create_draft
  2. แก้จำนวนเงินที่ปรับได้ → คำนวณยอดรวมสดฝั่ง client (เหมือน mockup) → กด "บันทึก" → PUT lines แบบ batch
  3. ประชุมรอบนี้เสร็จ → กด "ส่งต่อรอบถัดไป" → clone_next_round → เปิดหน้า type ถัดไปพร้อมข้อมูล clone มาแล้ว
  4. กด "ส่งออก Excel" ได้ทุกจุด → generate ไฟล์ตามข้อมูล ณ ขณะนั้น

Error Handling

  • ยังไม่มีข้อมูลจาก ร.2/4/5/6 เลย → โชว์ tree เปล่าพร้อมข้อความ "ยังไม่มีข้อมูลจาก ร.2 กรุณากรอกฟอร์มก่อน" ลิงก์ไปหน้านั้น
  • กัน "ส่งต่อรอบถัดไป" ซ้ำซ้อน — เช็คว่ามี report ที่ parent_uid ชี้มาที่ตัวปัจจุบันอยู่แล้วหรือยังก่อนอนุญาตให้ clone อีกครั้ง
  • แก้ ร.2/4/5/6 ต้นทางหลัง snapshot ไปแล้ว ไม่ไหลย้อนกลับอัตโนมัติ (ดูหัวข้อ Data Model) — ต้องมี UI แจ้งชัดเจน

Testing

ระบบนี้ไม่มี automated test (ตรวจสอบด้วยมือทั้งระบบตาม convention เดิม) — verify โดยเทียบยอดรวมกับไฟล์ Excel จริงที่ดาวน์โหลดมาตรวจสอบแล้วระหว่างขั้นตอนออกแบบ (มี type 1,2,3,4,5,10 อยู่ในมือ) ให้ตัวเลขตรงกันทุก node ก่อนถือว่าใช้ได้

Rollout

ระบบ production ที่ใช้งานจริงกับการประชุมจริงของมหาวิทยาลัย — deploy เป็นฟีเจอร์คู่ขนาน ไม่ทับของเดิมทันที: ทดสอบกับ 1 หน่วยงานจริงก่อน 1 รอบประชุม เทียบผลกับของเดิมให้ตรงกัน ก่อนค่อยเปิดใช้แทนของเดิมทั้งระบบ

จุดที่ยังไม่ชัด / ต้องตรวจเพิ่มตอน implementation

  • ร.7: หาไม่เจอในระบบเลยทั้ง frontend/backend ไม่ทราบว่าเคยมีหรือถูกยุบรวมกับฟอร์มอื่น — ไม่กระทบ scope นี้เพราะไม่มีข้อมูลอ้างอิงในไฟล์ Excel เดิมที่ตรวจแล้ว
  • budget_progress_id == 2 ใน GetSummaryExpense (BudgetProgress.cs:419) ความหมายจริงยังไม่ยืนยัน (draft/submitted?) กระทบว่าจะดึงข้อมูลสถานะไหนมาแสดง
  • request_budget_income ใน GetSummaryExpense ไม่ได้ filter type_id (ต่างจาก Request.cs:651 ที่ filter type_id == 1) — อาจดึงมาทั้ง "งบเงินอุดหนุน" และ "งบรายจ่ายอื่นๆ" ปนกัน ต้อง filter เพิ่มถ้าต้องการเฉพาะ ร.6
  • โครงสร้าง tree schema ของ type 2,3,5,6 (chain ต่อจาก 1,4) ยังไม่ได้ตรวจไฟล์จริงเทียบเท่า type 1,4 — type 3 ยืนยันแล้วว่าตรงกับ type 1/2, type 5 ยืนยันแล้วว่าตรงกับ type 4, type 6 ไม่มีข้อมูลจริงในระบบให้ตรวจ อนุมานจาก chain เท่านั้น ต้องระวังตอน implement
  • กลุ่ม C (type 10) ยังไม่ได้ออกแบบ data model โดยละเอียด (matrix by campus) — เป็น sub-project แยกที่จะ spec เพิ่มเมื่อเริ่มลงมือ