from __future__ import annotations import csv import re import sqlite3 import zipfile from collections import Counter, defaultdict from datetime import datetime from pathlib import Path from typing import Any from xml.sax.saxutils import escape import sys ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from runtime_config import DB_PATH as CONFIG_DB_PATH # noqa: E402 YEAR = 2025 WEHAGO_STATUSES = ("voucher_matched", "voucher_recheck", "voucher_unmatched") MATCHED_STATUSES = ("voucher_matched", "voucher_recheck") REPORT_DIR = ROOT / "reports" DB_PATH = ROOT / "data.db" if (ROOT / "data.db").exists() else CONFIG_DB_PATH def clean(value: Any) -> str: return "" if value is None else str(value).strip() def amount(value: Any) -> float: try: return float(value or 0) except (TypeError, ValueError): return 0.0 def pct(n: int, d: int) -> str: return f"{(n / d * 100):.2f}%" if d else "0.00%" def norm_text(value: str) -> str: text = clean(value) text = text.replace("(주)", "").replace("㈜", "").replace("(주)", "") text = text.replace("주식회사", "").replace("유한회사", "").replace("재단법인", "") text = text.replace("사단법인", "").replace("(재)", "").replace("(사)", "") text = re.sub(r"\b외\s*\d+\s*명\b", "", text) text = re.sub(r"[\s()/,_\-.·]+", "", text) return text def account_core(value: str) -> str: text = clean(value) text = text.replace("원가)", "").replace("판관)", "") text = re.sub(r"\([^)]*\)", "", text) text = re.sub(r"[\s/]+", "", text) return text def account_family(value: str) -> str: text = clean(value) core = account_core(text) pairs = [ ("복리후생", "welfare"), ("접대", "entertainment"), ("부서비", "department"), ("여비교통", "travel"), ("시내교통", "travel"), ("차량유지", "vehicle"), ("차량렌탈", "vehicle"), ("지급임차료", "rent"), ("임차료", "rent"), ("통신", "communication"), ("수도광열", "utility"), ("전력", "utility"), ("전기요금", "utility"), ("가스수도", "utility"), ("소모품", "supplies"), ("사무용품", "supplies"), ("전산용품", "supplies"), ("교육훈련", "training"), ("행사비용", "event"), ("합사경비", "site_office"), ("감리현장운영비", "site_operation"), ("관리현장운영비", "site_operation"), ("외주비", "outsourcing"), ("기술협력비", "outsourcing"), ("설계외주비", "outsourcing"), ("세금과공과", "tax_dues"), ("수수료", "fee"), ("선급금", "advance"), ("전도금", "advance"), ("보통예금", "cash"), ("외상매입금", "payable"), ("미지급금", "payable"), ("외상매출금", "receivable"), ("미수금", "receivable"), ("용역미수금", "receivable"), ("부가세", "tax"), ("매입세액", "tax"), ("매출세액", "tax"), ("예수", "withholding"), ("용역수입", "revenue_service"), ("설계용역수입", "revenue_service"), ("임대수입", "revenue_rent"), ("이자수익", "revenue_interest"), ("잡이익", "other_income"), ("잡손실", "other_loss"), ] for token, family in pairs: if token in text or token in core: return family return core def is_substantive_reclass(left: str, right: str) -> bool: left_family = account_family(left) right_family = account_family(right) if not left_family or not right_family or left_family == right_family: return False if "tax" in {left_family, right_family}: return False if {left_family, right_family} in ( {"cash", "payable"}, {"cash", "receivable"}, {"payable", "receivable"}, ): return False return True def is_tax_account(account: str) -> bool: return any(token in clean(account) for token in ("부가세", "매입세액", "매출세액", "불공제")) def is_clearing_account(account: str) -> bool: return any( token in clean(account) for token in ( "보통예금", "외상매입금", "외상매출금", "미지급금", "미수금", "선급금", "전도금", "예수금", "예수", "부가세", "세액", ) ) def row_side_amount(row: dict[str, Any], prefix: str) -> tuple[str, float]: debit = amount(row[f"{prefix}_debit"]) credit = amount(row[f"{prefix}_credit"]) if abs(debit) >= abs(credit): return "debit", debit return "credit", credit def has_ledger(row: dict[str, Any]) -> bool: return bool( clean(row["ledger_account_name"]) or clean(row["ledger_desc"]) or abs(amount(row["ledger_debit"])) >= 0.5 or abs(amount(row["ledger_credit"])) >= 0.5 ) def has_erp(row: dict[str, Any]) -> bool: return bool( clean(row["voucher_account_name"]) or clean(row["voucher_desc"]) or abs(amount(row["voucher_debit"])) >= 0.5 or abs(amount(row["voucher_credit"])) >= 0.5 ) def latest_signature(conn: sqlite3.Connection) -> str: row = conn.execute( """ SELECT signature FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature LIKE 'compare-query-v5|%' GROUP BY signature ORDER BY MAX(updated_at) DESC LIMIT 1 """, (YEAR, YEAR), ).fetchone() if not row: raise RuntimeError("latest compare signature not found") return clean(row[0]) def group_key(group: dict[str, Any]) -> tuple[str, int]: return (clean(group["status_key"]), int(group["group_index"])) def display_key(group: dict[str, Any]) -> str: return f"{group['fiscal_year']} {clean(group['ledger_date'])} {clean(group['voucher_no'])}".strip() def summarize_group(group: dict[str, Any], rows: list[dict[str, Any]]) -> str: erp = clean(group["draft_no"]) or "ERP 전표 없음" ledger_accounts = clean(group["ledger_accounts"]) or "-" erp_accounts = clean(group["voucher_accounts"]) or "-" ledger_vendors = clean(group["ledger_vendors"]) or "-" erp_vendors = clean(group["voucher_vendors"]) or "-" descs = [] for row in rows: for key in ("ledger_desc", "voucher_desc"): text = clean(row.get(key)) if text and text not in descs: descs.append(text) if len(descs) >= 2: break desc = " / ".join(descs[:2]) return ( f"{display_key(group)} | ERP {erp} | " f"더존 계정 {ledger_accounts} / ERP 계정 {erp_accounts} | " f"더존 거래처 {ledger_vendors} / ERP 거래처 {erp_vendors}" + (f" | 적요 {desc}" if desc else "") ) def account_reclass(rows: list[dict[str, Any]]) -> bool: for row in rows: if not (has_ledger(row) and has_erp(row)): continue ledger_account = clean(row["ledger_account_name"]) erp_account = clean(row["voucher_account_name"]) if not ledger_account or not erp_account: continue if is_tax_account(ledger_account) or is_tax_account(erp_account): continue ledger_side, ledger_amount = row_side_amount(row, "ledger") erp_side, erp_amount = row_side_amount(row, "voucher") if ledger_side == erp_side and abs(ledger_amount - erp_amount) < 0.5: if is_substantive_reclass(ledger_account, erp_account): return True return False def broad_account_difference(rows: list[dict[str, Any]]) -> bool: for row in rows: if not (has_ledger(row) and has_erp(row)): continue if clean(row["ledger_account_name"]) and clean(row["voucher_account_name"]): if account_core(row["ledger_account_name"]) != account_core(row["voucher_account_name"]): return True return False def vendor_change(rows: list[dict[str, Any]]) -> bool: for row in rows: if not (has_ledger(row) and has_erp(row)): continue ledger_vendor = clean(row["ledger_vendor"]) erp_vendor = clean(row["voucher_vendor"]) if not ledger_vendor or not erp_vendor: continue if "국민" in ledger_vendor and "국민" in erp_vendor: continue left = norm_text(ledger_vendor) right = norm_text(erp_vendor) if not left or not right: continue if left in right or right in left: continue common_len = 0 for i in range(len(left)): for j in range(i + 3, len(left) + 1): if left[i:j] in right: common_len = max(common_len, j - i) if common_len >= 4: continue if left != right: ledger_side, ledger_amount = row_side_amount(row, "ledger") erp_side, erp_amount = row_side_amount(row, "voucher") if ledger_side == erp_side and abs(ledger_amount - erp_amount) < 0.5: return True return False def amount_display_difference(rows: list[dict[str, Any]]) -> bool: ledger_only = [r for r in rows if has_ledger(r) and not has_erp(r) and not is_tax_account(r["ledger_account_name"])] erp_only = [r for r in rows if has_erp(r) and not has_ledger(r) and not is_tax_account(r["voucher_account_name"])] if not ledger_only or not erp_only: return False tax_like = [ r for r in rows if is_tax_account(r["ledger_account_name"]) or is_tax_account(r["voucher_account_name"]) ] for lrow in ledger_only: lside, lamt = row_side_amount(lrow, "ledger") if lside != "debit" or lamt <= 0: continue for erow in erp_only: eside, eamt = row_side_amount(erow, "voucher") if eside != "debit" or eamt <= 0 or lamt <= eamt: continue diff = lamt - eamt looks_vat = abs(diff - round(eamt * 0.1)) <= 2 or any( abs(diff - max(amount(t["ledger_debit"]), amount(t["ledger_credit"]), amount(t["voucher_debit"]), amount(t["voucher_credit"]))) <= 2 for t in tax_like ) if looks_vat: return True return False def advance_unsettled(group: dict[str, Any], rows: list[dict[str, Any]]) -> bool: text = " ".join( [clean(group["ledger_accounts"]), clean(group["ledger_vendors"])] + [clean(r["ledger_desc"]) + " " + clean(r["ledger_account_name"]) + " " + clean(r["ledger_vendor"]) for r in rows] ) return bool( ("주재비" in text) or ("전도금" in text and ("운영" in text or "정산" in text or "감리현장" in text)) or ("관리현장운영비" in text) ) def management_scope_difference(group: dict[str, Any], rows: list[dict[str, Any]]) -> bool: text = " ".join( [clean(group["ledger_accounts"]), clean(group["ledger_vendors"])] + [clean(r["ledger_desc"]) + " " + clean(r["ledger_account_name"]) + " " + clean(r["ledger_vendor"]) for r in rows] ) return any(token in text for token in ("RP 매수", "RP 매도", "기타예금", "투자자산", "유가증권", "증권", "CMA", "HMC투자")) def writing_method_difference(group: dict[str, Any], rows: list[dict[str, Any]]) -> bool: if group["status_key"] not in MATCHED_STATUSES: return False has_l_only = any(has_ledger(r) and not has_erp(r) for r in rows) has_e_only = any(has_erp(r) and not has_ledger(r) for r in rows) if not (has_l_only and has_e_only): return False return True def primary_vendor(group: dict[str, Any]) -> str: vendors = clean(group.get("ledger_vendors")) if not vendors: vendors = clean(group.get("voucher_vendors")) return norm_text(vendors.split(",")[0]) def group_month(group: dict[str, Any]) -> str: date_text = clean(group.get("ledger_date")) match = re.match(r"(\d{1,2})[-./]", date_text) return match.group(1).zfill(2) if match else "" def build_diverse_samples( case_keys: set[tuple[str, int]], group_by_key: dict[tuple[str, int], dict[str, Any]], rows_by_group: dict[tuple[str, int], list[dict[str, Any]]], excluded: set[tuple[str, int]], limit: int = 5, ) -> list[str]: candidates = [ key for key in sorted(case_keys, key=lambda k: (group_by_key[k]["ledger_date"], group_by_key[k]["voucher_no"], k[1])) if key not in excluded ] selected: list[tuple[str, int]] = [] used_months: set[str] = set() used_vendors: set[str] = set() def try_pick(require_new_month: bool, require_new_vendor: bool) -> None: if len(selected) >= limit: return for key in candidates: if key in selected: continue group = group_by_key[key] month = group_month(group) vendor = primary_vendor(group) if require_new_month and month and month in used_months: continue if require_new_vendor and vendor and vendor in used_vendors: continue selected.append(key) if month: used_months.add(month) if vendor: used_vendors.add(vendor) if len(selected) >= limit: return try_pick(require_new_month=True, require_new_vendor=True) try_pick(require_new_month=False, require_new_vendor=True) try_pick(require_new_month=False, require_new_vendor=False) return [summarize_group(group_by_key[key], rows_by_group[key]) for key in selected[:limit]] def docx_paragraph(text: str, style: str | None = None) -> str: style_xml = f'' if style else "" return f"{style_xml}{escape(text)}" def docx_table(headers: list[str], rows: list[list[str]]) -> str: def cell(value: str, bold: bool = False) -> str: run_pr = "" if bold else "" return ( "" f"{run_pr}{escape(str(value))}" ) table_rows = ["" + "".join(cell(header, True) for header in headers) + ""] for row in rows: table_rows.append("" + "".join(cell(str(value)) for value in row) + "") return ( "" "" "" "" "" "" "" "" + "".join(table_rows) + "" ) def write_docx( path: Path, title: str, meta_lines: list[str], summary_rows: list[list[str]], detail_rows_for_doc: list[list[str]], sample_rows_by_case: dict[str, list[str]], ) -> None: body: list[str] = [docx_paragraph(title, "Title")] body.extend(docx_paragraph(line) for line in meta_lines) body.append(docx_paragraph("요약", "Heading1")) body.append(docx_table(["구분", "전표 수", "전체 대비", "비고"], summary_rows)) body.append(docx_paragraph("세부 유형별 수치", "Heading1")) body.append(docx_table(["대분류", "세부 유형", "전표 수", "전체 대비", "산정 기준"], detail_rows_for_doc)) body.append(docx_paragraph("추가 사례", "Heading1")) for case_name, sample_rows in sample_rows_by_case.items(): body.append(docx_paragraph(case_name, "Heading2")) for sample in sample_rows: body.append(docx_paragraph(sample)) document_xml = ( '' '' "" + "".join(body) + '' '' "" ) styles_xml = ( '' '' '' '' '' '' '' '' "" ) content_types = ( '' '' '' '' '' '' "" ) rels = ( '' '' '' "" ) doc_rels = ( '' '' '' "" ) with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as docx: docx.writestr("[Content_Types].xml", content_types) docx.writestr("_rels/.rels", rels) docx.writestr("word/_rels/document.xml.rels", doc_rels) docx.writestr("word/document.xml", document_xml) docx.writestr("word/styles.xml", styles_xml) def main() -> None: conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row signature = latest_signature(conn) groups = [ dict(row) for row in conn.execute( f""" SELECT * FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ? AND status_key IN ({','.join('?' for _ in WEHAGO_STATUSES)}) ORDER BY status_key, group_index """, (YEAR, YEAR, signature, *WEHAGO_STATUSES), ) ] rows_by_group: dict[tuple[str, int], list[dict[str, Any]]] = defaultdict(list) for row in conn.execute( f""" SELECT * FROM wehago_compare_query_rows WHERE start_year = ? AND end_year = ? AND signature = ? AND status_key IN ({','.join('?' for _ in WEHAGO_STATUSES)}) ORDER BY status_key, group_index, row_index """, (YEAR, YEAR, signature, *WEHAGO_STATUSES), ): record = dict(row) rows_by_group[(record["status_key"], int(record["group_index"]))].append(record) total = len(groups) status_counts = Counter(group["status_key"] for group in groups) cases: dict[str, set[tuple[str, int]]] = defaultdict(set) for group in groups: key = group_key(group) rows = rows_by_group[key] if group["status_key"] in MATCHED_STATUSES: if broad_account_difference(rows): cases["광의 계정명 차이"].add(key) if account_reclass(rows): cases["계정 재분류 후보"].add(key) if vendor_change(rows): cases["거래처/대상자 변경 및 보완"].add(key) if amount_display_difference(rows): cases["금액 표시 기준 차이"].add(key) if writing_method_difference(group, rows): cases["전표 작성 방식 차이"].add(key) if group["status_key"] == "voucher_unmatched": if advance_unsettled(group, rows): cases["ERP상 주재비/전도금 미정산"].add(key) if management_scope_difference(group, rows): cases["관리 대상 차이"].add(key) info_union = ( cases["계정 재분류 후보"] | cases["거래처/대상자 변경 및 보완"] | cases["금액 표시 기준 차이"] ) unmatched_union = ( set(group_key(g) for g in groups if g["status_key"] == "voucher_unmatched") | cases["전표 작성 방식 차이"] ) voucher_unmatched_keys = set(group_key(g) for g in groups if g["status_key"] == "voucher_unmatched") residual_unentered = voucher_unmatched_keys - cases["ERP상 주재비/전도금 미정산"] - cases["관리 대상 차이"] cases["전표 미입력/ERP 직접 대응 없음"].update(residual_unentered) group_by_key = {group_key(group): group for group in groups} given_examples = { ("voucher_unmatched", 71), ("voucher_matched", 2833), ("voucher_matched", 2016), ("voucher_unmatched", 174), ("voucher_matched", 51), ("voucher_unmatched", 431), ("voucher_unmatched", 1042), } def samples(case_name: str, limit: int = 5) -> list[str]: return build_diverse_samples(cases[case_name], group_by_key, rows_by_group, given_examples, limit) ts = datetime.now().strftime("%Y%m%d_%H%M%S") md_path = REPORT_DIR / f"wehago_case_distribution_report_{YEAR}_{ts}.md" csv_path = REPORT_DIR / f"wehago_case_distribution_samples_{YEAR}_{ts}.csv" docx_path = REPORT_DIR / f"wehago_case_distribution_report_{YEAR}_{ts}.docx" summary_rows = [ ["① 매칭 전표의 정보 차이(중복 제거)", f"{len(info_union):,}", pct(len(info_union), total), "계정 재분류 후보, 거래처 변경, 금액 표시 차이 중 하나 이상"], ["② 미매칭/부분미매칭 발생 유형(중복 제거)", f"{len(unmatched_union):,}", pct(len(unmatched_union), total), "더존-only 미매칭 + 전표 작성 방식 차이"], ["더존-only 미매칭", f"{status_counts['voucher_unmatched']:,}", pct(status_counts["voucher_unmatched"], total), "ERP 직접 대응 행이 없는 더존 전표"], ["재검토", f"{status_counts['voucher_recheck']:,}", pct(status_counts["voucher_recheck"], total), "매칭 후보이나 검토 필요"], ] detail_rows = [ ("①", "계정 재분류 후보", "매칭/재검토 중 금액·차대 방향은 같고, 계정의 핵심 의미군이 달라진 행 포함. 단순 계정명·보조명 차이는 제외"), ("①", "광의 계정명 차이", "세액·채권채무·예금 등 시스템 계정명 차이를 포함한 계정명 차이. 참고 지표로만 사용"), ("①", "거래처/대상자 변경 및 보완", "매칭/재검토 중 금액·차대 방향은 같고, 거래처 핵심 명칭이 서로 겹치지 않는 행 포함"), ("①", "금액 표시 기준 차이", "ERP 공급가/부가세 분리 금액이 더존 비용 합산 표시로 나타난 후보"), ("②", "ERP상 주재비/전도금 미정산", "더존-only 중 주재비, 전도금, 관리현장운영비 정산 문구/계정 포함"), ("②", "전표 작성 방식 차이", "매칭/재검토 중 한쪽 행만 남는 분리·합산 전표 구조 포함"), ("②", "관리 대상 차이", "더존-only 중 RP, 기타예금, 증권, 투자자산 등 관리 대상 거래"), ("②", "전표 미입력/ERP 직접 대응 없음", "더존-only 중 위 주재비/투자관리 유형으로 분류되지 않은 잔여"), ] detail_rows_for_doc = [ [major, name, f"{len(cases[name]):,}", pct(len(cases[name]), total), note] for major, name, note in detail_rows ] sample_rows_by_case = { name: samples(name) for _, name, _ in detail_rows if name != "광의 계정명 차이" } lines: list[str] = [] lines.append(f"# 더존 전표 기준 케이스별 분포 분석 ({YEAR})") lines.append("") lines.append(f"- 기준 DB: `{DB_PATH}`") lines.append(f"- 기준 projection: `{signature}`") lines.append(f"- 분모: WEHAGO/더존 전표 그룹 {total:,}건 = 매칭 {status_counts['voucher_matched']:,}건 + 재검토 {status_counts['voucher_recheck']:,}건 + 더존-only 미매칭 {status_counts['voucher_unmatched']:,}건") lines.append("- 보완 기준: 계정 재분류는 계정의 핵심 의미군이 달라진 경우만 포함하고, 단순 계정명·보조명 차이는 제외했다.") lines.append("- 보완 기준: 거래처/대상자 변경은 핵심 회사명·조직명 문자열이 서로 겹치지 않는 경우만 포함하고, 약칭·법인격·부서명 차이는 제외했다.") lines.append("- 사례 선정: 2025년 1월부터 순차 추출하되, 월과 거래처가 과도하게 겹치지 않도록 우선 분산 추출했다.") lines.append("- 주의: 세부유형은 한 전표가 둘 이상의 유형에 동시에 해당될 수 있어 단순 합산하면 대분류 중복이 발생한다.") lines.append("") lines.append("## 요약") lines.append("") lines.append("| 구분 | 전표 수 | 전체 대비 | 비고 |") lines.append("|---|---:|---:|---|") for row in summary_rows: lines.append("| " + " | ".join(row) + " |") lines.append("") lines.append("## 세부 유형별 수치") lines.append("") lines.append("| 대분류 | 세부 유형 | 전표 수 | 전체 대비 | 산정 기준 |") lines.append("|---|---|---:|---:|---|") for major, name, note in detail_rows: lines.append(f"| {major} | {name} | {len(cases[name]):,} | {pct(len(cases[name]), total)} | {note} |") lines.append("") lines.append("## 추가 사례") lines.append("") for _, name, _ in detail_rows: if name == "광의 계정명 차이": continue lines.append(f"### {name}") sample_list = sample_rows_by_case[name] if not sample_list: lines.append("- 추가 사례 없음") else: for item in sample_list: lines.append(f"- {item}") lines.append("") md_path.write_text("\n".join(lines), encoding="utf-8") with csv_path.open("w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["case_name", "sample_no", "sample"]) for _, name, _ in detail_rows: if name == "광의 계정명 차이": continue for idx, item in enumerate(sample_rows_by_case[name], start=1): writer.writerow([name, idx, item]) meta_lines = [ f"기준 DB: {DB_PATH}", f"분모: WEHAGO/더존 전표 그룹 {total:,}건 = 매칭 {status_counts['voucher_matched']:,}건 + 재검토 {status_counts['voucher_recheck']:,}건 + 더존-only 미매칭 {status_counts['voucher_unmatched']:,}건", "보완 기준: 계정 재분류는 계정의 핵심 의미군이 달라진 경우만 포함하고, 거래처 변경은 핵심 회사명·조직명이 서로 겹치지 않는 경우만 포함했다.", "사례 선정: 월과 거래처가 과도하게 겹치지 않도록 우선 분산 추출했다.", ] write_docx( docx_path, f"더존 전표 기준 케이스별 분포 분석 ({YEAR})", meta_lines, summary_rows, detail_rows_for_doc, sample_rows_by_case, ) print(md_path) print(csv_path) print(docx_path) if __name__ == "__main__": main()