from __future__ import annotations import argparse import re import shutil from collections import defaultdict from copy import copy from dataclasses import dataclass, field from datetime import date, datetime from difflib import SequenceMatcher from pathlib import Path from typing import Iterable from openpyxl import load_workbook DEFAULT_DIR = Path("downloads") / "wehago_account_ledger_20260421" DEFAULT_LEDGER = DEFAULT_DIR / "\ud1b5\ud569_\uacc4\uc815\ubcc4\uc6d0\uc7a5_20260422_edit_ver.2.xlsx" DEFAULT_VOUCHER = DEFAULT_DIR / "\uc804\ud45c\ub0b4\uc5ed\uc870\ud68c_2025_20250101_20251231_260422.xlsx" LEDGER_OUT_NAME = "ledger_with_matched_voucher_detected_context60_v10_20260422.xlsx" VOUCHER_OUT_NAME = "voucher_with_matched_ledger_detected_context60_v10_20260422.xlsx" LEDGER_HEADERS = [ "matched_\uac00\uc804\ud45c\ubc88\ud638", "matched_\uacc4\uc815\ucf54\ub4dc", "matched_\uacc4\uc815\uba85\uce6d", "matched_\ucc28\ubcc0\uacf5\uae09\uac00", "matched_\ub300\ubcc0\uacf5\uae09\uac00", "matched_\ud655\uc815\uc804\ud45c\ubc88\ud638", "matched_\uac00\uc804\ud45c\uc791\uc131\uc77c", "matched_\uc77c\uc790\ucc28\uc774\uc77c", "matched_\uac80\uc99d\uadfc\uac70", "review_flag", "review_memo", ] VOUCHER_HEADERS = [ "matched_\uacc4\uc815\ucf54\ub4dc", "matched_\uacc4\uc815\uba85", "matched_\uc77c\uc790", "matched_\ucc28\ubcc0", "matched_\ub300\ubcc0", "matched_\uc804\ud45c\ubc88\ud638", "matched_\uc77c\uc790\ucc28\uc774\uc77c", "matched_\uac80\uc99d\uadfc\uac70", "review_flag", "review_memo", ] # Strict rules for balance-sheet, tax, revenue, and other accounts where # counterparty lines must not be treated as the same accounting line. EXACT_RULES = { "10110501": {"103"}, "10115301": {"135"}, "20110101": {"251"}, "20111103": {"253"}, "20111105": {"253"}, "20111108": {"253"}, "20111109": {"253"}, "20111111": {"253"}, "20112901": {"255"}, } PREFIX_RULES = [ ("101101", {"101"}), ("101105", {"103"}), ("101106", {"104"}), ("101107", {"106", "123"}), ("101111", {"108", "251"}), ("101115", {"108"}), ("101119", {"120"}), ("101129", {"114"}), ("101137", {"131"}), ("101139", {"133"}), ("101153", {"135"}), ("101155", {"136"}), ("101157", {"137"}), ("101159", {"138"}), ("201101", {"251"}), ("201111", {"253"}), ("20111501", {"254"}), ("20111503", {"254"}), ("20111505", {"275"}), ("20111507", {"274"}), ("20111509", {"276"}), ("20111511", {"276"}), ("201115", {"254"}), ("201129", {"255"}), ("40110101", {"411"}), ("40110102", {"412"}), ("40110103", {"415"}), ("40110301", {"413"}), ("40110401", {"414"}), ("501701", {"602"}), ("701101", {"901"}), ("701103", {"903"}), ("701105", {"905"}), ("701107", {"906"}), ("701109", {"908"}), ("701111", {"914"}), ("701115", {"930"}), ("703101", {"931"}), ("703103", {"933"}), ("703105", {"935"}), ("703107", {"937"}), ("703125", {"960"}), ("901", {"998"}), ] @dataclass class VoucherLine: id: int row: int group_id: str draft_no: str confirmed_no: str draft_date: date | None side: str amount: int account_code: str account_name: str desc: str vendor: str group_blob: str debit_supply: int credit_supply: int tax_debit: int tax_credit: int norm_desc: str norm_vendor: str norm_blob: str match_id: int | None = None score: int = 0 reason: str = "" date_diff: int | None = None match_ledger_ids: list[int] = field(default_factory=list) @dataclass class VoucherAggregate: id: int group_id: str row: int draft_no: str confirmed_no: str draft_date: date | None side: str amount: int account_code: str account_name: str desc: str vendor: str group_blob: str line_ids: list[int] norm_desc: str norm_vendor: str norm_blob: str match_id: int | None = None score: int = 0 reason: str = "" date_diff: int | None = None @dataclass class LedgerLine: id: int row: int ledger_date: date side: str amount: int account_code: str account_name: str desc: str vendor: str debit: int credit: int voucher_no: str source_row_no: str norm_desc: str norm_vendor: str norm_blob: str match_kind: str = "" match_id: int | None = None score: int = 0 reason: str = "" date_diff: int | None = None @dataclass(order=True) class Candidate: sort_key: tuple ledger_id: int = field(compare=False) voucher_kind: str = field(compare=False) voucher_id: int = field(compare=False) score: int = field(compare=False) reason: str = field(compare=False) date_diff: int = field(compare=False) def clean(value) -> str: return "" if value is None else str(value).strip() def norm(value) -> str: text = clean(value).lower() text = re.sub(r"\s+", "", text) return re.sub(r"[\(\)\[\]\{\},._\-/\\:;*×%◇]", "", text) def norm_party(value) -> str: text = clean(value).lower() text = text.replace("㈜", "") text = text.replace("(주)", "").replace("(주)", "") text = text.replace("주식회사", "").replace("주.", "") return norm(text) def amount(value) -> int: if value is None or value == "": return 0 if isinstance(value, bool): return int(value) if isinstance(value, (int, float)): return int(round(value)) try: return int(round(float(str(value).replace(",", "").strip()))) except ValueError: return 0 def ymd(value) -> date | None: if value is None or value == "": return None if isinstance(value, datetime): return value.date() if isinstance(value, date): return value text = str(value).strip().replace(".0", "") match = re.match(r"^(\d{4})(\d{2})(\d{2})$", text) if match: return date(int(match.group(1)), int(match.group(2)), int(match.group(3))) match = re.match(r"^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$", text) if match: return date(int(match.group(1)), int(match.group(2)), int(match.group(3))) return None def parse_ledger_date(value) -> date | None: if isinstance(value, datetime): return value.date() if isinstance(value, date): return value text = clean(value) match = re.match(r"^(\d{1,2})[-/.](\d{1,2})$", text) if match: return date(2025, int(match.group(1)), int(match.group(2))) return ymd(text) def date_from_voucher_no(value) -> date | None: match = re.search(r"-(\d{8})-", clean(value)) return ymd(match.group(1)) if match else None def mmdd(value: date | None) -> str: return value.strftime("%m-%d") if value else "" def group_no(value) -> str: return re.sub(r"-\d+$", "", clean(value)) def draft_group_no(value) -> str: return group_no(value) def setting_group_no(value: str) -> str: match = re.search(r"(11-\d{8}-[A-Z0-9]+)-0*(\d+)-0*\d+", clean(value)) if not match: return "" return f"{match.group(1)}-{int(match.group(2))}" def setting_group_refs(value: str) -> list[str]: refs = [] for match in re.finditer(r"(11-\d{8}-[A-Z0-9]+)-0*(\d+)-0*\d+", clean(value)): refs.append(f"{match.group(1)}-{int(match.group(2))}") return list(dict.fromkeys(refs)) def account_base(name: str) -> str: text = clean(name) if ")" in text and text.index(")") <= 3: text = text.split(")", 1)[1] text = re.sub(r"\(.*?\)|\[.*?\]", "", text) return norm(text) ACCOUNT_EQUIVALENT_GROUPS = [ {"외상매출금", "용역미수금"}, {"단기대여금", "관계회사단기대여금"}, ] def equivalent_account_names(left_name: str, right_name: str) -> bool: left = account_base(left_name) right = account_base(right_name) if not left or not right: return False if left in right or right in left: return True for group in ACCOUNT_EQUIVALENT_GROUPS: normalized = {norm(item) for item in group} if any(item in left for item in normalized) and any(item in right for item in normalized): return True return False def balance_sheet_context_accounts(voucher, ledger: LedgerLine) -> bool: voucher_code = clean(voucher.account_code) return ledger.account_code in {"108", "114", "131", "251"} and voucher_code[:3] in {"101", "201"} def economic_side_amount(side: str, line_amount: int) -> tuple[str, int, str]: if line_amount < 0: flipped = "credit" if side == "debit" else "debit" return flipped, abs(line_amount), "negative_amount_side_flip" return side, line_amount, "" def reversal_context_accounts(voucher, ledger: LedgerLine) -> bool: voucher_code = clean(voucher.account_code) receivable_ledger = ledger.account_code in {"108", "251"} payable_voucher = voucher_code.startswith("201111") or voucher_code.startswith("201101") return receivable_ledger and payable_voucher def negative_reversal_context(voucher, ledger: LedgerLine) -> bool: ledger_side, ledger_amount, ledger_reason = economic_side_amount(ledger.side, ledger.amount) voucher_side, voucher_amount, voucher_reason = economic_side_amount(voucher.side, voucher.amount) if not ledger_reason and not voucher_reason: return False if (ledger_side, ledger_amount) != (voucher_side, voucher_amount): return False return reversal_context_accounts(voucher, ledger) def source_line_for_multi_sum(line: VoucherLine) -> bool: if line.account_code.startswith("2"): return False if line.account_code.startswith("101101") or line.account_code.startswith("101105"): return False return True def allowed_codes(satis_code: str) -> set[str]: code = clean(satis_code).replace(".0", "") if code in EXACT_RULES: return EXACT_RULES[code] for prefix, allowed in PREFIX_RULES: if code.startswith(prefix): return allowed return set() def compatible_account(voucher, ledger: LedgerLine) -> tuple[bool, str]: allowed = allowed_codes(voucher.account_code) if allowed and ledger.account_code in allowed: return True, "semantic_code_ok" # Cost/expense accounts often differ only by SATIS detail and WEHAGO's # 6xx/8xx split. Use the normalized account-name base as a secondary rule. if voucher.account_code[:1] in ("5", "6") and ledger.account_code[:1] in ("6", "8"): if equivalent_account_names(voucher.account_name, ledger.account_name): return True, "account_name_base_ok" if equivalent_account_names(voucher.account_name, ledger.account_name): return True, "account_name_equivalent_ok" return False, "semantic_mismatch" def ledger_vendor_key(vendor: str) -> str: text = clean(vendor) if "/" in text: text = text.split("/")[-1] return norm_party(text) def vendor_status(ledger: LedgerLine, voucher) -> str: ledger_key = ledger_vendor_key(ledger.vendor) voucher_key = norm_party(voucher.vendor) voucher_blob = voucher.norm_blob if not ledger_key or not voucher_key: return "missing" if ledger_key in voucher_key or voucher_key in ledger_key or ledger_key in voucher_blob: return "match" if "/" in clean(ledger.vendor) and len(ledger_key) >= 2: return "mismatch" return "weak_mismatch" def date_score(diff: int) -> int: if diff == 0: return 140 if diff <= 3: return 120 if diff <= 7: return 90 if diff <= 15: return 60 if diff <= 31: return 35 if diff <= 60: return 5 if diff <= 90: return -35 if diff <= 180: return -110 return -190 def related_labor_desc(ledger: LedgerLine, voucher) -> bool: left = ledger.norm_desc right = voucher.norm_blob labor_cost = "인건비" in right labor_work = any(token in left for token in ["근무대가", "야간근무", "휴일근무", "휴일및야간근무"]) if not labor_cost or not labor_work: return False ratio = SequenceMatcher(None, ledger.norm_desc, voucher.norm_desc).ratio() if voucher.norm_desc else 0 return ratio >= 0.45 or any(token in right for token in ["책임감리", "건설사업관리", "고속도로", "터널"]) def best_voucher_date(voucher, ledger_date: date) -> tuple[int, str]: dates = [] if voucher.draft_date: dates.append(("draft", voucher.draft_date)) confirmed_date = date_from_voucher_no(voucher.confirmed_no) if confirmed_date: dates.append(("confirmed", confirmed_date)) if not dates: return 9999, "missing" basis, best = min(dates, key=lambda item: abs((item[1] - ledger_date).days)) return abs((best - ledger_date).days), basis def strong_desc_similarity(ledger: LedgerLine, voucher) -> tuple[bool, str, int]: if not ledger.norm_desc or not voucher.norm_desc: return False, "", 0 if ledger.norm_desc == voucher.norm_desc: return True, "absolute_desc_exact", 100 if ledger.norm_desc in voucher.norm_desc or voucher.norm_desc in ledger.norm_desc: return True, "absolute_desc_contains", 85 ratio = SequenceMatcher(None, ledger.norm_desc, voucher.norm_desc).ratio() if ratio >= 0.82: return True, f"absolute_desc_sim_{ratio:.2f}", int(70 * ratio) if ledger.norm_desc in voucher.norm_blob: return True, "absolute_ledger_desc_in_voucher_context", 70 return False, "", 0 def absolute_amount_desc_context(ledger: LedgerLine, voucher) -> tuple[bool, str, int]: if ledger.amount == voucher.amount: return False, "", 0 if abs(ledger.amount) != abs(voucher.amount): return False, "", 0 if ledger.side != voucher.side: return False, "", 0 desc_ok, desc_reason, desc_bonus = strong_desc_similarity(ledger, voucher) if not desc_ok: return False, "", 0 diff, _basis = best_voucher_date(voucher, ledger.ledger_date) v_status = vendor_status(ledger, voucher) if diff > 60 and v_status != "match": return False, "", 0 if v_status == "mismatch": return False, "", 0 bonus = 35 + desc_bonus reason = f"absolute_amount_desc_similarity,{desc_reason}" return True, reason, bonus def score_pair(ledger: LedgerLine, voucher) -> tuple[int, str, int]: ok, account_reason = compatible_account(voucher, ledger) if not voucher.draft_date and not date_from_voucher_no(voucher.confirmed_no): return -999, "missing_draft_date", 9999 v_status = vendor_status(ledger, voucher) diff, date_basis = best_voucher_date(voucher, ledger.ledger_date) ledger_account_base = account_base(ledger.account_name) account_context_in_blob = bool( ledger_account_base and len(ledger_account_base) >= 2 and ledger_account_base in voucher.norm_blob ) labor_desc_related = related_labor_desc(ledger, voucher) desc_strong = bool( account_context_in_blob or labor_desc_related or ( ledger.norm_desc and ( ledger.norm_desc == voucher.norm_desc or ledger.norm_desc in voucher.norm_desc or voucher.norm_desc in ledger.norm_desc or ledger.norm_desc in voucher.norm_blob ) ) ) negative_reversal = negative_reversal_context(voucher, ledger) absolute_desc_context, absolute_desc_reason, absolute_desc_bonus = absolute_amount_desc_context(ledger, voucher) context_vendor_override = ( v_status == "mismatch" and diff <= 60 and desc_strong and (ok or negative_reversal or voucher.account_code[:1] in ("5", "6")) ) account_context_override = ( not ok and diff <= 60 and ( (v_status == "match" and desc_strong) or (negative_reversal and v_status == "match" and desc_strong) or (absolute_desc_context and v_status in {"match", "missing", "weak_mismatch"}) or (v_status == "match" and diff <= 7 and balance_sheet_context_accounts(voucher, ledger)) or context_vendor_override ) ) if v_status == "mismatch" and not context_vendor_override: return -999, "vendor_mismatch", 9999 if not ok: if not account_context_override: return -999, account_reason, 9999 account_reason = "receivable_payable_reversal_context" if negative_reversal else "context_override_account_mismatch" score = 35 + date_score(diff) reasons = [account_reason, f"{date_basis}_diff_{diff}d"] if negative_reversal: score += 90 ledger_flip = economic_side_amount(ledger.side, ledger.amount)[2] voucher_flip = economic_side_amount(voucher.side, voucher.amount)[2] if ledger_flip: reasons.append(ledger_flip) if voucher_flip: reasons.append(f"voucher_{voucher_flip}") if absolute_desc_context: score += absolute_desc_bonus reasons.append(absolute_desc_reason) confirmed_date = date_from_voucher_no(voucher.confirmed_no) if confirmed_date and abs((confirmed_date - ledger.ledger_date).days) == 0: score += 12 reasons.append("confirmed_date_exact") if ledger.norm_desc and voucher.norm_desc: if ledger.norm_desc == voucher.norm_desc: score += 100 reasons.append("desc_exact") elif ledger.norm_desc in voucher.norm_desc or voucher.norm_desc in ledger.norm_desc: score += 75 reasons.append("desc_contains") else: ratio = SequenceMatcher(None, ledger.norm_desc, voucher.norm_desc).ratio() if ratio >= 0.72: score += int(55 * ratio) reasons.append(f"desc_sim_{ratio:.2f}") if ledger.norm_desc and ledger.norm_desc in voucher.norm_blob: score += 55 reasons.append("ledger_desc_in_voucher_group_context") if voucher.norm_desc and voucher.norm_desc in ledger.norm_blob: score += 30 reasons.append("voucher_desc_in_ledger_context") if account_context_in_blob: score += 55 reasons.append("ledger_account_in_voucher_context") if labor_desc_related: score += 70 reasons.append("desc_related_labor_cost_terms") if v_status == "match": score += 95 reasons.append("vendor_strong_match") elif context_vendor_override: score += 35 reasons.append("vendor_context_override_by_desc") elif v_status == "weak_mismatch": score -= 45 reasons.append("vendor_weak_mismatch") else: score -= 10 reasons.append("vendor_missing") if diff > 90 and v_status != "match": score -= 100 reasons.append("long_date_gap_without_vendor") if isinstance(voucher, VoucherAggregate): score += 35 reasons.append(f"group_sum_{len(voucher.line_ids)}lines") if voucher.group_id.startswith("multi:"): score += 45 reasons.append("multi_voucher_sum_by_settlement_refs") return score, ",".join(reasons), diff def load_vouchers(path: Path) -> tuple[list[VoucherLine], list[VoucherAggregate]]: workbook = load_workbook(path, read_only=True, data_only=True) sheet = workbook.worksheets[0] next(sheet.iter_rows(min_row=1, max_row=1, values_only=True)) raw_rows = [] groups = defaultdict(lambda: {"blob": [], "vendors": [], "descs": []}) settlement_groups = defaultdict(lambda: {"refs": [], "rows": [], "blob": [], "vendors": [], "descs": [], "date": None, "confirmed": []}) for row_number, row in enumerate(sheet.iter_rows(min_row=2, values_only=True), 2): values = list(row) base = group_no(values[10]) draft_base = draft_group_no(values[1]) desc = clean(values[15]) or clean(values[16]) vendor = clean(values[18]) blob = " ".join( clean(values[index]) for index in [8, 9, 11, 12, 13, 14, 15, 16, 18, 21, 22] if index < len(values) and values[index] is not None ) raw_rows.append( { "row": row_number, "values": values, "group": base, "draft_group": draft_base, "draft_date": date_from_voucher_no(values[1]), "desc": desc, "vendor": vendor, "blob": blob, } ) groups[base]["blob"].append(blob) if vendor: groups[base]["vendors"].append(vendor) if desc: groups[base]["descs"].append(desc) settlement_refs = setting_group_refs(blob) if settlement_refs: settlement = settlement_groups[draft_base] settlement["refs"].extend(settlement_refs) settlement["rows"].append(row_number) settlement["blob"].append(blob) if vendor: settlement["vendors"].append(vendor) if desc: settlement["descs"].append(desc) if not settlement["date"]: settlement["date"] = date_from_voucher_no(values[1]) confirmed = clean(values[10]) if confirmed: settlement["confirmed"].append(confirmed) lines: list[VoucherLine] = [] for raw in raw_rows: values = raw["values"] debit = amount(values[4]) credit = amount(values[6]) if not debit and not credit: continue side, line_amount = ("debit", debit) if debit and (not credit or abs(debit) >= abs(credit)) else ("credit", credit) group_data = groups[raw["group"]] vendor = raw["vendor"] or next(iter(group_data["vendors"]), "") desc = raw["desc"] or next(iter(group_data["descs"]), "") group_blob = " ".join(group_data["blob"] + [desc, vendor]) lines.append( VoucherLine( id=len(lines), row=raw["row"], group_id=raw["group"], draft_no=clean(values[1]), confirmed_no=clean(values[10]), draft_date=raw["draft_date"], side=side, amount=line_amount, account_code=clean(values[2]).replace(".0", ""), account_name=clean(values[3]), desc=desc, vendor=vendor, group_blob=group_blob, debit_supply=debit, credit_supply=credit, tax_debit=amount(values[5]), tax_credit=amount(values[7]), norm_desc=norm(desc), norm_vendor=norm(vendor), norm_blob=norm(group_blob), ) ) aggregates: list[VoucherAggregate] = [] source_by_draft_group = defaultdict(list) for line in lines: source_by_draft_group[draft_group_no(line.draft_no)].append(line) by_group_key = defaultdict(list) for line in lines: if line.account_code.startswith("2"): continue key = (line.group_id, line.side, account_base(line.account_name), norm(line.vendor)) by_group_key[key].append(line) for (group_id, side, _account_base, _vendor), group_lines in by_group_key.items(): if len(group_lines) < 2: continue first = group_lines[0] total = sum(item.amount for item in group_lines) desc = " / ".join(dict.fromkeys(item.desc for item in group_lines if item.desc)) aggregates.append( VoucherAggregate( id=len(aggregates), group_id=group_id, row=first.row, draft_no="; ".join(item.draft_no for item in group_lines), confirmed_no="; ".join(dict.fromkeys(item.confirmed_no for item in group_lines if item.confirmed_no)), draft_date=first.draft_date, side=side, amount=total, account_code=first.account_code, account_name=first.account_name, desc=desc or first.desc, vendor=first.vendor, group_blob=" ".join(item.group_blob for item in group_lines), line_ids=[item.id for item in group_lines], norm_desc=norm(desc or first.desc), norm_vendor=norm(first.vendor), norm_blob=norm(" ".join(item.group_blob for item in group_lines)), ) ) for settlement_group, data in settlement_groups.items(): refs = [ref for ref in dict.fromkeys(data["refs"]) if ref != settlement_group] if len(refs) < 2: continue source_lines = [ line for ref in refs for line in source_by_draft_group.get(ref, []) if source_line_for_multi_sum(line) ] by_multi_key = defaultdict(list) for line in source_lines: by_multi_key[(line.side, norm(line.vendor))].append(line) for (side, _vendor), group_lines in by_multi_key.items(): if len(group_lines) < 2: continue first = group_lines[0] total = sum(item.amount for item in group_lines) if not total: continue desc = " / ".join(dict.fromkeys(item.desc for item in group_lines if item.desc)) vendor = first.vendor or next(iter(data["vendors"]), "") blob = " ".join(data["blob"] + [item.group_blob for item in group_lines]) aggregates.append( VoucherAggregate( id=len(aggregates), group_id=f"multi:{settlement_group}", row=min(item.row for item in group_lines), draft_no="; ".join(item.draft_no for item in group_lines), confirmed_no="; ".join(dict.fromkeys(data["confirmed"] + [item.confirmed_no for item in group_lines if item.confirmed_no])), draft_date=data["date"] or first.draft_date, side=side, amount=total, account_code="; ".join(dict.fromkeys(item.account_code for item in group_lines if item.account_code)), account_name="; ".join(dict.fromkeys(item.account_name for item in group_lines if item.account_name)), desc=desc or " / ".join(dict.fromkeys(data["descs"])), vendor=vendor, group_blob=blob, line_ids=[item.id for item in group_lines], norm_desc=norm(desc or " / ".join(dict.fromkeys(data["descs"]))), norm_vendor=norm(vendor), norm_blob=norm(blob), ) ) return lines, aggregates def load_ledgers(path: Path) -> list[LedgerLine]: workbook = load_workbook(path, read_only=True, data_only=True) sheet = workbook.worksheets[0] next(sheet.iter_rows(min_row=1, max_row=1, values_only=True)) ledgers: list[LedgerLine] = [] for row_number, row in enumerate(sheet.iter_rows(min_row=2, values_only=True), 2): values = list(row) ledger_date = parse_ledger_date(values[5]) desc = clean(values[6]) debit = amount(values[8]) credit = amount(values[9]) if not ledger_date or desc.startswith("[") or (not debit and not credit): continue # Avoid carrying opening balance lines into matching without relying on a Korean literal. if not values[5] and not values[11]: continue side, line_amount = ("debit", debit) if debit and (not credit or abs(debit) >= abs(credit)) else ("credit", credit) vendor = clean(values[7]) blob = " ".join([desc, vendor, clean(values[1]), clean(values[11])]) ledgers.append( LedgerLine( id=len(ledgers), row=row_number, ledger_date=ledger_date, side=side, amount=line_amount, account_code=clean(values[0]).replace(".0", ""), account_name=clean(values[1]), desc=desc, vendor=vendor, debit=debit, credit=credit, voucher_no=clean(values[11]), source_row_no=clean(values[4]), norm_desc=norm(desc), norm_vendor=norm(vendor), norm_blob=norm(blob), ) ) return ledgers def build_candidates(ledgers: list[LedgerLine], voucher_lines: list[VoucherLine], aggregates: list[VoucherAggregate]) -> list[Candidate]: line_by_amount = defaultdict(list) line_by_abs_amount = defaultdict(list) line_by_economic_amount = defaultdict(list) aggregate_by_amount = defaultdict(list) for voucher in voucher_lines: line_by_amount[(voucher.side, voucher.amount)].append(voucher) line_by_abs_amount[(voucher.side, abs(voucher.amount))].append(voucher) economic_side, economic_amount, _reason = economic_side_amount(voucher.side, voucher.amount) line_by_economic_amount[(economic_side, economic_amount)].append(voucher) for aggregate in aggregates: aggregate_by_amount[(aggregate.side, aggregate.amount)].append(aggregate) candidates: list[Candidate] = [] for ledger in ledgers: for voucher in line_by_amount.get((ledger.side, ledger.amount), []): score, reason, diff = score_pair(ledger, voucher) if score >= 135: exact_desc = int(bool(ledger.norm_desc and ledger.norm_desc == voucher.norm_desc)) strong_vendor = int("vendor_strong_match" in reason) candidates.append( Candidate( sort_key=(score, strong_vendor, exact_desc, -diff), ledger_id=ledger.id, voucher_kind="line", voucher_id=voucher.id, score=score, reason=reason, date_diff=diff, ) ) for voucher in line_by_abs_amount.get((ledger.side, abs(ledger.amount)), []): if voucher.amount == ledger.amount: continue score, reason, diff = score_pair(ledger, voucher) if "absolute_amount_desc_similarity" not in reason: continue if score >= 150: exact_desc = int(bool(ledger.norm_desc and ledger.norm_desc == voucher.norm_desc)) strong_vendor = int("vendor_strong_match" in reason) candidates.append( Candidate( sort_key=(score, strong_vendor, exact_desc, -diff), ledger_id=ledger.id, voucher_kind="absline", voucher_id=voucher.id, score=score, reason=reason, date_diff=diff, ) ) ledger_economic_side, ledger_economic_amount, ledger_economic_reason = economic_side_amount(ledger.side, ledger.amount) if ledger_economic_reason: for voucher in line_by_economic_amount.get((ledger_economic_side, ledger_economic_amount), []): if (voucher.side, voucher.amount) == (ledger.side, ledger.amount): continue score, reason, diff = score_pair(ledger, voucher) if "receivable_payable_reversal_context" not in reason: continue if score >= 155: exact_desc = int(bool(ledger.norm_desc and ledger.norm_desc == voucher.norm_desc)) strong_vendor = int("vendor_strong_match" in reason) candidates.append( Candidate( sort_key=(score, strong_vendor, exact_desc, -diff), ledger_id=ledger.id, voucher_kind="line", voucher_id=voucher.id, score=score, reason=reason, date_diff=diff, ) ) for aggregate in aggregate_by_amount.get((ledger.side, ledger.amount), []): score, reason, diff = score_pair(ledger, aggregate) if score >= 155: exact_desc = int(bool(ledger.norm_desc and ledger.norm_desc == aggregate.norm_desc)) strong_vendor = int("vendor_strong_match" in reason) kind = "multi" if aggregate.group_id.startswith("multi:") else "aggregate" candidates.append( Candidate( sort_key=(score, strong_vendor, exact_desc, -diff), ledger_id=ledger.id, voucher_kind=kind, voucher_id=aggregate.id, score=score, reason=reason, date_diff=diff, ) ) candidates.sort(reverse=True) return candidates def assign_matches(ledgers: list[LedgerLine], voucher_lines: list[VoucherLine], aggregates: list[VoucherAggregate], candidates: list[Candidate]) -> None: used_ledgers: set[int] = set() used_voucher_lines: set[int] = set() ordered_candidates = ( [item for item in candidates if item.voucher_kind not in {"multi", "absline"}] + [item for item in candidates if item.voucher_kind == "multi"] + [item for item in candidates if item.voucher_kind == "absline"] ) for candidate in ordered_candidates: if candidate.ledger_id in used_ledgers: continue if candidate.voucher_kind in {"line", "absline"}: voucher = voucher_lines[candidate.voucher_id] line_ids = [voucher.id] else: voucher = aggregates[candidate.voucher_id] line_ids = voucher.line_ids if any(line_id in used_voucher_lines for line_id in line_ids): continue ledger = ledgers[candidate.ledger_id] ledger.match_kind = candidate.voucher_kind ledger.match_id = candidate.voucher_id ledger.score = candidate.score ledger.reason = candidate.reason ledger.date_diff = candidate.date_diff if candidate.voucher_kind in {"line", "absline"}: voucher.match_id = ledger.id voucher.score = candidate.score voucher.reason = candidate.reason voucher.date_diff = candidate.date_diff else: voucher.match_id = ledger.id voucher.score = candidate.score voucher.reason = candidate.reason voucher.date_diff = candidate.date_diff for line_id in voucher.line_ids: line = voucher_lines[line_id] line.match_id = ledger.id line.score = candidate.score line.reason = candidate.reason line.date_diff = candidate.date_diff used_ledgers.add(ledger.id) used_voucher_lines.update(line_ids) def compact_range(values: Iterable[str]) -> str: numbers = [] text_values = [] for value in values: text = clean(value) if not text: continue try: numbers.append(int(float(text))) except ValueError: text_values.append(text) if numbers and not text_values: numbers = sorted(dict.fromkeys(numbers)) if len(numbers) > 1 and numbers == list(range(numbers[0], numbers[-1] + 1)): return f"{numbers[0]}-{numbers[-1]}" return "; ".join(str(item) for item in numbers) return "; ".join(dict.fromkeys(text_values + [str(item) for item in numbers])) def ledger_group_key(ledger: LedgerLine) -> tuple: return ( ledger.side, ledger.ledger_date, ledger.account_code, account_base(ledger.account_name), ledger_vendor_key(ledger.vendor), ledger.norm_desc, ) def make_ledger_group_probe(group_lines: list[LedgerLine], total: int) -> LedgerLine: first = group_lines[0] voucher_numbers = "; ".join(item.voucher_no for item in group_lines if item.voucher_no) source_rows = compact_range(item.source_row_no for item in group_lines) blob = " ".join([first.desc, first.vendor, first.account_name, voucher_numbers, source_rows]) return LedgerLine( id=-1, row=min(item.row for item in group_lines), ledger_date=first.ledger_date, side=first.side, amount=total, account_code=first.account_code, account_name=first.account_name, desc=first.desc, vendor=first.vendor, debit=total if first.side == "debit" else 0, credit=total if first.side == "credit" else 0, voucher_no=voucher_numbers, source_row_no=source_rows, norm_desc=first.norm_desc, norm_vendor=first.norm_vendor, norm_blob=norm(blob), ) def assign_ledger_group_matches(ledgers: list[LedgerLine], voucher_lines: list[VoucherLine]) -> int: unmatched_groups = defaultdict(list) for ledger in ledgers: if ledger.match_id is None: unmatched_groups[ledger_group_key(ledger)].append(ledger) available_vouchers = defaultdict(list) for voucher in voucher_lines: if voucher.match_id is None: available_vouchers[(voucher.side, voucher.amount)].append(voucher) group_candidates = [] for group_lines in unmatched_groups.values(): if len(group_lines) < 2: continue total = sum(item.amount for item in group_lines) if not total: continue probe = make_ledger_group_probe(group_lines, total) for voucher in available_vouchers.get((probe.side, probe.amount), []): score, reason, diff = score_pair(probe, voucher) if score < 185: continue reason = ",".join( [ reason, f"ledger_group_sum_to_single_voucher", f"group_sum_{len(group_lines)}ledger_lines", f"group_source_rows_{probe.source_row_no}", f"group_vouchers_{probe.voucher_no}", ] ) exact_desc = int(bool(probe.norm_desc and probe.norm_desc == voucher.norm_desc)) strong_vendor = int("vendor_strong_match" in reason) group_candidates.append((score, strong_vendor, exact_desc, -diff, group_lines, voucher, reason, diff)) matched_count = 0 used_ledgers = {ledger.id for ledger in ledgers if ledger.match_id is not None} used_vouchers = {voucher.id for voucher in voucher_lines if voucher.match_id is not None} for score, _strong_vendor, _exact_desc, _neg_diff, group_lines, voucher, reason, diff in sorted( group_candidates, key=lambda item: item[:4], reverse=True ): if voucher.id in used_vouchers: continue if any(ledger.id in used_ledgers for ledger in group_lines): continue ledger_ids = [ledger.id for ledger in group_lines] for ledger in group_lines: ledger.match_kind = "ledger_group" ledger.match_id = voucher.id ledger.score = score ledger.reason = reason ledger.date_diff = diff used_ledgers.add(ledger.id) matched_count += 1 voucher.match_id = group_lines[0].id voucher.match_ledger_ids = ledger_ids voucher.score = score voucher.reason = reason voucher.date_diff = diff used_vouchers.add(voucher.id) return matched_count def review(diff: int | None) -> tuple[str, str]: if diff is None: return "", "" if diff > 90: return "REVIEW_LONG_DATE_GAP", f"draft-ledger date diff {diff}d" if diff >= 31: return "REVIEW_DATE_GAP", f"draft-ledger date diff {diff}d" return "", "" def style_like(target, source) -> None: if not source.has_style: return target.font = copy(source.font) target.fill = copy(source.fill) target.border = copy(source.border) target.alignment = copy(source.alignment) target.number_format = source.number_format target.protection = copy(source.protection) def write_ledger_output( source_path: Path, output_path: Path, ledgers: list[LedgerLine], voucher_lines: list[VoucherLine], aggregates: list[VoucherAggregate], ) -> None: ledger_by_row = {item.row: item for item in ledgers} workbook = load_workbook(source_path) sheet = workbook.worksheets[0] for offset, header in enumerate(LEDGER_HEADERS): cell = sheet.cell(row=1, column=15 + offset, value=header) style_like(cell, sheet.cell(row=1, column=14)) sheet.column_dimensions[cell.column_letter].width = 22 if offset != 8 else 60 for row_number in range(2, sheet.max_row + 1): ledger = ledger_by_row.get(row_number) values = [None] * len(LEDGER_HEADERS) if ledger and ledger.match_id is not None: if ledger.match_kind in ("line", "absline", "ledger_group"): match = voucher_lines[ledger.match_id] debit_value = match.debit_supply credit_value = match.credit_supply else: match = aggregates[ledger.match_id] debit_value = match.amount if match.side == "debit" else 0 credit_value = match.amount if match.side == "credit" else 0 flag, memo = review(ledger.date_diff) values = [ match.draft_no, match.account_code, match.account_name, debit_value, credit_value, match.confirmed_no, mmdd(match.draft_date), ledger.date_diff, ledger.reason, flag, memo, ] for offset, value in enumerate(values): sheet.cell(row=row_number, column=15 + offset, value=value) workbook.save(output_path) def write_voucher_output(source_path: Path, output_path: Path, ledgers: list[LedgerLine], voucher_lines: list[VoucherLine]) -> None: ledger_by_id = {item.id: item for item in ledgers} voucher_by_row = {item.row: item for item in voucher_lines} workbook = load_workbook(source_path) sheet = workbook.worksheets[0] for offset, header in enumerate(VOUCHER_HEADERS): cell = sheet.cell(row=1, column=24 + offset, value=header) style_like(cell, sheet.cell(row=1, column=23)) sheet.column_dimensions[cell.column_letter].width = 18 if offset != 7 else 60 for row_number in range(2, sheet.max_row + 1): voucher = voucher_by_row.get(row_number) values = [None] * len(VOUCHER_HEADERS) if voucher and voucher.match_id is not None: matched_ledgers = [ledger_by_id[item] for item in voucher.match_ledger_ids] if voucher.match_ledger_ids else [ledger_by_id[voucher.match_id]] first = matched_ledgers[0] flag, memo = review(voucher.date_diff) values = [ "; ".join(dict.fromkeys(item.account_code for item in matched_ledgers)), "; ".join(dict.fromkeys(item.account_name for item in matched_ledgers)), mmdd(first.ledger_date), sum(item.debit for item in matched_ledgers) or None, sum(item.credit for item in matched_ledgers) or None, "; ".join(item.voucher_no for item in matched_ledgers if item.voucher_no), voucher.date_diff, voucher.reason, flag, memo, ] for offset, value in enumerate(values): sheet.cell(row=row_number, column=24 + offset, value=value) workbook.save(output_path) def run(ledger_path: Path, voucher_path: Path, output_dir: Path) -> tuple[Path, Path, dict]: output_dir.mkdir(parents=True, exist_ok=True) ledger_out = output_dir / LEDGER_OUT_NAME voucher_out = output_dir / VOUCHER_OUT_NAME voucher_lines, aggregates = load_vouchers(voucher_path) ledgers = load_ledgers(ledger_path) candidates = build_candidates(ledgers, voucher_lines, aggregates) assign_matches(ledgers, voucher_lines, aggregates, candidates) ledger_group_matches = assign_ledger_group_matches(ledgers, voucher_lines) write_ledger_output(ledger_path, ledger_out, ledgers, voucher_lines, aggregates) write_voucher_output(voucher_path, voucher_out, ledgers, voucher_lines) stats = { "ledger_rows": len(ledgers), "voucher_lines": len(voucher_lines), "voucher_aggregates": len(aggregates), "candidates": len(candidates), "ledger_group_matches": ledger_group_matches, "matched_ledgers": sum(1 for item in ledgers if item.match_id is not None), "matched_voucher_lines": sum(1 for item in voucher_lines if item.match_id is not None), } return ledger_out, voucher_out, stats def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Match WEHAGO ledger rows against SATIS voucher rows.") parser.add_argument("--ledger", type=Path, default=DEFAULT_LEDGER, help="Path to ledger xlsx.") parser.add_argument("--voucher", type=Path, default=DEFAULT_VOUCHER, help="Path to voucher xlsx.") parser.add_argument("--output-dir", type=Path, default=DEFAULT_DIR, help="Directory for output xlsx files.") return parser.parse_args() def main() -> None: args = parse_args() ledger_out, voucher_out, stats = run(args.ledger, args.voucher, args.output_dir) print(f"ledger_out={ledger_out}") print(f"voucher_out={voucher_out}") for key, value in stats.items(): print(f"{key}={value}") if __name__ == "__main__": main()