diff --git a/PTC/dashboard_preview.html b/PTC/dashboard_preview.html new file mode 100644 index 0000000..4fa333c --- /dev/null +++ b/PTC/dashboard_preview.html @@ -0,0 +1,5130 @@ + + + + + + PTC 프로젝트 관리 + + + + + + + + +
+
+
PTC 화면을 준비하는 중입니다.
+
+ 이 메시지가 계속 보이면 브라우저에서 필수 스크립트나 API 서버 연결이 막힌 상태입니다. +
+
+ 초기 스크립트를 불러오는 중입니다. +
+
+ 접속 주소 예시: `http://localhost:4000/PTC/` 또는 `PTC/index.html?apiBase=http://localhost:4000` +
+
+
+ + + diff --git a/server/ptc_api_server.py b/server/ptc_api_server.py index 6635920..dad24dc 100644 --- a/server/ptc_api_server.py +++ b/server/ptc_api_server.py @@ -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, "

PTC dashboard preview frontend not found

") + 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, diff --git a/windows/start_ptc_share.bat b/windows/start_ptc_share.bat index 2559f4f..6ea9c9d 100644 --- a/windows/start_ptc_share.bat +++ b/windows/start_ptc_share.bat @@ -1,77 +1,7 @@ @echo off -setlocal EnableExtensions - -set "PROJECT_DIR=/home/hyein/project" -set "API_PORT=4000" -set "LOCAL_URL=http://localhost:4000/PTC/" -set "SHARE_URL=" -set "LAN_IP=" -set "CURRENT_SOURCE=" - -net session >nul 2>&1 -if not "%errorlevel%"=="0" ( - echo 관리자 권한으로 다시 실행해 공유 설정까지 적용합니다... - powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%~f0' -Verb RunAs" - exit /b -) - -for /f "usebackq delims=" %%i in (`wsl.exe bash -lc "if [ -f /home/hyein/project/server/ptc_source_path.txt ]; then cat /home/hyein/project/server/ptc_source_path.txt; else echo /home/hyein/project/PTC(2023-2026.02).xlsx; fi"`) do ( - set "CURRENT_SOURCE=%%i" -) - -echo PTC 서버 시작 중... -echo 원본 파일: %CURRENT_SOURCE% -wsl.exe bash -lc "pkill -f '/home/hyein/project/server/ptc_api_server.py' >/dev/null 2>&1 || true; nohup python3 /home/hyein/project/server/ptc_api_server.py >/tmp/ptc_api.log 2>&1 & sleep 3" -if errorlevel 1 ( - echo WSL에서 서버 시작 명령 실행에 실패했습니다. - echo WSL이 실행 가능한지 확인해 주세요. - pause - exit /b 1 -) - -echo 로컬 서버 상태 확인 중... -wsl.exe bash -lc "curl -fsS http://127.0.0.1:4000/api/health >/tmp/ptc_api_health.json && curl -fsSI http://127.0.0.1:4000/PTC/ >/tmp/ptc_web_health.txt" -if errorlevel 1 ( - echo 로컬 서버 확인에 실패했습니다. - echo 아래 로그를 확인해 주세요. - wsl.exe bash -lc "tail -n 80 /tmp/ptc_api.log" - pause - exit /b 1 -) - -echo 브라우저를 엽니다... -start "" "%LOCAL_URL%" - -echo. -echo 로컬 실행 완료 -echo 메인 화면: %LOCAL_URL% -echo. - -echo 사내망 공유용 Windows IP 확인 중... -for /f "usebackq delims=" %%i in (`powershell -NoProfile -Command "$ip = Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' -and $_.PrefixOrigin -ne 'WellKnown' } | Select-Object -ExpandProperty IPAddress -First 1; if ($ip) { $ip }"`) do ( - set "LAN_IP=%%i" -) - -echo WSL IP 확인 중... -for /f "usebackq delims=" %%i in (`wsl.exe bash -lc "hostname -I | awk '{print $1}'"`) do ( - set "WSL_IP=%%i" -) - -if "%LAN_IP%"=="" goto :share_done -if "%WSL_IP%"=="" goto :share_done - -echo 사내망 공유 설정 중... -netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=%API_PORT% >nul 2>&1 -netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=%API_PORT% connectaddress=%WSL_IP% connectport=%API_PORT% >nul 2>&1 -netsh advfirewall firewall delete rule name="PTC 4000" >nul 2>&1 -netsh advfirewall firewall add rule name="PTC 4000" dir=in action=allow protocol=TCP localport=%API_PORT% >nul 2>&1 - -:share_done -if not "%LAN_IP%"=="" ( - set "SHARE_URL=http://%LAN_IP%:%API_PORT%/PTC/" - echo 사내망 접속 주소: %SHARE_URL% - echo %SHARE_URL%| clip - echo 공유 주소를 클립보드에 복사했습니다. -) - -pause +setlocal +pushd "%~dp0" >nul 2>&1 +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0start_ptc_share.ps1" +set "EXIT_CODE=%ERRORLEVEL%" +popd >nul 2>&1 +exit /b %EXIT_CODE% diff --git a/windows/start_ptc_share.ps1 b/windows/start_ptc_share.ps1 new file mode 100644 index 0000000..408d24e --- /dev/null +++ b/windows/start_ptc_share.ps1 @@ -0,0 +1,91 @@ +$ErrorActionPreference = "Stop" + +function Test-IsAdmin { + $currentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($currentIdentity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +if (-not (Test-IsAdmin)) { + Start-Process -FilePath "powershell.exe" -Verb RunAs -ArgumentList @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", "`"$PSCommandPath`"" + ) + exit 0 +} + +$projectDir = "/home/hyein/project" +$apiPort = 4000 +$localUrl = "http://localhost:$apiPort/PTC/" +$preferredLanIp = "172.16.40.36" +$defaultSource = "/home/hyein/project/PTC(2023-2026.02).xlsx" +$sourceConfigPath = "/home/hyein/project/server/ptc_source_path.txt" + +function Invoke-WslBash([string]$command) { + $output = & wsl.exe bash -lc $command 2>&1 + $exitCode = $LASTEXITCODE + return [PSCustomObject]@{ + Output = @($output) + ExitCode = $exitCode + } +} + +$currentSourceResult = Invoke-WslBash "cat '$sourceConfigPath' 2>/dev/null || printf '%s\n' '$defaultSource'" +$currentSource = ($currentSourceResult.Output | Select-Object -First 1).Trim() +if ([string]::IsNullOrWhiteSpace($currentSource)) { + $currentSource = $defaultSource +} + +Write-Host "Starting PTC server..." +Write-Host "Source file: $currentSource" + +$startResult = Invoke-WslBash "pkill -f '/home/hyein/project/server/ptc_api_server.py' >/dev/null 2>&1 || true; nohup python3 /home/hyein/project/server/ptc_api_server.py >/tmp/ptc_api.log 2>&1 & sleep 3" +if ($startResult.ExitCode -ne 0) { + Write-Host "Failed to start the server in WSL." -ForegroundColor Red + Read-Host "Press Enter to exit" + exit 1 +} + +$healthResult = Invoke-WslBash "curl -fsS http://127.0.0.1:$apiPort/api/health >/tmp/ptc_api_health.json && curl -fsSI http://127.0.0.1:$apiPort/PTC/ >/tmp/ptc_web_health.txt" +if ($healthResult.ExitCode -ne 0) { + Write-Host "Local server check failed. Recent server log:" -ForegroundColor Red + $logResult = Invoke-WslBash "tail -n 80 /tmp/ptc_api.log" + $logResult.Output | ForEach-Object { Write-Host $_ } + Read-Host "Press Enter to exit" + exit 1 +} + +Start-Process $localUrl +Write-Host "" +Write-Host "Local URL: $localUrl" + +$lanIp = Get-NetIPAddress -AddressFamily IPv4 | + Where-Object { + $_.IPAddress -notlike '127.*' -and + $_.IPAddress -notlike '169.254.*' -and + $_.PrefixOrigin -ne 'WellKnown' + } | + Select-Object -ExpandProperty IPAddress -First 1 + +$wslIpResult = Invoke-WslBash "hostname -I | awk '{print \$1}'" +$wslIp = ($wslIpResult.Output | Select-Object -First 1).Trim() + +if (-not [string]::IsNullOrWhiteSpace($lanIp) -and -not [string]::IsNullOrWhiteSpace($wslIp)) { + Write-Host "Configuring LAN sharing..." + & netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=$apiPort | Out-Null + & netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=$apiPort connectaddress=$wslIp connectport=$apiPort | Out-Null + & netsh advfirewall firewall delete rule name="PTC 4000" | Out-Null + & netsh advfirewall firewall add rule name="PTC 4000" dir=in action=allow protocol=TCP localport=$apiPort | Out-Null + + $shareIp = if ([string]::IsNullOrWhiteSpace($preferredLanIp)) { $lanIp } else { $preferredLanIp } + $shareUrl = "http://$shareIp:$apiPort/PTC/" + Write-Host "LAN URL: $shareUrl" + Set-Clipboard -Value $shareUrl + Write-Host "The share URL has been copied to the clipboard." +} +else { + Write-Host "LAN sharing was skipped because Windows IP or WSL IP could not be detected." +} + +Read-Host "Press Enter to close"