Spaces:
Runtime error
Runtime error
File size: 40,192 Bytes
74728c6 1c4669e 74728c6 356e1d3 74728c6 d620b26 74728c6 c3e99b0 74728c6 ce49ffd 74728c6 ce49ffd 74728c6 ce49ffd 74728c6 1c4669e 74728c6 d620b26 74728c6 c3e99b0 74728c6 c3e99b0 74728c6 060822e 74728c6 014227e 74728c6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 | """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() |