Files
PTC/server/ptc_api_server.py

4832 lines
226 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import json
import re
import sqlite3
from collections import defaultdict
from datetime import datetime, timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from xml.etree import ElementTree as ET
from zipfile import ZipFile
BASE_DIR = Path("/home/hyein/project")
DEFAULT_XLSX_PATH = BASE_DIR / "PTC 입출금내역(2015~).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_ADMIN_DASHBOARD_PATH = BASE_DIR / "PTC" / "admin_dashboard.html"
FRONTEND_MANAGEMENT_DASHBOARD_PATH = BASE_DIR / "PTC" / "management_dashboard_preview.html"
FRONTEND_CACHE: dict[str, str | int] = {"mtime_ns": -1, "html": ""}
FRONTEND_PREVIEW_CACHE: dict[str, str | int] = {"mtime_ns": -1, "html": ""}
FRONTEND_ADMIN_CACHE: dict[str, str | int] = {"mtime_ns": -1, "html": ""}
FRONTEND_MANAGEMENT_CACHE: dict[str, str | int] = {"mtime_ns": -1, "html": ""}
NS = {"a": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
PROJECT_TYPE_OPTIONS = ["관리", "영업", "시공", "설계", "개발", "기술", "교휴", "기타"]
METHOD_FAMILY_OPTIONS = ["복합말뚝", "합성형라멘", "강관거더", "가시설"]
METHOD_FAMILY_MAP = {
"HCP": "복합말뚝",
"CFT": "복합말뚝",
"DDH": "복합말뚝",
"GC": "합성형라멘",
"PB": "합성형라멘",
"IT": "합성형라멘",
"DR": "합성형라멘",
"SGC": "합성형라멘",
"RSD": "강관거더",
"RSW": "가시설",
}
METHOD_OPTIONS = ["HCP", "CFT", "DDH", "GC", "PB", "IT", "DR", "SGC", "RSD", "RSW"]
MANAGEMENT_ACCOUNT_CATEGORY_ORDER = ["일반운영비", "법정,의무", "외부전문,전략", "안전관리비", "인건비"]
ACCOUNT_MASTER = {
"711": {"project_type": "시공", "category": "자재비", "name": "강관"},
"712": {"project_type": "시공", "category": "자재비", "name": "PHC"},
"713": {"project_type": "시공", "category": "자재비", "name": "결합구"},
"714": {"project_type": "시공", "category": "자재비", "name": "부자재"},
"715": {"project_type": "시공", "category": "자재비", "name": "주자재"},
"721": {"project_type": "시공", "category": "외주비", "name": "항타장비"},
"722": {"project_type": "시공", "category": "외주비", "name": "두부보강"},
"723": {"project_type": "시공", "category": "외주비", "name": "시험용역"},
"725": {"project_type": "시공", "category": "외주비", "name": "외주비 등"},
"726": {"project_type": "시공", "category": "외주비", "name": "제작"},
"727": {"project_type": "시공", "category": "외주비", "name": "인장"},
"728": {"project_type": "시공", "category": "외주비", "name": "가설"},
"729": {"project_type": "시공", "category": "외주비", "name": "철근가공"},
"730": {"project_type": "시공", "category": "외주비", "name": "공장제작"},
"724": {"project_type": "시공", "category": "인건비", "name": "노무비"},
"513": {"project_type": "시공", "category": "인건비", "name": "시공 퇴직금"},
"731": {"project_type": "시공", "category": "장비비", "name": "장비비"},
"733": {"project_type": "시공", "category": "운반비", "name": "운반비"},
"732": {"project_type": "시공", "category": "운반비", "name": "유류비"},
"744": {"project_type": "시공", "category": "안전관리비", "name": "안전관리비(현장)"},
"734": {"project_type": "시공", "category": "경비", "name": "주재비"},
"735": {"project_type": "시공", "category": "경비", "name": "기타경비"},
"736": {"project_type": "시공", "category": "경비", "name": "복리후생비"},
"737": {"project_type": "시공", "category": "경비", "name": "여비교통비"},
"738": {"project_type": "시공", "category": "경비", "name": "지급임차료"},
"739": {"project_type": "시공", "category": "경비", "name": "보증수수료"},
"740": {"project_type": "시공", "category": "경비", "name": "소모자재비"},
"741": {"project_type": "시공", "category": "경비", "name": "잡자재대"},
"742": {"project_type": "시공", "category": "경비", "name": "가스수도료"},
"743": {"project_type": "시공", "category": "경비", "name": "수선비"},
"811": {"project_type": "관리", "category": "일반운영비", "name": "복리후생비"},
"812": {"project_type": "관리", "category": "일반운영비", "name": "여비교통비"},
"813": {"project_type": "관리", "category": "일반운영비", "name": "접대비"},
"814": {"project_type": "관리", "category": "일반운영비", "name": "통신비"},
"822": {"project_type": "관리", "category": "일반운영비", "name": "차량유지비"},
"823": {"project_type": "관리", "category": "일반운영비", "name": "연구개발비"},
"825": {"project_type": "관리", "category": "일반운영비", "name": "교육훈련비"},
"826": {"project_type": "관리", "category": "일반운영비", "name": "도서인쇄비"},
"827": {"project_type": "관리", "category": "일반운영비", "name": "광고선전비"},
"829": {"project_type": "관리", "category": "일반운영비", "name": "사무용품비"},
"830": {"project_type": "관리", "category": "일반운영비", "name": "소모품비"},
"843": {"project_type": "관리", "category": "일반운영비", "name": "부서비"},
"817": {"project_type": "관리", "category": "법정,의무", "name": "세금과공과금"},
"819": {"project_type": "관리", "category": "법정,의무", "name": "지급임차료"},
"821": {"project_type": "관리", "category": "법정,의무", "name": "보험료"},
"831": {"project_type": "관리", "category": "외부전문,전략", "name": "지급수수료"},
"849": {"project_type": "관리", "category": "외부전문,전략", "name": "지원서비스"},
"850": {"project_type": "관리", "category": "안전관리비", "name": "안전관리비(본사)"},
"501": {"project_type": "관리", "category": "인건비", "name": "관리 임금"},
"502": {"project_type": "관리", "category": "인건비", "name": "공무 임금"},
"503": {"project_type": "관리", "category": "인건비", "name": "시공 임금"},
"504": {"project_type": "관리", "category": "인건비", "name": "설계 임금"},
"505": {"project_type": "관리", "category": "인건비", "name": "지원 임금"},
"511": {"project_type": "관리", "category": "인건비", "name": "관리 퇴직금"},
"512": {"project_type": "관리", "category": "인건비", "name": "공무 퇴직금"},
"514": {"project_type": "관리", "category": "인건비", "name": "설계 퇴직금"},
"515": {"project_type": "관리", "category": "인건비", "name": "지원 퇴직금"},
"521": {"project_type": "관리", "category": "인건비", "name": "소득세"},
"522": {"project_type": "관리", "category": "인건비", "name": "주민세"},
"523": {"project_type": "관리", "category": "인건비", "name": "4대보험"},
"524": {"project_type": "관리", "category": "인건비", "name": "퇴직급여"},
}
ALLOWED_ACCOUNT_CODES_BY_PROJECT_TYPE = {
project_type: {code for code, item in ACCOUNT_MASTER.items() if item["project_type"] == project_type}
for project_type in ("시공", "관리")
}
SUGGESTED_ACCOUNT_REMAP = {
("시공", "811"): "736",
("시공", "812"): "737",
("시공", "819"): "738",
("시공", "850"): "744",
("관리", "736"): "811",
("관리", "737"): "812",
("관리", "738"): "819",
("관리", "744"): "850",
}
INCOME_ACCOUNT_CATEGORY_MAP = {
"401": "공사수입",
"402": "용역수입",
"403": "기타수입",
"110": "당좌자산",
}
INCOME_ACCOUNT_NAME_MAP = {
"401": "공사수입",
"402": "용역수입",
"403": "기타수입",
"110": "받을어음",
}
SPECIAL_ACCOUNT_MASTER = {
"901": {"section": "영업외 수지", "group": "영업외수익", "category": "이자수입", "name": "이자수입"},
"903": {"section": "영업외 수지", "group": "영업외수익", "category": "잡이익", "name": "잡이익"},
"904": {"section": "영업외 수지", "group": "영업외수익", "category": "배당수익", "name": "배당수익"},
"961": {"section": "영업외 수지", "group": "영업외비용", "category": "이자비용", "name": "이자비용"},
"962": {"section": "영업외 수지", "group": "영업외비용", "category": "잡손실", "name": "잡손실"},
"963": {"section": "영업외 수지", "group": "영업외비용", "category": "가지급금", "name": "가지급금"},
"999": {"section": "영업외 수지", "group": "영업외비용", "category": "법인세등", "name": "법인세등"},
"103": {"section": "자산", "group": "당좌자산", "category": "보통예금", "name": "보통예금"},
"124": {"section": "자산", "group": "당좌자산", "category": "매도가능증권", "name": "매도가능증권"},
"135": {"section": "자산", "group": "당좌자산", "category": "매입부가세", "name": "매입부가세"},
"178": {"section": "자산", "group": "투자자산", "category": "회원권", "name": "회원권"},
"191": {"section": "자산", "group": "투자자산", "category": "출자금", "name": "출자금"},
"192": {"section": "자산", "group": "투자자산", "category": "임차보증금", "name": "임차보증금"},
"194": {"section": "자산", "group": "기타비유동자산", "category": "전도금", "name": "전도금"},
"195": {"section": "자산", "group": "기타비유동자산", "category": "보증금", "name": "보증금"},
"196": {"section": "자산", "group": "기타비유동자산", "category": "대여금", "name": "대여금"},
"206": {"section": "자산", "group": "유형자산", "category": "기계장치", "name": "기계장치"},
"208": {"section": "자산", "group": "유형자산", "category": "차량운반구", "name": "차량운반구"},
"210": {"section": "자산", "group": "유형자산", "category": "공구기구", "name": "공구기구"},
"212": {"section": "자산", "group": "유형자산", "category": "비품", "name": "비품"},
"219": {"section": "자산", "group": "유형자산", "category": "시설장치", "name": "시설장치"},
"231": {"section": "자산", "group": "무형자산", "category": "영업권", "name": "영업권"},
"241": {"section": "자산", "group": "무형자산", "category": "사용수익기부자산", "name": "사용수익기부자산"},
"257": {"section": "부채", "group": "유동부채", "category": "가수금", "name": "가수금"},
"258": {"section": "부채", "group": "유동부채", "category": "매출부가세", "name": "매출부가세"},
"259": {"section": "부채", "group": "유동부채", "category": "선수금", "name": "선수금"},
"260": {"section": "부채", "group": "유동부채", "category": "단기차입금", "name": "단기차입금"},
"293": {"section": "부채", "group": "비유동부채", "category": "장기차입금", "name": "장기차입금"},
"294": {"section": "부채", "group": "비유동부채", "category": "임대보증금", "name": "임대보증금"},
}
MANAGEMENT_EXCLUDED_ACCOUNT_CODES = {
"110", # 받을어음
"124", # 매도가능증권
"135", # 매입부가세
"191", # 출자금
"192", # 임차보증금
"194", # 전도금
"195", # 보증금
"196", # 대여금
"206", # 기계장치
"208", # 차량운반구
"212", # 비품
"219", # 시설장치
"258", # 매출부가세
"259", # 선수금
"260", # 단기차입금
"294", # 임대보증금
"901", # 이자수입
"902", # 국고보조금
"903", # 잡이익
"904", # 배당수익
"961", # 이자비용
"962", # 잡손실
"999", # 법인세등
}
# In project/project-lifecycle screens, these accounts should be hidden
# entirely from aggregates and detail rows.
PROJECT_VIEW_EXCLUDED_ACCOUNT_CODES = set(MANAGEMENT_EXCLUDED_ACCOUNT_CODES)
ACCOUNT_STRUCTURE_TEMPLATE = [
{"section": "수입", "group": "수입", "categories": ["공사수입", "용역수입", "기타수입", "당좌자산"]},
{"section": "영업외 수지", "group": "영업외수익", "categories": ["이자수입", "잡이익", "배당수익"]},
{"section": "영업외 수지", "group": "영업외비용", "categories": ["이자비용", "잡손실", "가지급금", "법인세등"]},
{"section": "자산", "group": "당좌자산", "categories": ["보통예금", "매도가능증권", "매입부가세"]},
{"section": "자산", "group": "투자자산", "categories": ["회원권", "출자금", "임차보증금"]},
{"section": "자산", "group": "기타비유동자산", "categories": ["전도금", "보증금", "대여금"]},
{"section": "자산", "group": "유형자산", "categories": ["기계장치", "차량운반구", "공구기구", "비품", "시설장치"]},
{"section": "자산", "group": "무형자산", "categories": ["영업권", "사용수익기부자산"]},
{"section": "부채", "group": "유동부채", "categories": ["가수금", "매출부가세", "선수금", "단기차입금"]},
{"section": "부채", "group": "비유동부채", "categories": ["장기차입금", "임대보증금"]},
{"section": "지출", "group": "시공", "categories": ["자재비", "외주비", "인건비", "장비비", "운반비", "안전관리비", "경비"]},
{"section": "지출", "group": "관리", "categories": ["일반운영비", "법정,의무", "외부전문,전략", "안전관리비", "인건비"]},
]
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 get_frontend_admin_dashboard_html() -> str:
if not FRONTEND_ADMIN_DASHBOARD_PATH.exists():
raise FileNotFoundError("PTC admin dashboard frontend not found")
mtime_ns = FRONTEND_ADMIN_DASHBOARD_PATH.stat().st_mtime_ns
if FRONTEND_ADMIN_CACHE["mtime_ns"] != mtime_ns:
FRONTEND_ADMIN_CACHE["mtime_ns"] = mtime_ns
FRONTEND_ADMIN_CACHE["html"] = FRONTEND_ADMIN_DASHBOARD_PATH.read_text(encoding="utf-8")
return str(FRONTEND_ADMIN_CACHE["html"])
def get_frontend_management_dashboard_html() -> str:
if not FRONTEND_MANAGEMENT_DASHBOARD_PATH.exists():
raise FileNotFoundError("PTC management dashboard frontend not found")
mtime_ns = FRONTEND_MANAGEMENT_DASHBOARD_PATH.stat().st_mtime_ns
if FRONTEND_MANAGEMENT_CACHE["mtime_ns"] != mtime_ns:
FRONTEND_MANAGEMENT_CACHE["mtime_ns"] = mtime_ns
FRONTEND_MANAGEMENT_CACHE["html"] = FRONTEND_MANAGEMENT_DASHBOARD_PATH.read_text(encoding="utf-8")
return str(FRONTEND_MANAGEMENT_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 ""
def resolve_project_type(project_code: str, raw_project_type: str, master_project_type: str = "") -> str:
inferred = infer_project_type_from_code(project_code)
raw = (raw_project_type or "").strip()
master = (master_project_type or "").strip()
# Project codes like "23-설계-13" are more stable than mixed transaction
# labels, so when the code clearly encodes the type, trust it first.
if inferred:
return inferred
if master:
return master
return raw
def resolve_construction_family(construction_method: str, stored_family: str = "") -> str:
method = (construction_method or "").strip().upper()
family = (stored_family or "").strip()
return METHOD_FAMILY_MAP.get(method, family)
def resolve_account_name(account_code: str, fallback_name: str = "") -> str:
code = (account_code or "").strip()
if code in INCOME_ACCOUNT_NAME_MAP:
return INCOME_ACCOUNT_NAME_MAP[code]
if code in SPECIAL_ACCOUNT_MASTER:
return SPECIAL_ACCOUNT_MASTER[code]["name"]
return ACCOUNT_MASTER.get(code, {}).get("name", fallback_name)
def suggest_account_code(project_type: str, account_code: str, account_name: str) -> str:
explicit = SUGGESTED_ACCOUNT_REMAP.get((project_type, account_code))
if explicit:
return explicit
normalized_name = (account_name or "").strip()
for code, item in ACCOUNT_MASTER.items():
if item["project_type"] == project_type and item["name"] == normalized_name:
return code
return ""
def get_category_account_items(section: str, group: str, category: str) -> list[dict]:
items = []
if section == "수입":
for code, target_category in INCOME_ACCOUNT_CATEGORY_MAP.items():
if target_category == category:
items.append({"account_code": code, "account_name": resolve_account_name(code, category)})
return items
if section in {"영업외 수지", "자산", "부채"}:
for code, meta in SPECIAL_ACCOUNT_MASTER.items():
if meta["section"] == section and meta["group"] == group and meta["category"] == category:
items.append({"account_code": code, "account_name": meta["name"]})
return items
for code, meta in ACCOUNT_MASTER.items():
if meta["project_type"] == group and meta["category"] == category:
items.append({"account_code": code, "account_name": meta["name"]})
return items
def col_to_num(col: str) -> int:
value = 0
for ch in col:
if ch.isalpha():
value = value * 26 + ord(ch.upper()) - 64
return value
def excel_serial_to_date(value: str) -> str:
if not value:
return ""
try:
number = float(value)
except ValueError:
return value
base = datetime(1899, 12, 30)
return (base + timedelta(days=number)).strftime("%Y-%m-%d")
def parse_amount(value: str) -> float:
text = (value or "").strip()
if not text or text == "-":
return 0.0
return float(text.replace(",", ""))
def normalize_transaction_type(in_out: str, account_name: str) -> str:
if "" in in_out:
return "revenue"
if "" in in_out:
if "수입" in account_name or "매출" in account_name:
return "revenue"
return "cost_expense"
return "unknown"
def correct_transaction_date(
transaction_date: str,
raw_value: str,
project_code: str,
account_code: str,
description: str,
) -> str:
if (
transaction_date == "2106-10-31"
and raw_value == "75545"
and project_code == "15-시공-25"
and account_code == "711"
and (description or "").strip() == "강관말뚝"
):
return "2016-10-31"
return transaction_date
def read_xlsx_rows(path: Path) -> list[dict]:
with ZipFile(path) as book:
shared_strings = []
root = ET.fromstring(book.read("xl/sharedStrings.xml"))
for si in root.findall("a:si", NS):
text = "".join(node.text or "" for node in si.iterfind(".//a:t", NS))
shared_strings.append(text)
sheet = ET.fromstring(book.read("xl/worksheets/sheet1.xml"))
rows = []
for row in sheet.find("a:sheetData", NS).findall("a:row", NS):
values = defaultdict(str)
for cell in row.findall("a:c", NS):
ref = cell.attrib.get("r", "")
match = re.match(r"([A-Z]+)(\d+)", ref)
col = col_to_num(match.group(1)) if match else None
node = cell.find("a:v", NS)
if node is None:
value = ""
else:
value = node.text or ""
if cell.attrib.get("t") == "s":
value = shared_strings[int(value)]
values[col] = value
width = max(values) if values else 0
rows.append([values[i] for i in range(1, width + 1)])
if not rows:
return []
header_row_index = 0
headers = rows[0] if rows else []
for idx, candidate in enumerate(rows):
normalized = [str(cell).strip() for cell in candidate if str(cell).strip()]
if "거래일" in normalized and "입/출금" in normalized and "계정코드" in normalized:
header_row_index = idx
headers = candidate
break
data_rows = rows[header_row_index + 1 :]
width = len(headers)
items = []
def payload_get(payload: dict, *keys: str) -> str:
for key in keys:
if key in payload and payload.get(key) not in (None, ""):
return payload.get(key, "")
return ""
for source_row_no, row in enumerate(data_rows, start=header_row_index + 2):
current = row + [""] * (width - len(row)) if len(row) < width else row[:width]
payload = dict(zip(headers, current))
transaction_date_raw = payload_get(payload, "거래일")
in_out = payload_get(payload, "입/출금")
account_code = payload_get(payload, "계정코드")
account_name = payload_get(payload, "구분", "계정과목")
department_name = payload_get(payload, "부서")
vendor_name = payload_get(payload, "거래처")
project_code = payload_get(payload, "프로젝트코드")
raw_project_type = payload_get(payload, "프로젝트 구분(안)")
project_name = payload_get(payload, "프로젝트명")
description = payload_get(payload, "적요")
supply_amount_raw = payload_get(payload, "공급가액")
vat_amount_raw = payload_get(payload, "부가세")
total_amount_raw = payload_get(payload, "합계금액")
remarks = payload_get(payload, "비고")
transaction_date = correct_transaction_date(
excel_serial_to_date(transaction_date_raw),
transaction_date_raw,
project_code,
account_code,
description,
)
items.append(
{
"source_row_no": source_row_no,
"transaction_date_raw": transaction_date_raw,
"transaction_date": transaction_date,
"in_out": in_out,
"account_code": account_code,
"account_name": account_name,
"department_name": department_name,
"vendor_name": vendor_name,
"project_code": project_code,
"project_type": raw_project_type or infer_project_type_from_code(project_code),
"project_name": project_name,
"description": description,
"supply_amount_raw": supply_amount_raw,
"vat_amount_raw": vat_amount_raw,
"total_amount_raw": total_amount_raw,
"remarks": remarks,
"supply_amount": parse_amount(supply_amount_raw),
"vat_amount": parse_amount(vat_amount_raw),
"total_amount": parse_amount(total_amount_raw),
"normalized_type": normalize_transaction_type(
in_out, account_name
),
}
)
return items
def normalize_name(value: str) -> str:
return re.sub(r"\s+|,|\(|\)|~|-", "", (value or "").strip().lower())
def read_method_rows(path: Path) -> list[dict]:
if not path.exists():
return []
with ZipFile(path) as book:
shared_strings = []
root = ET.fromstring(book.read("xl/sharedStrings.xml"))
for si in root.findall("a:si", NS):
text = "".join(node.text or "" for node in si.iterfind(".//a:t", NS))
shared_strings.append(text)
sheet = ET.fromstring(book.read("xl/worksheets/sheet1.xml"))
rows = []
for row in sheet.find("a:sheetData", NS).findall("a:row", NS)[1:]:
values = defaultdict(str)
for cell in row.findall("a:c", NS):
ref = cell.attrib.get("r", "")
match = re.match(r"([A-Z]+)(\d+)", ref)
col = col_to_num(match.group(1)) if match else None
node = cell.find("a:v", NS)
if node is None:
value = ""
else:
value = node.text or ""
if cell.attrib.get("t") == "s":
value = shared_strings[int(value)]
values[col] = value
rows.append(
{
"project_code": values[1].strip(),
"project_name": values[2].strip(),
"construction_method": values[3].strip().upper(),
}
)
return rows
def get_conn() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db() -> None:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = get_conn()
cur = conn.cursor()
cur.execute(
"""
create table if not exists ptc_transactions (
id integer primary key autoincrement,
source_row_no integer not null,
transaction_date_raw text,
transaction_date text,
in_out text,
account_code text,
account_name text,
account_code_final text,
account_name_final text,
department_name text,
vendor_name text,
project_code text,
project_type text,
project_name text,
description text,
supply_amount_raw text,
vat_amount_raw text,
total_amount_raw text,
remarks text,
supply_amount real not null default 0,
vat_amount real not null default 0,
total_amount real not null default 0,
normalized_type text,
imported_at text not null
)
"""
)
cur.execute(
"""
create table if not exists meta (
key text primary key,
value text not null
)
"""
)
cur.execute(
"""
create table if not exists project_master (
project_code text primary key,
project_name text,
project_type text,
construction_family text,
construction_method text,
start_date text,
end_date text,
note text,
updated_at text not null
)
"""
)
cur.execute(
"""
create table if not exists project_relations (
project_code text not null,
related_project_code text not null,
updated_at text not null,
primary key (project_code, related_project_code)
)
"""
)
cur.execute(
"""
create table if not exists project_lifecycle_allocations (
base_project_code text not null,
source_project_code text not null,
allocation_numerator integer not null default 1,
allocation_denominator integer not null default 1,
updated_at text not null,
primary key (base_project_code, source_project_code)
)
"""
)
cur.execute(
"""
create table if not exists project_budget_lines (
project_code text not null,
section text not null,
group_name text not null,
category text not null,
budget_amount real not null default 0,
updated_at text not null,
primary key (project_code, section, group_name, category)
)
"""
)
cur.execute(
"""
create table if not exists project_progress (
project_code text primary key,
progress_rate real not null default 0,
contract_pile_count real not null default 0,
constructed_pile_count real not null default 0,
updated_at text not null
)
"""
)
cur.execute(
"""
create table if not exists project_budget_account_lines (
project_code text not null,
section text not null,
group_name text not null,
category text not null,
account_code text not null,
account_name text,
budget_amount real not null default 0,
updated_at text not null,
primary key (project_code, section, group_name, category, account_code)
)
"""
)
cur.execute(
"""
create table if not exists project_pile_progress_entries (
id integer primary key autoincrement,
project_code text not null,
work_date text not null,
start_date text not null,
end_date text,
pile_count real not null default 0,
note text,
sort_order integer not null default 0,
updated_at text not null
)
"""
)
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)")
cur.execute("create index if not exists idx_project_lifecycle_allocations_base_project_code on project_lifecycle_allocations(base_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")
if "start_date" not in existing_cols:
cur.execute("alter table project_master add column start_date text")
if "end_date" not in existing_cols:
cur.execute("alter table project_master add column end_date text")
progress_cols = [row["name"] for row in cur.execute("pragma table_info(project_progress)").fetchall()]
if "contract_pile_count" not in progress_cols:
cur.execute("alter table project_progress add column contract_pile_count real not null default 0")
if "constructed_pile_count" not in progress_cols:
cur.execute("alter table project_progress add column constructed_pile_count real not null default 0")
pile_progress_cols = [row["name"] for row in cur.execute("pragma table_info(project_pile_progress_entries)").fetchall()]
if "work_date" not in pile_progress_cols:
cur.execute("alter table project_pile_progress_entries add column work_date text")
if "start_date" not in pile_progress_cols and "work_date" in pile_progress_cols:
cur.execute("alter table project_pile_progress_entries add column start_date text")
cur.execute("update project_pile_progress_entries set start_date = coalesce(nullif(start_date, ''), work_date)")
if "end_date" not in pile_progress_cols:
cur.execute("alter table project_pile_progress_entries add column end_date text")
cur.execute("update project_pile_progress_entries set work_date = coalesce(nullif(work_date, ''), start_date)")
cur.execute("update project_pile_progress_entries set end_date = coalesce(nullif(end_date, ''), start_date)")
txn_cols = [row["name"] for row in cur.execute("pragma table_info(ptc_transactions)").fetchall()]
if "account_code_final" not in txn_cols:
cur.execute("alter table ptc_transactions add column account_code_final text")
if "account_name_final" not in txn_cols:
cur.execute("alter table ptc_transactions add column account_name_final text")
cur.execute(
"""
update ptc_transactions
set
account_code_final = coalesce(nullif(account_code_final, ''), account_code),
account_name_final = coalesce(nullif(account_name_final, ''), account_name)
"""
)
conn.commit()
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)
cur.execute("delete from ptc_transactions")
cur.executemany(
"""
insert into ptc_transactions (
source_row_no, transaction_date_raw, transaction_date, in_out, account_code,
account_name, account_code_final, account_name_final, department_name, vendor_name, project_code, project_type,
project_name, description, supply_amount_raw, vat_amount_raw, total_amount_raw,
remarks, supply_amount, vat_amount, total_amount, normalized_type, imported_at
) values (
:source_row_no, :transaction_date_raw, :transaction_date, :in_out, :account_code,
:account_name, :account_code, :account_name, :department_name, :vendor_name, :project_code, :project_type,
:project_name, :description, :supply_amount_raw, :vat_amount_raw, :total_amount_raw,
:remarks, :supply_amount, :vat_amount, :total_amount, :normalized_type, :imported_at
)
""",
[{**item, "imported_at": datetime.utcnow().isoformat()} for item in rows],
)
cur.execute(
"insert into meta(key, value) values('xlsx_source_signature', ?) "
"on conflict(key) do update set value = excluded.value",
(xlsx_source_signature,),
)
conn.commit()
method_mtime = str(int(METHOD_XLSX_PATH.stat().st_mtime)) if METHOD_XLSX_PATH.exists() else ""
row = cur.execute("select value from meta where key = 'method_xlsx_mtime'").fetchone()
needs_method_refresh = METHOD_XLSX_PATH.exists() and (row is None or row["value"] != method_mtime)
if needs_method_refresh:
method_rows = read_method_rows(METHOD_XLSX_PATH)
project_rows = cur.execute(
"select distinct project_code, project_name, project_type from ptc_transactions where coalesce(project_code,'') <> ''"
).fetchall()
by_code = {row["project_code"]: row for row in project_rows}
by_name = {normalize_name(row["project_name"]): row for row in project_rows if row["project_name"]}
for item in method_rows:
target = None
if item["project_code"] and item["project_code"] in by_code:
target = by_code[item["project_code"]]
elif item["project_name"] and normalize_name(item["project_name"]) in by_name:
target = by_name[normalize_name(item["project_name"])]
if not target:
continue
method = item["construction_method"]
family = METHOD_FAMILY_MAP.get(method, "")
updated_at = datetime.now().isoformat()
cur.execute(
"""
insert into project_master (
project_code, project_name, project_type, construction_family, construction_method, note, updated_at
) values (?, ?, ?, ?, ?, ?, ?)
on conflict(project_code) do update set
project_name = coalesce(project_master.project_name, excluded.project_name),
project_type = coalesce(project_master.project_type, excluded.project_type),
construction_family = excluded.construction_family,
construction_method = excluded.construction_method,
updated_at = excluded.updated_at
""",
(
target["project_code"],
target["project_name"],
resolve_project_type(target["project_code"], target["project_type"]),
family,
method,
"",
updated_at,
),
)
cur.execute(
"insert into meta(key, value) values('method_xlsx_mtime', ?) "
"on conflict(key) do update set value = excluded.value",
(method_mtime,),
)
conn.commit()
conn.close()
def fetch_project_master(conn: sqlite3.Connection, project_code: str) -> dict | None:
row = conn.execute(
"""
select project_code, project_name, project_type, construction_family, construction_method, start_date, end_date, note, updated_at
from project_master
where project_code = ?
""",
(project_code,),
).fetchone()
return dict(row) if row else None
def fetch_project_defaults(conn: sqlite3.Connection, project_code: str) -> dict:
row = conn.execute(
"""
select project_code, max(project_name) as project_name, max(project_type) as project_type
from ptc_transactions
where project_code = ?
group by project_code
""",
(project_code,),
).fetchone()
return dict(row) if row else {"project_code": project_code, "project_name": "", "project_type": ""}
def build_related_projects(conn: sqlite3.Connection, project_code: str, project_name: str = "") -> list[dict]:
excluded_values = sorted(PROJECT_VIEW_EXCLUDED_ACCOUNT_CODES)
excluded_placeholders = ",".join("?" for _ in excluded_values) if excluded_values else ""
excluded_clause = (
f"and coalesce(tx.account_code_final, '') not in ({excluded_placeholders})"
if excluded_placeholders
else ""
)
rows = conn.execute(
f"""
with recursive related_codes(project_code) as (
select ?
union
select pr.related_project_code
from project_relations pr
join related_codes rc on rc.project_code = pr.project_code
where coalesce(pr.related_project_code, '') <> ''
union
select pr.project_code
from project_relations pr
join related_codes rc on rc.project_code = pr.related_project_code
where coalesce(pr.project_code, '') <> ''
),
code_set as (
select distinct project_code
from related_codes
where coalesce(project_code, '') <> ''
),
project_summary as (
select
tx.project_code as project_code,
max(tx.project_name) as project_name,
max(tx.project_type) as transaction_project_type,
sum(case when tx.in_out = '입금' then coalesce(tx.supply_amount, 0) else 0 end) as income_supply,
sum(case when tx.in_out = '출금' then coalesce(tx.supply_amount, 0) else 0 end) as expense_supply,
count(*) as txn_count,
min(tx.transaction_date) as min_date,
max(tx.transaction_date) as max_date
from ptc_transactions tx
join code_set cs on cs.project_code = tx.project_code
where 1 = 1
{excluded_clause}
group by tx.project_code
),
pile_progress_summary as (
select
project_code,
sum(coalesce(pile_count, 0)) as entry_pile_total
from project_pile_progress_entries
group by project_code
),
master_rows as (
select
pm.project_code as project_code,
pm.project_name as project_name,
pm.project_type as master_project_type,
pm.construction_family as construction_family,
pm.construction_method as construction_method,
pm.start_date as start_date,
pm.end_date as end_date,
pm.note as note
from project_master pm
join code_set cs on cs.project_code = pm.project_code
)
select
cs.project_code,
coalesce(ps.project_name, mr.project_name, '') as project_name,
coalesce(ps.transaction_project_type, '') as transaction_project_type,
coalesce(mr.master_project_type, '') as master_project_type,
coalesce(mr.construction_family, '') as construction_family,
coalesce(mr.construction_method, '') as construction_method,
coalesce(mr.start_date, '') as start_date,
coalesce(mr.end_date, '') as end_date,
coalesce(mr.note, '') as note,
coalesce(ps.income_supply, 0) as income_supply,
coalesce(ps.expense_supply, 0) as expense_supply,
coalesce(ps.txn_count, 0) as txn_count,
coalesce(ps.min_date, '') as min_date,
coalesce(ps.max_date, '') as max_date,
case
when coalesce(pp.contract_pile_count, 0) > 0 then
(coalesce(psum.entry_pile_total, pp.constructed_pile_count, 0) / pp.contract_pile_count) * 100
else coalesce(pp.progress_rate, 0)
end as progress_rate
from code_set cs
left join project_summary ps on ps.project_code = cs.project_code
left join master_rows mr on mr.project_code = cs.project_code
left join project_progress pp on pp.project_code = cs.project_code
left join pile_progress_summary psum on psum.project_code = cs.project_code
order by cs.project_code
""",
[project_code, *excluded_values],
).fetchall()
role_order = {"영업": 0, "설계": 1, "시공": 2, "관리": 3}
items: list[dict] = []
seen_codes: set[str] = set()
for row in rows:
row_dict = dict(row)
code = (row_dict.get("project_code") or "").strip()
if not code or code in seen_codes:
continue
seen_codes.add(code)
resolved_type = resolve_project_type(
code,
row_dict.get("transaction_project_type") or "",
row_dict.get("master_project_type") or "",
)
income_supply = float(row_dict.get("income_supply") or 0)
expense_supply = float(row_dict.get("expense_supply") or 0)
item = {
"project_code": code,
"project_name": row_dict.get("project_name") or project_name or "",
"project_type": resolved_type,
"construction_family": resolve_construction_family(
row_dict.get("construction_method") or "",
row_dict.get("construction_family") or "",
),
"construction_method": row_dict.get("construction_method") or "",
"start_date": row_dict.get("start_date") or "",
"end_date": row_dict.get("end_date") or "",
"note": row_dict.get("note") or "",
"income_supply": income_supply,
"expense_supply": expense_supply,
"profit_supply": income_supply - expense_supply,
"txn_count": int(row_dict.get("txn_count") or 0),
"min_date": row_dict.get("min_date") or "",
"max_date": row_dict.get("max_date") or "",
"progress_rate": float(row_dict.get("progress_rate") or 0),
"is_current": code == project_code,
}
items.append(item)
items.sort(key=lambda item: (role_order.get(item["project_type"], 9), item["project_code"]))
return items
def fetch_lifecycle_allocation_map(conn: sqlite3.Connection, base_project_code: str) -> dict[str, dict]:
if not base_project_code:
return {}
rows = conn.execute(
"""
select
source_project_code,
allocation_numerator,
allocation_denominator
from project_lifecycle_allocations
where base_project_code = ?
""",
(base_project_code,),
).fetchall()
allocation_map: dict[str, dict] = {}
for row in rows:
source_project_code = (row["source_project_code"] or "").strip()
numerator = int(row["allocation_numerator"] or 0)
denominator = int(row["allocation_denominator"] or 1)
if not source_project_code:
continue
if denominator <= 0:
denominator = 1
numerator = max(0, min(numerator, denominator))
allocation_map[source_project_code] = {
"allocation_numerator": numerator,
"allocation_denominator": denominator,
"allocation_ratio": (numerator / denominator) if denominator > 0 else 1.0,
"has_custom_allocation": True,
}
return allocation_map
def resolve_lifecycle_allocation(project_type: str, allocation_item: dict | None) -> tuple[int, int, float]:
if project_type in {"영업", "설계"}:
if allocation_item:
numerator = int(allocation_item.get("allocation_numerator") or 0)
denominator = int(allocation_item.get("allocation_denominator") or 1)
if denominator <= 0:
denominator = 1
numerator = max(0, min(numerator, denominator))
ratio = numerator / denominator
return numerator, denominator, ratio
return 1, 1, 1.0
return 1, 1, 1.0
def _to_year_month(value: str | None) -> str:
text = str(value or "").strip()
if len(text) < 7 or text[4] != "-":
return ""
month = text[5:7]
if not month.isdigit():
return ""
mm = int(month)
if mm < 1 or mm > 12:
return ""
return text[:7]
def _iter_year_months(start_ym: str, end_ym: str):
if not start_ym or not end_ym:
return
try:
sy, sm = int(start_ym[:4]), int(start_ym[5:7])
ey, em = int(end_ym[:4]), int(end_ym[5:7])
except Exception:
return
if (ey, em) < (sy, sm):
return
y, m = sy, sm
while (y, m) <= (ey, em):
yield f"{y:04d}-{m:02d}"
m += 1
if m > 12:
y += 1
m = 1
def calculate_monthly_shared_distribution(conn: sqlite3.Connection, base_project_code: str) -> dict:
project_rows = conn.execute(
"""
select
pm.project_code,
pm.start_date,
pm.end_date,
min(case when coalesce(t.transaction_date, '') <> '' then t.transaction_date end) as min_tx_date,
max(case when coalesce(t.transaction_date, '') <> '' then t.transaction_date end) as max_tx_date
from project_master pm
left join ptc_transactions t on t.project_code = pm.project_code
where coalesce(pm.project_type, '') = '시공'
group by pm.project_code, pm.start_date, pm.end_date
"""
).fetchall()
project_ranges: dict[str, tuple[str, str]] = {}
for row in project_rows:
code = (row["project_code"] or "").strip()
if not code:
continue
start_ym = _to_year_month(row["start_date"]) or _to_year_month(row["min_tx_date"])
end_ym = _to_year_month(row["end_date"]) or _to_year_month(row["max_tx_date"]) or start_ym
if not start_ym:
continue
project_ranges[code] = (start_ym, end_ym)
base_range = project_ranges.get(base_project_code)
if not base_range:
return {"labor_shared": 0.0, "common_shared": 0.0}
pool_rows = conn.execute(
"""
select
substr(coalesce(transaction_date, ''), 1, 7) as ym,
account_code_final as account_code,
coalesce(sum(supply_amount), 0) as expense_supply
from ptc_transactions
where in_out = '출금'
and coalesce(transaction_date, '') <> ''
group by substr(coalesce(transaction_date, ''), 1, 7), account_code_final
having ym <> ''
"""
).fetchall()
labor_pool_by_month: dict[str, float] = defaultdict(float)
common_pool_by_month: dict[str, float] = defaultdict(float)
for row in pool_rows:
ym = (row["ym"] or "").strip()
code = (row["account_code"] or "").strip()
amount = float(row["expense_supply"] or 0)
if not ym or amount == 0:
continue
meta = ACCOUNT_MASTER.get(code) or {}
if meta.get("project_type") != "관리":
continue
if meta.get("category") == "인건비":
labor_pool_by_month[ym] += amount
else:
common_pool_by_month[ym] += amount
candidate_months = sorted(set([*labor_pool_by_month.keys(), *common_pool_by_month.keys()]))
if not candidate_months:
return {"labor_shared": 0.0, "common_shared": 0.0}
month_active_counts: dict[str, int] = defaultdict(int)
for start_ym, end_ym in project_ranges.values():
for ym in _iter_year_months(start_ym, end_ym):
month_active_counts[ym] += 1
base_start_ym, base_end_ym = base_range
labor_shared = 0.0
common_shared = 0.0
for ym in candidate_months:
if ym < base_start_ym:
continue
if base_end_ym and ym > base_end_ym:
continue
active_count = int(month_active_counts.get(ym) or 0)
if active_count <= 0:
continue
labor_shared += float(labor_pool_by_month.get(ym) or 0) / active_count
common_shared += float(common_pool_by_month.get(ym) or 0) / active_count
return {"labor_shared": labor_shared, "common_shared": common_shared}
def build_company_allocated_project_rows(
conn: sqlite3.Connection, project_rows: list[sqlite3.Row], source_project_type: str
) -> tuple[list[dict], dict]:
raw_rows = rows_to_dicts(project_rows)
if source_project_type not in {"영업", "설계"} or not raw_rows:
return raw_rows, {"enabled": False}
related_cache: dict[str, list[str]] = {}
project_meta_cache: dict[str, dict] = {}
allocated_map: dict[str, dict] = {}
def get_project_meta(project_code: str) -> dict:
cached = project_meta_cache.get(project_code)
if cached is not None:
return cached
master = fetch_project_master(conn, project_code) or {}
defaults = fetch_project_defaults(conn, project_code)
resolved_type = resolve_project_type(
project_code,
defaults.get("project_type", ""),
master.get("project_type", ""),
)
item = {
"project_code": project_code,
"project_name": (master.get("project_name") or defaults.get("project_name") or "").strip(),
"project_type": resolved_type,
}
project_meta_cache[project_code] = item
return item
def get_target_codes(source_project_code: str) -> list[str]:
cached = related_cache.get(source_project_code)
if cached is not None:
return cached
related = build_related_projects(conn, source_project_code)
candidates = [item for item in related if (item.get("project_code") or "").strip() and item.get("project_code") != source_project_code]
construction_targets = [item for item in candidates if (item.get("project_type") or "").strip() == "시공"]
target_items = construction_targets if construction_targets else candidates
target_codes = sorted({(item.get("project_code") or "").strip() for item in target_items if (item.get("project_code") or "").strip()})
if not target_codes:
target_codes = [source_project_code]
related_cache[source_project_code] = target_codes
return target_codes
for row in raw_rows:
source_code = (row.get("project_code") or "").strip()
if not source_code:
continue
target_codes = get_target_codes(source_code)
divisor = max(len(target_codes), 1)
income_supply = float(row.get("income_supply_sum") or 0)
expense_supply = float(row.get("expense_supply_sum") or 0)
supply_sum = float(row.get("supply_sum") or 0)
txn_count = float(row.get("txn_count") or 0)
income_count = float(row.get("income_count") or 0)
expense_count = float(row.get("expense_count") or 0)
for target_code in target_codes:
meta = get_project_meta(target_code)
entry = allocated_map.setdefault(
target_code,
{
"project_code": target_code,
"project_name": meta.get("project_name") or target_code,
"project_type": meta.get("project_type") or "",
"txn_count": 0.0,
"income_count": 0.0,
"expense_count": 0.0,
"income_supply_sum": 0.0,
"expense_supply_sum": 0.0,
"supply_sum": 0.0,
},
)
entry["txn_count"] += txn_count / divisor
entry["income_count"] += income_count / divisor
entry["expense_count"] += expense_count / divisor
entry["income_supply_sum"] += income_supply / divisor
entry["expense_supply_sum"] += expense_supply / divisor
entry["supply_sum"] += supply_sum / divisor
allocated_rows = list(allocated_map.values())
for row in allocated_rows:
row["txn_count"] = int(round(float(row.get("txn_count") or 0)))
row["income_count"] = int(round(float(row.get("income_count") or 0)))
row["expense_count"] = int(round(float(row.get("expense_count") or 0)))
allocated_rows.sort(key=lambda item: (-float(item.get("supply_sum") or 0), item.get("project_code") or ""))
return allocated_rows, {
"enabled": True,
"mode": "project_count_equal_split",
"source_project_type": source_project_type,
"source_project_count": len(raw_rows),
"target_project_count": len(allocated_rows),
}
def build_project_lifecycle_cost(
conn: sqlite3.Connection, related_projects: list[dict], current_project_type: str, base_project_code: str
) -> dict | None:
if current_project_type != "시공":
return None
allocation_map = fetch_lifecycle_allocation_map(conn, base_project_code)
role_order = ["영업", "설계", "시공", "관리"]
rows = [item for item in related_projects if item.get("project_type") in role_order]
if not rows:
return None
rows_with_allocation: list[dict] = []
for item in rows:
row = dict(item)
project_type = (row.get("project_type") or "").strip()
numerator, denominator, ratio = resolve_lifecycle_allocation(
project_type,
allocation_map.get((row.get("project_code") or "").strip()),
)
row["allocation_numerator"] = numerator
row["allocation_denominator"] = denominator
row["allocation_ratio"] = ratio
row["has_custom_allocation"] = bool(allocation_map.get((row.get("project_code") or "").strip()))
row["adjusted_expense_supply"] = float(row.get("expense_supply") or 0) * ratio
rows_with_allocation.append(row)
rows.sort(key=lambda item: (role_order.index(item["project_type"]), item["project_code"]))
total_income = sum(float(item.get("income_supply") or 0) for item in rows_with_allocation)
total_expense = sum(float(item.get("expense_supply") or 0) for item in rows_with_allocation)
project_codes = [item.get("project_code") for item in rows_with_allocation if item.get("project_code")]
breakdown_components = {
"시공비": {"direct": 0.0, "shared": 0.0, "total": 0.0},
"인건비": {"direct": 0.0, "shared": 0.0, "total": 0.0},
"관리비": {"direct": 0.0, "shared": 0.0, "total": 0.0},
}
breakdown_project_maps: dict[str, dict[str, dict]] = {
"시공비": {},
"인건비": {},
"관리비": {},
}
breakdown_account_maps: dict[str, dict[str, dict]] = {
"시공비": {},
"인건비": {},
"관리비": {},
}
project_lookup = {item.get("project_code"): item for item in rows_with_allocation if item.get("project_code")}
if project_codes:
placeholders = ",".join("?" for _ in project_codes)
excluded_values = sorted(PROJECT_VIEW_EXCLUDED_ACCOUNT_CODES)
excluded_placeholders = ",".join("?" for _ in excluded_values) if excluded_values else ""
excluded_clause = (
f"and coalesce(t.account_code_final, '') not in ({excluded_placeholders})"
if excluded_placeholders
else ""
)
expense_rows = conn.execute(
f"""
select
t.project_code,
t.project_type,
t.account_code_final as account_code,
coalesce(sum(t.supply_amount), 0) as expense_supply
from ptc_transactions t
where t.in_out = '출금'
and coalesce(t.project_code, '') in ({placeholders})
{excluded_clause}
group by t.project_code, t.project_type, t.account_code_final
""",
[*project_codes, *excluded_values],
).fetchall()
for row in expense_rows:
account_code = (row["account_code"] or "").strip()
project_code = (row["project_code"] or "").strip()
project_type = (row["project_type"] or "").strip()
meta = ACCOUNT_MASTER.get(account_code)
bucket = classify_lifecycle_bucket(account_code, project_code, project_type, meta)
project_info = project_lookup.get(project_code, {})
numerator = int(project_info.get("allocation_numerator") or 1)
denominator = int(project_info.get("allocation_denominator") or 1)
allocation_ratio = float(project_info.get("allocation_ratio") or 1.0)
base_expense_supply = float(row["expense_supply"] or 0)
# 영업/설계는 배분 비율이 직접분이며, 미배분 잔액은 공통배분분으로 분리한다.
if project_type in {"영업", "설계"}:
direct_expense_supply = base_expense_supply * allocation_ratio
shared_expense_supply = max(base_expense_supply - direct_expense_supply, 0.0)
else:
direct_expense_supply = base_expense_supply
shared_expense_supply = 0.0
breakdown_components[bucket]["direct"] += direct_expense_supply
breakdown_components[bucket]["shared"] += shared_expense_supply
breakdown_components[bucket]["total"] += base_expense_supply
project_entry = breakdown_project_maps[bucket].setdefault(
project_code,
{
"project_code": project_code,
"project_name": project_info.get("project_name") or "",
"project_type": project_info.get("project_type") or project_type or "",
"construction_family": project_info.get("construction_family") or "",
"construction_method": project_info.get("construction_method") or "",
"allocation_numerator": numerator,
"allocation_denominator": denominator,
"allocation_ratio": allocation_ratio,
"direct_expense_supply": 0.0,
"shared_expense_supply": 0.0,
"expense_supply": 0.0,
},
)
project_entry["direct_expense_supply"] += direct_expense_supply
project_entry["shared_expense_supply"] += shared_expense_supply
project_entry["expense_supply"] += base_expense_supply
account_entry = breakdown_account_maps[bucket].setdefault(
account_code or "미지정",
{
"account_code": account_code or "",
"account_name": (meta or {}).get("name") or (row["account_code"] or ""),
"direct_expense_supply": 0.0,
"shared_expense_supply": 0.0,
"expense_supply": 0.0,
},
)
account_entry["direct_expense_supply"] += direct_expense_supply
account_entry["shared_expense_supply"] += shared_expense_supply
account_entry["expense_supply"] += base_expense_supply
monthly_shared = calculate_monthly_shared_distribution(conn, base_project_code)
labor_shared = float(monthly_shared.get("labor_shared") or 0.0)
common_shared = float(monthly_shared.get("common_shared") or 0.0)
base_project_info = project_lookup.get(base_project_code) or {}
if labor_shared:
breakdown_components["인건비"]["shared"] += labor_shared
breakdown_components["인건비"]["total"] += labor_shared
project_entry = breakdown_project_maps["인건비"].setdefault(
base_project_code,
{
"project_code": base_project_code,
"project_name": base_project_info.get("project_name") or "",
"project_type": base_project_info.get("project_type") or "시공",
"construction_family": base_project_info.get("construction_family") or "",
"construction_method": base_project_info.get("construction_method") or "",
"allocation_numerator": 1,
"allocation_denominator": 1,
"allocation_ratio": 1.0,
"direct_expense_supply": 0.0,
"shared_expense_supply": 0.0,
"expense_supply": 0.0,
},
)
project_entry["shared_expense_supply"] += labor_shared
project_entry["expense_supply"] += labor_shared
account_entry = breakdown_account_maps["인건비"].setdefault(
"SHARED_LABOR",
{
"account_code": "SHARED_LABOR",
"account_name": "월별 공통배분(인건비)",
"direct_expense_supply": 0.0,
"shared_expense_supply": 0.0,
"expense_supply": 0.0,
},
)
account_entry["shared_expense_supply"] += labor_shared
account_entry["expense_supply"] += labor_shared
if common_shared:
breakdown_components["관리비"]["shared"] += common_shared
breakdown_components["관리비"]["total"] += common_shared
project_entry = breakdown_project_maps["관리비"].setdefault(
base_project_code,
{
"project_code": base_project_code,
"project_name": base_project_info.get("project_name") or "",
"project_type": base_project_info.get("project_type") or "시공",
"construction_family": base_project_info.get("construction_family") or "",
"construction_method": base_project_info.get("construction_method") or "",
"allocation_numerator": 1,
"allocation_denominator": 1,
"allocation_ratio": 1.0,
"direct_expense_supply": 0.0,
"shared_expense_supply": 0.0,
"expense_supply": 0.0,
},
)
project_entry["shared_expense_supply"] += common_shared
project_entry["expense_supply"] += common_shared
account_entry = breakdown_account_maps["관리비"].setdefault(
"SHARED_COMMON",
{
"account_code": "SHARED_COMMON",
"account_name": "월별 공통배분(관리비)",
"direct_expense_supply": 0.0,
"shared_expense_supply": 0.0,
"expense_supply": 0.0,
},
)
account_entry["shared_expense_supply"] += common_shared
account_entry["expense_supply"] += common_shared
total_expense = (
breakdown_components["시공비"]["total"]
+ breakdown_components["인건비"]["total"]
+ breakdown_components["관리비"]["total"]
)
breakdown = [
{
"label": "시공비",
"expense_supply": breakdown_components["시공비"]["total"],
"direct_expense_supply": breakdown_components["시공비"]["direct"],
"shared_expense_supply": breakdown_components["시공비"]["shared"],
"projects": sorted(
breakdown_project_maps["시공비"].values(),
key=lambda item: (-float(item.get("expense_supply") or 0), item.get("project_code") or ""),
),
"accounts": sorted(
breakdown_account_maps["시공비"].values(),
key=lambda item: (-float(item.get("expense_supply") or 0), item.get("account_code") or ""),
),
},
{
"label": "인건비",
"expense_supply": breakdown_components["인건비"]["total"],
"direct_expense_supply": breakdown_components["인건비"]["direct"],
"shared_expense_supply": breakdown_components["인건비"]["shared"],
"projects": sorted(
breakdown_project_maps["인건비"].values(),
key=lambda item: (-float(item.get("expense_supply") or 0), item.get("project_code") or ""),
),
"accounts": sorted(
breakdown_account_maps["인건비"].values(),
key=lambda item: (-float(item.get("expense_supply") or 0), item.get("account_code") or ""),
),
},
{
"label": "관리비",
"expense_supply": breakdown_components["관리비"]["total"],
"direct_expense_supply": breakdown_components["관리비"]["direct"],
"shared_expense_supply": breakdown_components["관리비"]["shared"],
"projects": sorted(
breakdown_project_maps["관리비"].values(),
key=lambda item: (-float(item.get("expense_supply") or 0), item.get("project_code") or ""),
),
"accounts": sorted(
breakdown_account_maps["관리비"].values(),
key=lambda item: (-float(item.get("expense_supply") or 0), item.get("account_code") or ""),
),
},
]
return {
"rows": sorted(
rows_with_allocation,
key=lambda item: (role_order.index(item["project_type"]), item["project_code"]),
),
"breakdown": breakdown,
"summary": {
"income_supply": total_income,
"expense_supply": total_expense,
"profit_supply": total_income - total_expense,
},
}
def classify_lifecycle_bucket(account_code: str, project_code: str, project_type: str, meta: dict | None = None) -> str:
if (account_code or "").strip() == "724":
return "시공비"
meta = meta or ACCOUNT_MASTER.get(account_code)
if meta:
if meta.get("category") == "인건비":
return "인건비"
if meta.get("project_type") == "관리":
return "관리비"
return "시공비"
if "-시공-" in project_code or project_type == "시공":
return "시공비"
return "관리비"
def build_lifecycle_account_detail(
conn: sqlite3.Connection,
related_projects: list[dict],
base_project_code: str,
current_project_type: str,
bucket_label: str,
account_code: str,
) -> dict | None:
if current_project_type != "시공" or not account_code:
return None
role_order = ["영업", "설계", "시공", "관리"]
rows = [item for item in related_projects if item.get("project_type") in role_order]
if not rows:
return None
project_codes = [item.get("project_code") for item in rows if item.get("project_code")]
if not project_codes:
return None
placeholders = ",".join("?" for _ in project_codes)
excluded_values = sorted(PROJECT_VIEW_EXCLUDED_ACCOUNT_CODES)
excluded_placeholders = ",".join("?" for _ in excluded_values) if excluded_values else ""
excluded_clause = (
f"and coalesce(account_code_final, '') not in ({excluded_placeholders})"
if excluded_placeholders
else ""
)
query_values = [account_code, *project_codes, *excluded_values]
tx_rows = conn.execute(
f"""
select
source_row_no,
transaction_date,
in_out,
project_code,
project_name,
project_type,
vendor_name,
department_name,
description,
account_code_final as account_code,
account_name_final as account_name,
supply_amount
from ptc_transactions
where in_out = '출금'
and coalesce(account_code_final, '') = ?
and coalesce(project_code, '') in ({placeholders})
{excluded_clause}
order by transaction_date desc, source_row_no desc
""",
query_values,
).fetchall()
allocation_map = fetch_lifecycle_allocation_map(conn, base_project_code)
related_project_type_map = {
(item.get("project_code") or "").strip(): (item.get("project_type") or "").strip()
for item in related_projects
if (item.get("project_code") or "").strip()
}
filtered_transactions: list[dict] = []
project_map: dict[str, dict] = {}
for row in tx_rows:
row_dict = dict(row)
tx_project_code = (row_dict.get("project_code") or "").strip()
tx_project_type = related_project_type_map.get(tx_project_code) or (row_dict.get("project_type") or "").strip()
meta = ACCOUNT_MASTER.get(account_code)
bucket = classify_lifecycle_bucket(account_code, tx_project_code, tx_project_type, meta)
if bucket != bucket_label:
continue
numerator, denominator, ratio = resolve_lifecycle_allocation(tx_project_type, allocation_map.get(tx_project_code))
allocated_supply_amount = float(row_dict.get("supply_amount") or 0) * ratio
row_dict["allocated_supply_amount"] = allocated_supply_amount
row_dict["allocation_numerator"] = numerator
row_dict["allocation_denominator"] = denominator
row_dict["allocation_ratio"] = ratio
filtered_transactions.append(row_dict)
project_entry = project_map.setdefault(
tx_project_code or "-",
{
"project_code": tx_project_code or "",
"project_name": row_dict.get("project_name") or "",
"project_type": tx_project_type or "",
"allocation_numerator": numerator,
"allocation_denominator": denominator,
"allocation_ratio": ratio,
"expense_supply_sum": 0.0,
"txn_count": 0,
},
)
project_entry["expense_supply_sum"] += allocated_supply_amount
project_entry["txn_count"] += 1
summary = {
"account_code": account_code,
"account_name": (ACCOUNT_MASTER.get(account_code) or {}).get("name") or account_code,
"income_supply_sum": 0.0,
"expense_supply_sum": sum(float(row.get("allocated_supply_amount") or 0) for row in filtered_transactions),
"txn_count": len(filtered_transactions),
"min_date": min((row.get("transaction_date") or "" for row in filtered_transactions), default=""),
"max_date": max((row.get("transaction_date") or "" for row in filtered_transactions), default=""),
}
return {
"summary": summary,
"projects": sorted(
project_map.values(),
key=lambda item: (-float(item.get("expense_supply_sum") or 0), item.get("project_code") or ""),
),
"transactions": filtered_transactions[:100],
}
def get_project_account_issues(conn: sqlite3.Connection, project_code: str, resolved_project_type: str) -> list[dict]:
allowed_codes = ALLOWED_ACCOUNT_CODES_BY_PROJECT_TYPE.get(resolved_project_type)
if not allowed_codes:
return []
rows = conn.execute(
"""
select
account_code_final as account_code,
account_name_final as account_name,
count(*) as txn_count,
coalesce(sum(supply_amount), 0) as supply_sum
from ptc_transactions
where project_code = ?
group by account_code_final, account_name_final
order by supply_sum desc, account_code_final
""",
(project_code,),
).fetchall()
items = []
for row in rows:
code = (row["account_code"] or "").strip()
if not code or code not in ACCOUNT_MASTER or code in allowed_codes:
continue
suggested_code = suggest_account_code(resolved_project_type, code, row["account_name"] or "")
items.append(
{
"account_code": code,
"account_name": row["account_name"] or "",
"txn_count": row["txn_count"],
"supply_sum": row["supply_sum"],
"suggested_code": suggested_code,
"suggested_name": resolve_account_name(suggested_code, ""),
"is_invalid": True,
}
)
return items
def build_account_structure_rows(account_rows: list[sqlite3.Row]) -> list[dict]:
aggregated: dict[tuple[str, str, str], dict] = {}
extra_rows: list[dict] = []
category_account_labels = defaultdict(list)
for code, category in INCOME_ACCOUNT_CATEGORY_MAP.items():
category_account_labels[("수입", category)].append(f"{code} {resolve_account_name(code, category)}")
for code, meta in SPECIAL_ACCOUNT_MASTER.items():
category_account_labels[(meta["group"], meta["category"])].append(f"{code} {meta['name']}")
for code, meta in ACCOUNT_MASTER.items():
category_account_labels[(meta["project_type"], meta["category"])].append(f"{code} {meta['name']}")
for row in account_rows:
code = (row["code"] or "").strip()
name = (row["name"] or "").strip()
count = row["count"] or 0
total = row["total"] or 0
if code in INCOME_ACCOUNT_CATEGORY_MAP:
key = ("수입", "수입", INCOME_ACCOUNT_CATEGORY_MAP[code])
elif code in SPECIAL_ACCOUNT_MASTER:
meta = SPECIAL_ACCOUNT_MASTER[code]
key = (meta["section"], meta["group"], meta["category"])
elif code in ACCOUNT_MASTER:
meta = ACCOUNT_MASTER[code]
key = ("지출", meta["project_type"], meta["category"])
else:
extra_rows.append(
{
"section": "기타",
"group": "미분류",
"category": f"{code} {name}".strip(),
"account_items": [{"account_code": code, "account_name": name, "actual_amount": total, "budget_amount": 0}],
"count": count,
"total": total,
}
)
continue
current = aggregated.setdefault(
key,
{
"section": key[0],
"group": key[1],
"category": key[2],
"account_labels": " / ".join(category_account_labels.get((key[1], key[2]), [])),
"account_items": [
{**item, "actual_amount": 0, "budget_amount": 0}
for item in get_category_account_items(key[0], key[1], key[2])
],
"count": 0,
"total": 0,
},
)
current["count"] += count
current["total"] += total
for account_item in current["account_items"]:
if account_item["account_code"] == code:
account_item["actual_amount"] = total
break
rows = []
for block in ACCOUNT_STRUCTURE_TEMPLATE:
for category in block["categories"]:
key = (block["section"], block["group"], category)
item = aggregated.get(
key,
{
"section": block["section"],
"group": block["group"],
"category": category,
"account_labels": " / ".join(category_account_labels.get((block["group"], category), [])),
"account_items": [
{**item, "actual_amount": 0, "budget_amount": 0}
for item in get_category_account_items(block["section"], block["group"], category)
],
"count": 0,
"total": 0,
},
)
if "account_labels" not in item:
item["account_labels"] = " / ".join(category_account_labels.get((block["group"], category), []))
rows.append(item)
rows.extend(extra_rows)
return rows
def build_budget_analysis(conn: sqlite3.Connection, project_code: str, account_structure_rows: list[dict]) -> dict:
pile_progress_rows = conn.execute(
"""
select
id,
coalesce(nullif(start_date, ''), work_date) as start_date,
coalesce(nullif(end_date, ''), nullif(start_date, ''), work_date) as end_date,
pile_count,
note,
sort_order
from project_pile_progress_entries
where project_code = ?
order by coalesce(nullif(start_date, ''), work_date) asc, sort_order asc, id asc
""",
(project_code,),
).fetchall()
item_budget_rows = conn.execute(
"""
select section, group_name, category, budget_amount
from project_budget_lines
where project_code = ?
""",
(project_code,),
).fetchall()
item_budget_map = {
(row["section"], row["group_name"], row["category"]): row["budget_amount"] or 0
for row in item_budget_rows
}
budget_rows = conn.execute(
"""
select section, group_name, category, account_code, budget_amount
from project_budget_account_lines
where project_code = ?
""",
(project_code,),
).fetchall()
budget_map = {
(row["section"], row["group_name"], row["category"], row["account_code"]): row["budget_amount"] or 0
for row in budget_rows
}
progress_row = conn.execute(
"select progress_rate, contract_pile_count, constructed_pile_count from project_progress where project_code = ?",
(project_code,),
).fetchone()
progress_rate = progress_row["progress_rate"] if progress_row else 0
contract_pile_count = float(progress_row["contract_pile_count"] or 0) if progress_row else 0
constructed_pile_count = float(progress_row["constructed_pile_count"] or 0) if progress_row else 0
entry_pile_total = sum(float(row["pile_count"] or 0) for row in pile_progress_rows)
if pile_progress_rows:
constructed_pile_count = entry_pile_total
if contract_pile_count > 0:
progress_rate = (constructed_pile_count / contract_pile_count) * 100
rows = []
expense_budget_total = 0.0
expense_actual_total = 0.0
revenue_budget_total = 0.0
revenue_actual_total = 0.0
for item in account_structure_rows:
account_items = []
for account_item in item.get("account_items", []):
budget_amount_item = float(
budget_map.get((item["section"], item["group"], item["category"], account_item["account_code"]), 0) or 0
)
account_items.append(
{
**account_item,
"budget_amount": budget_amount_item,
}
)
account_budget_total = sum(account_item["budget_amount"] for account_item in account_items)
budget_amount = float(
item_budget_map.get((item["section"], item["group"], item["category"]), account_budget_total) or 0
)
actual_amount = float(item["total"] or 0)
execution_rate = (actual_amount / budget_amount * 100) if budget_amount > 0 else 0
row = {
**item,
"account_items": account_items,
"budget_amount": budget_amount,
"account_budget_total": account_budget_total,
"actual_amount": actual_amount,
"execution_rate": execution_rate,
}
rows.append(row)
if item["section"] == "지출":
expense_budget_total += budget_amount
expense_actual_total += actual_amount
if item["section"] == "수입":
revenue_budget_total += budget_amount
revenue_actual_total += actual_amount
execution_rate_total = (expense_actual_total / expense_budget_total * 100) if expense_budget_total > 0 else 0
return {
"progress_rate": progress_rate,
"contract_pile_count": contract_pile_count,
"constructed_pile_count": constructed_pile_count,
"pile_progress_entries": [dict(row) for row in pile_progress_rows],
"execution_rate_total": execution_rate_total,
"expense_budget_total": expense_budget_total,
"expense_actual_total": expense_actual_total,
"revenue_budget_total": revenue_budget_total,
"revenue_actual_total": revenue_actual_total,
"rows": rows,
}
def build_where(params: dict[str, list[str]]) -> tuple[str, list]:
clauses = []
values = []
keyword = params.get("keyword", [""])[0].strip().lower()
project_type = params.get("project_type", ["전체"])[0]
in_out = params.get("in_out", ["전체"])[0]
if keyword:
like = f"%{keyword}%"
clauses.append(
"""
(
lower(coalesce(account_code_final, '')) like ?
or lower(coalesce(account_name_final, '')) like ?
or lower(coalesce(department_name, '')) like ?
or lower(coalesce(vendor_name, '')) like ?
or lower(coalesce(project_code, '')) like ?
or lower(coalesce(project_type, '')) like ?
or lower(coalesce(project_name, '')) like ?
or lower(coalesce(description, '')) like ?
)
"""
)
values.extend([like] * 8)
if project_type and project_type != "전체":
clauses.append("project_type = ?")
values.append(project_type)
if in_out and in_out != "전체":
clauses.append("in_out = ?")
values.append(in_out)
where = " where " + " and ".join(clauses) if clauses else ""
return where, values
def build_project_where(project_code: str, keyword: str = "", in_out: str = "전체") -> tuple[str, list]:
clauses = ["project_code = ?"]
values = [project_code]
if PROJECT_VIEW_EXCLUDED_ACCOUNT_CODES:
excluded_placeholders = ",".join("?" for _ in PROJECT_VIEW_EXCLUDED_ACCOUNT_CODES)
clauses.append(f"coalesce(account_code_final, '') not in ({excluded_placeholders})")
values.extend(sorted(PROJECT_VIEW_EXCLUDED_ACCOUNT_CODES))
if keyword.strip():
like = f"%{keyword.strip().lower()}%"
clauses.append(
"""
(
lower(coalesce(account_code_final, '')) like ?
or lower(coalesce(account_name_final, '')) like ?
or lower(coalesce(department_name, '')) like ?
or lower(coalesce(vendor_name, '')) like ?
or lower(coalesce(project_name, '')) like ?
or lower(coalesce(description, '')) like ?
)
"""
)
values.extend([like] * 6)
if in_out and in_out != "전체":
clauses.append("in_out = ?")
values.append(in_out)
return " where " + " and ".join(clauses), values
def query_summary(conn: sqlite3.Connection, params: dict[str, list[str]]) -> dict:
where, values = build_where(params)
row = conn.execute(
f"""
select
count(*) as 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(supply_amount), 0) as supply_sum,
coalesce(sum(vat_amount), 0) as vat_sum,
coalesce(sum(total_amount), 0) as total_sum,
min(transaction_date) as min_date,
max(transaction_date) as max_date
from ptc_transactions
{where}
""",
values,
).fetchone()
missing_row = conn.execute(
f"""
select count(*) as missing_critical
from ptc_transactions
{where}
{" and " if where else " where "}
(
coalesce(account_code_final, '') = ''
or coalesce(account_name_final, '') = ''
or coalesce(transaction_date, '') = ''
or coalesce(description, '') = ''
)
""",
values,
).fetchone()
return {
"count": row["count"],
"income_count": row["income_count"],
"expense_count": row["expense_count"],
"supply_sum": row["supply_sum"],
"vat_sum": row["vat_sum"],
"total_sum": row["total_sum"],
"min_date": row["min_date"] or "",
"max_date": row["max_date"] or "",
"missing_critical": missing_row["missing_critical"],
}
def rows_to_dicts(rows) -> list[dict]:
return [dict(row) for row in rows]
class Handler(BaseHTTPRequestHandler):
def _read_json(self) -> dict:
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length) if length > 0 else b"{}"
return json.loads(raw.decode("utf-8"))
def _send_html(self, status: int, html: str) -> None:
body = html.encode("utf-8")
self.send_response(status)
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_frontend_admin_dashboard(self) -> None:
if not FRONTEND_ADMIN_DASHBOARD_PATH.exists():
self._send_html(404, "<h1>PTC admin dashboard frontend not found</h1>")
return
self._send_html(200, get_frontend_admin_dashboard_html())
def _send_frontend_management_dashboard(self) -> None:
if not FRONTEND_MANAGEMENT_DASHBOARD_PATH.exists():
self._send_html(404, "<h1>PTC management dashboard frontend not found</h1>")
return
self._send_html(200, get_frontend_management_dashboard_html())
def _send(self, status: int, payload: dict) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
self.wfile.write(body)
def do_OPTIONS(self) -> None:
self._send(200, {"ok": True})
def do_POST(self) -> None:
parsed = urlparse(self.path)
conn = get_conn()
try:
if parsed.path == "/api/project-master/upsert":
payload = self._read_json()
project_code = str(payload.get("project_code", "")).strip()
if not project_code:
self._send(400, {"ok": False, "message": "project_code is required"})
return
project_name = str(payload.get("project_name", "")).strip()
project_type = str(payload.get("project_type", "")).strip()
if project_type and project_type not in PROJECT_TYPE_OPTIONS:
self._send(400, {"ok": False, "message": "invalid project_type"})
return
construction_family = str(payload.get("construction_family", "")).strip()
construction_method = str(payload.get("construction_method", "")).strip()
if construction_method and construction_method not in METHOD_OPTIONS:
self._send(400, {"ok": False, "message": "invalid construction_method"})
return
construction_family = resolve_construction_family(construction_method, construction_family)
start_date = str(payload.get("start_date", "")).strip()
end_date = str(payload.get("end_date", "")).strip()
note = str(payload.get("note", "")).strip()
raw_related_project_codes = payload.get("related_project_codes", [])
if isinstance(raw_related_project_codes, str):
raw_related_project_codes = re.split(r"[\s,]+", raw_related_project_codes)
related_project_codes = []
for code in raw_related_project_codes if isinstance(raw_related_project_codes, list) else []:
normalized = str(code or "").strip()
if not normalized or normalized == project_code or normalized in related_project_codes:
continue
related_project_codes.append(normalized)
updated_at = datetime.now().isoformat()
conn.execute(
"""
insert into project_master (
project_code, project_name, project_type, construction_family, construction_method, start_date, end_date, note, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
on conflict(project_code) do update set
project_name = excluded.project_name,
project_type = excluded.project_type,
construction_family = excluded.construction_family,
construction_method = excluded.construction_method,
start_date = excluded.start_date,
end_date = excluded.end_date,
note = excluded.note,
updated_at = excluded.updated_at
""",
(project_code, project_name, project_type, construction_family, construction_method, start_date, end_date, note, updated_at),
)
conn.execute(
"delete from project_relations where project_code = ? or related_project_code = ?",
(project_code, project_code),
)
for related_project_code in related_project_codes:
conn.execute(
"""
insert or replace into project_relations(project_code, related_project_code, updated_at)
values (?, ?, ?)
""",
(project_code, related_project_code, updated_at),
)
conn.execute(
"""
insert or replace into project_relations(project_code, related_project_code, updated_at)
values (?, ?, ?)
""",
(related_project_code, project_code, updated_at),
)
conn.commit()
item = fetch_project_master(conn, project_code) or {}
item["related_projects"] = build_related_projects(conn, project_code, item.get("project_name") or project_name)
self._send(200, {"ok": True, "item": item})
return
if parsed.path == "/api/lifecycle-allocation/upsert":
payload = self._read_json()
base_project_code = str(payload.get("base_project_code", "")).strip()
source_project_code = str(payload.get("source_project_code", "")).strip()
allocation_numerator = int(payload.get("allocation_numerator", 1) or 0)
allocation_denominator = int(payload.get("allocation_denominator", 1) or 1)
if not base_project_code or not source_project_code:
self._send(400, {"ok": False, "message": "base_project_code and source_project_code are required"})
return
if allocation_denominator <= 0:
self._send(400, {"ok": False, "message": "allocation_denominator must be greater than 0"})
return
if allocation_numerator < 0:
self._send(400, {"ok": False, "message": "allocation_numerator must be 0 or greater"})
return
if allocation_numerator > allocation_denominator:
self._send(400, {"ok": False, "message": "allocation_numerator must be <= allocation_denominator"})
return
updated_at = datetime.now().isoformat()
conn.execute(
"""
insert into project_lifecycle_allocations (
base_project_code, source_project_code, allocation_numerator, allocation_denominator, updated_at
) values (?, ?, ?, ?, ?)
on conflict(base_project_code, source_project_code) do update set
allocation_numerator = excluded.allocation_numerator,
allocation_denominator = excluded.allocation_denominator,
updated_at = excluded.updated_at
""",
(
base_project_code,
source_project_code,
allocation_numerator,
allocation_denominator,
updated_at,
),
)
conn.commit()
self._send(
200,
{
"ok": True,
"item": {
"base_project_code": base_project_code,
"source_project_code": source_project_code,
"allocation_numerator": allocation_numerator,
"allocation_denominator": allocation_denominator,
"allocation_ratio": allocation_numerator / allocation_denominator if allocation_denominator > 0 else 1.0,
"updated_at": updated_at,
},
},
)
return
if parsed.path == "/api/lifecycle-allocation/delete":
payload = self._read_json()
base_project_code = str(payload.get("base_project_code", "")).strip()
source_project_code = str(payload.get("source_project_code", "")).strip()
if not base_project_code or not source_project_code:
self._send(400, {"ok": False, "message": "base_project_code and source_project_code are required"})
return
conn.execute(
"""
delete from project_lifecycle_allocations
where base_project_code = ?
and source_project_code = ?
""",
(base_project_code, source_project_code),
)
conn.commit()
self._send(200, {"ok": True})
return
if parsed.path == "/api/project-master/batch-update-method":
payload = self._read_json()
project_codes = payload.get("project_codes", [])
construction_method = str(payload.get("construction_method", "")).strip()
note = str(payload.get("note", "")).strip()
if not isinstance(project_codes, list) or not project_codes:
self._send(400, {"ok": False, "message": "project_codes is required"})
return
if construction_method and construction_method not in METHOD_OPTIONS:
self._send(400, {"ok": False, "message": "invalid construction_method"})
return
construction_family = resolve_construction_family(construction_method, "")
updated_at = datetime.now().isoformat()
updated_items = []
for raw_code in project_codes:
project_code = str(raw_code).strip()
if not project_code:
continue
default_item = fetch_project_defaults(conn, project_code)
existing = fetch_project_master(conn, project_code) or {}
project_name = existing.get("project_name") or default_item.get("project_name") or ""
project_type = resolve_project_type(
project_code,
default_item.get("project_type", ""),
existing.get("project_type", ""),
)
merged_note = note if note else (existing.get("note") or "")
start_date = existing.get("start_date") or ""
end_date = existing.get("end_date") or ""
conn.execute(
"""
insert into project_master (
project_code, project_name, project_type, construction_family, construction_method, start_date, end_date, note, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
on conflict(project_code) do update set
project_name = excluded.project_name,
project_type = excluded.project_type,
construction_family = excluded.construction_family,
construction_method = excluded.construction_method,
start_date = excluded.start_date,
end_date = excluded.end_date,
note = excluded.note,
updated_at = excluded.updated_at
""",
(
project_code,
project_name,
project_type,
construction_family,
construction_method,
start_date,
end_date,
merged_note,
updated_at,
),
)
updated_items.append(project_code)
conn.commit()
self._send(
200,
{
"ok": True,
"updated_count": len(updated_items),
"project_codes": updated_items,
},
)
return
if parsed.path == "/api/project-account-remap":
payload = self._read_json()
project_code = str(payload.get("project_code", "")).strip()
from_account_code = str(payload.get("from_account_code", "")).strip()
to_account_code = str(payload.get("to_account_code", "")).strip()
if not project_code or not from_account_code or not to_account_code:
self._send(400, {"ok": False, "message": "project_code, from_account_code, to_account_code are required"})
return
if to_account_code not in ACCOUNT_MASTER:
self._send(400, {"ok": False, "message": "invalid to_account_code"})
return
project_default = fetch_project_defaults(conn, project_code)
project_master = fetch_project_master(conn, project_code) or {}
resolved_project_type = resolve_project_type(
project_code,
project_default.get("project_type", ""),
project_master.get("project_type", ""),
)
allowed_codes = ALLOWED_ACCOUNT_CODES_BY_PROJECT_TYPE.get(resolved_project_type, set())
if allowed_codes and to_account_code not in allowed_codes:
self._send(400, {"ok": False, "message": "target account is not allowed for project type"})
return
to_account_name = resolve_account_name(to_account_code, "")
updated_at = datetime.now().isoformat()
cur = conn.cursor()
cur.execute(
"""
update ptc_transactions
set
account_code_final = ?,
account_name_final = ?,
imported_at = imported_at
where project_code = ?
and account_code_final = ?
""",
(to_account_code, to_account_name, project_code, from_account_code),
)
changed_rows = cur.rowcount
conn.commit()
self._send(
200,
{
"ok": True,
"updated_count": changed_rows,
"project_code": project_code,
"from_account_code": from_account_code,
"to_account_code": to_account_code,
"to_account_name": to_account_name,
"updated_at": updated_at,
},
)
return
if parsed.path == "/api/project-account-remap-rows":
payload = self._read_json()
project_code = str(payload.get("project_code", "")).strip()
rows = payload.get("rows", [])
if not project_code or not isinstance(rows, list):
self._send(400, {"ok": False, "message": "project_code and rows are required"})
return
project_default = fetch_project_defaults(conn, project_code)
project_master = fetch_project_master(conn, project_code) or {}
resolved_project_type = resolve_project_type(
project_code,
project_default.get("project_type", ""),
project_master.get("project_type", ""),
)
allowed_codes = ALLOWED_ACCOUNT_CODES_BY_PROJECT_TYPE.get(resolved_project_type, set())
cur = conn.cursor()
updated_count = 0
for item in rows:
source_row_no = int(item.get("source_row_no", 0) or 0)
to_account_code = str(item.get("to_account_code", "")).strip()
if source_row_no <= 0 or not to_account_code:
continue
if to_account_code not in ACCOUNT_MASTER:
continue
if allowed_codes and to_account_code not in allowed_codes:
continue
to_account_name = resolve_account_name(to_account_code, "")
cur.execute(
"""
update ptc_transactions
set
account_code_final = ?,
account_name_final = ?,
imported_at = imported_at
where project_code = ?
and source_row_no = ?
""",
(to_account_code, to_account_name, project_code, source_row_no),
)
updated_count += cur.rowcount
conn.commit()
self._send(
200,
{
"ok": True,
"updated_count": updated_count,
"project_code": project_code,
},
)
return
if parsed.path == "/api/project-budget/upsert":
payload = self._read_json()
project_code = str(payload.get("project_code", "")).strip()
item_rows = payload.get("item_rows", [])
account_rows = payload.get("account_rows", [])
progress_rate = float(payload.get("progress_rate", 0) or 0)
contract_pile_count = float(payload.get("contract_pile_count", 0) or 0)
constructed_pile_count = float(payload.get("constructed_pile_count", 0) or 0)
if not project_code:
self._send(400, {"ok": False, "message": "project_code is required"})
return
if not isinstance(item_rows, list):
self._send(400, {"ok": False, "message": "item_rows must be a list"})
return
if not isinstance(account_rows, list):
self._send(400, {"ok": False, "message": "account_rows must be a list"})
return
updated_at = datetime.now().isoformat()
conn.execute("delete from project_budget_lines where project_code = ?", (project_code,))
conn.execute("delete from project_budget_account_lines where project_code = ?", (project_code,))
for item in item_rows:
section = str(item.get("section", "")).strip()
group_name = str(item.get("group", "")).strip()
category = str(item.get("category", "")).strip()
budget_amount = float(item.get("budget_amount", 0) or 0)
if not section or not group_name or not category:
continue
conn.execute(
"""
insert into project_budget_lines (
project_code, section, group_name, category, budget_amount, updated_at
) values (?, ?, ?, ?, ?, ?)
""",
(project_code, section, group_name, category, budget_amount, updated_at),
)
for item in account_rows:
section = str(item.get("section", "")).strip()
group_name = str(item.get("group", "")).strip()
category = str(item.get("category", "")).strip()
account_code = str(item.get("account_code", "")).strip()
account_name = str(item.get("account_name", "")).strip()
budget_amount = float(item.get("budget_amount", 0) or 0)
if not section or not group_name or not category or not account_code:
continue
conn.execute(
"""
insert into project_budget_account_lines (
project_code, section, group_name, category, account_code, account_name, budget_amount, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?)
""",
(project_code, section, group_name, category, account_code, account_name, budget_amount, updated_at),
)
conn.execute(
"""
insert into project_progress (
project_code, progress_rate, contract_pile_count, constructed_pile_count, updated_at
)
values (?, ?, ?, ?, ?)
on conflict(project_code) do update set
progress_rate = excluded.progress_rate,
contract_pile_count = excluded.contract_pile_count,
constructed_pile_count = excluded.constructed_pile_count,
updated_at = excluded.updated_at
""",
(project_code, progress_rate, contract_pile_count, constructed_pile_count, updated_at),
)
conn.commit()
self._send(200, {"ok": True, "project_code": project_code, "updated_at": updated_at})
return
if parsed.path == "/api/project-pile-progress/upsert":
payload = self._read_json()
project_code = str(payload.get("project_code", "")).strip()
contract_pile_count = float(payload.get("contract_pile_count", 0) or 0)
entries = payload.get("entries", [])
if not project_code:
self._send(400, {"ok": False, "message": "project_code is required"})
return
if not isinstance(entries, list):
self._send(400, {"ok": False, "message": "entries must be a list"})
return
updated_at = datetime.now().isoformat()
conn.execute("delete from project_pile_progress_entries where project_code = ?", (project_code,))
constructed_pile_count = 0.0
for idx, item in enumerate(entries):
start_date = str(item.get("start_date", "")).strip()
end_date = str(item.get("end_date", "")).strip()
pile_count = float(item.get("pile_count", 0) or 0)
note = str(item.get("note", "")).strip()
if not start_date:
continue
if not end_date:
end_date = start_date
constructed_pile_count += pile_count
conn.execute(
"""
insert into project_pile_progress_entries (
project_code, work_date, start_date, end_date, pile_count, note, sort_order, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?)
""",
(project_code, start_date, start_date, end_date, pile_count, note, idx, updated_at),
)
progress_rate = (constructed_pile_count / contract_pile_count * 100) if contract_pile_count > 0 else 0
conn.execute(
"""
insert into project_progress (
project_code, progress_rate, contract_pile_count, constructed_pile_count, updated_at
)
values (?, ?, ?, ?, ?)
on conflict(project_code) do update set
progress_rate = excluded.progress_rate,
contract_pile_count = excluded.contract_pile_count,
constructed_pile_count = excluded.constructed_pile_count,
updated_at = excluded.updated_at
""",
(project_code, progress_rate, contract_pile_count, constructed_pile_count, updated_at),
)
conn.commit()
self._send(
200,
{
"ok": True,
"project_code": project_code,
"contract_pile_count": contract_pile_count,
"constructed_pile_count": constructed_pile_count,
"progress_rate": progress_rate,
"updated_at": updated_at,
},
)
return
self._send(404, {"ok": False, "message": "Not found"})
finally:
conn.close()
def do_GET(self) -> None:
parsed = urlparse(self.path)
params = parse_qs(parsed.query)
conn = get_conn()
try:
if parsed.path in {"/PTC-admin", "/PTC-admin/", "/PTC/admin_dashboard.html"}:
self._send_frontend_admin_dashboard()
return
if parsed.path in {"/PTC-lab-manage", "/PTC-lab-manage/", "/PTC/management_dashboard_preview.html"}:
self._send_frontend_management_dashboard()
return
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>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PTC API Server</title>
<style>
:root {{
--ink: #102037;
--muted: #63758d;
--line: #d7e1ea;
--blue: #124c7c;
--cyan: #1786a1;
--soft: #f5f9fc;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
font-family: Arial, sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top right, rgba(23,134,161,0.10), transparent 24%),
linear-gradient(180deg, #f8fbff 0%, #eef3f7 100%);
}}
.page {{
width: min(1120px, calc(100vw - 32px));
margin: 0 auto;
padding: 28px 0 60px;
}}
.panel {{
background: rgba(255,255,255,0.92);
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: 0 16px 40px rgba(16, 32, 55, 0.06);
padding: 24px;
}}
.hero {{
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 18px;
}}
.cards {{
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin-top: 18px;
}}
.card {{
border: 1px solid var(--line);
border-radius: 18px;
padding: 18px;
background: linear-gradient(180deg, white, var(--soft));
}}
.route {{
border: 1px solid var(--line);
border-radius: 16px;
padding: 16px;
background: white;
margin-top: 12px;
}}
.method {{
display: inline-block;
padding: 4px 8px;
border-radius: 999px;
background: #e8f4fb;
color: var(--blue);
font-size: 11px;
font-weight: 700;
margin-right: 8px;
}}
.path {{
font-family: monospace;
font-size: 14px;
font-weight: 700;
color: var(--ink);
}}
.subtle {{
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}}
a {{ color: var(--cyan); text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
@media (max-width: 900px) {{
.hero, .cards {{ grid-template-columns: 1fr; }}
}}
</style>
</head>
<body>
<div class="page">
<section class="panel">
<div class="hero">
<div>
<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 원본 엑셀 파일을 읽어 요약, 프로젝트 집계, 계정 집계, 거래 미리보기를 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: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>
<div style="font-size:20px;font-weight:700;margin-top:8px;">/api/health</div>
<div class="subtle" style="margin-top:10px;">서버 상태와 row count 확인</div>
</div>
<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;">/api/summary</div>
<div class="subtle" style="margin-top:10px;">건수, 기간, 공급가액, 누락값</div>
</div>
</div>
</section>
<section class="panel" style="margin-top:18px;">
<h2 style="margin:0 0 10px;font-size:22px;">주요 API</h2>
<div class="route">
<span class="method">GET</span><span class="path">/api/health</span>
<div class="subtle" style="margin-top:8px;">API 서버가 정상 동작하는지와 적재 건수를 반환합니다.</div>
</div>
<div class="route">
<span class="method">GET</span><span class="path">/api/summary</span>
<div class="subtle" style="margin-top:8px;">건수, 입금/출금, 공급가액, 부가세, 기간, 누락값 요약을 반환합니다.</div>
</div>
<div class="route">
<span class="method">GET</span><span class="path">/api/top-accounts</span>
<div class="subtle" style="margin-top:8px;">계정코드별 상위 집계를 반환합니다.</div>
</div>
<div class="route">
<span class="method">GET</span><span class="path">/api/top-projects</span>
<div class="subtle" style="margin-top:8px;">프로젝트별 상위 집계를 반환합니다.</div>
</div>
<div class="route">
<span class="method">GET</span><span class="path">/api/project-mismatches</span>
<div class="subtle" style="margin-top:8px;">프로젝트코드 대비 프로젝트명/구분 불일치를 반환합니다.</div>
</div>
<div class="route">
<span class="method">GET</span><span class="path">/api/transactions?limit=30</span>
<div class="subtle" style="margin-top:8px;">원본 거래 미리보기를 반환합니다.</div>
</div>
</section>
<section class="panel" style="margin-top:18px;">
<h2 style="margin:0 0 10px;font-size:22px;">필터 예시</h2>
<div class="route">
<span class="path">/api/summary?project_type=시공</span>
</div>
<div class="route">
<span class="path">/api/top-accounts?in_out=출금</span>
</div>
<div class="route">
<span class="path">/api/transactions?keyword=여비교통비&amp;limit=20</span>
</div>
</section>
</div>
</body>
</html>"""
self._send_html(200, html)
return
if parsed.path == "/api/health":
count = conn.execute("select count(*) as count from ptc_transactions").fetchone()["count"]
self._send(200, {"ok": True, "row_count": count})
return
if parsed.path == "/api/project-master-options":
self._send(
200,
{
"project_type_options": PROJECT_TYPE_OPTIONS,
"method_family_options": METHOD_FAMILY_OPTIONS,
"method_options": METHOD_OPTIONS,
"method_family_map": METHOD_FAMILY_MAP,
"account_master": ACCOUNT_MASTER,
"allowed_account_codes_by_project_type": {
key: sorted(value) for key, value in ALLOWED_ACCOUNT_CODES_BY_PROJECT_TYPE.items()
},
},
)
return
if parsed.path == "/api/summary":
summary = query_summary(conn, params)
self._send(200, summary)
return
if parsed.path == "/api/dashboard-prototype":
project_type = params.get("project_type", ["전체"])[0]
date_from = params.get("date_from", [""])[0].strip()
date_to = params.get("date_to", [""])[0].strip()
exclude_asset_accounts = params.get("exclude_asset_accounts", ["0"])[0].strip() in {"1", "true", "yes"}
base_clauses = ["t.project_code is not null", "t.project_code <> ''"]
base_values: list[str] = []
if project_type and project_type != "전체":
base_clauses.append("coalesce(pm.project_type, t.project_type) = ?")
base_values.append(project_type)
if exclude_asset_accounts and MANAGEMENT_EXCLUDED_ACCOUNT_CODES:
excluded_placeholders = ",".join("?" for _ in MANAGEMENT_EXCLUDED_ACCOUNT_CODES)
base_clauses.append(f"coalesce(t.account_code_final, '') not in ({excluded_placeholders})")
base_values.extend(sorted(MANAGEMENT_EXCLUDED_ACCOUNT_CODES))
clauses = list(base_clauses)
values = list(base_values)
if date_from:
clauses.append("coalesce(t.transaction_date, '') >= ?")
values.append(date_from)
if date_to:
clauses.append("coalesce(t.transaction_date, '') <= ?")
values.append(date_to)
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(pb.revenue_budget_total, 0) as revenue_budget_total,
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 (
select project_code, coalesce(sum(budget_amount), 0) as revenue_budget_total
from project_budget_lines
where section = '수입'
group by project_code
) pb on pb.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, pb.revenue_budget_total, 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)
revenue_budget_total = float(row["revenue_budget_total"] 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,
"revenue_budget_total": revenue_budget_total,
"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)
loan_clauses = ["account_code_final = '196'"]
loan_values: list[str] = []
if date_from:
loan_clauses.append("coalesce(transaction_date, '') >= ?")
loan_values.append(date_from)
if date_to:
loan_clauses.append("coalesce(transaction_date, '') <= ?")
loan_values.append(date_to)
loan_where = " where " + " and ".join(loan_clauses)
loan_summary = conn.execute(
f"""
select
coalesce(sum(case when in_out = '입금' then supply_amount else 0 end), 0) as recovered_supply,
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as loaned_supply,
sum(case when in_out = '입금' then 1 else 0 end) as recovered_count,
sum(case when in_out = '출금' then 1 else 0 end) as loaned_count,
min(transaction_date) as min_date,
max(transaction_date) as max_date
from ptc_transactions
{loan_where}
""",
loan_values,
).fetchone()
loan_project_rows = conn.execute(
f"""
select
project_code,
max(project_name) as project_name,
coalesce(sum(case when in_out = '입금' then supply_amount else 0 end), 0) as recovered_supply,
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as loaned_supply,
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) - coalesce(sum(case when in_out = '입금' then supply_amount else 0 end), 0) as outstanding_supply,
count(*) as txn_count
from ptc_transactions
{loan_where}
group by project_code
having coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) > 0
order by outstanding_supply desc, loaned_supply desc, project_code desc
limit 8
""",
loan_values,
).fetchall()
loan_vendor_rows = conn.execute(
f"""
select
coalesce(vendor_name, '거래처없음') as vendor_name,
coalesce(sum(case when in_out = '입금' then supply_amount else 0 end), 0) as recovered_supply,
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as loaned_supply,
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) - coalesce(sum(case when in_out = '입금' then supply_amount else 0 end), 0) as outstanding_supply,
count(*) as txn_count
from ptc_transactions
{loan_where}
group by coalesce(vendor_name, '거래처없음')
having coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) > 0
order by outstanding_supply desc, loaned_supply desc, vendor_name
limit 8
""",
loan_values,
).fetchall()
loan_summary_dict = dict(loan_summary) if loan_summary else {}
recovered_supply = float(loan_summary_dict.get("recovered_supply") or 0)
loaned_supply = float(loan_summary_dict.get("loaned_supply") or 0)
loan_summary_dict["outstanding_supply"] = max(loaned_supply - recovered_supply, 0)
yearly_rows = conn.execute(
f"""
select
substr(coalesce(t.transaction_date, ''), 1, 4) as year,
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
from ptc_transactions t
left join project_master pm on pm.project_code = t.project_code
{where}
group by substr(coalesce(t.transaction_date, ''), 1, 4)
having coalesce(substr(coalesce(t.transaction_date, ''), 1, 4), '') <> ''
order by year asc
""",
values,
).fetchall()
yearly_items = []
for row in yearly_rows:
income_supply = float(row["income_supply"] or 0)
expense_supply = float(row["expense_supply"] or 0)
profit_supply = income_supply - expense_supply
yearly_items.append(
{
"year": row["year"],
"income_supply": income_supply,
"expense_supply": expense_supply,
"profit_supply": profit_supply,
"margin_rate": (profit_supply / income_supply * 100) if income_supply > 0 else 0.0,
}
)
month_anchor = datetime.today().replace(day=1)
ongoing_month_index = (month_anchor.year * 12 + month_anchor.month - 1) - 5
ongoing_start_year = ongoing_month_index // 12
ongoing_start_month = (ongoing_month_index % 12) + 1
ongoing_start = f"{ongoing_start_year:04d}-{ongoing_start_month:02d}-01"
selected_project_codes = {str(row["project_code"] or "").strip() for row in rows if str(row["project_code"] or "").strip()}
ongoing_rows = conn.execute(
f"""
select
t.project_code,
coalesce(pm.project_type, max(t.project_type)) as project_type,
coalesce(pm.project_name, max(t.project_name)) as project_name,
max(coalesce(t.transaction_date, '')) as latest_transaction_date
from ptc_transactions t
left join project_master pm on pm.project_code = t.project_code
{" where " + " and ".join(base_clauses)}
group by t.project_code, pm.project_name, pm.project_type
""",
base_values,
).fetchall()
ongoing_project_count = 0
ongoing_project_codes: list[str] = []
for row in ongoing_rows:
project_code = str(row["project_code"] or "").strip()
project_type_value = str(row["project_type"] or "").strip()
project_name = str(row["project_name"] or "").strip()
latest_transaction_date = str(row["latest_transaction_date"] or "").strip()
if not (
"-시공-" in project_code
and "-관리-" not in project_code
and "-설계-" not in project_code
and "시공관리" not in project_name
and project_type_value not in {"관리", "설계"}
):
continue
if selected_project_codes and project_code not in selected_project_codes:
continue
if latest_transaction_date >= ongoing_start:
ongoing_project_count += 1
ongoing_project_codes.append(project_code)
self._send(
200,
{
"overview": overview,
"yearly_overview": yearly_items,
"ongoing_project_count": ongoing_project_count,
"ongoing_project_codes": ongoing_project_codes,
"ongoing_window_start": ongoing_start,
"amount_buckets": amount_buckets,
"status_bands": status_bands,
"date_from": date_from,
"date_to": date_to,
"loan_risk": {
"summary": loan_summary_dict,
"projects": rows_to_dicts(loan_project_rows),
"vendors": rows_to_dicts(loan_vendor_rows),
},
"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"
).fetchall()
self._send(200, {"items": [row["project_type"] for row in rows]})
return
if parsed.path == "/api/projects":
keyword = params.get("keyword", [""])[0].strip().lower()
project_type = params.get("project_type", ["전체"])[0]
date_from = params.get("date_from", [""])[0].strip()
date_to = params.get("date_to", [""])[0].strip()
clauses = ["project_code is not null", "project_code <> ''"]
values = []
if PROJECT_VIEW_EXCLUDED_ACCOUNT_CODES:
excluded_placeholders = ",".join("?" for _ in PROJECT_VIEW_EXCLUDED_ACCOUNT_CODES)
clauses.append(f"coalesce(account_code_final, '') not in ({excluded_placeholders})")
values.extend(sorted(PROJECT_VIEW_EXCLUDED_ACCOUNT_CODES))
if project_type and project_type != "전체":
clauses.append("project_type = ?")
values.append(project_type)
if date_from:
clauses.append("coalesce(transaction_date, '') >= ?")
values.append(date_from)
if date_to:
clauses.append("coalesce(transaction_date, '') <= ?")
values.append(date_to)
where = " where " + " and ".join(clauses)
rows = conn.execute(
f"""
select
project_code,
max(project_name) as project_name,
max(project_type) as project_type,
count(*) as txn_count,
coalesce(sum(supply_amount), 0) as supply_sum,
min(transaction_date) as min_date,
max(transaction_date) as max_date
from ptc_transactions
{where}
group by project_code
order by supply_sum desc, project_code
""",
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 = master_rows.get(item["project_code"])
item["project_type"] = resolve_project_type(
item["project_code"],
item["project_type"],
master.get("project_type") if master else "",
)
if master:
item["project_name"] = master.get("project_name") or item["project_name"]
item["construction_family"] = resolve_construction_family(
master.get("construction_method"),
master.get("construction_family"),
)
item["construction_method"] = master.get("construction_method") or ""
item["note"] = master.get("note") or ""
else:
item["construction_family"] = ""
item["construction_method"] = ""
item["note"] = ""
related_projects = build_related_projects(
conn,
item["project_code"],
item.get("project_name") or "",
)
related_search_terms = []
for rel in related_projects:
related_search_terms.extend(
[
rel.get("project_code") or "",
rel.get("project_name") or "",
rel.get("project_type") or "",
]
)
item["related_projects"] = related_projects
item["related_search_text"] = " ".join(
filter(
None,
[
item.get("project_code") or "",
item.get("project_name") or "",
item.get("project_type") or "",
*related_search_terms,
],
)
).lower()
if keyword:
items = [
item
for item in items
if keyword in (item.get("related_search_text") or "")
]
self._send(200, {"items": items})
return
if parsed.path == "/api/vendors":
keyword = params.get("keyword", [""])[0].strip().lower()
date_from = params.get("date_from", [""])[0].strip()
date_to = params.get("date_to", [""])[0].strip()
clauses = ["coalesce(vendor_name, '') <> ''"]
values: list[str] = []
if keyword:
clauses.append(
"""
(
lower(coalesce(vendor_name, '')) like ?
or lower(coalesce(account_code_final, '')) like ?
or lower(coalesce(account_name_final, '')) like ?
)
"""
)
like = f"%{keyword}%"
values.extend([like, like, like])
if date_from:
clauses.append("coalesce(transaction_date, '') >= ?")
values.append(date_from)
if date_to:
clauses.append("coalesce(transaction_date, '') <= ?")
values.append(date_to)
where = f"where {' and '.join(clauses)}"
rows = conn.execute(
f"""
select
vendor_name,
count(*) as txn_count,
coalesce(sum(supply_amount), 0) as supply_sum,
min(transaction_date) as min_date,
max(transaction_date) as max_date
from ptc_transactions
{where}
group by vendor_name
order by supply_sum desc, vendor_name
""",
values,
).fetchall()
self._send(200, {"items": rows_to_dicts(rows)})
return
if parsed.path == "/api/accounts":
keyword = params.get("keyword", [""])[0].strip().lower()
date_from = params.get("date_from", [""])[0].strip()
date_to = params.get("date_to", [""])[0].strip()
clauses = ["coalesce(account_code_final, '') <> ''"]
values: list[str] = []
if keyword:
clauses.append(
"""
(
lower(coalesce(account_code_final, '')) like ?
or lower(coalesce(account_name_final, '')) like ?
or lower(coalesce(vendor_name, '')) like ?
)
"""
)
like = f"%{keyword}%"
values.extend([like, like, like])
if date_from:
clauses.append("coalesce(transaction_date, '') >= ?")
values.append(date_from)
if date_to:
clauses.append("coalesce(transaction_date, '') <= ?")
values.append(date_to)
where = f"where {' and '.join(clauses)}"
rows = conn.execute(
f"""
select
account_code_final as account_code,
account_name_final as account_name,
count(*) as txn_count,
coalesce(sum(supply_amount), 0) as supply_sum,
min(transaction_date) as min_date,
max(transaction_date) as max_date
from ptc_transactions
{where}
group by account_code_final, account_name_final
order by cast(account_code_final as integer) asc, account_code_final asc
""",
values,
).fetchall()
self._send(200, {"items": rows_to_dicts(rows)})
return
if parsed.path == "/api/management-accounts":
keyword = params.get("keyword", [""])[0].strip().lower()
date_from = params.get("date_from", [""])[0].strip()
date_to = params.get("date_to", [""])[0].strip()
clauses = [
"coalesce(account_code_final, '') <> ''",
"(coalesce(project_type, '') = '관리' or coalesce(project_code, '') like '%-관리-%')",
]
values: list[str] = []
if MANAGEMENT_EXCLUDED_ACCOUNT_CODES:
excluded_placeholders = ",".join("?" for _ in MANAGEMENT_EXCLUDED_ACCOUNT_CODES)
clauses.append(f"coalesce(account_code_final, '') not in ({excluded_placeholders})")
values.extend(sorted(MANAGEMENT_EXCLUDED_ACCOUNT_CODES))
if keyword:
clauses.append(
"""
(
lower(coalesce(account_code_final, '')) like ?
or lower(coalesce(account_name_final, '')) like ?
or lower(coalesce(vendor_name, '')) like ?
)
"""
)
like = f"%{keyword}%"
values.extend([like, like, like])
if date_from:
clauses.append("coalesce(transaction_date, '') >= ?")
values.append(date_from)
if date_to:
clauses.append("coalesce(transaction_date, '') <= ?")
values.append(date_to)
where = f"where {' and '.join(clauses)}"
rows = conn.execute(
f"""
select
account_code_final as account_code,
account_name_final as account_name,
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_supply_sum,
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply_sum,
coalesce(sum(supply_amount), 0) as supply_sum,
min(transaction_date) as min_date,
max(transaction_date) as max_date
from ptc_transactions
{where}
group by account_code_final, account_name_final
order by cast(account_code_final as integer) asc, account_code_final asc
""",
values,
).fetchall()
self._send(200, {"items": rows_to_dicts(rows)})
return
if parsed.path == "/api/management-overview":
date_from = params.get("date_from", [""])[0].strip()
date_to = params.get("date_to", [""])[0].strip()
clauses = [
"(coalesce(project_type, '') = '관리' or coalesce(project_code, '') like '%-관리-%')",
"coalesce(account_code_final, '') <> ''",
]
values: list[str] = []
if MANAGEMENT_EXCLUDED_ACCOUNT_CODES:
excluded_placeholders = ",".join("?" for _ in MANAGEMENT_EXCLUDED_ACCOUNT_CODES)
clauses.append(f"coalesce(account_code_final, '') not in ({excluded_placeholders})")
values.extend(sorted(MANAGEMENT_EXCLUDED_ACCOUNT_CODES))
if date_from:
clauses.append("coalesce(transaction_date, '') >= ?")
values.append(date_from)
if date_to:
clauses.append("coalesce(transaction_date, '') <= ?")
values.append(date_to)
where = f"where {' and '.join(clauses)}"
rows = conn.execute(
f"""
select
substr(coalesce(transaction_date, ''), 1, 4) as year,
account_code_final as account_code,
coalesce(sum(case when in_out = '입금' then supply_amount else 0 end), 0) as income_supply,
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply
from ptc_transactions
{where}
group by substr(coalesce(transaction_date, ''), 1, 4), account_code_final
order by year asc, account_code_final asc
""",
values,
).fetchall()
excluded_rows = []
if MANAGEMENT_EXCLUDED_ACCOUNT_CODES:
excluded_placeholders = ",".join("?" for _ in MANAGEMENT_EXCLUDED_ACCOUNT_CODES)
excluded_clauses = [
"(coalesce(project_type, '') = '관리' or coalesce(project_code, '') like '%-관리-%')",
"coalesce(account_code_final, '') in (" + excluded_placeholders + ")",
]
excluded_values: list[str] = list(sorted(MANAGEMENT_EXCLUDED_ACCOUNT_CODES))
if date_from:
excluded_clauses.append("coalesce(transaction_date, '') >= ?")
excluded_values.append(date_from)
if date_to:
excluded_clauses.append("coalesce(transaction_date, '') <= ?")
excluded_values.append(date_to)
excluded_where = f"where {' and '.join(excluded_clauses)}"
excluded_rows = conn.execute(
f"""
select
substr(coalesce(transaction_date, ''), 1, 4) as year,
account_code_final as account_code,
coalesce(max(account_name_final), '') as account_name,
coalesce(sum(case when in_out = '입금' then supply_amount else 0 end), 0) as income_supply,
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply,
count(*) as txn_count
from ptc_transactions
{excluded_where}
group by substr(coalesce(transaction_date, ''), 1, 4), account_code_final
order by year asc, cast(account_code_final as integer) asc, account_code_final asc
""",
excluded_values,
).fetchall()
by_year: dict[str, dict] = {}
for row in rows:
year = (row["year"] or "").strip() or "미상"
account_code = (row["account_code"] or "").strip()
master = ACCOUNT_MASTER.get(account_code) or {}
if master.get("project_type") != "관리":
continue
category = master.get("category") or "기타"
if category not in MANAGEMENT_ACCOUNT_CATEGORY_ORDER:
continue
if year not in by_year:
by_year[year] = {
"year": year,
"income_supply": 0.0,
"total_expense": 0.0,
"project_applied_admin_expense": 0.0,
"common_admin_expense": 0.0,
"categories": {key: 0.0 for key in MANAGEMENT_ACCOUNT_CATEGORY_ORDER},
"excluded_total": 0.0,
"excluded_income_total": 0.0,
"excluded_expense_total": 0.0,
"excluded_accounts": [],
}
income_amount = float(row["income_supply"] or 0)
amount = float(row["expense_supply"] or 0)
by_year[year]["income_supply"] += income_amount
by_year[year]["total_expense"] += amount
by_year[year]["categories"][category] += amount
for row in excluded_rows:
year = (row["year"] or "").strip() or "미상"
if year not in by_year:
by_year[year] = {
"year": year,
"income_supply": 0.0,
"total_expense": 0.0,
"project_applied_admin_expense": 0.0,
"common_admin_expense": 0.0,
"categories": {key: 0.0 for key in MANAGEMENT_ACCOUNT_CATEGORY_ORDER},
"excluded_total": 0.0,
"excluded_income_total": 0.0,
"excluded_expense_total": 0.0,
"excluded_accounts": [],
}
income_supply = float(row["income_supply"] or 0)
expense_supply = float(row["expense_supply"] or 0)
total_supply = income_supply + expense_supply
by_year[year]["excluded_total"] += total_supply
by_year[year]["excluded_income_total"] += income_supply
by_year[year]["excluded_expense_total"] += expense_supply
by_year[year]["excluded_accounts"].append(
{
"account_code": row["account_code"],
"account_name": row["account_name"] or (ACCOUNT_MASTER.get((row["account_code"] or "").strip()) or {}).get("name") or "",
"income_supply": income_supply,
"expense_supply": expense_supply,
"total_supply": total_supply,
"txn_count": int(row["txn_count"] or 0),
}
)
split_clauses = ["coalesce(account_code_final, '') <> ''"]
split_values: list[str] = []
if date_from:
split_clauses.append("coalesce(transaction_date, '') >= ?")
split_values.append(date_from)
if date_to:
split_clauses.append("coalesce(transaction_date, '') <= ?")
split_values.append(date_to)
split_where = f"where {' and '.join(split_clauses)}"
management_split_rows = conn.execute(
f"""
select
substr(coalesce(transaction_date, ''), 1, 4) as year,
coalesce(project_code, '') as project_code,
account_code_final as account_code,
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply
from ptc_transactions
{split_where}
group by substr(coalesce(transaction_date, ''), 1, 4), coalesce(project_code, ''), account_code_final
having year <> ''
""",
split_values,
).fetchall()
relation_rows = conn.execute(
"""
select
pr.project_code as project_code,
pr.related_project_code as related_project_code,
coalesce(pm.project_type, '') as project_type,
coalesce(rpm.project_type, '') as related_project_type
from project_relations pr
left join project_master pm on pm.project_code = pr.project_code
left join project_master rpm on rpm.project_code = pr.related_project_code
"""
).fetchall()
connected_codes: set[str] = set()
for rel in relation_rows:
left_code = (rel["project_code"] or "").strip()
right_code = (rel["related_project_code"] or "").strip()
left_type = (rel["project_type"] or "").strip()
right_type = (rel["related_project_type"] or "").strip()
if left_type == "시공" and right_code:
connected_codes.add(right_code)
connected_codes.add(left_code)
if right_type == "시공" and left_code:
connected_codes.add(left_code)
connected_codes.add(right_code)
for row in management_split_rows:
year = (row["year"] or "").strip() or "미상"
project_code = (row["project_code"] or "").strip()
account_code = (row["account_code"] or "").strip()
expense_supply = float(row["expense_supply"] or 0)
if not account_code or expense_supply == 0:
continue
master = ACCOUNT_MASTER.get(account_code) or {}
if master.get("project_type") != "관리":
continue
if (master.get("category") or "") == "인건비":
continue
if year not in by_year:
by_year[year] = {
"year": year,
"income_supply": 0.0,
"total_expense": 0.0,
"project_applied_admin_expense": 0.0,
"common_admin_expense": 0.0,
"categories": {key: 0.0 for key in MANAGEMENT_ACCOUNT_CATEGORY_ORDER},
"excluded_total": 0.0,
"excluded_income_total": 0.0,
"excluded_expense_total": 0.0,
"excluded_accounts": [],
}
is_project_applied = (
(bool(project_code) and "-시공-" in project_code)
or (project_code in connected_codes)
)
if is_project_applied:
by_year[year]["project_applied_admin_expense"] += expense_supply
else:
by_year[year]["common_admin_expense"] += expense_supply
items = []
for year in sorted(by_year.keys()):
item = by_year[year]
items.append({
"year": item["year"],
"income_supply": item["income_supply"],
"total_expense": item["total_expense"],
"project_applied_admin_expense": item["project_applied_admin_expense"],
"common_admin_expense": item["common_admin_expense"],
"categories": [
{"name": category, "amount": item["categories"][category]}
for category in MANAGEMENT_ACCOUNT_CATEGORY_ORDER
],
"excluded_total": item["excluded_total"],
"excluded_income_total": item["excluded_income_total"],
"excluded_expense_total": item["excluded_expense_total"],
"excluded_accounts": item["excluded_accounts"],
})
company_clauses = ["coalesce(account_code_final, '') <> ''"]
company_values: list[str] = []
if MANAGEMENT_EXCLUDED_ACCOUNT_CODES:
company_excluded_placeholders = ",".join("?" for _ in MANAGEMENT_EXCLUDED_ACCOUNT_CODES)
company_clauses.append(f"coalesce(account_code_final, '') not in ({company_excluded_placeholders})")
company_values.extend(sorted(MANAGEMENT_EXCLUDED_ACCOUNT_CODES))
if date_from:
company_clauses.append("coalesce(transaction_date, '') >= ?")
company_values.append(date_from)
if date_to:
company_clauses.append("coalesce(transaction_date, '') <= ?")
company_values.append(date_to)
company_where = f"where {' and '.join(company_clauses)}"
yearly_profit_rows = conn.execute(
f"""
select
substr(coalesce(transaction_date, ''), 1, 4) as year,
coalesce(sum(case when in_out = '입금' then supply_amount else 0 end), 0) as income_supply,
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply
from ptc_transactions
{company_where}
group by substr(coalesce(transaction_date, ''), 1, 4)
having coalesce(substr(coalesce(transaction_date, ''), 1, 4), '') <> ''
order by year asc
""",
company_values,
).fetchall()
yearly_profit_items = []
for row in yearly_profit_rows:
income_supply = float(row["income_supply"] or 0)
expense_supply = float(row["expense_supply"] or 0)
yearly_profit_items.append(
{
"year": row["year"],
"income_supply": income_supply,
"expense_supply": expense_supply,
"profit_supply": income_supply - expense_supply,
}
)
construction_clauses = [
"coalesce(account_code_final, '') <> ''",
"coalesce(project_code, '') like '%-시공-%'",
"coalesce(project_name, '') not like '%시공관리%'",
"coalesce(project_type, '') not in ('관리', '설계')",
]
construction_values: list[str] = []
if MANAGEMENT_EXCLUDED_ACCOUNT_CODES:
construction_excluded_placeholders = ",".join("?" for _ in MANAGEMENT_EXCLUDED_ACCOUNT_CODES)
construction_clauses.append(f"coalesce(account_code_final, '') not in ({construction_excluded_placeholders})")
construction_values.extend(sorted(MANAGEMENT_EXCLUDED_ACCOUNT_CODES))
if date_from:
construction_clauses.append("coalesce(transaction_date, '') >= ?")
construction_values.append(date_from)
if date_to:
construction_clauses.append("coalesce(transaction_date, '') <= ?")
construction_values.append(date_to)
construction_where = f"where {' and '.join(construction_clauses)}"
yearly_construction_rows = conn.execute(
f"""
select
substr(coalesce(transaction_date, ''), 1, 4) as year,
coalesce(sum(case when in_out = '입금' then supply_amount else 0 end), 0) as income_supply,
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply
from ptc_transactions
{construction_where}
group by substr(coalesce(transaction_date, ''), 1, 4)
having coalesce(substr(coalesce(transaction_date, ''), 1, 4), '') <> ''
order by year asc
""",
construction_values,
).fetchall()
yearly_construction_margin_items = []
for row in yearly_construction_rows:
income_supply = float(row["income_supply"] or 0)
expense_supply = float(row["expense_supply"] or 0)
profit_supply = income_supply - expense_supply
yearly_construction_margin_items.append(
{
"year": row["year"],
"income_supply": income_supply,
"expense_supply": expense_supply,
"profit_supply": profit_supply,
"margin_rate": (profit_supply / income_supply * 100) if income_supply > 0 else 0.0,
}
)
self._send(
200,
{
"items": items,
"category_order": MANAGEMENT_ACCOUNT_CATEGORY_ORDER,
"yearly_profit_items": yearly_profit_items,
"yearly_construction_margin_items": yearly_construction_margin_items,
},
)
return
if parsed.path == "/api/management-company-overview":
date_from = params.get("date_from", [""])[0].strip()
date_to = params.get("date_to", [""])[0].strip()
project_type_case = """
case
when coalesce(t.project_code, '') like '%-시공-%' then '시공'
when coalesce(t.project_code, '') like '%-영업-%' then '영업'
when coalesce(t.project_code, '') like '%-설계-%' then '설계'
when coalesce(t.project_code, '') like '%-관리-%' then '관리'
else coalesce(pm.project_type, t.project_type, '미지정')
end
"""
clauses = ["coalesce(t.account_code_final, '') <> ''"]
values: list[str] = []
if MANAGEMENT_EXCLUDED_ACCOUNT_CODES:
excluded_placeholders = ",".join("?" for _ in MANAGEMENT_EXCLUDED_ACCOUNT_CODES)
clauses.append(f"coalesce(t.account_code_final, '') not in ({excluded_placeholders})")
values.extend(sorted(MANAGEMENT_EXCLUDED_ACCOUNT_CODES))
if date_from:
clauses.append("coalesce(t.transaction_date, '') >= ?")
values.append(date_from)
if date_to:
clauses.append("coalesce(t.transaction_date, '') <= ?")
values.append(date_to)
where = f"where {' and '.join(clauses)}"
rows = conn.execute(
f"""
select
substr(coalesce(t.transaction_date, ''), 1, 4) as year,
{project_type_case} as project_type,
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
from ptc_transactions t
left join project_master pm on pm.project_code = t.project_code
{where}
group by substr(coalesce(t.transaction_date, ''), 1, 4), {project_type_case}
having coalesce(substr(coalesce(t.transaction_date, ''), 1, 4), '') <> ''
order by year asc, project_type asc
""",
values,
).fetchall()
preferred_types = ["시공", "영업", "설계", "관리", "미지정"]
by_year: dict[str, dict] = {}
discovered_types: set[str] = set()
for row in rows:
year = (row["year"] or "").strip() or "미상"
project_type = (row["project_type"] or "").strip() or "미지정"
discovered_types.add(project_type)
if year not in by_year:
by_year[year] = {
"year": year,
"income_supply": 0.0,
"expense_supply": 0.0,
"types": {},
}
income_supply = float(row["income_supply"] or 0)
expense_supply = float(row["expense_supply"] or 0)
by_year[year]["income_supply"] += income_supply
by_year[year]["expense_supply"] += expense_supply
by_year[year]["types"][project_type] = {
"project_type": project_type,
"income_supply": income_supply,
"expense_supply": expense_supply,
}
ordered_types = [item for item in preferred_types if item in discovered_types]
ordered_types.extend(sorted(discovered_types - set(ordered_types)))
items = []
for year in sorted(by_year.keys()):
item = by_year[year]
total_income = float(item["income_supply"] or 0)
total_expense = float(item["expense_supply"] or 0)
type_items = []
for project_type in ordered_types:
type_item = item["types"].get(project_type) or {
"project_type": project_type,
"income_supply": 0.0,
"expense_supply": 0.0,
}
type_items.append(
{
**type_item,
"income_ratio": (float(type_item["income_supply"] or 0) / total_income * 100) if total_income > 0 else 0.0,
"expense_ratio": (float(type_item["expense_supply"] or 0) / total_expense * 100) if total_expense > 0 else 0.0,
}
)
items.append(
{
"year": year,
"income_supply": total_income,
"expense_supply": total_expense,
"profit_supply": total_income - total_expense,
"margin_rate": ((total_income - total_expense) / total_income * 100) if total_income > 0 else 0.0,
"types": type_items,
}
)
self._send(200, {"project_type_order": ordered_types, "items": items})
return
if parsed.path == "/api/management-overview-accounts":
year = params.get("year", [""])[0].strip()
category = params.get("category", [""])[0].strip()
date_from = params.get("date_from", [""])[0].strip()
date_to = params.get("date_to", [""])[0].strip()
if not year or not category:
self._send(400, {"ok": False, "message": "year and category are required"})
return
if category not in MANAGEMENT_ACCOUNT_CATEGORY_ORDER:
self._send(400, {"ok": False, "message": "invalid category"})
return
category_codes = [
code
for code, meta in ACCOUNT_MASTER.items()
if meta.get("project_type") == "관리" and meta.get("category") == category
]
if not category_codes:
self._send(200, {"items": []})
return
placeholders = ",".join("?" for _ in category_codes)
clauses = [
"(coalesce(project_type, '') = '관리' or coalesce(project_code, '') like '%-관리-%')",
"in_out = '출금'",
"substr(coalesce(transaction_date, ''), 1, 4) = ?",
f"account_code_final in ({placeholders})",
]
values: list[str] = [year, *category_codes]
if MANAGEMENT_EXCLUDED_ACCOUNT_CODES:
excluded_placeholders = ",".join("?" for _ in MANAGEMENT_EXCLUDED_ACCOUNT_CODES)
clauses.append(f"coalesce(account_code_final, '') not in ({excluded_placeholders})")
values.extend(sorted(MANAGEMENT_EXCLUDED_ACCOUNT_CODES))
if date_from:
clauses.append("coalesce(transaction_date, '') >= ?")
values.append(date_from)
if date_to:
clauses.append("coalesce(transaction_date, '') <= ?")
values.append(date_to)
where = " where " + " and ".join(clauses)
rows = conn.execute(
f"""
select
account_code_final as account_code,
max(account_name_final) as account_name,
count(*) as transaction_count,
coalesce(sum(supply_amount), 0) as expense_amount
from ptc_transactions
{where}
group by account_code_final
order by expense_amount desc, account_code_final asc
""",
values,
).fetchall()
self._send(200, {"items": rows_to_dicts(rows)})
return
if parsed.path == "/api/management-company-accounts":
year = params.get("year", [""])[0].strip()
project_type = params.get("project_type", [""])[0].strip()
if not year or not project_type:
self._send(400, {"ok": False, "message": "year and project_type are required"})
return
project_type_case = """
case
when coalesce(t.project_code, '') like '%-시공-%' then '시공'
when coalesce(t.project_code, '') like '%-영업-%' then '영업'
when coalesce(t.project_code, '') like '%-설계-%' then '설계'
when coalesce(t.project_code, '') like '%-관리-%' then '관리'
else coalesce(pm.project_type, t.project_type, '미지정')
end
"""
clauses = [
"substr(coalesce(t.transaction_date, ''), 1, 4) = ?",
"coalesce(t.account_code_final, '') <> ''",
f"{project_type_case} = ?",
]
values: list[str] = [year, project_type]
if MANAGEMENT_EXCLUDED_ACCOUNT_CODES:
excluded_placeholders = ",".join("?" for _ in MANAGEMENT_EXCLUDED_ACCOUNT_CODES)
clauses.append(f"coalesce(t.account_code_final, '') not in ({excluded_placeholders})")
values.extend(sorted(MANAGEMENT_EXCLUDED_ACCOUNT_CODES))
where = f"where {' and '.join(clauses)}"
rows = conn.execute(
f"""
select
t.account_code_final as account_code,
max(t.account_name_final) as account_name,
sum(case when t.in_out = '입금' then 1 else 0 end) as income_count,
sum(case when t.in_out = '출금' then 1 else 0 end) as expense_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
{where}
group by t.account_code_final
order by (coalesce(sum(case when t.in_out = '입금' then t.supply_amount else 0 end), 0) +
coalesce(sum(case when t.in_out = '출금' then t.supply_amount else 0 end), 0)) desc,
t.account_code_final asc
""",
values,
).fetchall()
self._send(200, {"items": rows_to_dicts(rows)})
return
if parsed.path == "/api/company-account-detail":
year = params.get("year", [""])[0].strip()
project_type = params.get("project_type", [""])[0].strip()
account_code = params.get("account_code", [""])[0].strip()
if not year or not project_type or not account_code:
self._send(400, {"ok": False, "message": "year, project_type and account_code are required"})
return
project_type_case = """
case
when coalesce(t.project_code, '') like '%-시공-%' then '시공'
when coalesce(t.project_code, '') like '%-영업-%' then '영업'
when coalesce(t.project_code, '') like '%-설계-%' then '설계'
when coalesce(t.project_code, '') like '%-관리-%' then '관리'
else coalesce(pm.project_type, t.project_type, '미지정')
end
"""
detail_clauses = [
"substr(coalesce(t.transaction_date, ''), 1, 4) = ?",
f"{project_type_case} = ?",
"coalesce(t.account_code_final, '') = ?",
]
detail_values: list[str] = [year, project_type, account_code]
if MANAGEMENT_EXCLUDED_ACCOUNT_CODES:
excluded_placeholders = ",".join("?" for _ in MANAGEMENT_EXCLUDED_ACCOUNT_CODES)
detail_clauses.append(f"coalesce(t.account_code_final, '') not in ({excluded_placeholders})")
detail_values.extend(sorted(MANAGEMENT_EXCLUDED_ACCOUNT_CODES))
detail_where = " where " + " and ".join(detail_clauses)
summary = conn.execute(
f"""
select
t.account_code_final as account_code,
max(t.account_name_final) as account_name,
count(*) as txn_count,
sum(case when t.in_out = '입금' then 1 else 0 end) as income_count,
sum(case when t.in_out = '출금' then 1 else 0 end) as expense_count,
coalesce(sum(case when t.in_out = '입금' then t.supply_amount else 0 end), 0) as income_supply_sum,
coalesce(sum(case when t.in_out = '출금' then t.supply_amount else 0 end), 0) as expense_supply_sum,
coalesce(sum(t.supply_amount), 0) as supply_sum,
min(t.transaction_date) as min_date,
max(t.transaction_date) as max_date
from ptc_transactions t
left join project_master pm on pm.project_code = t.project_code
{detail_where}
group by t.account_code_final
""",
detail_values,
).fetchone()
project_rows = conn.execute(
f"""
select
t.project_code,
max(t.project_name) as project_name,
count(*) as txn_count,
sum(case when t.in_out = '입금' then 1 else 0 end) as income_count,
sum(case when t.in_out = '출금' then 1 else 0 end) as expense_count,
coalesce(sum(case when t.in_out = '입금' then t.supply_amount else 0 end), 0) as income_supply_sum,
coalesce(sum(case when t.in_out = '출금' then t.supply_amount else 0 end), 0) as expense_supply_sum,
coalesce(sum(t.supply_amount), 0) as supply_sum
from ptc_transactions t
left join project_master pm on pm.project_code = t.project_code
{detail_where}
group by t.project_code
order by supply_sum desc, t.project_code desc
limit 50
""",
detail_values,
).fetchall()
transaction_rows = conn.execute(
f"""
select
t.source_row_no,
t.transaction_date,
t.in_out,
t.project_code,
t.project_name,
t.vendor_name,
t.description,
t.supply_amount
from ptc_transactions t
left join project_master pm on pm.project_code = t.project_code
{detail_where}
order by t.transaction_date desc, t.source_row_no desc
limit 100
""",
detail_values,
).fetchall()
allocated_projects, allocation_meta = build_company_allocated_project_rows(
conn, project_rows, project_type
)
self._send(
200,
{
"summary": dict(summary) if summary else None,
"projects": allocated_projects,
"project_allocation": allocation_meta,
"transactions": rows_to_dicts(transaction_rows),
},
)
return
if parsed.path == "/api/account-detail":
account_code = params.get("account_code", [""])[0].strip()
project_code = params.get("project_code", [""])[0].strip()
date_from = params.get("date_from", [""])[0].strip()
date_to = params.get("date_to", [""])[0].strip()
if not account_code:
self._send(400, {"ok": False, "message": "account_code is required"})
return
detail_clauses = ["account_code_final = ?"]
detail_values: list[str] = [account_code]
if project_code:
detail_clauses.append("project_code = ?")
detail_values.append(project_code)
if date_from:
detail_clauses.append("coalesce(transaction_date, '') >= ?")
detail_values.append(date_from)
if date_to:
detail_clauses.append("coalesce(transaction_date, '') <= ?")
detail_values.append(date_to)
detail_where = " where " + " and ".join(detail_clauses)
summary = conn.execute(
f"""
select
account_code_final as account_code,
account_name_final as account_name,
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_supply_sum,
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply_sum,
coalesce(sum(supply_amount), 0) as supply_sum,
min(transaction_date) as min_date,
max(transaction_date) as max_date
from ptc_transactions
{detail_where}
group by account_code_final, account_name_final
""",
detail_values,
).fetchone()
project_rows = conn.execute(
f"""
select
project_code,
max(project_name) as project_name,
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_supply_sum,
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply_sum,
coalesce(sum(supply_amount), 0) as supply_sum
from ptc_transactions
{detail_where}
group by project_code
order by supply_sum desc, project_code
limit 30
""",
detail_values,
).fetchall()
vendor_rows = conn.execute(
f"""
select
vendor_name,
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_supply_sum,
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply_sum,
coalesce(sum(supply_amount), 0) as supply_sum
from ptc_transactions
{detail_where}
group by vendor_name
order by supply_sum desc, vendor_name
limit 30
""",
detail_values,
).fetchall()
transaction_rows = conn.execute(
f"""
select
source_row_no,
transaction_date,
in_out,
project_code,
project_name,
vendor_name,
department_name,
description,
supply_amount
from ptc_transactions
{detail_where}
order by transaction_date desc, source_row_no desc
limit 100
""",
detail_values,
).fetchall()
self._send(
200,
{
"summary": dict(summary) if summary else None,
"projects": rows_to_dicts(project_rows),
"vendors": rows_to_dicts(vendor_rows),
"transactions": rows_to_dicts(transaction_rows),
},
)
return
if parsed.path == "/api/management-account-detail":
account_code = params.get("account_code", [""])[0].strip()
date_from = params.get("date_from", [""])[0].strip()
date_to = params.get("date_to", [""])[0].strip()
if not account_code:
self._send(400, {"ok": False, "message": "account_code is required"})
return
detail_clauses = [
"account_code_final = ?",
"(coalesce(project_type, '') = '관리' or coalesce(project_code, '') like '%-관리-%')",
]
detail_values: list[str] = [account_code]
if MANAGEMENT_EXCLUDED_ACCOUNT_CODES:
excluded_placeholders = ",".join("?" for _ in MANAGEMENT_EXCLUDED_ACCOUNT_CODES)
detail_clauses.append(f"coalesce(account_code_final, '') not in ({excluded_placeholders})")
detail_values.extend(sorted(MANAGEMENT_EXCLUDED_ACCOUNT_CODES))
if date_from:
detail_clauses.append("coalesce(transaction_date, '') >= ?")
detail_values.append(date_from)
if date_to:
detail_clauses.append("coalesce(transaction_date, '') <= ?")
detail_values.append(date_to)
detail_where = " where " + " and ".join(detail_clauses)
summary = conn.execute(
f"""
select
account_code_final as account_code,
account_name_final as account_name,
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_supply_sum,
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply_sum,
coalesce(sum(supply_amount), 0) as supply_sum,
min(transaction_date) as min_date,
max(transaction_date) as max_date
from ptc_transactions
{detail_where}
group by account_code_final, account_name_final
""",
detail_values,
).fetchone()
project_rows = conn.execute(
f"""
select
project_code,
max(project_name) as project_name,
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_supply_sum,
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply_sum,
coalesce(sum(supply_amount), 0) as supply_sum
from ptc_transactions
{detail_where}
group by project_code
order by supply_sum desc, project_code desc
limit 50
""",
detail_values,
).fetchall()
transaction_rows = conn.execute(
f"""
select
source_row_no,
transaction_date,
in_out,
project_code,
project_name,
vendor_name,
department_name,
description,
supply_amount
from ptc_transactions
{detail_where}
order by transaction_date desc, source_row_no desc
limit 100
""",
detail_values,
).fetchall()
self._send(
200,
{
"summary": dict(summary) if summary else None,
"projects": rows_to_dicts(project_rows),
"transactions": rows_to_dicts(transaction_rows),
},
)
return
if parsed.path == "/api/lifecycle-account-detail":
project_code = params.get("project_code", [""])[0].strip()
bucket_label = params.get("bucket_label", [""])[0].strip()
account_code = params.get("account_code", [""])[0].strip()
if not project_code or not bucket_label or not account_code:
self._send(400, {"ok": False, "message": "project_code, bucket_label and account_code are required"})
return
master = fetch_project_master(conn, project_code) or fetch_project_defaults(conn, project_code)
project_name = (master or {}).get("project_name") or ""
resolved_project_type = resolve_project_type(project_code, (master or {}).get("project_type") or "")
related_projects = build_related_projects(conn, project_code, project_name)
detail = build_lifecycle_account_detail(
conn,
related_projects,
project_code,
resolved_project_type,
bucket_label,
account_code,
)
if not detail:
self._send(404, {"ok": False, "message": "lifecycle account detail not found"})
return
self._send(200, detail)
return
if parsed.path == "/api/management-excluded-account-detail":
account_code = params.get("account_code", [""])[0].strip()
date_from = params.get("date_from", [""])[0].strip()
date_to = params.get("date_to", [""])[0].strip()
if not account_code:
self._send(400, {"ok": False, "message": "account_code is required"})
return
detail_clauses = [
"account_code_final = ?",
"(coalesce(project_type, '') = '관리' or coalesce(project_code, '') like '%-관리-%')",
]
detail_values: list[str] = [account_code]
if date_from:
detail_clauses.append("coalesce(transaction_date, '') >= ?")
detail_values.append(date_from)
if date_to:
detail_clauses.append("coalesce(transaction_date, '') <= ?")
detail_values.append(date_to)
detail_where = " where " + " and ".join(detail_clauses)
summary = conn.execute(
f"""
select
account_code_final as account_code,
account_name_final as account_name,
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_supply_sum,
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply_sum,
coalesce(sum(supply_amount), 0) as supply_sum,
min(transaction_date) as min_date,
max(transaction_date) as max_date
from ptc_transactions
{detail_where}
group by account_code_final, account_name_final
""",
detail_values,
).fetchone()
project_rows = conn.execute(
f"""
select
project_code,
max(project_name) as project_name,
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_supply_sum,
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply_sum,
coalesce(sum(supply_amount), 0) as supply_sum
from ptc_transactions
{detail_where}
group by project_code
order by supply_sum desc, project_code desc
limit 50
""",
detail_values,
).fetchall()
transaction_rows = conn.execute(
f"""
select
source_row_no,
transaction_date,
in_out,
project_code,
project_name,
vendor_name,
department_name,
description,
supply_amount
from ptc_transactions
{detail_where}
order by transaction_date desc, source_row_no desc
limit 100
""",
detail_values,
).fetchall()
self._send(
200,
{
"summary": dict(summary) if summary else None,
"projects": rows_to_dicts(project_rows),
"transactions": rows_to_dicts(transaction_rows),
},
)
return
if parsed.path == "/api/vendor-detail":
vendor_name = params.get("vendor_name", [""])[0].strip()
project_code = params.get("project_code", [""])[0].strip()
account_code = params.get("account_code", [""])[0].strip()
date_from = params.get("date_from", [""])[0].strip()
date_to = params.get("date_to", [""])[0].strip()
if not vendor_name:
self._send(400, {"ok": False, "message": "vendor_name is required"})
return
detail_clauses = ["vendor_name = ?"]
detail_values: list[str] = [vendor_name]
if project_code:
detail_clauses.append("project_code = ?")
detail_values.append(project_code)
if account_code:
detail_clauses.append("account_code_final = ?")
detail_values.append(account_code)
if date_from:
detail_clauses.append("coalesce(transaction_date, '') >= ?")
detail_values.append(date_from)
if date_to:
detail_clauses.append("coalesce(transaction_date, '') <= ?")
detail_values.append(date_to)
detail_where = " where " + " and ".join(detail_clauses)
summary = conn.execute(
f"""
select
vendor_name,
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_supply_sum,
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply_sum,
coalesce(sum(supply_amount), 0) as supply_sum,
min(transaction_date) as min_date,
max(transaction_date) as max_date
from ptc_transactions
{detail_where}
group by vendor_name
""",
detail_values,
).fetchone()
project_rows = conn.execute(
f"""
select
project_code,
max(project_name) as project_name,
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_supply_sum,
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply_sum,
coalesce(sum(supply_amount), 0) as supply_sum
from ptc_transactions
{detail_where}
group by project_code
order by supply_sum desc, project_code
limit 20
""",
detail_values,
).fetchall()
account_rows = conn.execute(
f"""
select
account_code_final as account_code,
account_name_final as account_name,
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_supply_sum,
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply_sum,
coalesce(sum(supply_amount), 0) as supply_sum
from ptc_transactions
{detail_where}
group by account_code_final, account_name_final
order by supply_sum desc, account_code_final
limit 30
""",
detail_values,
).fetchall()
transaction_rows = conn.execute(
f"""
select
source_row_no,
transaction_date,
in_out,
project_code,
project_name,
account_code_final as account_code,
account_name_final as account_name,
department_name,
description,
supply_amount
from ptc_transactions
{detail_where}
order by transaction_date desc, source_row_no desc
limit 100
""",
detail_values,
).fetchall()
self._send(
200,
{
"summary": dict(summary) if summary else None,
"projects": rows_to_dicts(project_rows),
"accounts": rows_to_dicts(account_rows),
"transactions": rows_to_dicts(transaction_rows),
},
)
return
if parsed.path == "/api/project-detail":
project_code = params.get("project_code", [""])[0].strip()
if not project_code:
self._send(400, {"ok": False, "message": "project_code is required"})
return
keyword = params.get("keyword", [""])[0]
in_out = params.get("in_out", ["전체"])[0]
where, values = build_project_where(project_code, keyword, in_out)
summary = conn.execute(
f"""
select
project_code,
max(project_name) as project_name,
max(project_type) as project_type,
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(supply_amount), 0) as supply_sum,
coalesce(sum(vat_amount), 0) as vat_sum,
coalesce(sum(total_amount), 0) as total_sum,
min(transaction_date) as min_date,
max(transaction_date) as max_date
from ptc_transactions
{where}
""",
values,
).fetchone()
account_rows = conn.execute(
f"""
select
account_code_final as code,
account_name_final as name,
count(*) as count,
coalesce(sum(supply_amount), 0) as total
from ptc_transactions
{where}
group by account_code_final, account_name_final
order by total desc
limit 12
""",
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}
order by transaction_date desc, source_row_no desc
limit 20
""",
values,
).fetchall()
summary_dict = dict(summary) if summary else None
master = fetch_project_master(conn, project_code)
if summary_dict and master:
summary_dict["project_name"] = master.get("project_name") or summary_dict["project_name"]
summary_dict["project_type"] = resolve_project_type(
project_code,
summary_dict["project_type"],
master.get("project_type"),
)
summary_dict["construction_family"] = resolve_construction_family(
master.get("construction_method"),
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"] = ""
related_projects = build_related_projects(
conn,
project_code,
summary_dict.get("project_name") if summary_dict else "",
)
lifecycle_cost = build_project_lifecycle_cost(
conn,
related_projects,
summary_dict["project_type"] if summary_dict else "",
project_code,
)
account_issues = get_project_account_issues(
conn,
project_code,
summary_dict["project_type"] if summary_dict else "",
)
budget_analysis = build_budget_analysis(conn, project_code, build_account_structure_rows(account_rows))
self._send(
200,
{
"summary": summary_dict,
"project_master": master,
"account_structure": build_account_structure_rows(account_rows),
"budget_analysis": budget_analysis,
"accounts": rows_to_dicts(account_rows),
"account_issues": account_issues,
"transactions": rows_to_dicts(transaction_rows),
"related_projects": related_projects,
"lifecycle_cost": lifecycle_cost,
},
)
return
if parsed.path == "/api/project-account-issue-detail":
project_code = params.get("project_code", [""])[0].strip()
account_code = params.get("account_code", [""])[0].strip()
if not project_code or not account_code:
self._send(400, {"ok": False, "message": "project_code and account_code are required"})
return
rows = conn.execute(
"""
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 = ?
order by transaction_date desc, source_row_no desc
limit 100
""",
(project_code, account_code),
).fetchall()
summary = conn.execute(
"""
select
count(*) as txn_count,
coalesce(sum(supply_amount), 0) as supply_sum,
min(transaction_date) as min_date,
max(transaction_date) as max_date
from ptc_transactions
where project_code = ?
and account_code_final = ?
""",
(project_code, account_code),
).fetchone()
self._send(
200,
{
"project_code": project_code,
"account_code": account_code,
"account_name": resolve_account_name(account_code, rows[0]["account_name"] if rows else ""),
"summary": dict(summary) if summary else None,
"items": rows_to_dicts(rows),
},
)
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(
f"""
select
account_code_final as code,
account_name_final as name,
count(*) as count,
coalesce(sum(supply_amount), 0) as total
from ptc_transactions
{where}
group by account_code_final, account_name_final
order by total desc
limit 10
""",
values,
).fetchall()
self._send(200, {"items": rows_to_dicts(rows)})
return
if parsed.path == "/api/top-projects":
where, values = build_where(params)
rows = conn.execute(
f"""
select
coalesce(project_code, '(없음)') as project_code,
coalesce(project_name, '(없음)') as project_name,
coalesce(project_type, '(없음)') as project_type,
count(*) as count,
coalesce(sum(supply_amount), 0) as total
from ptc_transactions
{where}
group by project_code, project_name, project_type
order by total desc
limit 10
""",
values,
).fetchall()
self._send(200, {"items": rows_to_dicts(rows)})
return
if parsed.path == "/api/project-mismatches":
rows = conn.execute(
"""
select project_code, count(distinct project_name) as name_count, count(distinct project_type) as type_count
from ptc_transactions
where coalesce(project_code, '') <> ''
group by project_code
having count(distinct project_name) > 1 or count(distinct project_type) > 1
order by project_code
limit 20
"""
).fetchall()
self._send(200, {"items": rows_to_dicts(rows)})
return
if parsed.path == "/api/transactions":
where, values = build_where(params)
limit = int(params.get("limit", ["30"])[0])
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,
project_code,
project_name,
project_type,
description,
supply_amount,
vat_amount,
total_amount
from ptc_transactions
{where}
order by source_row_no
limit ?
""",
values + [limit],
).fetchall()
self._send(200, {"items": rows_to_dicts(rows)})
return
self._send(404, {"ok": False, "message": "Not found"})
finally:
conn.close()
def main() -> None:
init_db()
server = ThreadingHTTPServer(("0.0.0.0", 4000), Handler)
print("PTC API server listening on http://0.0.0.0:4000")
server.serve_forever()
if __name__ == "__main__":
main()