feat: 엑셀 원본 파일 선택 기능 및 프론트엔드/백엔드 최적화
- PTC(2023-2026.02).xlsx 최신화 - PTC/index.html: 에러 핸들링, 동적 API 베이스, 예산 계산 로직 개선 및 UI 최적화 - server/ptc_api_server.py: 4000 포트에서 프론트엔드 직접 서빙, 원본 엑셀 경로 설정 기능, DB 인덱스 추가 및 성능 최적화 - windows/: 원본 파일 선택을 위한 set_ptc_source.bat 추가 및 기존 스크립트 수정
This commit is contained in:
@@ -14,9 +14,12 @@ 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_CACHE: dict[str, str | int] = {"mtime": -1, "html": ""}
|
||||
NS = {"a": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
|
||||
PROJECT_TYPE_OPTIONS = ["관리", "영업", "시공", "설계", "개발", "기술", "교휴", "기타"]
|
||||
METHOD_FAMILY_OPTIONS = ["복합말뚝", "합성형라멘", "강관거더", "가시설"]
|
||||
@@ -169,6 +172,28 @@ 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 = int(FRONTEND_INDEX_PATH.stat().st_mtime)
|
||||
if FRONTEND_CACHE["mtime"] != mtime:
|
||||
FRONTEND_CACHE["mtime"] = mtime
|
||||
FRONTEND_CACHE["html"] = FRONTEND_INDEX_PATH.read_text(encoding="utf-8")
|
||||
return str(FRONTEND_CACHE["html"])
|
||||
|
||||
|
||||
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 +519,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 +565,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 +592,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 +949,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():
|
||||
@@ -1004,6 +1042,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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(self, status: int, payload: dict) -> None:
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
@@ -1391,8 +1435,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
params = parse_qs(parsed.query)
|
||||
conn = get_conn()
|
||||
try:
|
||||
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 +1538,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>
|
||||
@@ -1601,7 +1651,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 +1687,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"],
|
||||
@@ -2110,6 +2175,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(
|
||||
|
||||
Reference in New Issue
Block a user