cormort's picture
Update app.py
6f8c2d6 verified
"""
app.py — 基金 KPI Dashboard
支援:
‧ 特別收入基金 → FNGFRB2060(來源用途餘絀)/ FNGFRB2080(平衡表)
‧ 作業基金 → NBAWIR070(收支餘絀)/ FNGFRB2080(平衡表)/ FNWFRB2020(固定資產)
"""
from pathlib import Path
from typing import Dict, List, Tuple
import pandas as pd
import streamlit as st
from src.utils.parser import (
parse_xml_to_df,
convert_excel_to_xlsx,
convert_excel_to_ods,
convert_doc_to_docx,
)
from src.utils.visualizations import (
# 色彩
NEGATIVE, POSITIVE, NEUTRAL, WARN, INFO, SOURCE_MAIN, USES_MAIN, CARD_BG, TEXT_MAIN,
# 特別收入基金
plot_balance_sheet, plot_budget_vs_actual, plot_execution_gauge,
plot_kpi_trend, plot_monthly_stacked, plot_nested_donut,
plot_sankey, plot_sankey_nba, plot_variance_bar, to_yi,
# 作業基金
plot_surplus_waterfall, plot_fixed_assets_bar, plot_fixed_assets_treemap,
plot_nba_compare,
summarize_nba, summarize_fixed_assets,
summarize_cashflow, plot_cashflow_waterfall, plot_cashflow_detail, plot_cashflow_bs_diff,
)
# ── 頁面設定 ─────────────────────────────────────────────────────────────────
st.set_page_config(
page_title="基金 KPI Dashboard",
page_icon="🏥",
layout="wide",
initial_sidebar_state="expanded",
)
SAMPLES_DIR = Path("samples")
# ── 全域樣式(淺色主題)─────────────────────────────────────────────────────
CUSTOM_CSS = """
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,700;1,9..40,400&family=DM+Mono:wght@400;500&display=swap');
:root {
--bg: #F0F4F8;
--surface: #FFFFFF;
--surface-2: #F7F9FB;
--surface-3: #EDF1F5;
--border: rgba(0,0,0,0.08);
--border-hover: rgba(0,0,0,0.18);
--text: #1A2332;
--text-muted: #546E7A;
--text-faint: #90A4AE;
--source: #1976D2;
--source-bg: rgba(25,118,210,0.08);
--source-border: rgba(25,118,210,0.25);
--uses: #7B1FA2;
--uses-bg: rgba(123,31,162,0.08);
--uses-border: rgba(123,31,162,0.25);
--accent-green: #2E7D32;
--accent-red: #C62828;
--accent-amber: #E65100;
--accent-info: #00838F;
--accent-blue: #1976D2;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 18px;
}
html, body, [class*="css"] { font-family: 'DM Sans', ui-sans-serif, system-ui, sans-serif; color: var(--text); }
.stApp {
background: var(--bg) !important;
background-image:
radial-gradient(ellipse 70% 35% at 5% -5%, rgba(25,118,210,0.06) 0%, transparent 55%),
radial-gradient(ellipse 50% 25% at 95% 5%, rgba(123,31,162,0.04) 0%, transparent 50%) !important;
}
div.block-container { padding-top: 1.25rem; padding-bottom: 2.5rem; max-width: 1680px; }
/* ── Hero ── */
.hero {
background: linear-gradient(135deg, rgba(25,118,210,0.06), rgba(123,31,162,0.04));
border: 1px solid var(--border); border-radius: var(--radius-lg);
padding: 1.5rem 2rem; margin-bottom: 1.5rem;
}
.hero-title { font-size: 1.75rem; font-weight: 700; color: var(--text); margin: 0 0 0.35rem; letter-spacing: -0.3px; }
.hero-sub { font-size: 0.9rem; color: var(--text-muted); margin: 0; line-height: 1.6; }
.hero-stat { display: inline-flex; align-items: center; gap: 0.3rem; margin-right: 1.2rem; }
.hero-stat strong { color: var(--text); }
/* ── Fund-type 標籤 ── */
.fund-badge {
display: inline-flex; align-items: center; gap: 4px;
border-radius: 999px; padding: 3px 12px; font-size: 0.78rem; font-weight: 600;
margin-left: 0.75rem; vertical-align: middle;
}
.fund-badge.special { background: rgba(25,118,210,0.10); color: #1565C0; border: 1px solid rgba(25,118,210,0.25); }
.fund-badge.operating { background: rgba(0,131,143,0.10); color: #006064; border: 1px solid rgba(0,131,143,0.25); }
/* ── KPI 卡片 ── */
.kpi-card {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md);
padding: 1rem 1.1rem; min-height: 112px;
display: flex; flex-direction: column; justify-content: space-between;
transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease;
position: relative; overflow: hidden;
}
.kpi-card::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
background: var(--kpi-accent, transparent);
border-radius: var(--radius-md) var(--radius-md) 0 0;
}
.kpi-card:hover { transform: translateY(-2px); border-color: var(--border-hover); box-shadow: 0 6px 20px rgba(0,0,0,0.10); }
.kpi-label { font-size: 0.72rem; font-weight: 600; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 0.4rem; }
.kpi-value { font-size: 1.65rem; font-weight: 700; color: var(--text); line-height: 1.1; font-variant-numeric: tabular-nums; letter-spacing: -0.5px; }
.kpi-value.secondary { font-size: 1.1rem; color: var(--text-muted); }
.kpi-delta { font-size: 0.8rem; font-weight: 500; margin-top: 0.3rem; display: flex; align-items: center; gap: 0.25rem; }
/* ── 內容卡片 ── */
.section-card {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg);
padding: 1.25rem 1.4rem; margin-bottom: 1.1rem; box-shadow: 0 1px 4px rgba(0,0,0,0.05);
}
.card-title { font-size: 1rem; font-weight: 600; color: var(--text); margin: 0 0 0.2rem; letter-spacing: -0.15px; }
.card-sub { font-size: 0.8rem; color: var(--text-muted); margin: 0 0 0.9rem; }
/* ── 警示膠囊 ── */
.alerts-row { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 1rem; }
.alert-pill { display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.28rem 0.75rem;
border-radius: 999px; font-size: 0.78rem; font-weight: 500; white-space: nowrap; }
.alert-good { background: rgba(46,125,50,0.10); color: #2E7D32; border: 1px solid rgba(46,125,50,0.25); }
.alert-warn { background: rgba(230,81,0,0.10); color: #E65100; border: 1px solid rgba(230,81,0,0.25); }
.alert-bad { background: rgba(198,40,40,0.10); color: #C62828; border: 1px solid rgba(198,40,40,0.25); }
.alert-info { background: rgba(25,118,210,0.10); color: #1976D2; border: 1px solid rgba(25,118,210,0.25); }
/* ── 月份徽章 ── */
.month-badge { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md);
padding: 0.75rem 1.1rem; text-align: center; box-shadow: 0 1px 4px rgba(0,0,0,0.05); }
.month-badge .label { font-size: 0.7rem; color: var(--text-faint); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 0.25rem; }
.month-badge .value { font-size: 2.2rem; font-weight: 700; color: var(--text); line-height: 1; font-variant-numeric: tabular-nums; }
.month-badge .note { font-size: 0.7rem; color: var(--text-faint); margin-top: 0.2rem; }
/* ── 分隔線標題 ── */
.row-label { font-size: 0.7rem; font-weight: 600; color: var(--text-faint); text-transform: uppercase;
letter-spacing: 1px; margin: 0.25rem 0 0.5rem; }
/* ── 來源 / 用途標籤 ── */
.source-badge { display: inline-flex; align-items: center; gap: 5px;
background: var(--source-bg); color: var(--source); border: 1px solid var(--source-border);
border-radius: 999px; padding: 2px 10px; font-size: 0.72rem; font-weight: 600; }
.uses-badge { display: inline-flex; align-items: center; gap: 5px;
background: var(--uses-bg); color: var(--uses); border: 1px solid var(--uses-border);
border-radius: 999px; padding: 2px 10px; font-size: 0.72rem; font-weight: 600; }
/* ── Empty State ── */
.empty-state { text-align: center; padding: 3.5rem 1rem; color: var(--text-muted); }
.empty-state .icon { font-size: 2.5rem; margin-bottom: 1rem; opacity: .5; }
.empty-state h3 { font-size: 1.1rem; font-weight: 600; color: var(--text); margin: 0 0 0.5rem; }
.empty-state p { font-size: 0.875rem; margin: 0; line-height: 1.6; }
.empty-state ol { text-align: left; display: inline-block; margin: 1rem 0 0; font-size: 0.875rem; line-height: 2; }
/* ── Sidebar ── */
section[data-testid="stSidebar"] { background: var(--surface) !important; border-right: 1px solid var(--border) !important; }
section[data-testid="stSidebar"] .sidebar-section-title {
font-size: 0.68rem; font-weight: 700; letter-spacing: 1.2px; text-transform: uppercase;
color: var(--text-faint); margin: 1.25rem 0 0.5rem; padding-bottom: 0.4rem; border-bottom: 1px solid var(--border);
}
/* ── Tabs ── */
.stTabs [data-baseweb="tab-list"] { gap: 0.25rem; background: var(--surface-3) !important; border-radius: var(--radius-sm); padding: 0.25rem; }
.stTabs [data-baseweb="tab"] { border-radius: 6px !important; font-weight: 500; font-size: 0.85rem;
color: var(--text-muted) !important; padding: 0.45rem 0.9rem !important; transition: background .15s ease, color .15s ease; }
.stTabs [aria-selected="true"] { background: var(--surface) !important; color: var(--text) !important; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
.stSelectbox label, .stMultiSelect label { font-size: 0.8rem; color: var(--text-muted) !important; }
[data-testid="stExpander"] summary { font-size: 0.85rem; font-weight: 500; }
.mono { font-family: 'DM Mono', ui-monospace, monospace; }
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: var(--surface-3); }
::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.18); border-radius: 99px; }
</style>
"""
st.markdown(CUSTOM_CSS, unsafe_allow_html=True)
# ══════════════════════════════════════════════════════════════════════════════
# 工具函式
# ══════════════════════════════════════════════════════════════════════════════
def fmt_yi(value) -> str:
try: return f"{float(value):,.2f}"
except: return "–"
def fmt_pct(value) -> str:
try: return f"{float(value):.1f}%"
except: return "–"
def read_local_samples() -> List[Tuple[str, bytes]]:
if not SAMPLES_DIR.exists(): return []
return [(p.name, p.read_bytes()) for p in sorted(SAMPLES_DIR.glob("*.xml"))]
@st.cache_data(show_spinner=False)
def process_inputs(file_tuples: List[Tuple[str, bytes]]):
results = []
for filename, content in file_tuples:
try:
parsed = parse_xml_to_df(content, filename=filename)
parsed["filename"] = filename
results.append(parsed)
except Exception as e:
results.append({"type": "UNKNOWN", "month": "00", "program_id": "",
"data": pd.DataFrame(), "warnings": [], "errors": [str(e)], "filename": filename})
return results
def to_file_tuples(uploaded_files) -> List[Tuple[str, bytes]]:
return [(f.name, f.getvalue()) for f in uploaded_files]
def build_upload_summary(parsed_results: List[dict]) -> dict:
s = {"total": len(parsed_results), "ok": 0, "warning": 0, "error": 0}
for item in parsed_results:
if item.get("errors"): s["error"] += 1
elif item.get("warnings"): s["warning"] += 1; s["ok"] += 1
else: s["ok"] += 1
return s
def select_unique_record(records: List[dict], label: str, key: str):
if not records: return None
options = {f"{item.get('month','00')} · {item.get('filename')}": item
for item in sorted(records, key=lambda x: (x.get("month","00"), x.get("filename","")))}
selected = st.selectbox(label, list(options.keys()), index=max(0, len(options)-1), key=key)
return options[selected]
def _get_val(df: pd.DataFrame, code: str, col: str, exact: bool = True) -> float:
if df.empty or col not in df.columns or "科目代碼" not in df.columns: return 0.0
temp = df.copy(); temp["科目代碼"] = temp["科目代碼"].astype(str)
mask = temp["科目代碼"] == str(code) if exact else temp["科目代碼"].str.startswith(str(code))
return to_yi(temp.loc[mask, col]).sum() if mask.any() else 0.0
def summarize_2060(df: pd.DataFrame) -> Dict[str, float]:
empty = {k: 0.0 for k in ["source_execution_rate", "uses_execution_rate",
"source_execution_rate_ytd", "uses_execution_rate_ytd",
"surplus_month", "surplus_ytd", "ending_balance", "variance_ytd"]}
if df.empty: return empty
def gv(code, col, exact=True):
return _get_val(df, code, col, exact) or _get_val(df, code, col, not exact)
s_b = gv("4", "本年度法定預算數"); s_a = gv("4", "本年度截至本月份實際數")
u_b = gv("5", "本年度法定預算數"); u_a = gv("5", "本年度截至本月份實際數")
s_by = gv("4", "本年度截至本月份預算數"); u_by = gv("5", "本年度截至本月份預算數")
return {
"source_execution_rate": s_a / s_b * 100 if s_b else 0.0,
"uses_execution_rate": u_a / u_b * 100 if u_b else 0.0,
"source_execution_rate_ytd": s_a / s_by * 100 if s_by else 0.0,
"uses_execution_rate_ytd": u_a / u_by * 100 if u_by else 0.0,
"surplus_month": gv("6", "本月份實際數"),
"surplus_ytd": gv("6", "本年度截至本月份實際數"),
"ending_balance": gv("73", "本年度截至本月份實際數"),
"variance_ytd": s_a - s_by if s_by else 0.0,
}
def summarize_2080(df: pd.DataFrame) -> Dict[str, float]:
if df.empty or "金額" not in df.columns or "科目代碼" not in df.columns:
return {"assets": 0.0, "liabilities": 0.0, "equity": 0.0}
temp = df.copy(); temp["科目代碼"] = temp["科目代碼"].astype(str)
return {
"assets": to_yi(temp.loc[temp["科目代碼"].str.startswith("1"), "金額"]).sum(),
"liabilities": to_yi(temp.loc[temp["科目代碼"].str.startswith("2"), "金額"]).sum(),
"equity": to_yi(temp.loc[temp["科目代碼"].str.startswith("3"), "金額"]).sum(),
}
def build_trend_map(data_2060: List[dict]):
if not data_2060: return {}, []
months = sorted({d["month"] for d in data_2060})
rows = []
for d in data_2060:
df = d["data"].copy()
if "科目代碼" not in df.columns: continue
if "本月份實際數" not in df.columns: df["本月份實際數"] = 0
temp = df[["科目代碼", "本月份實際數"]].copy()
temp["月份"] = d["month"]; temp["本月份實際數"] = to_yi(temp["本月份實際數"])
rows.append(temp)
if not rows: return {}, months
combined = pd.concat(rows, ignore_index=True)
pivot = combined.pivot_table(index="科目代碼", columns="月份", values="本月份實際數", aggfunc="sum").reindex(columns=months).fillna(0)
return {str(idx): row.tolist() for idx, row in pivot.iterrows()}, months
def build_monthly_summary(data_2060: List[dict]) -> pd.DataFrame:
rows = []
for item in sorted(data_2060, key=lambda x: x["month"]):
s = summarize_2060(item["data"])
rows.append({"月份": item["month"],
"來源累計執行率(%)": round(s["source_execution_rate"], 2),
"用途累計執行率(%)": round(s["uses_execution_rate"], 2),
"本月賸餘(短絀)": round(s["surplus_month"], 2),
"累計賸餘(短絀)": round(s["surplus_ytd"], 2),
"期末餘額": round(s["ending_balance"], 2)})
return pd.DataFrame(rows)
def build_alerts_special(kpi: Dict[str, float]) -> List[Tuple[str, str]]:
alerts = []
u = kpi.get("uses_execution_rate", 0); v = kpi.get("variance_ytd", 0); s = kpi.get("surplus_ytd", 0)
if u < 70: alerts.append(("⚠ 用途執行率偏低,建議檢視進度或發包節點。", "bad"))
elif u < 95: alerts.append(("◐ 用途執行率略低於目標,可追蹤落後科目。", "warn"))
elif u <= 110: alerts.append(("✓ 用途執行率接近目標區間,整體表現穩健。", "good"))
else: alerts.append(("↑ 用途執行率高於 110%,請留意超額執行。", "warn"))
if v >= 0: alerts.append((f"+ 累計差異 +{v:.2f} 億,高於累計預算。", "warn" if v > 0.2 else "good"))
else: alerts.append((f"– 累計差異 {v:.2f} 億,低於累計預算。", "bad" if v < -0.2 else "warn"))
if s >= 0: alerts.append(("✓ 本年累計呈賸餘,期末資金緩衝相對充足。", "good"))
else: alerts.append(("✗ 本年累計為短絀,建議檢視支出節奏與資金調度。", "bad"))
return alerts
def build_alerts_nba(kpi: dict) -> List[Tuple[str, str]]:
alerts = []
ir = kpi.get("income_biz_rate", 0); er = kpi.get("expense_biz_rate", 0)
sn = kpi.get("surplus_net", 0)
if ir >= 100: alerts.append((f"✓ 業務收入達成率 {ir:.1f}%,超越全年預算。", "good"))
elif ir >= 85: alerts.append((f"◐ 業務收入達成率 {ir:.1f}%,接近目標。", "warn"))
else: alerts.append((f"⚠ 業務收入達成率 {ir:.1f}%,偏低。", "bad"))
if er > 105: alerts.append((f"↑ 業務成本費用執行率 {er:.1f}%,超出預算,請留意。", "warn"))
elif er >= 85: alerts.append((f"◑ 業務成本費用執行率 {er:.1f}%,進度正常。", "good"))
else: alerts.append((f"↓ 業務成本費用執行率 {er:.1f}%,尚有空間。", "info"))
if sn >= 0: alerts.append((f"✓ 本期賸餘 {sn:.2f} 億元,財務表現正向。", "good"))
else: alerts.append((f"✗ 本期短絀 {abs(sn):.2f} 億元,建議檢視費控。", "bad"))
return alerts
# ══════════════════════════════════════════════════════════════════════════════
# UI 元件
# ══════════════════════════════════════════════════════════════════════════════
def render_alerts(alerts: List[Tuple[str, str]]):
pills = []
for text, level in alerts:
css = {"good": "alert-good", "warn": "alert-warn", "bad": "alert-bad"}.get(level, "alert-info")
pills.append(f'<span class="alert-pill {css}">{text}</span>')
st.markdown(f'<div class="alerts-row">{"".join(pills)}</div>', unsafe_allow_html=True)
def render_kpi_card(label, value, unit="", delta_text="", delta_color="var(--text-muted)",
accent_color="transparent", value_class=""):
st.markdown(
f"""<div class="kpi-card" style="--kpi-accent:{accent_color}">
<div class="kpi-label">{label}</div>
<div>
<span class="kpi-value {value_class}">{value}</span>
{f'<span style="font-size:.8rem;color:var(--text-faint);margin-left:.3rem">{unit}</span>' if unit else ''}
</div>
<div class="kpi-delta" style="color:{delta_color}">{delta_text}</div>
</div>""", unsafe_allow_html=True)
def render_dataframe_with_sparkline(df: pd.DataFrame, trend_map: Dict[str, List[float]]):
display = df.copy()
if "科目代碼" in display.columns:
display["科目代碼"] = display["科目代碼"].astype(str)
display = display.sort_values("科目代碼", ascending=True, kind="stable")
rename_map = {}
for col in ["本年度法定預算數", "本月份實際數", "本月份預算數",
"本年度截至本月份實際數", "本年度截至本月份預算數"]:
if col in display.columns:
display[col] = to_yi(display[col])
rename_map[col] = f"{col} (億元)"
if rename_map: display = display.rename(columns=rename_map)
ac = "本年度截至本月份實際數 (億元)"; bc = "本年度法定預算數 (億元)"; bcy = "本年度截至本月份預算數 (億元)"
if ac in display.columns and bc in display.columns:
display["執行率(%)"] = display.apply(lambda r: round(r[ac]/r[bc]*100, 1) if r[bc] else 0, axis=1)
if ac in display.columns and bcy in display.columns:
display["累計差異(億元)"] = (display[ac] - display[bcy]).round(2)
if "科目代碼" in display.columns:
display["跨月趨勢"] = display["科目代碼"].astype(str).map(lambda k: trend_map.get(str(k), []))
preferred = [c for c in ["科目代碼", "項目", "科目", bc, "本月份實際數 (億元)", ac, bcy,
"執行率(%)", "累計差異(億元)", "跨月趨勢"] if c in display.columns]
if preferred: display = display[preferred].copy()
col_cfg = {}
if "執行率(%)" in display.columns:
col_cfg["執行率(%)"] = st.column_config.ProgressColumn("執行率(%)", min_value=0, max_value=150, format="%.1f%%")
if "跨月趨勢" in display.columns:
col_cfg["跨月趨勢"] = st.column_config.BarChartColumn("跨月趨勢", help="同一科目跨月份本月實際數(億元)")
st.dataframe(display, use_container_width=True, column_config=col_cfg)
def _rate_color(r: float) -> str:
if r >= 100: return POSITIVE
if r >= 80: return WARN
return NEGATIVE
# ══════════════════════════════════════════════════════════════════════════════
# Sidebar
# ══════════════════════════════════════════════════════════════════════════════
with st.sidebar:
st.markdown('<div class="sidebar-section-title">資料來源</div>', unsafe_allow_html=True)
uploaded_files = st.file_uploader("上傳 XML 檔案(可多檔)", type=["xml"], accept_multiple_files=True,
help="支援 FNGFRB2060 / FNGFRB2080 / NBAWIR070 / FNWFRB2020 格式")
use_bundled_samples = st.toggle("未上傳時使用內建範例", value=True)
with st.expander("⚙️ 檢視選項", expanded=False):
show_tables = st.checkbox("顯示明細資料表(含 sparkline)", value=False)
show_diagnostics = st.checkbox("顯示診斷 / 驗證訊息", value=True)
with st.expander("🛠️ 工具", expanded=False):
st.markdown("**格式轉換**")
conv_mode = st.radio("轉換模式", ["doc → docx", "xls → xlsx", "xls/xlsx → ods"], label_visibility="collapsed")
accepted = {"doc → docx": ["doc"], "xls → xlsx": ["xls"], "xls/xlsx → ods": ["xls","xlsx"]}[conv_mode]
conv_file = st.file_uploader("選擇欲轉換的檔案", type=accepted, key="conv_up")
if conv_file and st.button("🚀 開始轉換", use_container_width=True):
try:
with st.spinner("轉換中…"):
raw = conv_file.read(); ext = conv_file.name.rsplit(".",1)[-1].lower()
if "doc" in conv_mode:
out_bytes = convert_doc_to_docx(raw); out_name = conv_file.name.replace(".doc",".docx")
mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
elif "ods" in conv_mode:
out_bytes = convert_excel_to_ods(raw, ext); out_name = conv_file.name.replace(f".{ext}",".ods")
mime = "application/vnd.oasis.opendocument.spreadsheet"
else:
out_bytes = convert_excel_to_xlsx(raw, ext); out_name = conv_file.name.replace(".xls",".xlsx")
mime = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
st.success(f"完成!{out_name}")
st.download_button("📥 下載", data=out_bytes, file_name=out_name, mime=mime)
except Exception as e:
st.error(f"轉換失敗:{e}")
st.divider()
st.markdown("**範例 XML 下載**")
for name, content in read_local_samples():
st.download_button(label=name, data=content, file_name=name, mime="application/xml", key=f"dl_{name}")
# ══════════════════════════════════════════════════════════════════════════════
# 資料載入
# ══════════════════════════════════════════════════════════════════════════════
input_tuples = to_file_tuples(uploaded_files) if uploaded_files else []
if not input_tuples and use_bundled_samples:
input_tuples = read_local_samples()
if not input_tuples:
st.markdown("""<div class="hero">
<div class="hero-title">🏥 基金 KPI Dashboard</div>
<p class="hero-sub">支援特別收入基金與作業基金,即時掌握收支執行、財務結構與固定資產進度。</p>
</div>
<div class="empty-state">
<div class="icon">📂</div>
<h3>尚未載入任何資料</h3>
<p>請透過以下任一方式開始分析:</p>
<ol>
<li>由左側上傳 XML 檔案(2060、2080、NBA收支餘絀、固定資產)</li>
<li>開啟左側「未上傳時使用內建範例」開關</li>
</ol>
</div>""", unsafe_allow_html=True)
st.stop()
with st.spinner("解析中…"):
all_parsed = process_inputs(input_tuples)
summary = build_upload_summary(all_parsed)
# ── 分類 ─────────────────────────────────────────────────────────────────────
# 特別收入基金
data_2060 = [d for d in all_parsed if d.get("type") == "2060" and not d["data"].empty]
data_2080 = [d for d in all_parsed if d.get("type") == "2080" and not d["data"].empty]
# 作業基金
data_nba = [d for d in all_parsed if d.get("type") == "NBA" and not d["data"].empty]
data_massets = [d for d in all_parsed if d.get("type") == "MASSETS" and not d["data"].empty]
# 作業基金平衡表(BS_OPR)與特別收入基金平衡表結構相同,合併進 data_2080 顯示
data_bs_opr = [d for d in all_parsed if d.get("type") == "BS_OPR" and not d["data"].empty]
data_2080_all = data_2080 + data_bs_opr # 統一用這個做平衡表選擇器
has_special = bool(data_2060 or data_2080)
has_operating = bool(data_nba or data_massets or data_bs_opr)
trend_map, _ = build_trend_map(data_2060)
monthly_summary = build_monthly_summary(data_2060)
# ── Hero ─────────────────────────────────────────────────────────────────────
ok_html = f'<span class="hero-stat"><span style="color:var(--accent-green)">●</span> <strong>{summary["ok"]}</strong> 成功</span>'
warn_html = f'<span class="hero-stat"><span style="color:var(--accent-amber)">●</span> <strong>{summary["warning"]}</strong> 警告</span>'
err_html = f'<span class="hero-stat"><span style="color:var(--accent-red)">●</span> <strong>{summary["error"]}</strong> 錯誤</span>'
badge_special = '<span class="fund-badge special">特別收入基金</span>' if has_special else ""
badge_oper = '<span class="fund-badge operating">作業基金</span>' if has_operating else ""
st.markdown(f"""<div class="hero">
<div class="hero-title">🏥 基金 KPI Dashboard {badge_special}{badge_oper}</div>
<p class="hero-sub">共解析 <strong>{summary['total']}</strong> 份檔案 &nbsp;{ok_html}{warn_html}{err_html}</p>
</div>""", unsafe_allow_html=True)
# ── 篩選列 ───────────────────────────────────────────────────────────────────
fc1, fc2, fc3, fc4, fc5 = st.columns([1.1, 1.1, 1.1, 1.1, 0.5])
with fc1:
selected_2060 = select_unique_record(data_2060, "2060 來源用途餘絀表", "sel_2060") if data_2060 else None
with fc2:
selected_2080 = select_unique_record(data_2080_all, "平衡表", "sel_2080") if data_2080_all else None
with fc3:
selected_nba = select_unique_record(data_nba, "NBA 收支餘絀表", "sel_nba") if data_nba else None
with fc4:
selected_massets = select_unique_record(data_massets, "固定資產執行情形表", "sel_massets") if data_massets else None
with fc5:
cur_month = next((d.get("month","–") for d in [selected_2060, selected_nba, selected_massets, selected_2080] if d), "–")
st.markdown(f'<div class="month-badge"><div class="label">月份</div><div class="value">{cur_month}</div><div class="note">本次選擇</div></div>', unsafe_allow_html=True)
# ── 診斷訊息 ─────────────────────────────────────────────────────────────────
if show_diagnostics:
with st.expander("🔎 診斷 / 驗證訊息", expanded=False):
for item in all_parsed:
icon = "✅" if not item.get("errors") else "❌"
st.markdown(f"**{icon} {item.get('filename','(未命名)')}** — 類型:{item.get('type')} · 月份:{item.get('month')}")
for w in item.get("warnings", []): st.warning(w)
for e in item.get("errors", []): st.error(e)
if not any([selected_2060, selected_2080, selected_nba, selected_massets]):
st.markdown('<div class="empty-state"><div class="icon">⚠️</div><h3>可解析資料不足</h3><p>請確認 XML 格式正確。</p></div>', unsafe_allow_html=True)
st.stop()
# ══════════════════════════════════════════════════════════════════════════════
# KPI 摘要列
# ══════════════════════════════════════════════════════════════════════════════
kpi_sp = summarize_2060(selected_2060["data"]) if selected_2060 else {k:0.0 for k in ["source_execution_rate","uses_execution_rate","source_execution_rate_ytd","uses_execution_rate_ytd","surplus_month","surplus_ytd","ending_balance","variance_ytd"]}
kpi_nba = summarize_nba(selected_nba["data"]) if selected_nba else {k:0.0 for k in ["income_biz_budget","income_biz_actual","income_biz_rate","expense_biz_budget","expense_biz_actual","expense_biz_rate","income_other_actual","expense_other_actual","surplus_biz","surplus_net"]}
kpi_fa = summarize_fixed_assets(selected_massets["data"]) if selected_massets else {k:0.0 for k in ["budget_total","carryover","grand_total","actual_total","unpaid","exec_rate","project_count"]}
bal = summarize_2080(selected_2080["data"]) if selected_2080 else {"assets":0.0,"liabilities":0.0,"equity":0.0}
# 警示列
if selected_2060: render_alerts(build_alerts_special(kpi_sp))
if selected_nba: render_alerts(build_alerts_nba(kpi_nba))
# ══════════════════════════════════════════════════════════════════════════════
# 主 Tabs
# ══════════════════════════════════════════════════════════════════════════════
tabs_labels = ["🏠 高管首頁"]
if has_special: tabs_labels += ["📊 來源用途餘絀", "📈 跨月趨勢"]
if has_operating: tabs_labels += ["🏥 作業基金收支", "🏗️ 固定資產"]
if data_2080_all: tabs_labels += ["🗂️ 平衡表"]
if len(data_2080_all) >= 1: tabs_labels += ["💰 現金流量推估"]
tabs_labels += ["🧾 明細資料"]
tabs = st.tabs(tabs_labels)
tab_map = {name: tab for name, tab in zip(tabs_labels, tabs)}
# ───────────────────────────────────────────────────────────────────────────
# ① 高管首頁
# ───────────────────────────────────────────────────────────────────────────
with tab_map["🏠 高管首頁"]:
# ── 特別收入基金 KPI ──
if has_special:
st.markdown('<div class="row-label">特別收入基金 — 執行率</div>', unsafe_allow_html=True)
c1, c2, c3, c4 = st.columns(4)
src_rate = kpi_sp["source_execution_rate"]; use_rate = kpi_sp["uses_execution_rate"]
src_ytd = kpi_sp["source_execution_rate_ytd"]; use_ytd = kpi_sp["uses_execution_rate_ytd"]
with c1: render_kpi_card("基金來源執行率", fmt_pct(src_rate), "/全年", f"{'↑' if src_rate>=100 else '↓'} 對全年法定", SOURCE_MAIN, SOURCE_MAIN)
with c2: render_kpi_card("基金用途執行率", fmt_pct(use_rate), "/全年", f"{'↑' if use_rate>=100 else '↓'} 對全年法定", USES_MAIN, USES_MAIN)
with c3: render_kpi_card("來源執行率(累計)", fmt_pct(src_ytd), "/累計", "對應累計分配", SOURCE_MAIN, SOURCE_MAIN)
with c4: render_kpi_card("用途執行率(累計)", fmt_pct(use_ytd), "/累計", "對應累計分配", USES_MAIN, USES_MAIN)
st.markdown('<div class="row-label" style="margin-top:.6rem">特別收入基金 — 金額</div>', unsafe_allow_html=True)
c5, c6, c7, c8 = st.columns(4)
sm = kpi_sp["surplus_month"]; sy = kpi_sp["surplus_ytd"]; va = kpi_sp["variance_ytd"]
a, li, eq = bal["assets"], bal["liabilities"], bal["equity"]
with c5: render_kpi_card("本月賸餘(短絀)", fmt_yi(sm), "億元", "正值=賸餘", POSITIVE if sm>=0 else NEGATIVE, POSITIVE if sm>=0 else NEGATIVE)
with c6: render_kpi_card("累計賸餘(短絀)", fmt_yi(sy), "億元", f"期末餘額 {fmt_yi(kpi_sp['ending_balance'])} 億", POSITIVE if sy>=0 else NEGATIVE, POSITIVE if sy>=0 else NEGATIVE)
with c7: render_kpi_card("累計差異(實-預)", fmt_yi(va), "億元", "正值超累計預算", POSITIVE if va>=0 else NEGATIVE, INFO)
with c8: render_kpi_card("資產/負債/淨資產", f"{a:.1f}/{li:.1f}/{eq:.1f}", "億元", "資產=負債+淨資產" if abs(a-li-eq)<0.1 else "⚠ 不平衡", NEUTRAL, "#7B1FA2", "secondary")
# ── 作業基金 KPI ──
if has_operating:
st.markdown('<div class="row-label" style="margin-top:.8rem">作業基金 — 收支概況</div>', unsafe_allow_html=True)
o1, o2, o3, o4, o5, o6 = st.columns(6)
ir = kpi_nba["income_biz_rate"]; er = kpi_nba["expense_biz_rate"]
with o1: render_kpi_card("業務收入法定預算", fmt_yi(kpi_nba["income_biz_budget"]), "億元", "", NEUTRAL, SOURCE_MAIN)
with o2: render_kpi_card("業務收入累計實際", fmt_yi(kpi_nba["income_biz_actual"]), "億元", f"達成率 {ir:.1f}%", _rate_color(ir), SOURCE_MAIN)
with o3: render_kpi_card("業務成本費用", fmt_yi(kpi_nba["expense_biz_actual"]), "億元", f"執行率 {er:.1f}%", _rate_color(er), USES_MAIN)
with o4: render_kpi_card("業務外淨收入", fmt_yi(kpi_nba["income_other_actual"]-kpi_nba["expense_other_actual"]), "億元", "業務外收入-費用", POSITIVE if (kpi_nba["income_other_actual"]-kpi_nba["expense_other_actual"])>=0 else NEGATIVE, INFO)
with o5: render_kpi_card("本期賸餘(短絀)", fmt_yi(kpi_nba["surplus_net"]), "億元", "", POSITIVE if kpi_nba["surplus_net"]>=0 else NEGATIVE, POSITIVE if kpi_nba["surplus_net"]>=0 else NEGATIVE)
with o6:
render_kpi_card("固定資產執行率", fmt_pct(kpi_fa["exec_rate"]), f"共 {kpi_fa['project_count']} 項計畫",
f"合計預算 {fmt_yi(kpi_fa['grand_total'])} 億", _rate_color(kpi_fa["exec_rate"]), _rate_color(kpi_fa["exec_rate"]))
# ── 圖表區 ──
left, right = st.columns([1.15, 0.85])
with left:
st.markdown('<div class="section-card"><div class="card-title">整體健康度</div><div class="card-sub">執行進度儀錶與資金流向。</div>', unsafe_allow_html=True)
if selected_2060:
g1, g2 = st.columns([1, 1.25])
with g1:
st.plotly_chart(plot_execution_gauge(src_rate, use_rate, "佔全年法定預算 (%)"), use_container_width=True, key="home_g1")
st.plotly_chart(plot_execution_gauge(src_ytd, use_ytd, "佔累計分配預算 (%)", "來源累計", "用途累計"), use_container_width=True, key="home_g2")
with g2:
st.plotly_chart(plot_sankey(selected_2060["data"], "本年度截至本月份實際數", "特別收入基金資金流向"), use_container_width=True, key="home_sankey")
elif selected_nba:
st.plotly_chart(plot_surplus_waterfall(selected_nba["data"]), use_container_width=True, key="home_wf")
else:
st.markdown('<div class="empty-state"><p>尚無資料</p></div>', unsafe_allow_html=True)
st.markdown("</div>", unsafe_allow_html=True)
if selected_2060:
st.markdown('<div class="section-card"><div class="card-title">落差最大科目</div><div class="card-sub">紅色偏低、綠色偏高。</div>', unsafe_allow_html=True)
st.plotly_chart(plot_variance_bar(selected_2060["data"]), use_container_width=True, key="home_var")
st.markdown("</div>", unsafe_allow_html=True)
elif selected_massets:
st.markdown('<div class="section-card"><div class="card-title">固定資產計畫執行率</div><div class="card-sub">各計畫目前執行進度。</div>', unsafe_allow_html=True)
st.plotly_chart(plot_fixed_assets_bar(selected_massets["data"]), use_container_width=True, key="home_fa")
st.markdown("</div>", unsafe_allow_html=True)
with right:
if not monthly_summary.empty:
st.markdown('<div class="section-card"><div class="card-title">近月趨勢</div><div class="card-sub">執行率與賸餘短絀變化。</div>', unsafe_allow_html=True)
st.plotly_chart(plot_kpi_trend(monthly_summary), use_container_width=True, key="home_trend")
st.markdown("</div>", unsafe_allow_html=True)
if selected_2080:
st.markdown('<div class="section-card"><div class="card-title">平衡表快照</div><div class="card-sub">資產 / 負債 / 淨資產結構。</div>', unsafe_allow_html=True)
st.plotly_chart(plot_balance_sheet(selected_2080["data"]), use_container_width=True, key="home_bs")
st.markdown("</div>", unsafe_allow_html=True)
if selected_nba and not selected_2060:
st.markdown('<div class="section-card"><div class="card-title">收支比較</div><div class="card-sub">各科目預算 vs 實際。</div>', unsafe_allow_html=True)
st.plotly_chart(plot_nba_compare(selected_nba["data"]), use_container_width=True, key="home_nba_cmp")
st.markdown("</div>", unsafe_allow_html=True)
# ───────────────────────────────────────────────────────────────────────────
# ② 來源用途餘絀(特別收入基金)
# ───────────────────────────────────────────────────────────────────────────
if "📊 來源用途餘絀" in tab_map:
with tab_map["📊 來源用途餘絀"]:
if not selected_2060:
st.markdown('<div class="empty-state"><div class="icon">📄</div><h3>尚未上傳 2060 檔案</h3></div>', unsafe_allow_html=True)
else:
df60 = selected_2060["data"]
df_src = df60[df60["科目代碼"].astype(str).str.startswith("4")].copy() if "科目代碼" in df60.columns else pd.DataFrame()
df_use = df60[df60["科目代碼"].astype(str).str.startswith("5")].copy() if "科目代碼" in df60.columns else pd.DataFrame()
s_b = _get_val(df60,"4","本年度法定預算數") or _get_val(df60,"4","本年度法定預算數",False)
s_a = _get_val(df60,"4","本年度截至本月份實際數") or _get_val(df60,"4","本年度截至本月份實際數",False)
u_b = _get_val(df60,"5","本年度法定預算數") or _get_val(df60,"5","本年度法定預算數",False)
u_a = _get_val(df60,"5","本年度截至本月份實際數") or _get_val(df60,"5","本年度截至本月份實際數",False)
st.markdown('<div class="card-title" style="margin-bottom:1rem;font-size:1.25rem">基金收支執行總覽</div>', unsafe_allow_html=True)
b1,b2,b3,b4 = st.columns(4)
with b1: render_kpi_card("來源法定預算", f"{s_b:,.2f}", "億元", "全年預估收入", NEUTRAL, SOURCE_MAIN)
with b2: render_kpi_card("來源累計實際", f"{s_a:,.2f}", "億元", f"達成率:{s_a/s_b*100 if s_b else 0:.1f}%", SOURCE_MAIN, SOURCE_MAIN)
with b3: render_kpi_card("用途法定預算", f"{u_b:,.2f}", "億元", "全年預估支出", NEUTRAL, USES_MAIN)
with b4: render_kpi_card("用途累計實際", f"{u_a:,.2f}", "億元", f"執行率:{u_a/u_b*100 if u_b else 0:.1f}%", USES_MAIN, USES_MAIN)
st.markdown('<div style="height:1.5rem"></div>', unsafe_allow_html=True)
st.markdown('<div class="section-card"><div class="card-title">收支結構與缺口分析</div><div class="card-sub">對比預算與實際的組成比例,掌握未執行缺口。</div>', unsafe_allow_html=True)
d1,d2 = st.columns(2)
with d1: st.plotly_chart(plot_nested_donut(df_src, "💰 來源結構與佔比 (億元)"), use_container_width=True, key="sp_donut_src")
with d2: st.plotly_chart(plot_nested_donut(df_use, "💸 用途結構與佔比 (億元)"), use_container_width=True, key="sp_donut_use")
st.markdown("</div>", unsafe_allow_html=True)
st.markdown('<div class="section-card"><div class="card-title">各科目執行進度追蹤</div><div class="card-sub">法定預算與累計實際之落差。</div>', unsafe_allow_html=True)
r1,r2 = st.columns(2)
with r1: st.plotly_chart(plot_budget_vs_actual(df_src, "來源科目:預算 vs 實際 (億元)"), use_container_width=True, key="sp_bar_src")
with r2: st.plotly_chart(plot_budget_vs_actual(df_use, "用途科目:預算 vs 實際 (億元)"), use_container_width=True, key="sp_bar_use")
st.markdown("</div>", unsafe_allow_html=True)
st.markdown('<div class="section-card"><div class="card-title">資金流動生態系 (Sankey)</div><div class="card-sub">預算與實際資金路徑對比。</div>', unsafe_allow_html=True)
s1,s2 = st.columns(2)
with s1: st.plotly_chart(plot_sankey(df60, "本年度法定預算數", "【預算視角】預估資金流向 (億元)"), use_container_width=True, key="sp_san1")
with s2: st.plotly_chart(plot_sankey(df60, "本年度截至本月份實際數", "【實際視角】真實資金流向 (億元)"), use_container_width=True, key="sp_san2")
st.markdown("</div>", unsafe_allow_html=True)
# ───────────────────────────────────────────────────────────────────────────
# ③ 平衡表
# ───────────────────────────────────────────────────────────────────────────
if "🗂️ 平衡表" in tab_map:
with tab_map["🗂️ 平衡表"]:
if not selected_2080:
st.markdown('<div class="empty-state"><div class="icon">📄</div><h3>尚未上傳 2080 檔案</h3></div>', unsafe_allow_html=True)
else:
st.markdown('<div class="section-card"><div class="card-title">平衡表階層結構</div><div class="card-sub">Treemap 顯示大類與科目占比;點擊可放大子項目。</div>', unsafe_allow_html=True)
st.plotly_chart(plot_balance_sheet(selected_2080["data"]), use_container_width=True, key="bs_main")
st.markdown("</div>", unsafe_allow_html=True)
a, li, eq = bal["assets"], bal["liabilities"], bal["equity"]
m1,m2,m3 = st.columns(3)
with m1: render_kpi_card("總資產", f"{a:.2f}", "億元", "", INFO, INFO)
with m2: render_kpi_card("總負債", f"{li:.2f}", "億元", "", NEGATIVE, NEGATIVE)
with m3: render_kpi_card("淨資產", f"{eq:.2f}", "億元", "資產−負債" if abs(a-li-eq)<0.1 else "⚠ 差異", POSITIVE, "#7B1FA2")
# ───────────────────────────────────────────────────────────────────────────
# ④ 跨月趨勢(特別收入基金)
# ───────────────────────────────────────────────────────────────────────────
if "📈 跨月趨勢" in tab_map:
with tab_map["📈 跨月趨勢"]:
if not data_2060:
st.markdown('<div class="empty-state"><div class="icon">📈</div><h3>尚未上傳任何 2060 檔案</h3></div>', unsafe_allow_html=True)
else:
all_months = sorted({d["month"] for d in data_2060})
sel_months = st.multiselect("選擇月份區間", all_months, default=all_months[-min(6,len(all_months)):])
filtered = [d for d in data_2060 if d["month"] in sel_months] if sel_months else data_2060
src_dict, use_dict = {}, {}
for item in filtered:
df, m = item["data"], item["month"]
if "科目代碼" not in df.columns: continue
s_df = df[df["科目代碼"].astype(str).str.startswith("4")].copy()
u_df = df[df["科目代碼"].astype(str).str.startswith("5")].copy()
if not s_df.empty: src_dict[m] = s_df
if not u_df.empty: use_dict[m] = u_df
st.markdown('<div class="section-card"><div class="card-title">基金來源跨月趨勢</div><div class="card-sub">各來源科目隨月份變化。</div>', unsafe_allow_html=True)
st.plotly_chart(plot_monthly_stacked(src_dict, "來源|各月本月實際(億元)"), use_container_width=True, key="tr_src")
st.markdown("</div>", unsafe_allow_html=True)
st.markdown('<div class="section-card"><div class="card-title">基金用途跨月趨勢</div><div class="card-sub">快速抓出增加最快或異常波動的用途科目。</div>', unsafe_allow_html=True)
st.plotly_chart(plot_monthly_stacked(use_dict, "用途|各月本月實際(億元)"), use_container_width=True, key="tr_use")
st.markdown("</div>", unsafe_allow_html=True)
if not monthly_summary.empty:
st.markdown('<div class="section-card"><div class="card-title">KPI 趨勢(全月)</div><div class="card-sub">含 100% 目標參考線。</div>', unsafe_allow_html=True)
st.plotly_chart(plot_kpi_trend(monthly_summary, "全月 KPI 趨勢"), use_container_width=True, key="tr_kpi")
st.markdown("</div>", unsafe_allow_html=True)
# ───────────────────────────────────────────────────────────────────────────
# ⑤ 作業基金收支
# ───────────────────────────────────────────────────────────────────────────
if "🏥 作業基金收支" in tab_map:
with tab_map["🏥 作業基金收支"]:
if not selected_nba:
st.markdown('<div class="empty-state"><div class="icon">🏥</div><h3>尚未上傳收支餘絀表</h3><p>請上傳 NBAWIR070 XML 檔案。</p></div>', unsafe_allow_html=True)
else:
df_nba = selected_nba["data"]
df_nba["科目代碼"] = df_nba["科目代碼"].astype(str)
# KPI 四欄
st.markdown('<div class="card-title" style="margin-bottom:1rem;font-size:1.25rem">作業基金收支執行總覽</div>', unsafe_allow_html=True)
n1,n2,n3,n4 = st.columns(4)
ib = kpi_nba["income_biz_budget"]; ia = kpi_nba["income_biz_actual"]
eb = kpi_nba["expense_biz_budget"]; ea = kpi_nba["expense_biz_actual"]
sb = kpi_nba["surplus_biz"]; sn = kpi_nba["surplus_net"]
ir = kpi_nba["income_biz_rate"]; er = kpi_nba["expense_biz_rate"]
with n1: render_kpi_card("業務收入法定預算", f"{ib:,.2f}", "億元", "全年預估", NEUTRAL, SOURCE_MAIN)
with n2: render_kpi_card("業務收入累計實際", f"{ia:,.2f}", "億元", f"達成率:{ir:.1f}%", _rate_color(ir), SOURCE_MAIN)
with n3: render_kpi_card("業務成本費用", f"{ea:,.2f}", "億元", f"執行率:{er:.1f}%", _rate_color(er), USES_MAIN)
with n4: render_kpi_card("本期賸餘(短絀)", f"{sn:,.2f}", "億元", f"業務賸餘 {sb:.2f} 億", POSITIVE if sn>=0 else NEGATIVE, POSITIVE if sn>=0 else NEGATIVE)
st.markdown('<div style="height:1.5rem"></div>', unsafe_allow_html=True)
# 瀑布 + Sankey
st.markdown('<div class="section-card"><div class="card-title">收支結構分析</div><div class="card-sub">業務→業務外→本期的收支瀑布,與資金流向。</div>', unsafe_allow_html=True)
wf1, wf2 = st.columns([0.42, 0.58])
with wf1: st.plotly_chart(plot_surplus_waterfall(df_nba), use_container_width=True, key="nba_wf")
ac_nba = next((c for c in ["本年度截止本月份實際數","本年度截至本月份實際數"] if c in df_nba.columns), "")
with wf2: st.plotly_chart(plot_sankey_nba(df_nba, ac_nba, "收支資金流向(億元)"), use_container_width=True, key="nba_san")
st.markdown("</div>", unsafe_allow_html=True)
# 收入結構圓環 + 支出結構圓環
df_inc = df_nba[df_nba["科目代碼"].str.startswith("4")].copy()
df_exp = df_nba[df_nba["科目代碼"].str.startswith("5")].copy()
st.markdown('<div class="section-card"><div class="card-title">收支組成佔比</div><div class="card-sub">收入來源與費用結構的預算 vs 實際比例。</div>', unsafe_allow_html=True)
p1, p2 = st.columns(2)
with p1: st.plotly_chart(plot_nested_donut(df_inc, "💰 收入結構與佔比 (億元)"), use_container_width=True, key="nba_donut_inc")
with p2: st.plotly_chart(plot_nested_donut(df_exp, "💸 費用結構與佔比 (億元)"), use_container_width=True, key="nba_donut_exp")
st.markdown("</div>", unsafe_allow_html=True)
# 科目比較長條
st.markdown('<div class="section-card"><div class="card-title">各科目預算 vs 實際</div><div class="card-sub">LEVEL=2 頂層科目對比。</div>', unsafe_allow_html=True)
st.plotly_chart(plot_nba_compare(df_nba), use_container_width=True, key="nba_cmp")
st.markdown("</div>", unsafe_allow_html=True)
# ───────────────────────────────────────────────────────────────────────────
# ⑥ 固定資產
# ───────────────────────────────────────────────────────────────────────────
if "🏗️ 固定資產" in tab_map:
with tab_map["🏗️ 固定資產"]:
if not selected_massets:
st.markdown('<div class="empty-state"><div class="icon">🏗️</div><h3>尚未上傳固定資產執行情形表</h3><p>請上傳 FNWFRB2020 XML 檔案。</p></div>', unsafe_allow_html=True)
else:
df_fa = selected_massets["data"]
# KPI 五欄
st.markdown('<div class="card-title" style="margin-bottom:1rem;font-size:1.25rem">固定資產執行總覽</div>', unsafe_allow_html=True)
f1,f2,f3,f4,f5 = st.columns(5)
er_fa = kpi_fa["exec_rate"]
with f1: render_kpi_card("本年度法定預算", fmt_yi(kpi_fa["budget_total"]), "億元", "", NEUTRAL, SOURCE_MAIN)
with f2: render_kpi_card("以前年度保留數", fmt_yi(kpi_fa["carryover"]), "億元", "累積保留執行", NEUTRAL, INFO)
with f3: render_kpi_card("合計可用預算", fmt_yi(kpi_fa["grand_total"]), "億元", "法定+保留", NEUTRAL, USES_MAIN)
with f4: render_kpi_card("實際執行數", fmt_yi(kpi_fa["actual_total"]), "億元", f"含應付未付 {fmt_yi(kpi_fa['unpaid'])} 億", _rate_color(er_fa), _rate_color(er_fa))
with f5: render_kpi_card("整體執行率", fmt_pct(er_fa), f"共 {kpi_fa['project_count']} 項計畫", "", _rate_color(er_fa), _rate_color(er_fa))
st.markdown('<div style="height:1.5rem"></div>', unsafe_allow_html=True)
# 執行率長條 + Treemap
st.markdown('<div class="section-card"><div class="card-title">各計畫執行進度</div><div class="card-sub">綠色≥100%、橙色≥70%、紅色<70%;右側為預算規模分布。</div>', unsafe_allow_html=True)
fa1, fa2 = st.columns([1.2, 0.8])
with fa1: st.plotly_chart(plot_fixed_assets_bar(df_fa), use_container_width=True, key="fa_bar")
with fa2: st.plotly_chart(plot_fixed_assets_treemap(df_fa), use_container_width=True, key="fa_tree")
st.markdown("</div>", unsafe_allow_html=True)
# 明細表
st.markdown('<div class="section-card"><div class="card-title">固定資產明細資料</div><div class="card-sub">含差異或落後原因與改進措施。</div>', unsafe_allow_html=True)
display_fa = df_fa.copy()
for col in ["以前年度保留數","本年度法定預算數","合計","累計預算分配數","實支數","應付未付數","實際執行數-合計","比較增減"]:
if col in display_fa.columns:
display_fa[col] = (pd.to_numeric(display_fa[col], errors="coerce").fillna(0) / 1e8).round(3)
display_fa = display_fa.rename(columns={col: f"{col}(億元)"})
st.dataframe(display_fa, use_container_width=True)
st.markdown("</div>", unsafe_allow_html=True)
# ───────────────────────────────────────────────────────────────────────────
# ⑦ 現金流量推估
# ───────────────────────────────────────────────────────────────────────────
if "💰 現金流量推估" in tab_map:
with tab_map["💰 現金流量推估"]:
if len(data_2080_all) < 1:
st.markdown(
'<div class="empty-state"><div class="icon">💰</div>'
'<h3>需要至少一份平衡表</h3>'
'<p>上傳期初(年初)與期末(年底)兩份平衡表,即可自動推估現金流量。</p></div>',
unsafe_allow_html=True,
)
else:
# ── 選擇期初 / 期末平衡表 ──────────────────────────────────────
st.markdown(
'<div class="section-card">'
'<div class="card-title">選擇比較期間</div>'
'<div class="card-sub">'
'選擇兩份平衡表作為期初與期末;若只有一份,系統以零為期初(僅供參考)。'
'</div>',
unsafe_allow_html=True,
)
bs_options = {
f"{d.get('month','?')} 月 · {d.get('sender', d.get('filename',''))}": d
for d in sorted(data_2080_all, key=lambda x: x.get("month","00"))
}
bs_keys = list(bs_options.keys())
# ── 收支餘絀表選項(NBA 或 2060)────────────────────────────
income_stmt_all = data_nba + data_2060 # 合併兩種類型
income_options = {
f"{d.get('month','?')} 月 · {d.get('sender', d.get('filename',''))} "
f"[{'收支餘絀' if d.get('type')=='NBA' else '來源用途餘絀'}]": d
for d in sorted(income_stmt_all, key=lambda x: x.get("month","00"))
}
income_keys = list(income_options.keys())
cf1, cf2 = st.columns(2)
with cf1:
key_start = st.selectbox("期初平衡表", bs_keys,
index=0, key="cf_start")
with cf2:
key_end = st.selectbox("期末平衡表", bs_keys,
index=len(bs_keys)-1, key="cf_end")
# 收支餘絀表選擇(獨立一列,更清楚)
st.markdown(
'<div style="background:rgba(25,118,210,0.06);border:1px solid '
'rgba(25,118,210,0.2);border-radius:8px;padding:.75rem 1rem;margin:.5rem 0">'
'<span style="font-size:.8rem;font-weight:600;color:var(--source)">📋 本期賸餘來源</span>'
'<span style="font-size:.78rem;color:var(--text-muted);margin-left:.5rem">'
'必須從收支餘絀表取得,不可用平衡表的累積賸餘</span></div>',
unsafe_allow_html=True,
)
si1, si2 = st.columns([1.5, 1])
with si1:
if income_keys:
key_income = st.selectbox(
"對應的收支餘絀表",
income_keys,
index=len(income_keys)-1,
key="cf_income",
help="選擇與比較期間對應的收支餘絀表,本期賸餘將自動帶入",
)
selected_income = income_options[key_income]
# 自動計算本期賸餘
if selected_income.get("type") == "NBA":
auto_surplus = summarize_nba(selected_income["data"]).get("surplus_net", 0)
else:
auto_surplus = summarize_2060(selected_income["data"]).get("surplus_ytd", 0)
else:
key_income = None
auto_surplus = 0.0
st.warning("尚未上傳收支餘絀表,請上傳後選擇,或於右側手動輸入本期賸餘。")
with si2:
surplus_input = st.number_input(
"本期賸餘(短絀) 億元",
value=float(round(auto_surplus, 2)),
step=0.01, format="%.2f", key="cf_surplus",
help="從所選收支餘絀表自動帶入,可手動修正",
)
if income_keys and abs(surplus_input - auto_surplus) > 0.01:
st.caption(f"⚠ 已手動調整(原始值:{auto_surplus:.2f} 億)")
st.markdown("</div>", unsafe_allow_html=True)
df_start = bs_options[key_start]["data"]
df_end = bs_options[key_end]["data"]
# ── 計算現金流量 ────────────────────────────────────────────────
cf_items = summarize_cashflow(df_start, df_end, surplus=surplus_input)
# 期初 / 期末現金
cash_start = next((x["amount"] for x in cf_items if x["label"] == "期初現金及約當現金"), 0)
cash_end = next((x["amount"] for x in cf_items if x["label"] == "期末現金及約當現金"), 0)
net_change = next((x["amount"] for x in cf_items if x["label"] == "現金及約當現金淨增減"), 0)
error_val = next((x["amount"] for x in cf_items if "推估誤差" in x["label"]), 0)
# 三活動小計
op_sub = next((x["amount"] for x in cf_items if x.get("is_subtotal") and x["activity"]=="operating"), 0)
inv_sub = next((x["amount"] for x in cf_items if x.get("is_subtotal") and x["activity"]=="investing"), 0)
fin_sub = next((x["amount"] for x in cf_items if x.get("is_subtotal") and x["activity"]=="financing"), 0)
# ── KPI 卡片 ────────────────────────────────────────────────────
st.markdown(
'<div class="row-label">現金流量摘要</div>',
unsafe_allow_html=True,
)
ck1, ck2, ck3, ck4, ck5, ck6 = st.columns(6)
with ck1:
render_kpi_card("期初現金", fmt_yi(cash_start), "億元", "", NEUTRAL, SOURCE_MAIN)
with ck2:
render_kpi_card("期末現金", fmt_yi(cash_end), "億元", "", NEUTRAL, SOURCE_MAIN)
with ck3:
render_kpi_card("一、營業活動", fmt_yi(op_sub), "億元", "",
POSITIVE if op_sub >= 0 else NEGATIVE,
POSITIVE if op_sub >= 0 else NEGATIVE)
with ck4:
render_kpi_card("二、投資活動", fmt_yi(inv_sub), "億元", "",
POSITIVE if inv_sub >= 0 else NEGATIVE,
POSITIVE if inv_sub >= 0 else NEGATIVE)
with ck5:
render_kpi_card("三、籌資活動", fmt_yi(fin_sub), "億元", "",
POSITIVE if fin_sub >= 0 else NEGATIVE,
POSITIVE if fin_sub >= 0 else NEGATIVE)
with ck6:
# 排除科目的增減合計(揭露用)
excl_total = sum(
x["amount"] for x in cf_items
if x.get("is_excluded") and not x["label"].startswith("──")
)
err_color = POSITIVE if abs(error_val) < 0.5 else (WARN if abs(error_val) < 3 else NEGATIVE)
render_kpi_card(
"推估誤差",
f"{error_val:+.2f}",
"億元",
f"排除科目增減 {excl_total:+.2f} 億(非現金)" if abs(excl_total) > 0.01
else ("✓ 推估合理" if abs(error_val) < 0.5 else "⚠ 誤差偏大,請檢視"),
err_color, err_color,
)
# ── 綜覽瀑布圖 + 平衡表差異圖 ────────────────────────────────────
st.markdown(
'<div class="section-card">'
'<div class="card-title">現金流量概覽</div>'
'<div class="card-sub">'
'左:三大活動小計瀑布圖;右:平衡表各科目增減(期末-期初)。'
'</div>',
unsafe_allow_html=True,
)
wc1, wc2 = st.columns([1, 1.3])
with wc1:
st.plotly_chart(
plot_cashflow_waterfall(cf_items),
use_container_width=True, key="cf_wf",
)
with wc2:
st.plotly_chart(
plot_cashflow_bs_diff(df_start, df_end),
use_container_width=True, key="cf_bs_diff",
)
st.markdown("</div>", unsafe_allow_html=True)
# ── 三活動細項 ──────────────────────────────────────────────────
st.markdown(
'<div class="section-card">'
'<div class="card-title">各活動細項明細</div>'
'<div class="card-sub">正值=現金流入(綠),負值=現金流出(紅)。</div>',
unsafe_allow_html=True,
)
d1, d2, d3 = st.columns(3)
with d1:
st.plotly_chart(
plot_cashflow_detail(cf_items, "operating", "一、營業活動"),
use_container_width=True, key="cf_op",
)
with d2:
st.plotly_chart(
plot_cashflow_detail(cf_items, "investing", "二、投資活動"),
use_container_width=True, key="cf_inv",
)
with d3:
st.plotly_chart(
plot_cashflow_detail(cf_items, "financing", "三、籌資活動"),
use_container_width=True, key="cf_fin",
)
st.markdown("</div>", unsafe_allow_html=True)
# ── 明細表格 ────────────────────────────────────────────────────
st.markdown(
'<div class="section-card">'
'<div class="card-title">現金流量推估明細表</div>'
'<div class="card-sub">'
'依間接法編製;以平衡表科目增減推算,本期賸餘為調整起點。'
'⚠ 本表為推估性質,非正式財務報表,誤差來源主要為非現金調整項目不完整。'
'</div>',
unsafe_allow_html=True,
)
cf_display = []
act_label = ""
for x in cf_items:
if "推估誤差" in x["label"]:
continue
act = x["activity"]
# 加活動標題列
new_act_label = {
"operating": "一、營業活動",
"investing": "二、投資活動",
"financing": "三、籌資活動",
"total": "四、現金及約當現金",
"check": "",
}.get(act, "")
if new_act_label and new_act_label != act_label:
act_label = new_act_label
cf_display.append({
"活動類別": act_label,
"項目": "",
"金額(億元)": None,
"小計": "",
})
row = {
"活動類別": "",
"項目": (" " if not x.get("is_subtotal") and not x.get("is_total") else "") + x["label"],
"金額(億元)": None if (x.get("is_subtotal") or x.get("is_total")) else round(x["amount"], 2),
"小計": round(x["amount"], 2) if (x.get("is_subtotal") or x.get("is_total")) else None,
}
cf_display.append(row)
df_cf = pd.DataFrame(cf_display)
st.dataframe(
df_cf,
use_container_width=True,
column_config={
"金額(億元)": st.column_config.NumberColumn("金額(億元)", format="%.2f"),
"小計": st.column_config.NumberColumn("小計(億元)", format="%.2f"),
},
hide_index=True,
)
st.markdown("</div>", unsafe_allow_html=True)
# ── 排除科目說明 ────────────────────────────────────────────────
excl_items = [x for x in cf_items
if x.get("is_excluded") and not x["label"].startswith("──")]
pure_error = next((x["amount"] for x in cf_items
if "推估誤差" in x["label"] and not x.get("is_excluded")), 0)
st.markdown(
'<div class="section-card">'
'<div class="card-title">⚠ 推估限制與誤差說明</div>'
'<div class="card-sub">'
'現金期初期末為確定值;中間調整項目為推估,可能存在以下無法解釋的差異。'
'</div>',
unsafe_allow_html=True,
)
# 誤差數字摘要
err_col1, err_col2 = st.columns([1, 2])
with err_col1:
err_color = POSITIVE if abs(pure_error) < 0.5 else (WARN if abs(pure_error) < 3 else NEGATIVE)
render_kpi_card(
"無法解釋的差額",
f"{pure_error:+.2f}",
"億元",
"確定現金增減 − 三活動合計",
err_color, err_color,
)
with err_col2:
if abs(pure_error) < 0.5:
st.success("✓ 推估誤差在合理範圍(< 0.5 億元),三大活動分類已涵蓋主要現金流動。")
elif abs(pure_error) < 3:
st.warning(f"⚠ 推估誤差 {pure_error:+.2f} 億元,可能有部分科目分類不完整或含非現金交易。")
else:
st.error(f"❌ 推估誤差 {pure_error:+.2f} 億元偏大,建議檢視以下排除科目或調整分類。")
# 排除科目明細
if excl_items:
st.markdown("**刻意排除的科目**(變動為非現金或已含於本期賸餘):")
excl_df = pd.DataFrame([{
"科目": x["label"],
"期末−期初增減(億元)": round(x["amount"], 2),
"排除原因": x.get("exclude_reason", ""),
} for x in excl_items])
st.dataframe(excl_df, use_container_width=True, hide_index=True,
column_config={
"期末−期初增減(億元)": st.column_config.NumberColumn(format="%+.2f"),
})
excl_sum = sum(x["amount"] for x in excl_items)
st.caption(
f"排除科目增減合計:{excl_sum:+.2f} 億元。"
"若這些科目有顯著變動,表示存在非現金交易(如以物抵債、賸餘轉增資等),"
"無法在現金流量中反映。"
)
else:
st.info("本期排除科目均無增減,推估誤差為純分類誤差。")
st.markdown("</div>", unsafe_allow_html=True)
# ───────────────────────────────────────────────────────────────────────────
# ⑦ 明細資料
# ───────────────────────────────────────────────────────────────────────────
with tab_map["🧾 明細資料"]:
if not selected_2060 and not selected_2080 and not selected_nba:
st.markdown('<div class="empty-state"><div class="icon">🧾</div><h3>無可顯示的明細資料</h3></div>', unsafe_allow_html=True)
else:
if selected_2060:
st.markdown('<div class="section-card"><div class="card-title">2060 明細資料(特別收入基金)</div><div class="card-sub">依科目代碼排序;含執行率進度條與跨月 sparkline。</div>', unsafe_allow_html=True)
if show_tables: render_dataframe_with_sparkline(selected_2060["data"], trend_map)
else: st.caption("請在左側「⚙️ 檢視選項」勾選「顯示明細資料表(含 sparkline)」以載入表格。")
st.markdown("</div>", unsafe_allow_html=True)
if selected_nba:
st.markdown('<div class="section-card"><div class="card-title">收支餘絀明細(作業基金)</div><div class="card-sub">依科目代碼排序。</div>', unsafe_allow_html=True)
if show_tables:
d = selected_nba["data"].copy()
ac = next((c for c in ["本年度截止本月份實際數","本年度截至本月份實際數"] if c in d.columns), None)
if "科目代碼" in d.columns:
d["科目代碼"] = d["科目代碼"].astype(str)
d = d.sort_values("科目代碼", kind="stable")
for col in ["本年度法定預算數","本月份實際數","本月份預算數", ac]:
if col and col in d.columns:
d[col] = (pd.to_numeric(d[col], errors="coerce").fillna(0)/1e8).round(3)
d = d.rename(columns={col: f"{col}(億元)"})
if ac and f"{ac}(億元)" in d.columns and "本年度法定預算數(億元)" in d.columns:
d["執行率(%)"] = d.apply(lambda r: round(r[f"{ac}(億元)"]/r["本年度法定預算數(億元)"]*100, 1) if r["本年度法定預算數(億元)"] else 0, axis=1)
col_cfg = {}
if "執行率(%)" in d.columns:
col_cfg["執行率(%)"] = st.column_config.ProgressColumn("執行率(%)", min_value=0, max_value=150, format="%.1f%%")
st.dataframe(d, use_container_width=True, column_config=col_cfg)
else: st.caption("請在左側「⚙️ 檢視選項」勾選「顯示明細資料表(含 sparkline)」以載入表格。")
st.markdown("</div>", unsafe_allow_html=True)
if selected_2080:
st.markdown('<div class="section-card"><div class="card-title">2080 平衡表明細</div><div class="card-sub">金額已換算為億元。</div>', unsafe_allow_html=True)
if show_tables:
t = selected_2080["data"].copy()
if "金額" in t.columns: t["金額(億元)"] = (pd.to_numeric(t["金額"], errors="coerce").fillna(0)/1e8).round(4)
st.dataframe(t, use_container_width=True)
else: st.caption("請在左側「⚙️ 檢視選項」勾選「顯示明細資料表(含 sparkline)」以載入表格。")
st.markdown("</div>", unsafe_allow_html=True)