#!/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_lifecycle_common_allocation ( base_project_code text primary key, allocation_mode text not null default 'expense_ratio', updated_at text not null ) """ ) 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 fetch_lifecycle_common_allocation_mode(conn: sqlite3.Connection, base_project_code: str) -> str: if not base_project_code: return "expense_ratio" row = conn.execute( """ select allocation_mode from project_lifecycle_common_allocation where base_project_code = ? """, (base_project_code,), ).fetchone() mode = (row["allocation_mode"] or "").strip() if row else "" if mode not in {"expense_ratio", "income_ratio"}: return "expense_ratio" return mode 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, allocation_mode: str = "expense_ratio" ) -> 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, "labor_source_total": 0.0, "common_source_total": 0.0, "labor_project_basis_total": 0.0, "common_project_basis_total": 0.0, "labor_overall_basis_total": 0.0, "common_overall_basis_total": 0.0, "labor_allocation_details": [], "common_allocation_details": [], } 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) labor_pool_accounts_by_month: dict[str, dict[str, float]] = defaultdict(lambda: defaultdict(float)) common_pool_accounts_by_month: dict[str, dict[str, float]] = defaultdict(lambda: 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 labor_pool_accounts_by_month[ym][code] += amount else: common_pool_by_month[ym] += amount common_pool_accounts_by_month[ym][code] += 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, "labor_source_total": 0.0, "common_source_total": 0.0, "labor_project_basis_total": 0.0, "common_project_basis_total": 0.0, "labor_overall_basis_total": 0.0, "common_overall_basis_total": 0.0, "labor_allocation_details": [], "common_allocation_details": [], } month_active_projects: dict[str, set[str]] = defaultdict(set) for project_code, (start_ym, end_ym) in project_ranges.items(): for ym in _iter_year_months(start_ym, end_ym): month_active_projects[ym].add(project_code) monthly_project_amount_rows = conn.execute( """ select substr(coalesce(transaction_date, ''), 1, 7) as ym, coalesce(project_code, '') as project_code, coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply, coalesce(sum(case when in_out = '입금' then supply_amount else 0 end), 0) as income_supply from ptc_transactions where coalesce(transaction_date, '') <> '' group by substr(coalesce(transaction_date, ''), 1, 7), coalesce(project_code, '') having ym <> '' """ ).fetchall() project_expense_by_month: dict[tuple[str, str], float] = defaultdict(float) project_income_by_month: dict[tuple[str, str], float] = defaultdict(float) for row in monthly_project_amount_rows: ym = (row["ym"] or "").strip() project_code = (row["project_code"] or "").strip() if not ym or not project_code: continue project_expense_by_month[(ym, project_code)] += float(row["expense_supply"] or 0) project_income_by_month[(ym, project_code)] += float(row["income_supply"] or 0) base_start_ym, base_end_ym = base_range labor_shared = 0.0 common_shared = 0.0 labor_source_total = 0.0 common_source_total = 0.0 labor_project_basis_total = 0.0 common_project_basis_total = 0.0 labor_overall_basis_total = 0.0 common_overall_basis_total = 0.0 labor_allocation_details: list[dict] = [] common_allocation_details: list[dict] = [] labor_account_allocation_details: dict[str, list[dict]] = defaultdict(list) common_account_allocation_details: dict[str, list[dict]] = defaultdict(list) labor_account_allocated: dict[str, float] = defaultdict(float) common_account_allocated: dict[str, float] = defaultdict(float) for ym in candidate_months: if ym < base_start_ym: continue if base_end_ym and ym > base_end_ym: continue active_projects = sorted(month_active_projects.get(ym) or []) if not active_projects: continue if base_project_code not in active_projects: continue if allocation_mode == "income_ratio": base_value = float(project_income_by_month.get((ym, base_project_code)) or 0.0) total_value = sum(float(project_income_by_month.get((ym, code)) or 0.0) for code in active_projects) else: base_value = float(project_expense_by_month.get((ym, base_project_code)) or 0.0) total_value = sum(float(project_expense_by_month.get((ym, code)) or 0.0) for code in active_projects) if total_value > 0: ratio = max(0.0, min(1.0, base_value / total_value)) display_project_basis_amount = base_value display_total_basis_amount = total_value used_equal_split = False else: ratio = 1.0 / len(active_projects) display_project_basis_amount = 1.0 display_total_basis_amount = float(len(active_projects)) used_equal_split = True labor_pool = float(labor_pool_by_month.get(ym) or 0) common_pool = float(common_pool_by_month.get(ym) or 0) labor_shared += labor_pool * ratio common_shared += common_pool * ratio labor_source_total += labor_pool common_source_total += common_pool labor_project_basis_total += base_value common_project_basis_total += base_value labor_overall_basis_total += total_value common_overall_basis_total += total_value if labor_pool: labor_allocation_details.append( { "year_month": ym, "source_amount": labor_pool, "project_basis_amount": base_value, "total_basis_amount": total_value, "display_project_basis_amount": display_project_basis_amount, "display_total_basis_amount": display_total_basis_amount, "used_equal_split": used_equal_split, "allocated_amount": labor_pool * ratio, } ) for account_code, account_amount in (labor_pool_accounts_by_month.get(ym) or {}).items(): account_amount_value = float(account_amount or 0.0) allocated_value = account_amount_value * ratio labor_account_allocated[account_code] += allocated_value labor_account_allocation_details[account_code].append( { "year_month": ym, "source_amount": account_amount_value, "project_basis_amount": base_value, "total_basis_amount": total_value, "display_project_basis_amount": display_project_basis_amount, "display_total_basis_amount": display_total_basis_amount, "used_equal_split": used_equal_split, "allocated_amount": allocated_value, } ) if common_pool: common_allocation_details.append( { "year_month": ym, "source_amount": common_pool, "project_basis_amount": base_value, "total_basis_amount": total_value, "display_project_basis_amount": display_project_basis_amount, "display_total_basis_amount": display_total_basis_amount, "used_equal_split": used_equal_split, "allocated_amount": common_pool * ratio, } ) for account_code, account_amount in (common_pool_accounts_by_month.get(ym) or {}).items(): account_amount_value = float(account_amount or 0.0) allocated_value = account_amount_value * ratio common_account_allocated[account_code] += allocated_value common_account_allocation_details[account_code].append( { "year_month": ym, "source_amount": account_amount_value, "project_basis_amount": base_value, "total_basis_amount": total_value, "display_project_basis_amount": display_project_basis_amount, "display_total_basis_amount": display_total_basis_amount, "used_equal_split": used_equal_split, "allocated_amount": allocated_value, } ) return { "labor_shared": labor_shared, "common_shared": common_shared, "labor_source_total": labor_source_total, "common_source_total": common_source_total, "labor_project_basis_total": labor_project_basis_total, "common_project_basis_total": common_project_basis_total, "labor_overall_basis_total": labor_overall_basis_total, "common_overall_basis_total": common_overall_basis_total, "labor_allocation_details": labor_allocation_details, "common_allocation_details": common_allocation_details, "labor_account_allocated": dict(labor_account_allocated), "common_account_allocated": dict(common_account_allocated), "labor_account_allocation_details": dict(labor_account_allocation_details), "common_account_allocation_details": dict(common_account_allocation_details), } 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 common_allocation_mode = fetch_lifecycle_common_allocation_mode(conn, base_project_code) monthly_shared = calculate_monthly_shared_distribution(conn, base_project_code, common_allocation_mode) 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 labor_account_allocated = monthly_shared.get("labor_account_allocated") or {} labor_account_allocation_details = monthly_shared.get("labor_account_allocation_details") or {} if labor_account_allocated: for shared_code, shared_amount in labor_account_allocated.items(): shared_code_str = (shared_code or "").strip() if not shared_code_str: continue shared_amount_value = float(shared_amount or 0.0) shared_meta = ACCOUNT_MASTER.get(shared_code_str) or {} account_entry = breakdown_account_maps["인건비"].setdefault( shared_code_str, { "account_code": shared_code_str, "account_name": shared_meta.get("name") or shared_code_str, "direct_expense_supply": 0.0, "shared_expense_supply": 0.0, "expense_supply": 0.0, "allocation_source_amount": 0.0, "allocation_project_basis_amount": 0.0, "allocation_total_basis_amount": 0.0, "allocation_mode": common_allocation_mode, "allocation_details": [], }, ) account_entry["shared_expense_supply"] += shared_amount_value account_entry["expense_supply"] += shared_amount_value account_entry["allocation_source_amount"] = float( sum(float(row.get("source_amount") or 0.0) for row in (labor_account_allocation_details.get(shared_code_str) or [])) ) account_entry["allocation_project_basis_amount"] = float(monthly_shared.get("labor_project_basis_total") or 0.0) account_entry["allocation_total_basis_amount"] = float(monthly_shared.get("labor_overall_basis_total") or 0.0) account_entry["allocation_mode"] = common_allocation_mode account_entry["allocation_details"] = list(labor_account_allocation_details.get(shared_code_str) or []) else: 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, "allocation_source_amount": 0.0, "allocation_project_basis_amount": 0.0, "allocation_total_basis_amount": 0.0, "allocation_mode": common_allocation_mode, "allocation_details": [], }, ) account_entry["shared_expense_supply"] += labor_shared account_entry["expense_supply"] += labor_shared account_entry["allocation_source_amount"] = float(monthly_shared.get("labor_source_total") or 0.0) account_entry["allocation_project_basis_amount"] = float(monthly_shared.get("labor_project_basis_total") or 0.0) account_entry["allocation_total_basis_amount"] = float(monthly_shared.get("labor_overall_basis_total") or 0.0) account_entry["allocation_mode"] = common_allocation_mode account_entry["allocation_details"] = list(monthly_shared.get("labor_allocation_details") or []) 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 common_account_allocated = monthly_shared.get("common_account_allocated") or {} common_account_allocation_details = monthly_shared.get("common_account_allocation_details") or {} if common_account_allocated: for shared_code, shared_amount in common_account_allocated.items(): shared_code_str = (shared_code or "").strip() if not shared_code_str: continue shared_amount_value = float(shared_amount or 0.0) shared_meta = ACCOUNT_MASTER.get(shared_code_str) or {} account_entry = breakdown_account_maps["관리비"].setdefault( shared_code_str, { "account_code": shared_code_str, "account_name": shared_meta.get("name") or shared_code_str, "direct_expense_supply": 0.0, "shared_expense_supply": 0.0, "expense_supply": 0.0, "allocation_source_amount": 0.0, "allocation_project_basis_amount": 0.0, "allocation_total_basis_amount": 0.0, "allocation_mode": common_allocation_mode, "allocation_details": [], }, ) account_entry["shared_expense_supply"] += shared_amount_value account_entry["expense_supply"] += shared_amount_value account_entry["allocation_source_amount"] = float( sum(float(row.get("source_amount") or 0.0) for row in (common_account_allocation_details.get(shared_code_str) or [])) ) account_entry["allocation_project_basis_amount"] = float(monthly_shared.get("common_project_basis_total") or 0.0) account_entry["allocation_total_basis_amount"] = float(monthly_shared.get("common_overall_basis_total") or 0.0) account_entry["allocation_mode"] = common_allocation_mode account_entry["allocation_details"] = list(common_account_allocation_details.get(shared_code_str) or []) else: 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, "allocation_source_amount": 0.0, "allocation_project_basis_amount": 0.0, "allocation_total_basis_amount": 0.0, "allocation_mode": common_allocation_mode, "allocation_details": [], }, ) account_entry["shared_expense_supply"] += common_shared account_entry["expense_supply"] += common_shared account_entry["allocation_source_amount"] = float(monthly_shared.get("common_source_total") or 0.0) account_entry["allocation_project_basis_amount"] = float(monthly_shared.get("common_project_basis_total") or 0.0) account_entry["allocation_total_basis_amount"] = float(monthly_shared.get("common_overall_basis_total") or 0.0) account_entry["allocation_mode"] = common_allocation_mode account_entry["allocation_details"] = list(monthly_shared.get("common_allocation_details") or []) 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, "common_allocation_mode": common_allocation_mode, }, } 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=""), } allocation_mode = "" allocation_source_amount = 0.0 allocation_project_basis_amount = 0.0 allocation_total_basis_amount = 0.0 allocation_details: list[dict] = [] if account_code in {"SHARED_LABOR", "SHARED_COMMON"}: allocation_mode = fetch_lifecycle_common_allocation_mode(conn, base_project_code) monthly_shared = calculate_monthly_shared_distribution(conn, base_project_code, allocation_mode) if account_code == "SHARED_LABOR": allocation_source_amount = float(monthly_shared.get("labor_source_total") or 0.0) allocation_project_basis_amount = float(monthly_shared.get("labor_project_basis_total") or 0.0) allocation_total_basis_amount = float(monthly_shared.get("labor_overall_basis_total") or 0.0) allocation_details = list(monthly_shared.get("labor_allocation_details") or []) else: allocation_source_amount = float(monthly_shared.get("common_source_total") or 0.0) allocation_project_basis_amount = float(monthly_shared.get("common_project_basis_total") or 0.0) allocation_total_basis_amount = float(monthly_shared.get("common_overall_basis_total") or 0.0) allocation_details = list(monthly_shared.get("common_allocation_details") or []) else: allocation_mode = fetch_lifecycle_common_allocation_mode(conn, base_project_code) monthly_shared = calculate_monthly_shared_distribution(conn, base_project_code, allocation_mode) labor_detail_map = monthly_shared.get("labor_account_allocation_details") or {} common_detail_map = monthly_shared.get("common_account_allocation_details") or {} account_rows = labor_detail_map.get(account_code) or common_detail_map.get(account_code) or [] if account_rows: allocation_details = list(account_rows) allocation_source_amount = float(sum(float(row.get("source_amount") or 0.0) for row in account_rows)) allocation_project_basis_amount = float(sum(float(row.get("project_basis_amount") or 0.0) for row in account_rows)) allocation_total_basis_amount = float(sum(float(row.get("total_basis_amount") or 0.0) for row in account_rows)) return { "summary": summary, "allocation_mode": allocation_mode, "allocation_source_amount": allocation_source_amount, "allocation_project_basis_amount": allocation_project_basis_amount, "allocation_total_basis_amount": allocation_total_basis_amount, "allocation_details": allocation_details, "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, "

