Files
WEHAGO_DB/data_detact_260422.py
2026-04-23 11:19:01 +09:00

1245 lines
45 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()