Update wehago matching logic and exclude reports

This commit is contained in:
b17301
2026-07-03 09:07:04 +09:00
parent a09190ecd3
commit da073ad6ef
62 changed files with 36940 additions and 1554 deletions
+688
View File
@@ -0,0 +1,688 @@
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'<w:pPr><w:pStyle w:val="{style}"/></w:pPr>' if style else ""
return f"<w:p>{style_xml}<w:r><w:t>{escape(text)}</w:t></w:r></w:p>"
def docx_table(headers: list[str], rows: list[list[str]]) -> str:
def cell(value: str, bold: bool = False) -> str:
run_pr = "<w:rPr><w:b/></w:rPr>" if bold else ""
return (
"<w:tc><w:tcPr><w:tcW w:w=\"2400\" w:type=\"dxa\"/></w:tcPr>"
f"<w:p><w:r>{run_pr}<w:t>{escape(str(value))}</w:t></w:r></w:p></w:tc>"
)
table_rows = ["<w:tr>" + "".join(cell(header, True) for header in headers) + "</w:tr>"]
for row in rows:
table_rows.append("<w:tr>" + "".join(cell(str(value)) for value in row) + "</w:tr>")
return (
"<w:tbl><w:tblPr><w:tblW w:w=\"0\" w:type=\"auto\"/>"
"<w:tblBorders><w:top w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"808080\"/>"
"<w:left w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"808080\"/>"
"<w:bottom w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"808080\"/>"
"<w:right w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"808080\"/>"
"<w:insideH w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"808080\"/>"
"<w:insideV w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"808080\"/></w:tblBorders>"
"</w:tblPr>"
+ "".join(table_rows)
+ "</w:tbl>"
)
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 = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
"<w:body>"
+ "".join(body)
+ '<w:sectPr><w:pgSz w:w="11906" w:h="16838"/>'
'<w:pgMar w:top="1440" w:right="1000" w:bottom="1440" w:left="1000" w:header="720" w:footer="720" w:gutter="0"/>'
"</w:sectPr></w:body></w:document>"
)
styles_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
'<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/>'
'<w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style>'
'<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/>'
'<w:rPr><w:b/><w:sz w:val="26"/></w:rPr></w:style>'
'<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/>'
'<w:rPr><w:b/><w:sz w:val="22"/></w:rPr></w:style>'
"</w:styles>"
)
content_types = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
'<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>'
"</Types>"
)
rels = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
"</Relationships>"
)
doc_rels = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
"</Relationships>"
)
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()