Update wehago matching logic and exclude reports
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user