Full dashboard implementation: API, UI, and sharing scripts

This commit is contained in:
2026-03-25 11:35:54 +09:00
parent 28709a3457
commit 47cd2bf5b2
4 changed files with 5457 additions and 80 deletions

View File

@@ -19,7 +19,9 @@ XLSX_SOURCE_CONFIG_PATH = BASE_DIR / "server" / "ptc_source_path.txt"
METHOD_XLSX_PATH = BASE_DIR / "PTC공법.xlsx"
DB_PATH = BASE_DIR / "db" / "ptc_local.sqlite3"
FRONTEND_INDEX_PATH = BASE_DIR / "PTC" / "index.html"
FRONTEND_CACHE: dict[str, str | int] = {"mtime": -1, "html": ""}
FRONTEND_DASHBOARD_PREVIEW_PATH = BASE_DIR / "PTC" / "dashboard_preview.html"
FRONTEND_CACHE: dict[str, str | int] = {"mtime_ns": -1, "html": ""}
FRONTEND_PREVIEW_CACHE: dict[str, str | int] = {"mtime_ns": -1, "html": ""}
NS = {"a": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
PROJECT_TYPE_OPTIONS = ["관리", "영업", "시공", "설계", "개발", "기술", "교휴", "기타"]
METHOD_FAMILY_OPTIONS = ["복합말뚝", "합성형라멘", "강관거더", "가시설"]
@@ -187,13 +189,38 @@ def get_frontend_html() -> str:
if not FRONTEND_INDEX_PATH.exists():
raise FileNotFoundError("PTC frontend not found")
mtime = int(FRONTEND_INDEX_PATH.stat().st_mtime)
if FRONTEND_CACHE["mtime"] != mtime:
FRONTEND_CACHE["mtime"] = mtime
mtime_ns = FRONTEND_INDEX_PATH.stat().st_mtime_ns
if FRONTEND_CACHE["mtime_ns"] != mtime_ns:
FRONTEND_CACHE["mtime_ns"] = mtime_ns
FRONTEND_CACHE["html"] = FRONTEND_INDEX_PATH.read_text(encoding="utf-8")
return str(FRONTEND_CACHE["html"])
def get_frontend_dashboard_preview_html() -> str:
if not FRONTEND_DASHBOARD_PREVIEW_PATH.exists():
raise FileNotFoundError("PTC dashboard preview frontend not found")
mtime_ns = FRONTEND_DASHBOARD_PREVIEW_PATH.stat().st_mtime_ns
if FRONTEND_PREVIEW_CACHE["mtime_ns"] != mtime_ns:
FRONTEND_PREVIEW_CACHE["mtime_ns"] = mtime_ns
FRONTEND_PREVIEW_CACHE["html"] = FRONTEND_DASHBOARD_PREVIEW_PATH.read_text(encoding="utf-8")
return str(FRONTEND_PREVIEW_CACHE["html"])
def normalize_dashboard_family(value: str) -> str:
text = (value or "").strip()
if not text or text.upper() == "NULL":
return "기타/미지정"
return text
def normalize_dashboard_method(value: str) -> str:
text = (value or "").strip()
if not text or text.upper() == "NULL":
return "공법미지정"
return text
def infer_project_type_from_code(project_code: str) -> str:
match = re.match(r"\d{2}-(.+?)-\d+", (project_code or "").strip())
return match.group(1) if match else ""
@@ -1039,6 +1066,9 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
self.end_headers()
self.wfile.write(body)
@@ -1048,6 +1078,12 @@ class Handler(BaseHTTPRequestHandler):
return
self._send_html(200, get_frontend_html())
def _send_frontend_dashboard_preview(self) -> None:
if not FRONTEND_DASHBOARD_PREVIEW_PATH.exists():
self._send_html(404, "<h1>PTC dashboard preview frontend not found</h1>")
return
self._send_html(200, get_frontend_dashboard_preview_html())
def _send(self, status: int, payload: dict) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
@@ -1435,6 +1471,10 @@ class Handler(BaseHTTPRequestHandler):
params = parse_qs(parsed.query)
conn = get_conn()
try:
if parsed.path in {"/PTC-lab", "/PTC-lab/", "/PTC/dashboard_preview.html"}:
self._send_frontend_dashboard_preview()
return
if parsed.path in {"/PTC", "/PTC/", "/PTC/index.html"}:
self._send_frontend()
return
@@ -1641,6 +1681,188 @@ class Handler(BaseHTTPRequestHandler):
self._send(200, summary)
return
if parsed.path == "/api/dashboard-prototype":
project_type = params.get("project_type", ["전체"])[0]
clauses = ["t.project_code is not null", "t.project_code <> ''"]
values: list[str] = []
if project_type and project_type != "전체":
clauses.append("coalesce(pm.project_type, t.project_type) = ?")
values.append(project_type)
where = " where " + " and ".join(clauses)
rows = conn.execute(
f"""
select
t.project_code,
coalesce(pm.project_name, max(t.project_name)) as project_name,
coalesce(pm.project_type, max(t.project_type)) as project_type,
coalesce(pm.construction_family, '') as construction_family,
coalesce(pm.construction_method, '') as construction_method,
coalesce(pp.progress_rate, 0) as progress_rate,
coalesce(pp.contract_pile_count, 0) as contract_pile_count,
coalesce(pp.constructed_pile_count, 0) as constructed_pile_count,
coalesce(sum(case when t.in_out = '입금' then t.supply_amount else 0 end), 0) as income_supply,
coalesce(sum(case when t.in_out = '출금' then t.supply_amount else 0 end), 0) as expense_supply,
count(*) as txn_count
from ptc_transactions t
left join project_master pm on pm.project_code = t.project_code
left join project_progress pp on pp.project_code = t.project_code
{where}
group by t.project_code, pm.project_name, pm.project_type, pm.construction_family, pm.construction_method, pp.progress_rate, pp.contract_pile_count, pp.constructed_pile_count
""",
values,
).fetchall()
amount_buckets = [
{"key": "under_5", "label": "5억 미만", "min": 0, "max": 500_000_000},
{"key": "5_to_20", "label": "5억~20억", "min": 500_000_000, "max": 2_000_000_000},
{"key": "20_to_50", "label": "20억~50억", "min": 2_000_000_000, "max": 5_000_000_000},
{"key": "over_50", "label": "50억 이상", "min": 5_000_000_000, "max": None},
]
status_bands = [
{"key": "normal", "label": "정상"},
{"key": "upfront", "label": "선투입"},
{"key": "delay", "label": "회수지연"},
{"key": "risk", "label": "원가위험"},
]
def bucket_amount(value: float) -> str:
for bucket in amount_buckets:
if bucket["max"] is None and value >= bucket["min"]:
return bucket["key"]
if bucket["min"] <= value < bucket["max"]:
return bucket["key"]
return amount_buckets[0]["key"]
def classify_status(income_supply: float, expense_supply: float, progress_rate: float, contract_pile_count: float, constructed_pile_count: float) -> str:
profit_supply = income_supply - expense_supply
expense_ratio = (expense_supply / income_supply) if income_supply > 0 else None
has_progress_signal = progress_rate > 0 or contract_pile_count > 0 or constructed_pile_count > 0
if profit_supply >= 0:
return "normal"
if income_supply <= 0 and expense_supply > 0:
return "upfront"
if expense_ratio is not None and expense_ratio >= 1.15:
return "risk"
if expense_supply >= 300_000_000 and profit_supply < -100_000_000:
return "risk"
if income_supply > 0 and income_supply < expense_supply:
return "delay" if has_progress_signal else "risk"
return "normal"
by_method: dict[str, dict] = {}
overview = {
"project_count": 0,
"income_supply": 0.0,
"expense_supply": 0.0,
"profit_supply": 0.0,
"status_counts": {band["key"]: 0 for band in status_bands},
}
project_items = []
for row in rows:
method = normalize_dashboard_method(row["construction_method"] or "")
family = normalize_dashboard_family(row["construction_family"] or "")
income_supply = float(row["income_supply"] or 0)
expense_supply = float(row["expense_supply"] or 0)
profit_supply = income_supply - expense_supply
margin_rate = (profit_supply / income_supply * 100) if income_supply > 0 else 0.0
progress_rate = float(row["progress_rate"] or 0)
contract_pile_count = float(row["contract_pile_count"] or 0)
constructed_pile_count = float(row["constructed_pile_count"] or 0)
amount_bucket_key = bucket_amount(income_supply)
status_key = classify_status(income_supply, expense_supply, progress_rate, contract_pile_count, constructed_pile_count)
if method not in by_method:
by_method[method] = {
"method": method,
"family": family,
"project_count": 0,
"income_supply": 0.0,
"expense_supply": 0.0,
"profit_supply": 0.0,
"status_counts": {band["key"]: 0 for band in status_bands},
"cells": {
bucket["key"]: {
"bucket_key": bucket["key"],
"bucket_label": bucket["label"],
"project_count": 0,
"income_supply": 0.0,
"expense_supply": 0.0,
"profit_supply": 0.0,
"status_counts": {band["key"]: 0 for band in status_bands},
"projects": [],
}
for bucket in amount_buckets
},
}
cell = by_method[method]["cells"][amount_bucket_key]
cell["project_count"] += 1
cell["income_supply"] += income_supply
cell["expense_supply"] += expense_supply
cell["profit_supply"] += profit_supply
cell["status_counts"][status_key] += 1
if len(cell["projects"]) < 5:
cell["projects"].append({
"project_code": row["project_code"],
"project_name": row["project_name"],
"margin_rate": margin_rate,
"income_supply": income_supply,
"status_key": status_key,
})
by_method[method]["project_count"] += 1
by_method[method]["income_supply"] += income_supply
by_method[method]["expense_supply"] += expense_supply
by_method[method]["profit_supply"] += profit_supply
by_method[method]["status_counts"][status_key] += 1
overview["project_count"] += 1
overview["income_supply"] += income_supply
overview["expense_supply"] += expense_supply
overview["profit_supply"] += profit_supply
overview["status_counts"][status_key] += 1
project_items.append({
"project_code": row["project_code"],
"project_name": row["project_name"],
"project_type": row["project_type"],
"construction_method": method,
"construction_family": family,
"progress_rate": progress_rate,
"income_supply": income_supply,
"expense_supply": expense_supply,
"profit_supply": profit_supply,
"margin_rate": margin_rate,
"amount_bucket_key": amount_bucket_key,
"status_key": status_key,
})
overview["margin_rate"] = (overview["profit_supply"] / overview["income_supply"] * 100) if overview["income_supply"] > 0 else 0.0
method_items = []
for method, item in sorted(by_method.items(), key=lambda pair: (-pair[1]["income_supply"], pair[0])):
item["margin_rate"] = (item["profit_supply"] / item["income_supply"] * 100) if item["income_supply"] > 0 else 0.0
item["cells"] = [item["cells"][bucket["key"]] for bucket in amount_buckets]
method_items.append(item)
self._send(
200,
{
"overview": overview,
"amount_buckets": amount_buckets,
"status_bands": status_bands,
"methods": method_items,
"projects": sorted(project_items, key=lambda item: (-item["income_supply"], item["project_code"])),
},
)
return
if parsed.path == "/api/project-types":
rows = conn.execute(
"select distinct project_type from ptc_transactions where coalesce(project_type,'') <> '' order by project_type"
@@ -2092,11 +2314,15 @@ class Handler(BaseHTTPRequestHandler):
master.get("construction_family"),
)
summary_dict["construction_method"] = master.get("construction_method") or ""
summary_dict["start_date"] = master.get("start_date") or ""
summary_dict["end_date"] = master.get("end_date") or ""
summary_dict["note"] = master.get("note") or ""
elif summary_dict:
summary_dict["project_type"] = resolve_project_type(project_code, summary_dict["project_type"])
summary_dict["construction_family"] = resolve_construction_family("")
summary_dict["construction_method"] = ""
summary_dict["start_date"] = ""
summary_dict["end_date"] = ""
summary_dict["note"] = ""
account_issues = get_project_account_issues(
conn,