Spaces:
Runtime error
Runtime error
| """SPV 都更財務動態系統 - Urban Renewal Financial Management System. | |
| This module provides a comprehensive Streamlit-based dashboard for managing SPV | |
| (Special Purpose Vehicle) urban renewal project finances, including: | |
| - P&L and profit analysis with waterfall charts | |
| - Dynamic cash flow tracking with cumulative balance | |
| - Project scheduling with Gantt charts and milestones | |
| - Financing and sales management | |
| - Landowner management and agreement tracking | |
| - Risk dashboard and scenario simulation | |
| - PDF/Excel report generation | |
| """ | |
| import datetime | |
| import sqlite3 | |
| from pathlib import Path | |
| from typing import Optional | |
| import pandas as pd | |
| import plotly.express as px | |
| import plotly.figure_factory as ff | |
| import plotly.graph_objects as go | |
| import streamlit as st | |
| from database import ( | |
| DB_PATH, | |
| backup_current_project, | |
| calculate_scenario_impact, | |
| clear_to_actual, | |
| get_budget_vs_actual, | |
| get_landowner_stats, | |
| get_metadata, | |
| get_milestone_summary, | |
| init_db, | |
| load_full_project_example, | |
| set_metadata, | |
| get_database_file, | |
| restore_database_from_bytes, | |
| ) | |
| from reports import ( | |
| calculate_cashflow_summary, | |
| generate_excel_report, | |
| generate_pdf_report, | |
| ) | |
| from scenarios import ( | |
| ScenarioParams, | |
| run_sensitivity_analysis, | |
| simulate_scenario, | |
| ) | |
| from price_lookup import ( | |
| TAIPEI_DISTRICTS, | |
| get_average_prices, | |
| get_district_statistics, | |
| query_real_prices, | |
| ) | |
| # ============ Design System ============ | |
| COLORS = { | |
| "primary": "#F59E0B", | |
| "secondary": "#FBBF24", | |
| "cta": "#8B5CF6", | |
| "bg_dark": "#0F172A", | |
| "bg_card": "#1E293B", | |
| "text": "#F8FAFC", | |
| "muted": "#94A3B8", | |
| "border": "#334155", | |
| "income": "#10B981", | |
| "expense": "#EF4444", | |
| "warning": "#F97316", | |
| "info": "#3B82F6", | |
| } | |
| PLOTLY_LAYOUT = { | |
| "paper_bgcolor": "rgba(0,0,0,0)", | |
| "plot_bgcolor": "rgba(0,0,0,0)", | |
| "font": {"color": COLORS["text"], "family": "Inter, Noto Sans TC, sans-serif"}, | |
| "xaxis": { | |
| "gridcolor": COLORS["border"], | |
| "linecolor": COLORS["border"], | |
| "tickfont": {"color": COLORS["muted"]}, | |
| }, | |
| "yaxis": { | |
| "gridcolor": COLORS["border"], | |
| "linecolor": COLORS["border"], | |
| "tickfont": {"color": COLORS["muted"]}, | |
| }, | |
| "legend": {"font": {"color": COLORS["text"]}}, | |
| "margin": {"l": 40, "r": 40, "t": 60, "b": 40}, | |
| } | |
| def load_css() -> None: | |
| """Load custom CSS from style.css file.""" | |
| css_path = Path(__file__).parent / "style.css" | |
| if css_path.exists(): | |
| with open(css_path, encoding="utf-8") as f: | |
| st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True) | |
| def get_data() -> dict[str, pd.DataFrame]: | |
| """Load all data from database. | |
| Returns: | |
| dict: Dictionary containing all DataFrames. | |
| """ | |
| conn = sqlite3.connect(DB_PATH) | |
| data = { | |
| "transactions": pd.read_sql_query( | |
| "SELECT * FROM finance_transactions WHERE 類型='實際'", conn | |
| ), | |
| "params": pd.read_sql_query("SELECT * FROM project_params", conn), | |
| "stages": pd.read_sql_query("SELECT * FROM project_stages", conn), | |
| "loans": pd.read_sql_query("SELECT * FROM loan_contracts", conn), | |
| "inventory": pd.read_sql_query("SELECT * FROM inventory", conn), | |
| "landowners": pd.read_sql_query("SELECT * FROM landowners", conn), | |
| "milestones": pd.read_sql_query("SELECT * FROM milestones", conn), | |
| "risks": pd.read_sql_query("SELECT * FROM risk_events", conn), | |
| "scenarios": pd.read_sql_query("SELECT * FROM scenarios", conn), | |
| } | |
| # 嘗試載入土地成本表 (可能不存在於舊DB) | |
| try: | |
| data["land_costs"] = pd.read_sql_query("SELECT * FROM land_costs", conn) | |
| except Exception: | |
| data["land_costs"] = pd.DataFrame() | |
| conn.close() | |
| return data | |
| # ============ Chart Functions ============ | |
| def create_waterfall_chart(total_in: float, exp_df: pd.DataFrame) -> go.Figure: | |
| """Create styled waterfall chart for P&L analysis.""" | |
| fig = go.Figure( | |
| go.Waterfall( | |
| orientation="v", | |
| measure=["absolute"] + ["relative"] * len(exp_df) + ["total"], | |
| x=["總收入預估"] + exp_df["科目名稱"].tolist() + ["最終淨利"], | |
| y=[total_in] + (-exp_df["金額"]).tolist() + [0], | |
| connector={"line": {"color": COLORS["border"]}}, | |
| increasing={"marker": {"color": COLORS["income"]}}, | |
| decreasing={"marker": {"color": COLORS["expense"]}}, | |
| totals={"marker": {"color": COLORS["primary"]}}, | |
| textposition="outside", | |
| textfont={"color": COLORS["text"]}, | |
| ) | |
| ) | |
| fig.update_layout(**PLOTLY_LAYOUT, title_text="專案獲利結構分析", showlegend=False) | |
| return fig | |
| def create_gantt_chart( | |
| stages: pd.DataFrame, project_start: datetime.date | |
| ) -> Optional[go.Figure]: | |
| """Create styled Gantt chart for project scheduling.""" | |
| if stages.empty: | |
| return None | |
| df_g = [ | |
| { | |
| "Task": r["工項名稱"], | |
| "Start": project_start + datetime.timedelta(days=int(r["開始月份"] * 30.4)), | |
| "Finish": project_start | |
| + datetime.timedelta(days=int((r["開始月份"] + r["持續月份"]) * 30.4)), | |
| "Resource": "工程", | |
| } | |
| for _, r in stages.iterrows() | |
| ] | |
| fig = ff.create_gantt( | |
| df_g, | |
| index_col="Resource", | |
| group_tasks=True, | |
| colors=[COLORS["primary"]], | |
| show_colorbar=False, | |
| ) | |
| fig.update_layout(**PLOTLY_LAYOUT, title_text="工程排程甘特圖") | |
| return fig | |
| def create_cumulative_cashflow_chart(df: pd.DataFrame) -> go.Figure: | |
| """Create cumulative cash flow chart.""" | |
| if df.empty: | |
| return go.Figure() | |
| # 計算每月淨現金流 | |
| monthly = df.groupby("月份").apply( | |
| lambda x: x[x["科目性質"] == "收入"]["金額"].sum() | |
| - x[x["科目性質"] == "支出"]["金額"].sum() | |
| ) | |
| cumulative = monthly.cumsum() | |
| fig = go.Figure() | |
| fig.add_trace( | |
| go.Scatter( | |
| x=cumulative.index.tolist(), | |
| y=cumulative.values.tolist(), | |
| mode="lines+markers", | |
| name="累計現金流", | |
| line={"color": COLORS["primary"], "width": 3}, | |
| marker={"size": 6}, | |
| fill="tozeroy", | |
| fillcolor="rgba(245, 158, 11, 0.1)", | |
| ) | |
| ) | |
| # 添加零線和警戒線 | |
| fig.add_hline(y=0, line_dash="dash", line_color=COLORS["muted"]) | |
| # 標記最低點 | |
| min_val = cumulative.min() | |
| min_idx = cumulative.idxmin() | |
| if min_val < 0: | |
| fig.add_annotation( | |
| x=min_idx, | |
| y=min_val, | |
| text=f"資金最低點: {min_val:,.0f}萬", | |
| showarrow=True, | |
| arrowhead=2, | |
| arrowcolor=COLORS["expense"], | |
| font={"color": COLORS["expense"]}, | |
| ) | |
| fig.update_layout( | |
| **PLOTLY_LAYOUT, | |
| title_text="累計現金流走勢", | |
| xaxis_title="月份", | |
| yaxis_title="金額 (萬)", | |
| ) | |
| return fig | |
| def create_budget_vs_actual_chart(df: pd.DataFrame) -> go.Figure: | |
| """Create budget vs actual comparison chart.""" | |
| if df.empty: | |
| return go.Figure() | |
| fig = go.Figure() | |
| fig.add_trace( | |
| go.Bar( | |
| name="預算", | |
| x=df["工項名稱"], | |
| y=df["預算金額"], | |
| marker_color=COLORS["info"], | |
| ) | |
| ) | |
| fig.add_trace( | |
| go.Bar( | |
| name="實際支出", | |
| x=df["工項名稱"], | |
| y=df["實際支出"], | |
| marker_color=COLORS["primary"], | |
| ) | |
| ) | |
| fig.update_layout( | |
| **PLOTLY_LAYOUT, | |
| title_text="預算 vs 實際支出", | |
| barmode="group", | |
| ) | |
| return fig | |
| def create_landowner_pie_chart(stats: dict) -> go.Figure: | |
| """Create landowner agreement status pie chart.""" | |
| status_counts = stats.get("status_counts", {}) | |
| if not status_counts: | |
| return go.Figure() | |
| colors_map = { | |
| "已簽署": COLORS["income"], | |
| "協商中": COLORS["warning"], | |
| "待聯繫": COLORS["info"], | |
| "拒絕中": COLORS["expense"], | |
| } | |
| fig = go.Figure( | |
| go.Pie( | |
| labels=list(status_counts.keys()), | |
| values=list(status_counts.values()), | |
| marker={"colors": [colors_map.get(k, COLORS["muted"]) for k in status_counts]}, | |
| textinfo="label+percent", | |
| textfont={"color": COLORS["text"]}, | |
| hole=0.4, | |
| ) | |
| ) | |
| fig.update_layout(**PLOTLY_LAYOUT, title_text="地主同意書狀態", showlegend=True) | |
| return fig | |
| # ============ Main Application ============ | |
| def main() -> None: | |
| """Main application entry point.""" | |
| init_db() | |
| st.set_page_config( | |
| page_title="都更財務動態系統", | |
| page_icon="📊", | |
| layout="wide", | |
| initial_sidebar_state="expanded", | |
| ) | |
| load_css() | |
| st.title("都更計畫:現金流整合管理系統") | |
| # ============ Sidebar ============ | |
| with st.sidebar: | |
| st.header("專案管理面板") | |
| st.info("💡 提示:各項專案設定已移動至右側對應分頁中。") | |
| col_a, col_b = st.columns(2) | |
| if col_a.button("切換至原專案", use_container_width=True): | |
| backup_current_project() | |
| clear_to_actual() | |
| st.rerun() | |
| if col_b.button("載入範例", type="primary", use_container_width=True): | |
| load_full_project_example(100000.0) # 預設範例預算 | |
| st.rerun() | |
| st.divider() | |
| st.divider() | |
| with st.expander("專案檔案管理", expanded=False): | |
| # 專案匯出 | |
| db_bytes = get_database_file() | |
| if db_bytes: | |
| st.download_button( | |
| label="匯出專案檔 (.db)", | |
| data=db_bytes, | |
| file_name=f"project_export_{datetime.datetime.now().strftime('%Y%m%d')}.db", | |
| mime="application/x-sqlite3", | |
| use_container_width=True, | |
| ) | |
| # 專案匯入 | |
| uploaded_db = st.file_uploader("匯入專案檔", type=["db"], key="project_import") | |
| if uploaded_db: | |
| if st.button("確認覆蓋目前專案", type="primary", use_container_width=True): | |
| if restore_database_from_bytes(uploaded_db.getvalue()): | |
| st.success("專案已成功還原!") | |
| st.rerun() | |
| else: | |
| st.error("還原失敗,請檢查檔案格式。") | |
| st.caption("版本: v1.3.0 (Import/Export)") | |
| # 初始化專案設定 | |
| start_date_str = get_metadata("project_start", "2026-01-20") | |
| try: | |
| project_start = datetime.datetime.strptime(start_date_str, "%Y-%m-%d").date() | |
| except ValueError: | |
| project_start = datetime.date(2026, 1, 20) | |
| # ============ Main Tabs ============ | |
| tabs = st.tabs([ | |
| "損益分析", | |
| "現金流總表", | |
| "工程與時程", | |
| "融資與銷售", | |
| "地主管理", | |
| "風險與情境", | |
| "實價登錄查詢", | |
| ]) | |
| data = get_data() | |
| df = data["transactions"] | |
| # ============ Tab 1: P&L Analysis ============ | |
| with tabs[0]: | |
| # 專案設定區塊 | |
| with st.expander("專案設定", expanded=True): | |
| current_params = data["params"].set_index("參數名稱")["數值"].to_dict() | |
| current_budget = current_params.get("總工程預算", 100000.0) | |
| new_budget = st.number_input( | |
| "總工程預算 (萬)", | |
| value=float(current_budget), | |
| step=1000.0, | |
| key="tab1_budget" | |
| ) | |
| if new_budget != current_budget: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute( | |
| "INSERT OR REPLACE INTO project_params VALUES (?, ?)", | |
| ("總工程預算", new_budget) | |
| ) | |
| conn.commit() | |
| conn.close() | |
| st.rerun() | |
| if not df.empty: | |
| total_in = df[df["科目性質"] == "收入"]["金額"].sum() | |
| total_out = df[df["科目性質"] == "支出"]["金額"].sum() | |
| profit = total_in - total_out | |
| # ... (metrics display) ... | |
| st.subheader("專案獲利結構分析") | |
| c1, c2, c3, c4 = st.columns(4) | |
| c1.metric("總流入金額", f"{total_in:,.0f} 萬") | |
| c2.metric("總流出支出", f"{total_out:,.0f} 萬") | |
| c3.metric("預計結餘毛利", f"{profit:,.0f} 萬") | |
| c4.metric( | |
| "毛利率", | |
| f"{(profit / total_in * 100):.1f}%" if total_in > 0 else "N/A", | |
| ) | |
| # 土地取得成本摘要 | |
| land_costs = data.get("land_costs", pd.DataFrame()) | |
| if not land_costs.empty: | |
| st.divider() | |
| st.subheader("土地取得成本") | |
| lc1, lc2, lc3 = st.columns(3) | |
| coop_value = land_costs[land_costs["取得方式"] == "土地合作"]["權利價值萬"].sum() | |
| purchase_cost = land_costs[land_costs["取得方式"] == "收購"]["收購價格萬"].sum() | |
| total_land = coop_value + purchase_cost | |
| lc1.metric("合作土地價值", f"{coop_value:,.0f} 萬") | |
| lc2.metric("收購支出", f"{purchase_cost:,.0f} 萬") | |
| lc3.metric("土地成本總計", f"{total_land:,.0f} 萬") | |
| # 瀑布圖 | |
| exp_df = ( | |
| df[df["科目性質"] == "支出"] | |
| .groupby("科目名稱")["金額"] | |
| .sum() | |
| .reset_index() | |
| ) | |
| fig_w = create_waterfall_chart(total_in, exp_df) | |
| st.plotly_chart(fig_w, use_container_width=True) | |
| # 預算 vs 實際 | |
| budget_df = get_budget_vs_actual() | |
| if not budget_df.empty: | |
| st.subheader("預算 vs 實際支出") | |
| fig_bva = create_budget_vs_actual_chart(budget_df) | |
| st.plotly_chart(fig_bva, use_container_width=True) | |
| # 超支警示 | |
| over_budget = budget_df[budget_df["差異率"] > 10] | |
| if not over_budget.empty: | |
| st.warning( | |
| f"⚠️ 以下工項超出預算 10% 以上:" | |
| f"{', '.join(over_budget['工項名稱'].tolist())}" | |
| ) | |
| # 報表匯出區塊 | |
| st.divider() | |
| st.subheader("報表匯出") | |
| col_pdf, col_excel = st.columns(2) | |
| with col_pdf: | |
| pdf_bytes = generate_pdf_report( | |
| project_name="都更專案", | |
| total_income=total_in, | |
| total_expense=total_out, | |
| profit=profit, | |
| landowner_stats=get_landowner_stats(), | |
| milestone_summary=get_milestone_summary(), | |
| transactions_df=df, | |
| stages_df=data["stages"], | |
| risks_df=data["risks"], | |
| ) | |
| st.download_button( | |
| "下載 PDF", | |
| pdf_bytes, | |
| file_name="財務報告.pdf", | |
| mime="application/pdf", | |
| use_container_width=True, | |
| key="tab1_pdf_download" | |
| ) | |
| with col_excel: | |
| excel_bytes = generate_excel_report( | |
| transactions_df=df, | |
| stages_df=data["stages"], | |
| landowners_df=data["landowners"], | |
| milestones_df=data["milestones"], | |
| risks_df=data["risks"], | |
| loans_df=data["loans"], | |
| inventory_df=data["inventory"], | |
| ) | |
| st.download_button( | |
| "下載 Excel", | |
| excel_bytes, | |
| file_name="專案數據.xlsx", | |
| mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |
| use_container_width=True, | |
| key="tab1_excel_download" | |
| ) | |
| else: | |
| st.info("請利用左側選單載入數據。") | |
| # ============ Tab 2: Cash Flow ============ | |
| with tabs[1]: | |
| # 新增收支項目區塊 | |
| with st.expander("新增收支項目", expanded=False): | |
| with st.form("tab2_custom_item_form"): | |
| c1, c2, c3, c4 = st.columns(4) | |
| f_month = c1.number_input("月份", min_value=1, value=1, key="tab2_month") | |
| f_name = c2.text_input("項目名稱", key="tab2_name") | |
| f_type = c3.selectbox("科目性質", ["支出", "收入"], key="tab2_type") | |
| f_amt = c4.number_input("金額 (萬)", min_value=0.0, key="tab2_amt") | |
| if st.form_submit_button("確認提交", use_container_width=True): | |
| if f_name: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute( | |
| "INSERT OR REPLACE INTO finance_transactions " | |
| "VALUES (?, '實際', ?, ?, ?)", | |
| (f_month, f_name, f_amt, f_type), | |
| ) | |
| conn.commit() | |
| conn.close() | |
| st.rerun() | |
| st.subheader("動態現金流流水帳") | |
| if not df.empty: | |
| # 月份篩選 | |
| months = sorted(df["月份"].unique()) | |
| month_range = st.slider( | |
| "選擇月份範圍", | |
| min_value=int(min(months)), | |
| max_value=int(max(months)), | |
| value=(int(min(months)), int(max(months))), | |
| ) | |
| filtered_df = df[ | |
| (df["月份"] >= month_range[0]) & (df["月份"] <= month_range[1]) | |
| ] | |
| # 累計現金流圖 | |
| fig_cum = create_cumulative_cashflow_chart(df) | |
| st.plotly_chart(fig_cum, use_container_width=True) | |
| # 現金流摘要 | |
| summary = calculate_cashflow_summary(df) | |
| if summary["min_balance"] < 0: | |
| st.error( | |
| f"⚠️ 資金斷鏈預警:第 {summary['min_balance_month']} 月現金流" | |
| f"最低點達 {summary['min_balance']:,.0f} 萬" | |
| ) | |
| # 轉置表格 | |
| pivot_df = filtered_df.pivot_table( | |
| index="月份", columns="科目名稱", values="金額", fill_value=0 | |
| ).reset_index() | |
| # 計算每月累計餘額 | |
| if not pivot_df.empty: | |
| inc_cols = df[df["科目性質"] == "收入"]["科目名稱"].unique() | |
| exp_cols = df[df["科目性質"] == "支出"]["科目名稱"].unique() | |
| def calc_monthly_balance(row: pd.Series) -> float: | |
| inc = sum(row.get(c, 0) for c in inc_cols if c in row.index) | |
| exp = sum(row.get(c, 0) for c in exp_cols if c in row.index) | |
| return inc - exp | |
| pivot_df["月結餘"] = pivot_df.apply(calc_monthly_balance, axis=1) | |
| pivot_df["累計餘額"] = pivot_df["月結餘"].cumsum() | |
| st.dataframe( | |
| pivot_df.style.format(precision=0), | |
| use_container_width=True, | |
| height=400, | |
| ) | |
| else: | |
| st.info("請利用左側選單載入數據。") | |
| # ============ Tab 3: Project Schedule ============ | |
| with tabs[2]: | |
| # 排程設定 | |
| with st.expander("排程設定", expanded=True): | |
| new_date = st.date_input("計畫啟動日期", project_start, key="tab3_start_date") | |
| if new_date != project_start: | |
| set_metadata("project_start", str(new_date)) | |
| st.rerun() | |
| # 新增工程階段 | |
| with st.expander("新增工程階段", expanded=False): | |
| with st.form("tab3_stage_form"): | |
| c1, c2, c3 = st.columns(3) | |
| s_name = c1.text_input("工項名稱", key="tab3_s_name") | |
| s_start = c2.number_input("開始月份 (之後)", min_value=0, value=0, key="tab3_s_start") | |
| s_dur = c3.number_input("持續月份", min_value=1, value=6, key="tab3_s_dur") | |
| c4, c5 = st.columns(2) | |
| s_cost = c4.number_input("預算金額 (萬)", min_value=0.0, key="tab3_s_cost") | |
| s_actual = c5.number_input("實際支出 (萬)", min_value=0.0, key="tab3_s_actual") | |
| if st.form_submit_button("新增工項"): | |
| if s_name: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute( | |
| """INSERT INTO project_stages | |
| (工項名稱, 開始月份, 持續月份, 成本佔比, 撥款佔比, 預算金額, 實際支出) | |
| VALUES (?, ?, ?, 0, 0, ?, ?)""", | |
| (s_name, s_start, s_dur, s_cost, s_actual) | |
| ) | |
| conn.commit() | |
| conn.close() | |
| st.rerun() | |
| col_gantt, col_milestone = st.columns([2, 1]) | |
| with col_gantt: | |
| st.subheader("工程排程甘特圖") | |
| fig_g = create_gantt_chart(data["stages"], project_start) | |
| if fig_g: | |
| st.plotly_chart(fig_g, use_container_width=True) | |
| else: | |
| st.info("尚無工程階段資料。") | |
| with col_milestone: | |
| st.subheader("里程碑進度") | |
| milestones = data["milestones"] | |
| if not milestones.empty: | |
| for _, m in milestones.iterrows(): | |
| status_emoji = { | |
| "已完成": "✅", | |
| "進行中": "🔄", | |
| "待進行": "⏳", | |
| }.get(m["狀態"], "❓") | |
| st.markdown( | |
| f"{status_emoji} **{m['里程碑名稱']}** \n" | |
| f"預計:{m['預計日期']} | {m['狀態']}" | |
| ) | |
| else: | |
| st.info("尚無里程碑資料。") | |
| # 合約到期提醒 | |
| st.divider() | |
| st.subheader("合約到期提醒") | |
| loans = data["loans"] | |
| if not loans.empty and "到期日" in loans.columns: | |
| today = datetime.date.today() | |
| for _, loan in loans.iterrows(): | |
| if pd.notna(loan.get("到期日")): | |
| try: | |
| due_date = datetime.datetime.strptime( | |
| str(loan["到期日"]), "%Y-%m-%d" | |
| ).date() | |
| days_left = (due_date - today).days | |
| if 0 < days_left <= 90: | |
| st.warning( | |
| f"⚠️ {loan['貸款名稱']} 將於 {days_left} 天後到期 ({due_date})" | |
| ) | |
| except ValueError: | |
| pass | |
| # ============ Tab 4: Financing & Sales ============ | |
| with tabs[3]: | |
| # 新增融資合約 | |
| with st.expander("新增融資合約", expanded=False): | |
| with st.form("tab4_loan_form"): | |
| c1, c2, c3 = st.columns(3) | |
| l_name = c1.text_input("貸款名稱", key="tab4_l_name") | |
| l_amount = c2.number_input("授信額度 (萬)", min_value=0.0, key="tab4_l_amt") | |
| l_rate = c3.number_input("年利率 (%)", min_value=0.0, value=2.5, key="tab4_l_rate") | |
| c4, c5 = st.columns(2) | |
| l_drawn = c4.number_input("已動撥金額 (萬)", min_value=0.0, key="tab4_l_drawn") | |
| l_date = c5.date_input("到期日", key="tab4_l_date") | |
| if st.form_submit_button("新增合約"): | |
| if l_name: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute( | |
| """INSERT INTO loan_contracts | |
| (貸款名稱, 授信額度, 年利率, 狀態, 動撥金額, 到期日, 備註) | |
| VALUES (?, ?, ?, '已動撥', ?, ?, '')""", | |
| (l_name, l_amount, l_rate, l_drawn, l_date) | |
| ) | |
| conn.commit() | |
| conn.close() | |
| st.rerun() | |
| # 新增銷售庫存 | |
| with st.expander("新增銷售庫存", expanded=False): | |
| with st.form("tab4_inventory_form"): | |
| c1, c2, c3 = st.columns(3) | |
| i_name = c1.text_input("物件名稱", key="tab4_i_name") | |
| i_total = c2.number_input("總數量", min_value=1, value=10, key="tab4_i_total") | |
| i_back = c3.number_input("地主分回", min_value=0, value=0, key="tab4_i_back") | |
| c4, c5 = st.columns(2) | |
| i_price = c4.number_input("預計售價 (萬)", min_value=0.0, key="tab4_i_price") | |
| i_sold = c5.number_input("已售數量", min_value=0, value=0, key="tab4_i_sold") | |
| if st.form_submit_button("新增庫存"): | |
| if i_name: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute( | |
| """INSERT INTO inventory | |
| (物件名稱, 預計售價, 總數量, 地主分回數量, 已售數量, 銷售狀態) | |
| VALUES (?, ?, ?, ?, ?, '銷售中')""", | |
| (i_name, i_price, i_total, i_back, i_sold) | |
| ) | |
| conn.commit() | |
| conn.close() | |
| st.rerun() | |
| col_l, col_r = st.columns(2) | |
| with col_l: | |
| st.subheader("融資合約清單") | |
| loans = data["loans"] | |
| if not loans.empty: | |
| # 計算融資使用率 | |
| if "授信額度" in loans.columns and "動撥金額" in loans.columns: | |
| total_credit = loans["授信額度"].sum() | |
| total_drawn = loans["動撥金額"].sum() | |
| usage_rate = (total_drawn / total_credit * 100) if total_credit > 0 else 0 | |
| lm1, lm2 = st.columns(2) | |
| lm1.metric("融資使用率", f"{usage_rate:.1f}%") | |
| lm2.metric("已動撥金額", f"{total_drawn:,.0f} 萬") | |
| st.dataframe(loans, use_container_width=True) | |
| else: | |
| st.info("尚無融資合約資料。") | |
| with col_r: | |
| st.subheader("房屋銷售計畫") | |
| inventory = data["inventory"] | |
| if not inventory.empty: | |
| # 計算銷售進度 | |
| if "總數量" in inventory.columns: | |
| total_units = inventory["總數量"].sum() | |
| landlord_units = inventory["地主分回數量"].sum() | |
| sold_units = inventory.get("已售數量", pd.Series([0])).sum() | |
| sellable = total_units - landlord_units | |
| sales_rate = (sold_units / sellable * 100) if sellable > 0 else 0 | |
| sm1, sm2 = st.columns(2) | |
| sm1.metric("可售戶數", f"{sellable:.0f} 戶") | |
| sm2.metric("銷售進度", f"{sales_rate:.1f}%") | |
| # 銷售進度條 | |
| st.progress(min(sales_rate / 100, 1.0)) | |
| st.dataframe(inventory, use_container_width=True) | |
| else: | |
| st.info("尚無銷售計畫資料。") | |
| # ============ Tab 5: Landowner Management ============ | |
| with tabs[4]: | |
| st.subheader("地主管理") | |
| landowners = data["landowners"] | |
| land_costs = data.get("land_costs", pd.DataFrame()) | |
| stats = get_landowner_stats() | |
| # 統計卡片 | |
| c1, c2, c3, c4 = st.columns(4) | |
| c1.metric("地主總數", f"{stats.get('total', 0)} 人") | |
| c2.metric("同意率", f"{stats.get('agreement_rate', 0):.1f}%") | |
| c3.metric("總持分", f"{stats.get('total_share', 0):.1f}%") | |
| c4.metric("已簽署持分", f"{stats.get('agreed_share', 0):.1f}%") | |
| col_pie, col_table = st.columns([1, 2]) | |
| with col_pie: | |
| fig_pie = create_landowner_pie_chart(stats) | |
| if fig_pie.data: | |
| st.plotly_chart(fig_pie, use_container_width=True) | |
| with col_table: | |
| if not landowners.empty: | |
| st.dataframe(landowners, use_container_width=True, height=300) | |
| else: | |
| st.info("尚無地主資料。") | |
| # 土地取得成本管理 | |
| st.divider() | |
| st.subheader("土地取得成本") | |
| col_form, col_list = st.columns([1, 2]) | |
| with col_form: | |
| with st.form("land_cost_form"): | |
| st.markdown("**新增土地取得記錄**") | |
| lc_name = st.text_input("地主姓名") | |
| lc_type = st.selectbox("取得方式", ["土地合作", "收購"]) | |
| lc_area = st.number_input("土地面積 (坪)", min_value=0.0) | |
| lc_value = st.number_input("權利價值 (萬)", min_value=0.0) | |
| lc_price = st.number_input("收購價格 (萬)", min_value=0.0) | |
| lc_status = st.selectbox("付款狀態", ["待付款", "部分付款", "已付清"]) | |
| if st.form_submit_button("新增記錄"): | |
| if lc_name: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute( | |
| """INSERT INTO land_costs | |
| (地主姓名, 取得方式, 土地面積坪, 權利價值萬, 收購價格萬, 付款狀態) | |
| VALUES (?, ?, ?, ?, ?, ?)""", | |
| (lc_name, lc_type, lc_area, lc_value, lc_price, lc_status), | |
| ) | |
| conn.commit() | |
| conn.close() | |
| st.rerun() | |
| with col_list: | |
| if not land_costs.empty: | |
| st.dataframe(land_costs, use_container_width=True, height=250) | |
| # 土地成本摘要 | |
| coop_total = land_costs[land_costs["取得方式"] == "土地合作"]["權利價值萬"].sum() | |
| purchase_total = land_costs[land_costs["取得方式"] == "收購"]["收購價格萬"].sum() | |
| st.markdown( | |
| f"**合作土地價值**: {coop_total:,.0f} 萬 | " | |
| f"**收購支出**: {purchase_total:,.0f} 萬" | |
| ) | |
| else: | |
| st.info("尚無土地取得記錄。") | |
| # ============ Tab 6: Risk & Scenario ============ | |
| with tabs[5]: | |
| col_risk, col_scenario = st.columns(2) | |
| with col_risk: | |
| st.subheader("風險事件追蹤") | |
| risks = data["risks"] | |
| if not risks.empty: | |
| # 風險矩陣 | |
| for _, r in risks.iterrows(): | |
| severity_color = { | |
| "高": "🔴", | |
| "中": "🟡", | |
| "低": "🟢", | |
| }.get(r.get("影響程度", ""), "⚪") | |
| st.markdown( | |
| f"{severity_color} **{r['風險類型']}**: {r['描述']} \n" | |
| f"發生機率: {r['發生機率']} | 影響金額: {r['影響金額']:,.0f} 萬" | |
| ) | |
| st.caption(f"緩解措施: {r.get('緩解措施', 'N/A')}") | |
| st.divider() | |
| else: | |
| st.info("尚無風險事件資料。") | |
| with col_scenario: | |
| st.subheader("情境模擬") | |
| if not df.empty: | |
| total_in = df[df["科目性質"] == "收入"]["金額"].sum() | |
| total_out = df[df["科目性質"] == "支出"]["金額"].sum() | |
| base_profit = total_in - total_out | |
| loans = data["loans"] | |
| loan_amount = loans["授信額度"].sum() if not loans.empty else 0 | |
| base_rate = loans["年利率"].mean() if not loans.empty else 2.5 | |
| # 敏感度分析 | |
| sensitivity_df = run_sensitivity_analysis( | |
| base_income=total_in, | |
| base_expense=total_out, | |
| loan_amount=loan_amount, | |
| base_rate=base_rate, | |
| ) | |
| st.markdown(f"**基準淨利**: {base_profit:,.0f} 萬") | |
| st.dataframe( | |
| sensitivity_df.style.background_gradient( | |
| subset=["變動幅度 (%)"], | |
| cmap="RdYlGn_r", | |
| ), | |
| use_container_width=True, | |
| height=400, | |
| ) | |
| # 自訂情境 | |
| st.divider() | |
| st.markdown("**自訂情境模擬**") | |
| sc_delay = st.slider("銷售延遲 (月)", 0, 24, 0) | |
| sc_cost = st.slider("成本上漲 (%)", 0, 30, 0) | |
| sc_rate = st.slider("利率變動 (%)", -1.0, 3.0, 0.0, 0.1) | |
| if st.button("計算影響"): | |
| params = ScenarioParams( | |
| name="自訂情境", | |
| delay_months=sc_delay, | |
| cost_increase_pct=sc_cost, | |
| rate_adjustment=sc_rate, | |
| ) | |
| result = simulate_scenario( | |
| base_income=total_in, | |
| base_expense=total_out, | |
| loan_amount=loan_amount, | |
| base_rate=base_rate, | |
| monthly_cashflow=[], | |
| params=params, | |
| ) | |
| delta = result.profit_change | |
| delta_pct = result.profit_change_pct | |
| st.metric( | |
| "調整後淨利", | |
| f"{result.adjusted_profit:,.0f} 萬", | |
| delta=f"{delta:+,.0f} 萬 ({delta_pct:+.1f}%)", | |
| delta_color="inverse" if delta < 0 else "normal", | |
| ) | |
| else: | |
| st.info("請先載入專案數據。") | |
| # ============ Tab 7: Real Estate Price Lookup ============ | |
| with tabs[6]: | |
| st.subheader("實價登錄查詢") | |
| st.caption("查詢土地、建物、車位的實價登錄資訊,協助評估專案價值") | |
| col_filter, col_stats = st.columns([1, 1]) | |
| with col_filter: | |
| st.markdown("**查詢條件**") | |
| city = st.selectbox("城市", ["台北市", "新北市"]) | |
| if city == "台北市": | |
| districts = list(TAIPEI_DISTRICTS.keys()) | |
| else: | |
| districts = ["板橋區", "三重區", "中和區", "永和區", "新莊區"] | |
| district = st.selectbox("區域", districts) | |
| property_type = st.selectbox("物件類型", ["土地", "建物", "車位"]) | |
| if st.button("查詢實價登錄", type="primary", use_container_width=True): | |
| st.session_state["price_query"] = { | |
| "city": city, | |
| "district": district, | |
| "type": property_type, | |
| } | |
| with col_stats: | |
| st.markdown("**區域行情概覽**") | |
| if district: | |
| avg_prices = get_average_prices(city, district) | |
| stats = get_district_statistics(district) | |
| if avg_prices: | |
| pm1, pm2, pm3 = st.columns(3) | |
| pm1.metric( | |
| "土地均價", | |
| f"{avg_prices.get('土地平均單價', 0):.0f} 萬/坪", | |
| ) | |
| pm2.metric( | |
| "建物均價", | |
| f"{avg_prices.get('建物平均單價', 0):.0f} 萬/坪", | |
| ) | |
| pm3.metric( | |
| "車位均價", | |
| f"{avg_prices.get('車位平均總價', 0):.0f} 萬/位", | |
| ) | |
| st.markdown( | |
| f"**近期交易筆數**: 土地 {stats.get('land_transactions', 0)} 筆 | " | |
| f"建物 {stats.get('building_transactions', 0)} 筆 | " | |
| f"車位 {stats.get('parking_transactions', 0)} 筆" | |
| ) | |
| else: | |
| st.info("該區域暫無資料") | |
| # 查詢結果 | |
| st.divider() | |
| if "price_query" in st.session_state: | |
| query = st.session_state["price_query"] | |
| st.subheader(f"{query['city']} {query['district']} - {query['type']}實價登錄") | |
| result_df = query_real_prices(query["city"], query["district"], query["type"]) | |
| if not result_df.empty: | |
| # 統計摘要 | |
| result_df = result_df.sort_values("交易日期", ascending=False) | |
| avg_price = result_df["單價(萬/坪)"].mean() | |
| max_price = result_df["單價(萬/坪)"].max() | |
| min_price = result_df["單價(萬/坪)"].min() | |
| date_range = f"{result_df['交易日期'].min()} ~ {result_df['交易日期'].max()}" | |
| c1, c2, c3, c4, c5 = st.columns(5) | |
| c1.metric("查詢筆數", f"{len(result_df)} 筆") | |
| c2.metric("資料期間", date_range) | |
| c3.metric("平均單價", f"{avg_price:.0f} 萬/坪") | |
| c4.metric("最高單價", f"{max_price:.0f} 萬/坪") | |
| c5.metric("最低單價", f"{min_price:.0f} 萬/坪") | |
| # 結果表格 | |
| st.dataframe(result_df, use_container_width=True, height=300) | |
| # 價格分布圖 | |
| fig_price = go.Figure() | |
| fig_price.add_trace( | |
| go.Bar( | |
| x=result_df["地址"], | |
| y=result_df["單價(萬/坪)"], | |
| marker_color=COLORS["primary"], | |
| ) | |
| ) | |
| fig_price.update_layout( | |
| **PLOTLY_LAYOUT, | |
| title_text=f"{query['type']}單價分布", | |
| xaxis_title="地址", | |
| yaxis_title="單價 (萬/坪)", | |
| ) | |
| st.plotly_chart(fig_price, use_container_width=True) | |
| else: | |
| st.warning("查無符合條件的實價登錄資料") | |
| else: | |
| st.info("請選擇查詢條件後點擊「查詢實價登錄」按鈕") | |
| # 使用說明 | |
| with st.expander("關於實價登錄資料"): | |
| st.markdown(""" | |
| **資料來源說明**: | |
| - 本系統目前使用模擬資料進行展示 | |
| - 實際資料可從 [內政部不動產交易實價查詢服務網](https://lvr.land.moi.gov.tw/) 取得 | |
| - 開放資料下載:[內政部實價登錄批次資料](https://plvr.land.moi.gov.tw/DownloadOpenData) | |
| **資料欄位說明**: | |
| - **土地**:土地交易單價,以每坪計算 | |
| - **建物**:房屋交易單價,含公設 | |
| - **車位**:車位總價,不計入每坪單價 | |
| **注意事項**: | |
| - 實價登錄資料有 30 天申報期,可能有時間落差 | |
| - 交易條件不同會影響價格(如親友交易、法拍等) | |
| """) | |
| if __name__ == "__main__": | |
| main() |