Compare commits
4 Commits
f88d8e53cb
...
Dashboard
| Author | SHA1 | Date | |
|---|---|---|---|
| d4498c356f | |||
| 47cd2bf5b2 | |||
| 28709a3457 | |||
| b5e121136f |
Binary file not shown.
5130
PTC/dashboard_preview.html
Normal file
5130
PTC/dashboard_preview.html
Normal file
File diff suppressed because it is too large
Load Diff
2027
PTC/index.html
2027
PTC/index.html
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@
|
||||
- [ ] UI 렌더링 완성 (PTC 데이터 선택 시 테이블 공백 문제 해결)
|
||||
- [ ] PTC 고유 계정 체계(7xx, 8xx, 513) 분류 로직 고도화
|
||||
- [ ] PTC 전용 실행 예산 보고서 양식 개발 및 출력 기능
|
||||
- [ ] PTC 대시보드 고도화 (순유입/유출 잔액 합계 및 시각화 개선)
|
||||
- [x] PTC 대시보드 고도화 (순유입/유출 잔액 합계 및 시각화 개선)
|
||||
|
||||
---
|
||||
|
||||
@@ -48,3 +48,15 @@
|
||||
- PTC 순유입 및 유출 잔액 합계 산출 로직 개선
|
||||
- 대시보드 상의 차트 및 요약 테이블에 실시간 데이터 반영
|
||||
- 데이터 동기화 및 탭 전환 최적화
|
||||
|
||||
---
|
||||
|
||||
## 6. [PTC::Dashboard] 통합 대시보드 시각화 및 재무 상태 분석 기능 구현
|
||||
**설명**: 프로젝트 전반의 재무 현황을 시각화하고, 공법 및 금액별로 자동 분류하여 상태를 진단하는 통합 대시보드 기능을 구현합니다.
|
||||
|
||||
### 주요 작업
|
||||
- [x] 공법(Method) 및 제품군(Family)별 프로젝트 자동 분류 로직 구현
|
||||
- [x] 입금/출금 기반 수익률(Margin Rate) 및 진행률(Progress Rate) 연동 시각화
|
||||
- [x] 금액별 버킷(Buckets) 및 재무 상태(Status Bands: 정상, 선투입, 회수지연, 위험) 자동 판별 로직 적용
|
||||
- [x] API 서버(`/api/dashboard-prototype`) 및 프론트엔드 반응형 레이아웃 최적화
|
||||
- [x] Windows 사내망 공유용 PowerShell 자동화 스크립트(`start_ptc_share.ps1`) 개발
|
||||
|
||||
@@ -11,15 +11,15 @@
|
||||
|
||||
## 실행 방법
|
||||
|
||||
### 1. API 서버 실행
|
||||
### 1. 서버 실행
|
||||
```bash
|
||||
python3 server/ptc_api_server.py
|
||||
```
|
||||
서버는 기본적으로 4000 포트에서 실행됩니다.
|
||||
서버는 기본적으로 4000 포트에서 실행되며, API와 프론트엔드(`/PTC/`)를 함께 제공합니다.
|
||||
|
||||
### 2. 프론트엔드 접속
|
||||
`PTC/index.html` 파일을 브라우저로 열거나, 로컬 웹 서버(예: 8000 포트)를 통해 접속합니다.
|
||||
API 서버 주소는 `index.html` 내의 `API_BASE` 변수에서 설정할 수 있습니다.
|
||||
브라우저에서 `http://localhost:4000/PTC/` 로 접속합니다.
|
||||
필요하면 `index.html`의 `apiBase` 쿼리 파라미터로 API 주소를 덮어쓸 수 있습니다.
|
||||
|
||||
## 데이터 업데이트
|
||||
`db/import_ptc_xlsx.py` 스크립트를 사용하여 엑셀 데이터를 DB로 변환할 수 있습니다. 자세한 내용은 `db/README.md`를 참조하세요.
|
||||
|
||||
@@ -14,9 +14,14 @@ from zipfile import ZipFile
|
||||
|
||||
|
||||
BASE_DIR = Path("/home/hyein/project")
|
||||
XLSX_PATH = BASE_DIR / "PTC(2023-2026.02).xlsx"
|
||||
DEFAULT_XLSX_PATH = BASE_DIR / "PTC(2023-2026.02).xlsx"
|
||||
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_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 = ["복합말뚝", "합성형라멘", "강관거더", "가시설"]
|
||||
@@ -169,6 +174,53 @@ ACCOUNT_STRUCTURE_TEMPLATE = [
|
||||
]
|
||||
|
||||
|
||||
def get_xlsx_path() -> Path:
|
||||
if XLSX_SOURCE_CONFIG_PATH.exists():
|
||||
configured = XLSX_SOURCE_CONFIG_PATH.read_text(encoding="utf-8").strip()
|
||||
if configured:
|
||||
path = Path(configured).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = (BASE_DIR / path).resolve()
|
||||
return path
|
||||
return DEFAULT_XLSX_PATH
|
||||
|
||||
|
||||
def get_frontend_html() -> str:
|
||||
if not FRONTEND_INDEX_PATH.exists():
|
||||
raise FileNotFoundError("PTC frontend not found")
|
||||
|
||||
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 ""
|
||||
@@ -494,6 +546,15 @@ def init_db() -> None:
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute("create index if not exists idx_ptc_transactions_project_code on ptc_transactions(project_code)")
|
||||
cur.execute("create index if not exists idx_ptc_transactions_project_code_date on ptc_transactions(project_code, transaction_date desc, source_row_no desc)")
|
||||
cur.execute("create index if not exists idx_ptc_transactions_project_code_account on ptc_transactions(project_code, account_code_final)")
|
||||
cur.execute("create index if not exists idx_ptc_transactions_project_code_in_out on ptc_transactions(project_code, in_out)")
|
||||
cur.execute("create index if not exists idx_ptc_transactions_vendor_name on ptc_transactions(vendor_name)")
|
||||
cur.execute("create index if not exists idx_ptc_transactions_account_code_final on ptc_transactions(account_code_final)")
|
||||
cur.execute("create index if not exists idx_project_pile_progress_entries_project_code on project_pile_progress_entries(project_code)")
|
||||
cur.execute("create index if not exists idx_project_budget_lines_project_code on project_budget_lines(project_code)")
|
||||
cur.execute("create index if not exists idx_project_budget_account_lines_project_code on project_budget_account_lines(project_code)")
|
||||
existing_cols = [row["name"] for row in cur.execute("pragma table_info(project_master)").fetchall()]
|
||||
if "construction_family" not in existing_cols:
|
||||
cur.execute("alter table project_master add column construction_family text")
|
||||
@@ -531,11 +592,15 @@ def init_db() -> None:
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
xlsx_mtime = str(int(XLSX_PATH.stat().st_mtime))
|
||||
row = cur.execute("select value from meta where key = 'xlsx_mtime'").fetchone()
|
||||
needs_refresh = row is None or row["value"] != xlsx_mtime
|
||||
xlsx_path = get_xlsx_path()
|
||||
if not xlsx_path.exists():
|
||||
raise FileNotFoundError(f"PTC source xlsx not found: {xlsx_path}")
|
||||
|
||||
xlsx_source_signature = f"{xlsx_path.resolve()}|{int(xlsx_path.stat().st_mtime)}"
|
||||
row = cur.execute("select value from meta where key = 'xlsx_source_signature'").fetchone()
|
||||
needs_refresh = row is None or row["value"] != xlsx_source_signature
|
||||
if needs_refresh:
|
||||
rows = read_xlsx_rows(XLSX_PATH)
|
||||
rows = read_xlsx_rows(xlsx_path)
|
||||
cur.execute("delete from ptc_transactions")
|
||||
cur.executemany(
|
||||
"""
|
||||
@@ -554,9 +619,9 @@ def init_db() -> None:
|
||||
[{**item, "imported_at": datetime.utcnow().isoformat()} for item in rows],
|
||||
)
|
||||
cur.execute(
|
||||
"insert into meta(key, value) values('xlsx_mtime', ?) "
|
||||
"insert into meta(key, value) values('xlsx_source_signature', ?) "
|
||||
"on conflict(key) do update set value = excluded.value",
|
||||
(xlsx_mtime,),
|
||||
(xlsx_source_signature,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -911,7 +976,7 @@ def build_where(params: dict[str, list[str]]) -> tuple[str, list]:
|
||||
|
||||
|
||||
def build_project_where(project_code: str, keyword: str = "", in_out: str = "전체") -> tuple[str, list]:
|
||||
clauses = ["coalesce(project_code, '') = ?"]
|
||||
clauses = ["project_code = ?"]
|
||||
values = [project_code]
|
||||
|
||||
if keyword.strip():
|
||||
@@ -1001,9 +1066,24 @@ 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)
|
||||
|
||||
def _send_frontend(self) -> None:
|
||||
if not FRONTEND_INDEX_PATH.exists():
|
||||
self._send_html(404, "<h1>PTC frontend not found</h1>")
|
||||
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)
|
||||
@@ -1391,8 +1471,17 @@ 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
|
||||
|
||||
if parsed.path == "/":
|
||||
count = conn.execute("select count(*) as count from ptc_transactions").fetchone()["count"]
|
||||
xlsx_path = get_xlsx_path()
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
@@ -1489,22 +1578,23 @@ class Handler(BaseHTTPRequestHandler):
|
||||
<div style="display:inline-flex;padding:7px 12px;border-radius:999px;background:#eaf3fb;color:#124c7c;font-size:11px;font-weight:700;">PTC Data API</div>
|
||||
<h1 style="font-size:36px;line-height:1.2;margin:16px 0 12px;">PTC 원장 데이터 서버</h1>
|
||||
<p class="subtle">
|
||||
이 서버는 `PTC(2023-2026.02).xlsx`를 읽어 요약, 프로젝트 집계, 계정 집계, 거래 미리보기를 JSON API로 제공합니다.
|
||||
메인 화면은 <a href="http://localhost:8000/PTC">http://localhost:8000/PTC</a> 에서 확인할 수 있습니다.
|
||||
이 서버는 선택된 PTC 원본 엑셀 파일을 읽어 요약, 프로젝트 집계, 계정 집계, 거래 미리보기를 JSON API로 제공합니다.
|
||||
메인 화면은 <a href="http://localhost:4000/PTC/">http://localhost:4000/PTC/</a> 에서 바로 확인할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div style="font-size:12px;color:var(--muted);font-weight:700;">현재 적재 건수</div>
|
||||
<div style="font-size:34px;font-weight:700;margin-top:10px;">{count:,}</div>
|
||||
<div class="subtle" style="margin-top:10px;">원본 파일 기준 전체 거래 행 수</div>
|
||||
<div class="subtle" style="margin-top:10px;word-break:break-all;">원본 파일: {xlsx_path}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<div style="font-size:12px;color:var(--muted);font-weight:700;">메인 화면</div>
|
||||
<div style="font-size:20px;font-weight:700;margin-top:8px;">localhost:8000/PTC</div>
|
||||
<div class="subtle" style="margin-top:10px;">사용자가 보는 메인 대시보드</div>
|
||||
<div style="font-size:20px;font-weight:700;margin-top:8px;">localhost:4000/PTC/</div>
|
||||
<div class="subtle" style="margin-top:10px;">사용자가 보는 메인 대시보드와 API를 같은 서버에서 제공합니다.</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div style="font-size:12px;color:var(--muted);font-weight:700;">헬스체크</div>
|
||||
@@ -1591,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"
|
||||
@@ -1601,7 +1873,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if parsed.path == "/api/projects":
|
||||
keyword = params.get("keyword", [""])[0].strip().lower()
|
||||
project_type = params.get("project_type", ["전체"])[0]
|
||||
clauses = ["coalesce(project_code, '') <> ''"]
|
||||
clauses = ["project_code is not null", "project_code <> ''"]
|
||||
values = []
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
@@ -1637,8 +1909,23 @@ class Handler(BaseHTTPRequestHandler):
|
||||
values,
|
||||
).fetchall()
|
||||
items = rows_to_dicts(rows)
|
||||
project_codes = [item["project_code"] for item in items if item.get("project_code")]
|
||||
master_rows = {}
|
||||
if project_codes:
|
||||
placeholders = ",".join("?" for _ in project_codes)
|
||||
master_rows = {
|
||||
row["project_code"]: dict(row)
|
||||
for row in conn.execute(
|
||||
f"""
|
||||
select project_code, project_name, project_type, construction_family, construction_method, note
|
||||
from project_master
|
||||
where project_code in ({placeholders})
|
||||
""",
|
||||
project_codes,
|
||||
).fetchall()
|
||||
}
|
||||
for item in items:
|
||||
master = fetch_project_master(conn, item["project_code"])
|
||||
master = master_rows.get(item["project_code"])
|
||||
item["project_type"] = resolve_project_type(
|
||||
item["project_code"],
|
||||
item["project_type"],
|
||||
@@ -2027,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,
|
||||
@@ -2110,6 +2401,104 @@ class Handler(BaseHTTPRequestHandler):
|
||||
)
|
||||
return
|
||||
|
||||
if parsed.path == "/api/project-budget-actual-detail":
|
||||
project_code = params.get("project_code", [""])[0].strip()
|
||||
section = params.get("section", [""])[0].strip()
|
||||
group_name = params.get("group_name", [""])[0].strip()
|
||||
category = params.get("category", [""])[0].strip()
|
||||
if not project_code or not section or not group_name or not category:
|
||||
self._send(400, {"ok": False, "message": "project_code, section, group_name, category are required"})
|
||||
return
|
||||
|
||||
category_accounts = get_category_account_items(section, group_name, category)
|
||||
account_codes = [item["account_code"] for item in category_accounts if item.get("account_code")]
|
||||
if not account_codes:
|
||||
self._send(
|
||||
200,
|
||||
{
|
||||
"project_code": project_code,
|
||||
"section": section,
|
||||
"group_name": group_name,
|
||||
"category": category,
|
||||
"summary": {"txn_count": 0, "income_count": 0, "expense_count": 0, "income_sum": 0, "expense_sum": 0, "supply_sum": 0},
|
||||
"accounts": [],
|
||||
"transactions": [],
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
placeholders = ",".join("?" for _ in account_codes)
|
||||
values = [project_code, *account_codes]
|
||||
|
||||
summary = conn.execute(
|
||||
f"""
|
||||
select
|
||||
count(*) as txn_count,
|
||||
sum(case when in_out = '입금' then 1 else 0 end) as income_count,
|
||||
sum(case when in_out = '출금' then 1 else 0 end) as expense_count,
|
||||
coalesce(sum(case when in_out = '입금' then supply_amount else 0 end), 0) as income_sum,
|
||||
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_sum,
|
||||
coalesce(sum(supply_amount), 0) as supply_sum
|
||||
from ptc_transactions
|
||||
where project_code = ?
|
||||
and account_code_final in ({placeholders})
|
||||
""",
|
||||
values,
|
||||
).fetchone()
|
||||
|
||||
account_rows = conn.execute(
|
||||
f"""
|
||||
select
|
||||
account_code_final as account_code,
|
||||
max(account_name_final) as account_name,
|
||||
count(*) as txn_count,
|
||||
coalesce(sum(supply_amount), 0) as supply_sum
|
||||
from ptc_transactions
|
||||
where project_code = ?
|
||||
and account_code_final in ({placeholders})
|
||||
group by account_code_final
|
||||
order by account_code_final
|
||||
""",
|
||||
values,
|
||||
).fetchall()
|
||||
|
||||
transaction_rows = conn.execute(
|
||||
f"""
|
||||
select
|
||||
source_row_no,
|
||||
transaction_date,
|
||||
in_out,
|
||||
account_code_final as account_code,
|
||||
account_name_final as account_name,
|
||||
department_name,
|
||||
vendor_name,
|
||||
description,
|
||||
supply_amount,
|
||||
vat_amount,
|
||||
total_amount
|
||||
from ptc_transactions
|
||||
where project_code = ?
|
||||
and account_code_final in ({placeholders})
|
||||
order by transaction_date desc, source_row_no desc
|
||||
limit 100
|
||||
""",
|
||||
values,
|
||||
).fetchall()
|
||||
|
||||
self._send(
|
||||
200,
|
||||
{
|
||||
"project_code": project_code,
|
||||
"section": section,
|
||||
"group_name": group_name,
|
||||
"category": category,
|
||||
"summary": dict(summary) if summary else None,
|
||||
"accounts": rows_to_dicts(account_rows),
|
||||
"transactions": rows_to_dicts(transaction_rows),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if parsed.path == "/api/top-accounts":
|
||||
where, values = build_where(params)
|
||||
rows = conn.execute(
|
||||
|
||||
1
server/ptc_source_path.txt
Normal file
1
server/ptc_source_path.txt
Normal file
@@ -0,0 +1 @@
|
||||
/home/hyein/project/PTC(2023-2026.02).xlsx
|
||||
@@ -1,14 +1,17 @@
|
||||
사용 파일
|
||||
- start_ptc_share.bat : 관리자 권한으로 실행되며, WSL 서버 시작 + portproxy + 방화벽까지 자동 설정
|
||||
- start_ptc_share.bat : 공유용 실행 파일. 관리자 권한으로 다시 실행되어 WSL 서버 시작, IP 공유 설정, 방화벽 허용, 공유 주소 복사까지 처리합니다.
|
||||
- set_ptc_source.bat : 사용할 PTC 원본 `.xlsx` 파일을 선택하고 저장한 뒤 서버를 다시 시작합니다.
|
||||
- stop_ptc_share.bat : 공유 중지
|
||||
- check_ptc_share.bat : 현재 공유 상태 확인
|
||||
|
||||
사용 순서
|
||||
1. start_ptc_share.bat 실행
|
||||
2. 브라우저에서 http://172.16.40.36:8000/PTC/ 확인
|
||||
3. 안 되면 check_ptc_share.bat 실행
|
||||
1. 원본 파일을 바꾸려면 set_ptc_source.bat 실행
|
||||
2. start_ptc_share.bat 실행
|
||||
3. 같은 PC에서는 `http://localhost:4000/PTC/` 확인
|
||||
4. 다른 사람에게는 배치파일이 출력한 `http://내PCIP:4000/PTC/` 주소 전달
|
||||
4. 안 되면 check_ptc_share.bat 실행
|
||||
|
||||
주의
|
||||
- PC가 켜져 있어야 합니다.
|
||||
- WSL이 재시작되어 IP가 바뀌면 start_ptc_share.bat 를 다시 실행하세요.
|
||||
- 관리자 권한이 필요합니다.
|
||||
- 이제 프론트와 API를 모두 4000 포트 하나로 제공합니다.
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
set "HOST_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' } ^| Sort-Object InterfaceMetric, SkipAsSource ^| Select-Object -ExpandProperty IPAddress -First 1; if ($ip) { $ip }"`) do (
|
||||
set "HOST_IP=%%i"
|
||||
)
|
||||
|
||||
if "%HOST_IP%"=="" set "HOST_IP=localhost"
|
||||
|
||||
echo [Windows portproxy]
|
||||
netsh interface portproxy show v4tov4
|
||||
echo.
|
||||
echo [WSL web]
|
||||
wsl.exe bash -lc "curl -I -s http://127.0.0.1:8000/PTC/ | head -n 1"
|
||||
echo.
|
||||
echo [WSL api]
|
||||
wsl.exe bash -lc "curl -s http://127.0.0.1:4000/api/health"
|
||||
echo.
|
||||
echo [WSL web]
|
||||
wsl.exe bash -lc "curl -I -s http://127.0.0.1:4000/PTC/ | head -n 1"
|
||||
echo.
|
||||
echo [Office LAN web]
|
||||
powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://172.16.40.36:8000/PTC/' -UseBasicParsing -TimeoutSec 5).StatusCode } catch { $_.Exception.Message }"
|
||||
powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://%HOST_IP%:4000/PTC/' -UseBasicParsing -TimeoutSec 5).StatusCode } catch { $_.Exception.Message }"
|
||||
echo.
|
||||
echo [Office LAN api]
|
||||
powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://172.16.40.36:4000/api/health' -UseBasicParsing -TimeoutSec 5).Content } catch { $_.Exception.Message }"
|
||||
powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://%HOST_IP%:4000/api/health' -UseBasicParsing -TimeoutSec 5).Content } catch { $_.Exception.Message }"
|
||||
echo.
|
||||
pause
|
||||
|
||||
|
||||
42
windows/set_ptc_source.bat
Normal file
42
windows/set_ptc_source.bat
Normal file
@@ -0,0 +1,42 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
|
||||
set "CONFIG_PATH=/home/hyein/project/server/ptc_source_path.txt"
|
||||
set "SELECTED_FILE="
|
||||
set "WSL_SOURCE_PATH="
|
||||
|
||||
for /f "usebackq delims=" %%i in (`powershell -NoProfile -STA -Command "Add-Type -AssemblyName System.Windows.Forms; $dialog = New-Object System.Windows.Forms.OpenFileDialog; $dialog.Filter = 'Excel Files (*.xlsx)|*.xlsx'; $dialog.Title = 'PTC 원본 엑셀 파일 선택'; $dialog.InitialDirectory = [Environment]::GetFolderPath('Desktop'); if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $dialog.FileName }"`) do (
|
||||
set "SELECTED_FILE=%%i"
|
||||
)
|
||||
|
||||
if "%SELECTED_FILE%"=="" (
|
||||
echo 파일 선택이 취소되었습니다.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo 선택한 파일:
|
||||
echo %SELECTED_FILE%
|
||||
|
||||
for /f "usebackq delims=" %%i in (`wsl.exe wslpath -a "%SELECTED_FILE%"`) do (
|
||||
set "WSL_SOURCE_PATH=%%i"
|
||||
)
|
||||
|
||||
if "%WSL_SOURCE_PATH%"=="" (
|
||||
echo WSL 경로 변환에 실패했습니다.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
wsl.exe bash -lc "printf '%s\n' \"%WSL_SOURCE_PATH%\" > %CONFIG_PATH%"
|
||||
if errorlevel 1 (
|
||||
echo 원본 파일 설정 저장에 실패했습니다.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo 설정 저장 완료
|
||||
echo 다음 실행부터 이 파일을 사용합니다.
|
||||
echo.
|
||||
echo 바로 서버를 다시 시작합니다...
|
||||
call "%~dp0start_ptc_share.bat"
|
||||
@@ -1,70 +1,7 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
|
||||
set "PROJECT_DIR=/home/hyein/project"
|
||||
set "WEB_PORT=8000"
|
||||
set "API_PORT=4000"
|
||||
|
||||
net session >nul 2>&1
|
||||
if not "%errorlevel%"=="0" (
|
||||
echo 관리자 권한으로 다시 실행합니다...
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
|
||||
exit /b
|
||||
)
|
||||
|
||||
echo WSL IP 확인 중...
|
||||
for /f "usebackq delims=" %%i in (`wsl.exe bash -lc "hostname -I | cut -d' ' -f1"`) do (
|
||||
set "WSL_IP=%%i"
|
||||
)
|
||||
|
||||
if "%WSL_IP%"=="" (
|
||||
echo WSL IP를 확인하지 못했습니다.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo WSL IP: %WSL_IP%
|
||||
echo 기존 서버 정리 및 재실행 중...
|
||||
wsl.exe bash -lc "pkill -f 'python3 -m http.server 8000' >/dev/null 2>&1 || true; pkill -f '/home/hyein/project/server/ptc_api_server.py' >/dev/null 2>&1 || true; nohup python3 -m http.server 8000 --directory /home/hyein/project >/tmp/ptc_web.log 2>&1 & nohup python3 /home/hyein/project/server/ptc_api_server.py >/tmp/ptc_api.log 2>&1 & sleep 2"
|
||||
|
||||
echo 포트포워딩 갱신 중...
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=%WEB_PORT% >nul 2>&1
|
||||
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=%WEB_PORT% connectaddress=%WSL_IP% connectport=%WEB_PORT%
|
||||
if errorlevel 1 (
|
||||
echo 8000 포트포워딩 설정에 실패했습니다.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=%API_PORT% connectaddress=%WSL_IP% connectport=%API_PORT%
|
||||
if errorlevel 1 (
|
||||
echo 4000 포트포워딩 설정에 실패했습니다.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo 방화벽 규칙 적용 중...
|
||||
netsh advfirewall firewall delete rule name="PTC 8000" >nul 2>&1
|
||||
netsh advfirewall firewall delete rule name="PTC 4000" >nul 2>&1
|
||||
netsh advfirewall firewall add rule name="PTC 8000" dir=in action=allow protocol=TCP localport=%WEB_PORT% >nul
|
||||
netsh advfirewall firewall add rule name="PTC 4000" dir=in action=allow protocol=TCP localport=%API_PORT% >nul
|
||||
|
||||
echo 서버 상태 확인 중...
|
||||
wsl.exe bash -lc "curl -s http://127.0.0.1:4000/api/health >/tmp/ptc_api_health.json && curl -I -s http://127.0.0.1:8000/PTC/ >/tmp/ptc_web_health.txt"
|
||||
if errorlevel 1 (
|
||||
echo WSL 내부 서버 확인에 실패했습니다.
|
||||
echo /tmp/ptc_api.log 와 /tmp/ptc_web.log 를 확인해 주세요.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo 공유 준비 완료
|
||||
echo 메인 화면: http://172.16.40.36:%WEB_PORT%/PTC/
|
||||
echo API 안내 : http://172.16.40.36:%API_PORT%/
|
||||
echo.
|
||||
echo 참고: WSL이 재시작되어 IP가 바뀌면 이 파일을 다시 실행하세요.
|
||||
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%
|
||||
|
||||
91
windows/start_ptc_share.ps1
Normal file
91
windows/start_ptc_share.ps1
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user