PTC frontend not found

") return self._send_html(200, get_frontend_html()) def _send_frontend_dashboard_preview(self) -> None: if not FRONTEND_DASHBOARD_PREVIEW_PATH.exists(): self._send_html(404, "

PTC dashboard preview frontend not found

") return self._send_html(200, get_frontend_dashboard_preview_html()) def _send_frontend_admin_dashboard(self) -> None: if not FRONTEND_ADMIN_DASHBOARD_PATH.exists(): self._send_html(404, "

PTC admin dashboard frontend not found

") 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, "

PTC management dashboard frontend not found

") 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/lifecycle-common-allocation/upsert": payload = self._read_json() base_project_code = str(payload.get("base_project_code", "")).strip() allocation_mode = str(payload.get("allocation_mode", "")).strip() if not base_project_code: self._send(400, {"ok": False, "message": "base_project_code is required"}) return if allocation_mode not in {"expense_ratio", "income_ratio"}: self._send(400, {"ok": False, "message": "allocation_mode must be expense_ratio or income_ratio"}) return updated_at = datetime.now().isoformat() conn.execute( """ insert into project_lifecycle_common_allocation ( base_project_code, allocation_mode, updated_at ) values (?, ?, ?) on conflict(base_project_code) do update set allocation_mode = excluded.allocation_mode, updated_at = excluded.updated_at """, (base_project_code, allocation_mode, updated_at), ) conn.commit() self._send( 200, { "ok": True, "item": { "base_project_code": base_project_code, "allocation_mode": allocation_mode, "updated_at": updated_at, }, }, ) 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""" PTC API Server
PTC Data API

PTC 원장 데이터 서버

이 서버는 선택된 PTC 원본 엑셀 파일을 읽어 요약, 프로젝트 집계, 계정 집계, 거래 미리보기를 JSON API로 제공합니다. 메인 화면은 http://localhost:4000/PTC/ 에서 바로 확인할 수 있습니다.

현재 적재 건수
{count:,}
원본 파일 기준 전체 거래 행 수
원본 파일: {xlsx_path}
메인 화면
localhost:4000/PTC/
사용자가 보는 메인 대시보드와 API를 같은 서버에서 제공합니다.
헬스체크
/api/health
서버 상태와 row count 확인
요약
/api/summary
건수, 기간, 공급가액, 누락값

주요 API

GET/api/health
API 서버가 정상 동작하는지와 적재 건수를 반환합니다.
GET/api/summary
건수, 입금/출금, 공급가액, 부가세, 기간, 누락값 요약을 반환합니다.
GET/api/top-accounts
계정코드별 상위 집계를 반환합니다.
GET/api/top-projects
프로젝트별 상위 집계를 반환합니다.
GET/api/project-mismatches
프로젝트코드 대비 프로젝트명/구분 불일치를 반환합니다.
GET/api/transactions?limit=30
원본 거래 미리보기를 반환합니다.

필터 예시

/api/summary?project_type=시공
/api/top-accounts?in_out=출금
/api/transactions?keyword=여비교통비&limit=20
""" 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()