#!/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") XLSX_PATH = BASE_DIR / "PTC(2023-2026.02).xlsx" METHOD_XLSX_PATH = BASE_DIR / "PTC공법.xlsx" DB_PATH = BASE_DIR / "db" / "ptc_local.sqlite3" 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"] 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": "임대보증금"}, } 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 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 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 [] headers = rows[0] data_rows = rows[1:] width = len(headers) items = [] for source_row_no, row in enumerate(data_rows, start=2): current = row + [""] * (width - len(row)) if len(row) < width else row[:width] payload = dict(zip(headers, current)) items.append( { "source_row_no": source_row_no, "transaction_date_raw": payload.get("거래일", ""), "transaction_date": excel_serial_to_date(payload.get("거래일", "")), "in_out": payload.get("입/출금", ""), "account_code": payload.get("계정코드", ""), "account_name": payload.get("구분", ""), "department_name": payload.get("부서", ""), "vendor_name": payload.get("거래처", ""), "project_code": payload.get("프로젝트코드", ""), "project_type": payload.get("프로젝트 구분(안)", ""), "project_name": payload.get("프로젝트명", ""), "description": payload.get("적요", ""), "supply_amount_raw": payload.get("공급가액", ""), "vat_amount_raw": payload.get("부가세", ""), "total_amount_raw": payload.get("합계금액", ""), "remarks": payload.get("비고", ""), "supply_amount": parse_amount(payload.get("공급가액", "")), "vat_amount": parse_amount(payload.get("부가세", "")), "total_amount": parse_amount(payload.get("합계금액", "")), "normalized_type": normalize_transaction_type( payload.get("입/출금", ""), payload.get("구분", "") ), } ) 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, note text, 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, 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) ) """ ) 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") 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_mtime = str(int(XLSX_PATH.stat().st_mtime)) row = cur.execute("select value from meta where key = 'xlsx_mtime'").fetchone() needs_refresh = row is None or row["value"] != xlsx_mtime 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_mtime', ?) " "on conflict(key) do update set value = excluded.value", (xlsx_mtime,), ) 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, 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 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: 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 from project_progress where project_code = ?", (project_code,), ).fetchone() progress_rate = progress_row["progress_rate"] if progress_row else 0 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, "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 = ["coalesce(project_code, '') = ?"] values = [project_code] 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.end_headers() self.wfile.write(body) 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) note = str(payload.get("note", "")).strip() updated_at = datetime.now().isoformat() conn.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 = excluded.project_name, project_type = excluded.project_type, construction_family = excluded.construction_family, construction_method = excluded.construction_method, note = excluded.note, updated_at = excluded.updated_at """, (project_code, project_name, project_type, construction_family, construction_method, note, updated_at), ) conn.commit() self._send(200, {"ok": True, "item": fetch_project_master(conn, project_code)}) 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 "") conn.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 = excluded.project_name, project_type = excluded.project_type, construction_family = excluded.construction_family, construction_method = excluded.construction_method, note = excluded.note, updated_at = excluded.updated_at """, ( project_code, project_name, project_type, construction_family, construction_method, 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) 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, updated_at) values (?, ?, ?) on conflict(project_code) do update set progress_rate = excluded.progress_rate, updated_at = excluded.updated_at """, (project_code, progress_rate, updated_at), ) conn.commit() self._send(200, {"ok": True, "project_code": project_code, "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 == "/": count = conn.execute("select count(*) as count from ptc_transactions").fetchone()["count"] html = f"""
이 서버는 `PTC(2023-2026.02).xlsx`를 읽어 요약, 프로젝트 집계, 계정 집계, 거래 미리보기를 JSON API로 제공합니다. 메인 화면은 http://localhost:8000/PTC 에서 확인할 수 있습니다.