from __future__ import annotations import hashlib import json import re import threading import time from dataclasses import dataclass from datetime import date, datetime from difflib import SequenceMatcher from functools import lru_cache from pathlib import Path from typing import Any, Iterable from openpyxl import load_workbook from sqlalchemy import text WEHAGO_SOURCE_ROOT = Path(Path(__file__).resolve().parent.parent / "WEHAGO_DB") VOUCHER_HEADERS = [ "결재상태", "가전표번호", "계정코드", "계정명칭", "차변공급가", "차변부가세", "대변공급가", "대변부가세", "발의부서코드", "발의부서명칭", "확정전표번호", "지원부서코드", "지원부서명칭", "원가부서코드", "원가부서명칭", "적요1", "적요2", "거래처코드", "거래처명칭", "세무코드", "증빙일자", "전표종류", "관리항목", ] LEDGER_HEADERS = [ "일자", "적요", "거래처", "차변", "대변", "잔액", "전표번호", "계정코드", "계정명", ] CONSOLIDATED_LEDGER_PREFIX_HEADERS = ["계정코드", "계정명", "원본파일", "원본시트", "원본행번호"] STATUS_META = [ ("matched", "Matched", "WEHAGO 기준으로 정상 매칭된 항목"), ("ledger_only", "Unmatched", "WEHAGO에는 있으나 ERP와 연결되지 않은 항목"), ("voucher_only", "ERP Unmatched", "ERP 기준으로 매치되지 않은 항목"), ("amount_mismatch", "Recheck", "전표번호는 같지만 금액이 달라 다시 확인이 필요한 항목"), ("voucher_matched", "WEHAGO", "WEHAGO 전표 기준으로 매칭된 결과"), ("erp_voucher_matched", "Voucher", "Hanmac ERP 전표 기준으로 매칭된 결과"), ("voucher_unmatched", "WEHAGO Unmatched", "WEHAGO 전표 기준으로 매칭되지 않은 결과"), ("voucher_recheck", "WEHAGO Recheck", "WEHAGO 전표 기준으로 재검토가 필요한 결과"), ] DETAIL_COLUMN_MAP = { "matched": [ ("fiscal_year", "연도"), ("ledger_date", "일자"), ("voucher_no", "전표번호"), ("draft_no", "가전표번호"), ("ledger_account_name", "계정"), ("voucher_account_name", "ERP 계정"), ("ledger_vendor", "거래처"), ("voucher_vendor", "ERP 거래처"), ("ledger_debit", "차변"), ("ledger_credit", "대변"), ("voucher_debit", "ERP 차변"), ("voucher_credit", "ERP 대변"), ("ledger_desc", "WEHAGO 적요"), ("voucher_desc", "ERP 적요"), ], "ledger_only": [ ("fiscal_year", "연도"), ("voucher_no", "전표번호"), ("ledger_date", "일자"), ("ledger_account_name", "WEHAGO 계정"), ("ledger_vendor", "WEHAGO 거래처"), ("ledger_debit", "차변"), ("ledger_credit", "대변"), ("ledger_desc", "적요"), ], "amount_mismatch": [ ("fiscal_year", "연도"), ("ledger_date", "일자"), ("voucher_no", "전표번호"), ("draft_no", "가전표번호"), ("ledger_account_name", "계정"), ("voucher_account_name", "ERP 계정"), ("ledger_vendor", "거래처"), ("voucher_vendor", "ERP 거래처"), ("ledger_debit", "차변"), ("ledger_credit", "대변"), ("voucher_debit", "ERP 차변"), ("voucher_credit", "ERP 대변"), ("substitution_hint", "대체 검토"), ("ledger_desc", "WEHAGO 적요"), ("voucher_desc", "ERP 적요"), ], "voucher_only": [ ("fiscal_year", "연도"), ("voucher_no", "전표번호"), ("proof_date", "증빙일자"), ("voucher_account_name", "ERP 계정"), ("voucher_vendor", "ERP 거래처"), ("voucher_debit", "차변"), ("voucher_credit", "대변"), ("voucher_desc", "적요"), ], "voucher_matched": [ ("fiscal_year", "연도"), ("ledger_date", "WEHAGO 일자"), ("proof_date", "ERP 증빙일자"), ("voucher_no", "전표번호"), ("draft_no", "가전표번호"), ("ledger_row_count", "WEHAGO 행수"), ("voucher_row_count", "ERP 행수"), ("ledger_debit", "WEHAGO 차변"), ("ledger_credit", "WEHAGO 대변"), ("voucher_debit", "ERP 차변"), ("voucher_credit", "ERP 대변"), ("ledger_accounts", "WEHAGO 계정"), ("voucher_accounts", "ERP 계정"), ("ledger_vendors", "WEHAGO 거래처"), ("voucher_vendors", "ERP 거래처"), ("review_reason", "검증근거"), ], "voucher_unmatched": [ ("fiscal_year", "연도"), ("status_label", "구분"), ("ledger_date", "WEHAGO 일자"), ("proof_date", "ERP 증빙일자"), ("voucher_no", "전표번호"), ("draft_no", "가전표번호"), ("ledger_row_count", "WEHAGO 행수"), ("voucher_row_count", "ERP 행수"), ("ledger_debit", "WEHAGO 차변"), ("ledger_credit", "WEHAGO 대변"), ("voucher_debit", "ERP 차변"), ("voucher_credit", "ERP 대변"), ("ledger_accounts", "WEHAGO 계정"), ("voucher_accounts", "ERP 계정"), ("ledger_vendors", "WEHAGO 거래처"), ("voucher_vendors", "ERP 거래처"), ("review_reason", "검토사유"), ], "voucher_recheck": [ ("fiscal_year", "연도"), ("status_label", "구분"), ("ledger_date", "WEHAGO 일자"), ("proof_date", "ERP 증빙일자"), ("voucher_no", "전표번호"), ("draft_no", "가전표번호"), ("ledger_row_count", "WEHAGO 행수"), ("voucher_row_count", "ERP 행수"), ("ledger_debit", "WEHAGO 차변"), ("ledger_credit", "WEHAGO 대변"), ("voucher_debit", "ERP 차변"), ("voucher_credit", "ERP 대변"), ("ledger_accounts", "WEHAGO 계정"), ("voucher_accounts", "ERP 계정"), ("ledger_vendors", "WEHAGO 거래처"), ("voucher_vendors", "ERP 거래처"), ("review_reason", "검토사유"), ], "erp_voucher_matched": [ ("fiscal_year", "연도"), ("ledger_date", "WEHAGO 일자"), ("proof_date", "ERP 증빙일자"), ("voucher_no", "전표번호"), ("draft_no", "가전표번호"), ("ledger_row_count", "WEHAGO 행수"), ("voucher_row_count", "ERP 행수"), ("ledger_debit", "WEHAGO 차변"), ("ledger_credit", "WEHAGO 대변"), ("voucher_debit", "ERP 차변"), ("voucher_credit", "ERP 대변"), ("ledger_accounts", "WEHAGO 계정"), ("voucher_accounts", "ERP 계정"), ("ledger_vendors", "WEHAGO 거래처"), ("voucher_vendors", "ERP 거래처"), ("review_reason", "검증근거"), ], } WEHAGO_COLUMNS = [ ("ledger_date", "일자"), ("voucher_no", "전표번호"), ("account_code", "계정코드"), ("account_name", "계정명"), ("vendor_name", "거래처"), ("description", "적요"), ("debit", "차변"), ("credit", "대변"), ] ERP_COLUMNS = [ ("proof_date", "증빙일자"), ("confirmed_no", "확정전표번호"), ("draft_no", "가전표번호"), ("account_code", "계정코드"), ("account_name", "계정명"), ("vendor_name", "거래처"), ("desc1", "적요1"), ("desc2", "적요2"), ("debit_supply", "차변공급가"), ("credit_supply", "대변공급가"), ] _DASHBOARD_CACHE: dict[str, dict[str, Any]] = {} _DASHBOARD_CACHE_TTL_SEC = 20 _SUGGEST_CACHE: dict[str, dict[str, Any]] = {} _SUGGEST_CACHE_TTL_SEC = 20 _STATUS_ROWS_CACHE: dict[str, dict[str, Any]] = {} _STATUS_ROWS_CACHE_TTL_SEC = 300 _VOUCHER_SECTION_CACHE: dict[str, dict[str, Any]] = {} _VOUCHER_SECTION_CACHE_TTL_SEC = 300 _PAIR_RECOMMEND_CACHE: dict[str, dict[str, Any]] = {} _PAIR_RECOMMEND_CACHE_TTL_SEC = 120 _STATUS_CACHE_WARMING: set[str] = set() _STATUS_CACHE_WARMING_LOCK = threading.Lock() _METRIC_COUNTS_WARMING: set[str] = set() _METRIC_COUNTS_WARMING_LOCK = threading.Lock() ENABLE_GENERATED_RECHECK_CANDIDATES = False RECHECK_RESOLUTION_POLICY_VERSION = "recheck-flex-strong-conditions-v13-cross-year-and-match-validation" METRIC_COUNT_CACHE_VERSION = "voucher-summary-v5" _PAIR_RECOMMEND_PERSIST_TTL_SEC = 1800 _PAIR_RECOMMEND_JOB_EVENT = threading.Event() _PAIR_RECOMMEND_WORKER_LOCK = threading.Lock() _PAIR_RECOMMEND_WORKER_STARTED = False PAIR_RECOMMEND_POLICY_VERSION = "date-window-8m-no-month-token-gate-v1" PAIR_RECOMMEND_DATE_WINDOW_MONTHS = 8 @lru_cache(maxsize=8) def _file_content_signature(path_text: str, mtime_ns: int, size: int) -> str: path = Path(path_text) try: return hashlib.sha1(path.read_bytes()).hexdigest() except Exception: return f"{mtime_ns}:{size}" def _current_logic_signature() -> str: module_path = Path(__file__).resolve() stat = module_path.stat() file_sig = _file_content_signature(str(module_path), int(stat.st_mtime_ns), int(stat.st_size)) return "|".join( [ METRIC_COUNT_CACHE_VERSION, RECHECK_RESOLUTION_POLICY_VERSION, PAIR_RECOMMEND_POLICY_VERSION, "generated-recheck-on" if ENABLE_GENERATED_RECHECK_CANDIDATES else "generated-recheck-off", file_sig, ] ) def _fast_metric_counts_from_result_files(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int]: counts = {"matched": 0, "ledger_only": 0, "voucher_only": 0, "amount_mismatch": 0} if start_year is None or end_year is None: return counts for year in range(start_year, end_year + 1): bundle = discover_compare_result_bundle(year) if not bundle: continue ledger_wb = load_workbook(bundle["ledger_result"], read_only=True, data_only=True) ledger_ws = ledger_wb.worksheets[0] ledger_header = [clean(v) for v in next(ledger_ws.iter_rows(min_row=1, max_row=1, values_only=True))] ledger_idx = {name: idx for idx, name in enumerate(ledger_header)} matched_col = ledger_idx.get("matched_확정전표번호") if matched_col is not None: for row in ledger_ws.iter_rows(min_row=2, values_only=True): matched_no = clean(row[matched_col]) if matched_col < len(row) else "" if matched_no: counts["matched"] += 1 else: counts["ledger_only"] += 1 voucher_wb = load_workbook(bundle["voucher_result"], read_only=True, data_only=True) voucher_ws = voucher_wb.worksheets[0] voucher_header = [clean(v) for v in next(voucher_ws.iter_rows(min_row=1, max_row=1, values_only=True))] voucher_idx = {name: idx for idx, name in enumerate(voucher_header)} matched_col = voucher_idx.get("matched_전표번호") reason_col = voucher_idx.get("matched_검증근거") flag_col = voucher_idx.get("review_flag") for row in voucher_ws.iter_rows(min_row=2, values_only=True): matched_no = clean(row[matched_col]) if (matched_col is not None and matched_col < len(row)) else "" if not matched_no: counts["voucher_only"] += 1 continue review_reason = clean(row[reason_col]) if (reason_col is not None and reason_col < len(row)) else "" review_flag = row[flag_col] if (flag_col is not None and flag_col < len(row)) else "" if _normalize_flag(review_flag) or ("REVIEW" in review_reason): counts["amount_mismatch"] += 1 reviewed_count = int( conn.execute( text( """ SELECT COUNT(*) FROM wehago_recheck_reviews WHERE (:start_year IS NULL OR fiscal_year >= :start_year) AND (:end_year IS NULL OR fiscal_year <= :end_year) """ ), {"start_year": start_year, "end_year": end_year}, ).scalar_one() ) pair_count = int( conn.execute( text( """ SELECT COUNT(*) FROM wehago_manual_pair_matches WHERE (:start_year IS NULL OR fiscal_year >= :start_year) AND (:end_year IS NULL OR fiscal_year <= :end_year) """ ), {"start_year": start_year, "end_year": end_year}, ).scalar_one() ) counts["matched"] = max(0, counts["matched"] + reviewed_count + pair_count) counts["amount_mismatch"] = max(0, counts["amount_mismatch"] - reviewed_count) counts["ledger_only"] = max(0, counts["ledger_only"] - pair_count) counts["voucher_only"] = max(0, counts["voucher_only"] - pair_count) return counts def _empty_metric_counts() -> dict[str, int]: return {status_key: 0 for status_key, _, _ in STATUS_META} def _metric_counts_signature(conn: Any, start_year: int | None, end_year: int | None) -> str: return "|".join( [ _current_logic_signature(), _build_bundle_signature(start_year, end_year), _build_db_state_signature(conn, start_year, end_year), ] ) def _load_metric_counts_cache(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int] | None: if start_year is None or end_year is None: return _empty_metric_counts() signature = _metric_counts_signature(conn, start_year, end_year) cached = conn.execute( text( """ SELECT counts_json FROM wehago_metric_count_cache WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature LIMIT 1 """ ), {"start_year": start_year, "end_year": end_year, "signature": signature}, ).first() if not cached or not cached[0]: return None try: payload = json.loads(str(cached[0])) if isinstance(payload, dict): return {status_key: int(payload.get(status_key, 0) or 0) for status_key, _, _ in STATUS_META} except Exception: return None return None def _load_latest_metric_counts_cache_any_signature( conn: Any, start_year: int | None, end_year: int | None, ) -> dict[str, int] | None: if start_year is None or end_year is None: return _empty_metric_counts() cached = conn.execute( text( """ SELECT counts_json FROM wehago_metric_count_cache WHERE start_year = :start_year AND end_year = :end_year ORDER BY updated_at DESC, created_at DESC LIMIT 1 """ ), {"start_year": start_year, "end_year": end_year}, ).first() if not cached or not cached[0]: return None try: payload = json.loads(str(cached[0])) if isinstance(payload, dict): return {status_key: int(payload.get(status_key, 0) or 0) for status_key, _, _ in STATUS_META} except Exception: return None return None def _store_metric_counts_cache( conn: Any, start_year: int | None, end_year: int | None, counts: dict[str, int], ) -> None: if start_year is None or end_year is None: return signature = _metric_counts_signature(conn, start_year, end_year) conn.execute( text( """ INSERT INTO wehago_metric_count_cache ( start_year, end_year, signature, counts_json, created_at, updated_at ) VALUES ( :start_year, :end_year, :signature, :counts_json, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) ON CONFLICT(start_year, end_year, signature) DO UPDATE SET counts_json = excluded.counts_json, updated_at = CURRENT_TIMESTAMP """ ), { "start_year": start_year, "end_year": end_year, "signature": signature, "counts_json": json.dumps(counts, ensure_ascii=False), }, ) def _fallback_metric_counts_from_db(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int]: year_sql = build_year_filter_sql("c.fiscal_year") return { "matched": int( conn.execute( text( """ SELECT COUNT(*) FROM wehago_ledger_rows l JOIN wehago_comparison_results c ON c.fiscal_year = l.fiscal_year AND c.voucher_no = l.compare_voucher_no WHERE c.status = 'matched' AND """ + year_sql ), {"start_year": start_year, "end_year": end_year}, ).scalar_one() or 0 ), "ledger_only": int( conn.execute( text( """ SELECT COUNT(*) FROM wehago_ledger_rows l JOIN wehago_comparison_results c ON c.fiscal_year = l.fiscal_year AND c.voucher_no = l.compare_voucher_no WHERE c.status = 'ledger_only' AND """ + year_sql ), {"start_year": start_year, "end_year": end_year}, ).scalar_one() or 0 ), "amount_mismatch": int( conn.execute( text( """ SELECT COUNT(*) FROM wehago_ledger_rows l JOIN wehago_comparison_results c ON c.fiscal_year = l.fiscal_year AND c.voucher_no = l.compare_voucher_no WHERE c.status = 'amount_mismatch' AND """ + year_sql ), {"start_year": start_year, "end_year": end_year}, ).scalar_one() or 0 ), "voucher_only": int( conn.execute( text( """ SELECT COUNT(*) FROM wehago_voucher_rows v JOIN wehago_comparison_results c ON c.fiscal_year = v.fiscal_year AND c.voucher_no = v.compare_voucher_no WHERE c.status = 'voucher_only' AND """ + year_sql ), {"start_year": start_year, "end_year": end_year}, ).scalar_one() or 0 ), } def _voucher_metric_counts_from_db(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int]: year_sql = build_year_filter_sql("fiscal_year") matched_count = int( conn.execute( text( """ SELECT COUNT(*) FROM wehago_comparison_results WHERE status = 'matched' AND """ + year_sql ), {"start_year": start_year, "end_year": end_year}, ).scalar_one() or 0 ) unmatched_count = int( conn.execute( text( """ SELECT COUNT(*) FROM wehago_comparison_results WHERE status = 'ledger_only' AND """ + year_sql ), {"start_year": start_year, "end_year": end_year}, ).scalar_one() or 0 ) recheck_count = int( conn.execute( text( """ SELECT COUNT(*) FROM wehago_comparison_results WHERE status = 'amount_mismatch' AND """ + year_sql ), {"start_year": start_year, "end_year": end_year}, ).scalar_one() or 0 ) return { "voucher_matched": matched_count, "erp_voucher_matched": matched_count, "voucher_unmatched": unmatched_count, "voucher_recheck": recheck_count, } def _resolved_metric_counts(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int]: counts = _empty_metric_counts() if start_year is None or end_year is None: return counts if start_year != end_year: rows_by_status: dict[str, list[dict[str, Any]]] = { "matched": [], "ledger_only": [], "amount_mismatch": [], "voucher_only": [], } for year in range(start_year, end_year + 1): sections = _get_or_create_year_resolved_sections(conn, year) if not sections: db_counts = _fallback_metric_counts_from_db(conn, year, year) for status_key, value in db_counts.items(): counts[status_key] += value continue for status_key in rows_by_status: rows_by_status[status_key].extend(sections.get(status_key, {}).get("rows", [])) section_payload = { status_key: { "rows": list(rows), "count": len(rows), "columns": DETAIL_COLUMN_MAP[status_key], } for status_key, rows in rows_by_status.items() } section_payload = _promote_cross_year_auto_matches(section_payload) for status_key in counts: counts[status_key] = len(section_payload.get(status_key, {}).get("rows", [])) voucher_sections = _build_voucher_sections_from_rows_by_status( { "matched": list(section_payload.get("matched", {}).get("rows", [])), "ledger_only": list(section_payload.get("ledger_only", {}).get("rows", [])), "amount_mismatch": list(section_payload.get("amount_mismatch", {}).get("rows", [])), "voucher_only": list(section_payload.get("voucher_only", {}).get("rows", [])), } ) counts["voucher_matched"] = len(voucher_sections.get("voucher_matched", [])) counts["erp_voucher_matched"] = len(voucher_sections.get("erp_voucher_matched", [])) counts["voucher_unmatched"] = len(voucher_sections.get("voucher_unmatched", [])) counts["voucher_recheck"] = len(voucher_sections.get("voucher_recheck", [])) if not counts["voucher_matched"] and not counts["erp_voucher_matched"] and not counts["voucher_unmatched"] and not counts["voucher_recheck"] and any(counts.get(key) for key in ("matched", "ledger_only", "voucher_only", "amount_mismatch")): counts.update(_voucher_metric_counts_from_db(conn, start_year, end_year)) return counts rows_by_status: dict[str, list[dict[str, Any]]] = { "matched": [], "ledger_only": [], "amount_mismatch": [], "voucher_only": [], } for year in range(start_year, end_year + 1): sections = _get_or_create_year_resolved_sections(conn, year) if not sections: db_counts = _fallback_metric_counts_from_db(conn, year, year) for status_key, value in db_counts.items(): counts[status_key] += value continue for status_key in ("matched", "ledger_only", "amount_mismatch", "voucher_only"): counts[status_key] += len(sections.get(status_key, {}).get("rows", [])) rows_by_status[status_key].extend(sections.get(status_key, {}).get("rows", [])) voucher_sections = _build_voucher_sections_from_rows_by_status(rows_by_status) counts["voucher_matched"] = len(voucher_sections.get("voucher_matched", [])) counts["erp_voucher_matched"] = len(voucher_sections.get("erp_voucher_matched", [])) counts["voucher_unmatched"] = len(voucher_sections.get("voucher_unmatched", [])) counts["voucher_recheck"] = len(voucher_sections.get("voucher_recheck", [])) if not counts["voucher_matched"] and not counts["erp_voucher_matched"] and not counts["voucher_unmatched"] and not counts["voucher_recheck"] and any(counts.get(key) for key in ("matched", "ledger_only", "voucher_only", "amount_mismatch")): counts.update(_voucher_metric_counts_from_db(conn, start_year, end_year)) return counts def get_dashboard_metric_counts(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int]: cached = _load_metric_counts_cache(conn, start_year, end_year) if cached is not None: return cached counts = _resolved_metric_counts(conn, start_year, end_year) _store_metric_counts_cache(conn, start_year, end_year, counts) return counts def get_dashboard_metric_counts_nonblocking( engine: Any, start_year: int | None, end_year: int | None, ) -> tuple[dict[str, int], bool]: init_wehago_compare_db(engine) with engine.begin() as conn: cached = _load_metric_counts_cache(conn, start_year, end_year) if cached is not None: return cached, False fallback = _load_latest_metric_counts_cache_any_signature(conn, start_year, end_year) return fallback or _empty_metric_counts(), fallback is None def _warm_metric_counts_worker(engine: Any, start_year: int, end_year: int, warm_key: str) -> None: try: init_wehago_compare_db(engine) with engine.begin() as conn: counts = _resolved_metric_counts(conn, start_year, end_year) _store_metric_counts_cache(conn, start_year, end_year, counts) finally: with _METRIC_COUNTS_WARMING_LOCK: _METRIC_COUNTS_WARMING.discard(warm_key) def warm_metric_counts_async(engine: Any, start_year: int | None, end_year: int | None) -> None: if start_year is None or end_year is None: return if start_year > end_year: start_year, end_year = end_year, start_year init_wehago_compare_db(engine) with engine.begin() as conn: signature = _metric_counts_signature(conn, start_year, end_year) if _load_metric_counts_cache(conn, start_year, end_year) is not None: return warm_key = f"{start_year}:{end_year}:{signature}" with _METRIC_COUNTS_WARMING_LOCK: if warm_key in _METRIC_COUNTS_WARMING: return _METRIC_COUNTS_WARMING.add(warm_key) worker = threading.Thread( target=_warm_metric_counts_worker, args=(engine, start_year, end_year, warm_key), daemon=True, name=f"wehago-metric-counts-{start_year}-{end_year}", ) worker.start() @dataclass class WehagoImportSummary: scanned_files: int = 0 imported_files: int = 0 skipped_files: int = 0 voucher_rows: int = 0 ledger_rows: int = 0 comparison_rows: int = 0 @dataclass(slots=True) class MatchRowFeatures: row: dict[str, Any] row_key: str account_code: str account_name: str vendor_name: str desc_text: str account_tokens: set[str] vendor_tokens: set[str] desc_tokens: set[str] month_tokens: set[int] date_value: date | None debit_amount: float credit_amount: float match_amount: float primary_side: str positive_amounts: tuple[float, ...] def clean(value: Any) -> str: return "" if value is None else str(value).strip() def normalize_text(value: Any) -> str: text_value = clean(value).lower() text_value = re.sub(r"\s+", "", text_value) return re.sub(r"[\(\)\[\]\{\},._\-/\\:;*×%◇]", "", text_value) def normalize_voucher_no(value: Any) -> str: return clean(value).replace(" ", "") def _format_compare_date_key(value: Any) -> str: parsed = parse_excel_date(value) if not parsed: return "" return parsed.replace("-", "") def normalize_compare_voucher_no(value: Any, date_value: Any = None) -> str: voucher_no = normalize_voucher_no(value) if not voucher_no: return "" full_match = re.match(r"^11-(\d{8})-(\d+)-\d+$", voucher_no) if full_match: return f"{full_match.group(1)}-{int(full_match.group(2)):05d}" draft_match = re.match(r"^11-(\d{8})-[^-]+-(\d+)-\d+$", voucher_no) if draft_match: return f"{draft_match.group(1)}-{int(draft_match.group(2)):05d}" if re.fullmatch(r"\d+", voucher_no): date_key = _format_compare_date_key(date_value) if date_key: return f"{date_key}-{int(voucher_no):05d}" return voucher_no def _voucher_no_year(value: Any) -> int | None: voucher_no = normalize_voucher_no(value) match = re.match(r"^11-((?:19|20)\d{2})\d{4}-", voucher_no) if match: return int(match.group(1)) return None def choose_effective_erp_voucher_no(confirmed_no: Any, draft_no: Any, year_hint: int | None = None) -> str: confirmed = normalize_voucher_no(confirmed_no) draft = normalize_voucher_no(draft_no) if year_hint: confirmed_year = _voucher_no_year(confirmed) draft_year = _voucher_no_year(draft) if confirmed and confirmed_year == year_hint: return confirmed if draft and draft_year == year_hint: return draft return confirmed or draft def infer_voucher_fiscal_year( confirmed_no: Any, draft_no: Any, proof_date: Any, year_hint: int | None, ) -> int | None: effective_no = choose_effective_erp_voucher_no(confirmed_no, draft_no, year_hint) return _voucher_no_year(effective_no) or year_from_date_text(proof_date) or year_hint def parse_amount(value: Any) -> float: text_value = clean(value).replace(",", "") if not text_value: return 0.0 try: return float(text_value) except ValueError: return 0.0 def format_amount(value: Any) -> str: amount = parse_amount(value) if abs(amount - round(amount)) < 0.000001: return f"{int(round(amount)):,}" return f"{amount:,.2f}" def parse_excel_date(value: Any, default_year: int | None = None) -> str | None: if value is None or value == "": return None if isinstance(value, datetime): return value.date().isoformat() if isinstance(value, date): return value.isoformat() text_value = clean(value).replace(".0", "") for pattern in (r"^(\d{4})(\d{2})(\d{2})$", r"^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$"): match = re.match(pattern, text_value) if match: return f"{int(match.group(1)):04d}-{int(match.group(2)):02d}-{int(match.group(3)):02d}" match = re.match(r"^(\d{1,2})[-/.](\d{1,2})$", text_value) if match and default_year: return f"{default_year:04d}-{int(match.group(1)):02d}-{int(match.group(2)):02d}" return None def year_from_date_text(value: str | None) -> int | None: if not value: return None match = re.match(r"^(\d{4})-", value) return int(match.group(1)) if match else None def compute_file_hash(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def detect_file_kind(path: Path) -> tuple[str | None, list[str], str]: workbook = load_workbook(path, read_only=True, data_only=True) sheet = workbook.worksheets[0] header = [clean(item) for item in next(sheet.iter_rows(min_row=1, max_row=1, values_only=True))] if header[: len(VOUCHER_HEADERS)] == VOUCHER_HEADERS: return "voucher", header, sheet.title if header[: len(LEDGER_HEADERS)] == LEDGER_HEADERS: return "ledger", header, sheet.title if ( header[: len(CONSOLIDATED_LEDGER_PREFIX_HEADERS)] == CONSOLIDATED_LEDGER_PREFIX_HEADERS and header[len(CONSOLIDATED_LEDGER_PREFIX_HEADERS) : len(CONSOLIDATED_LEDGER_PREFIX_HEADERS) + len(LEDGER_HEADERS)] == LEDGER_HEADERS ): return "ledger", header, sheet.title return None, header, sheet.title def iter_voucher_files(source_root: Path) -> Iterable[Path]: for pattern in ("*.xlsx", "*.xlsm"): for path in sorted(source_root.glob(pattern)): if path.name.startswith("~$"): continue yield path def iter_ledger_files(source_root: Path) -> Iterable[Path]: data_download_dir = source_root / "data_download" if not data_download_dir.exists(): return for year_dir in sorted( path for path in data_download_dir.iterdir() if path.is_dir() and path.name.isdigit() and len(path.name) == 4 ): for pattern in ("*.xlsx", "*.xlsm"): for path in sorted(year_dir.glob(pattern)): if path.name.startswith("~$"): continue yield path def iter_source_files(source_root: Path) -> Iterable[Path]: seen: set[Path] = set() for path in iter_voucher_files(source_root): if path not in seen: seen.add(path) yield path for path in iter_ledger_files(source_root): if path not in seen: seen.add(path) yield path def infer_year_hint(path: Path, file_kind: str, sample_rows: list[tuple[Any, ...]]) -> int | None: for part in reversed(path.parts): if part.isdigit() and len(part) == 4: return int(part) match = re.search(r"(19|20)\d{2}", path.name) if match: return int(match.group(0)) if file_kind == "voucher": for row in sample_rows[:50]: proof_date = parse_excel_date(row[20] if len(row) > 20 else None) year_value = year_from_date_text(proof_date) if year_value: return year_value for row in sample_rows[:50]: voucher_no = normalize_compare_voucher_no(row[10] if len(row) > 10 else row[1] if len(row) > 1 else "") match = re.search(r"-(\d{4})\d{4}-", voucher_no) if match: return int(match.group(1)) if file_kind == "ledger": for row in sample_rows[:50]: date_text = parse_excel_date(row[0] if row else None) year_value = year_from_date_text(date_text) if year_value: return year_value return None def table_columns(conn: Any, table_name: str) -> set[str]: return {row[1] for row in conn.execute(text(f"PRAGMA table_info({table_name})")).fetchall()} def ensure_column(conn: Any, table_name: str, column_name: str, column_sql: str) -> None: if column_name in table_columns(conn, table_name): return conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_sql}")) def init_wehago_compare_db(engine: Any) -> None: with engine.begin() as conn: conn.execute( text( """ CREATE TABLE IF NOT EXISTS wehago_source_files ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_kind TEXT NOT NULL CHECK (file_kind IN ('voucher', 'ledger')), file_path TEXT NOT NULL UNIQUE, file_name TEXT NOT NULL, relative_path TEXT NOT NULL, year_hint INTEGER, sheet_name TEXT, file_size INTEGER NOT NULL, modified_ts REAL NOT NULL, file_hash TEXT NOT NULL, header_json TEXT NOT NULL, row_count INTEGER NOT NULL DEFAULT 0, imported_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS wehago_voucher_rows ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_file_id INTEGER NOT NULL REFERENCES wehago_source_files(id) ON DELETE CASCADE, sheet_name TEXT NOT NULL, row_number INTEGER NOT NULL, approval_status TEXT, draft_no TEXT, confirmed_no TEXT, account_code TEXT, account_name TEXT, debit_supply REAL NOT NULL DEFAULT 0, debit_tax REAL NOT NULL DEFAULT 0, credit_supply REAL NOT NULL DEFAULT 0, credit_tax REAL NOT NULL DEFAULT 0, issue_dept_code TEXT, issue_dept_name TEXT, support_dept_code TEXT, support_dept_name TEXT, cost_dept_code TEXT, cost_dept_name TEXT, desc1 TEXT, desc2 TEXT, vendor_code TEXT, vendor_name TEXT, tax_code TEXT, proof_date TEXT, voucher_type TEXT, management_item TEXT, compare_voucher_no TEXT, compare_amount REAL NOT NULL DEFAULT 0, compare_side TEXT, compare_vendor TEXT, compare_desc TEXT, fiscal_year INTEGER ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS wehago_ledger_rows ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_file_id INTEGER NOT NULL REFERENCES wehago_source_files(id) ON DELETE CASCADE, sheet_name TEXT NOT NULL, row_number INTEGER NOT NULL, ledger_date TEXT, description TEXT, vendor_name TEXT, debit REAL NOT NULL DEFAULT 0, credit REAL NOT NULL DEFAULT 0, balance REAL NOT NULL DEFAULT 0, voucher_no TEXT, account_code TEXT, account_name TEXT, compare_voucher_no TEXT, compare_amount REAL NOT NULL DEFAULT 0, compare_side TEXT, compare_vendor TEXT, compare_desc TEXT, fiscal_year INTEGER ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS wehago_comparison_results ( id INTEGER PRIMARY KEY AUTOINCREMENT, fiscal_year INTEGER, voucher_no TEXT NOT NULL, voucher_row_count INTEGER NOT NULL DEFAULT 0, ledger_row_count INTEGER NOT NULL DEFAULT 0, voucher_debit REAL NOT NULL DEFAULT 0, voucher_credit REAL NOT NULL DEFAULT 0, ledger_debit REAL NOT NULL DEFAULT 0, ledger_credit REAL NOT NULL DEFAULT 0, voucher_accounts TEXT NOT NULL DEFAULT '', ledger_accounts TEXT NOT NULL DEFAULT '', voucher_vendors TEXT NOT NULL DEFAULT '', ledger_vendors TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, notes TEXT NOT NULL DEFAULT '' ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS wehago_recheck_reviews ( review_key TEXT PRIMARY KEY, fiscal_year INTEGER, voucher_no TEXT NOT NULL DEFAULT '', draft_no TEXT NOT NULL DEFAULT '', voucher_account_code TEXT NOT NULL DEFAULT '', voucher_account_name TEXT NOT NULL DEFAULT '', voucher_vendor TEXT NOT NULL DEFAULT '', voucher_desc TEXT NOT NULL DEFAULT '', review_reason TEXT NOT NULL DEFAULT '', review_memo TEXT NOT NULL DEFAULT '', reviewed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS wehago_manual_pair_matches ( pair_key TEXT PRIMARY KEY, fiscal_year INTEGER, ledger_row_key TEXT NOT NULL UNIQUE, voucher_row_key TEXT NOT NULL UNIQUE, ledger_voucher_no TEXT NOT NULL DEFAULT '', ledger_account_code TEXT NOT NULL DEFAULT '', ledger_account_name TEXT NOT NULL DEFAULT '', ledger_vendor TEXT NOT NULL DEFAULT '', ledger_debit REAL NOT NULL DEFAULT 0, ledger_credit REAL NOT NULL DEFAULT 0, ledger_desc TEXT NOT NULL DEFAULT '', voucher_no TEXT NOT NULL DEFAULT '', draft_no TEXT NOT NULL DEFAULT '', voucher_account_code TEXT NOT NULL DEFAULT '', voucher_account_name TEXT NOT NULL DEFAULT '', voucher_vendor TEXT NOT NULL DEFAULT '', voucher_debit REAL NOT NULL DEFAULT 0, voucher_credit REAL NOT NULL DEFAULT 0, voucher_desc TEXT NOT NULL DEFAULT '', match_source TEXT NOT NULL DEFAULT 'manual', confidence_score REAL NOT NULL DEFAULT 0, confidence_level TEXT NOT NULL DEFAULT '', match_reason TEXT NOT NULL DEFAULT '', pair_note TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS wehago_action_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, action_type TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS wehago_result_row_cache ( fiscal_year INTEGER NOT NULL, ledger_result_path TEXT NOT NULL, ledger_result_mtime REAL NOT NULL, voucher_result_path TEXT NOT NULL, voucher_result_mtime REAL NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY ( fiscal_year, ledger_result_path, ledger_result_mtime, voucher_result_path, voucher_result_mtime ) ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS wehago_pair_recommend_cache ( cache_key TEXT PRIMARY KEY, start_year INTEGER, end_year INTEGER, ledger_voucher_no TEXT NOT NULL DEFAULT '', ledger_review_reason TEXT NOT NULL DEFAULT '', voucher_voucher_no TEXT NOT NULL DEFAULT '', voucher_review_reason TEXT NOT NULL DEFAULT '', row_limit INTEGER NOT NULL DEFAULT 300, payload_json TEXT NOT NULL DEFAULT '', pair_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, last_accessed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS wehago_metric_count_cache ( start_year INTEGER NOT NULL, end_year INTEGER NOT NULL, signature TEXT NOT NULL, counts_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (start_year, end_year, signature) ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS wehago_background_jobs ( job_key TEXT PRIMARY KEY, job_type TEXT NOT NULL, payload_json TEXT NOT NULL DEFAULT '{}', state TEXT NOT NULL DEFAULT 'queued', error_message TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, started_at TEXT NOT NULL DEFAULT '', finished_at TEXT NOT NULL DEFAULT '' ) """ ) ) ensure_column(conn, "wehago_result_row_cache", "resolved_state_signature", "TEXT NOT NULL DEFAULT ''") ensure_column(conn, "wehago_result_row_cache", "parse_state_signature", "TEXT NOT NULL DEFAULT ''") ensure_column(conn, "wehago_result_row_cache", "resolved_payload_json", "TEXT NOT NULL DEFAULT ''") ensure_column(conn, "wehago_result_row_cache", "resolved_created_at", "TEXT NOT NULL DEFAULT ''") ensure_column(conn, "wehago_source_files", "source_origin", "TEXT NOT NULL DEFAULT 'filesystem'") ensure_column(conn, "wehago_manual_pair_matches", "match_source", "TEXT NOT NULL DEFAULT 'manual'") ensure_column(conn, "wehago_manual_pair_matches", "confidence_score", "REAL NOT NULL DEFAULT 0") ensure_column(conn, "wehago_manual_pair_matches", "confidence_level", "TEXT NOT NULL DEFAULT ''") ensure_column(conn, "wehago_manual_pair_matches", "match_reason", "TEXT NOT NULL DEFAULT ''") conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_voucher_source ON wehago_voucher_rows(source_file_id)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_voucher_year_vno ON wehago_voucher_rows(fiscal_year, compare_voucher_no)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_ledger_source ON wehago_ledger_rows(source_file_id)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_ledger_year_vno ON wehago_ledger_rows(fiscal_year, compare_voucher_no)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_compare_year_status ON wehago_comparison_results(fiscal_year, status)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_source_origin_kind ON wehago_source_files(source_origin, file_kind)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_recheck_reviews_year ON wehago_recheck_reviews(fiscal_year, reviewed_at)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_manual_pairs_year ON wehago_manual_pair_matches(fiscal_year, created_at)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_action_history_created ON wehago_action_history(created_at)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_pair_recommend_cache_updated ON wehago_pair_recommend_cache(updated_at)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_metric_count_cache_range ON wehago_metric_count_cache(start_year, end_year, updated_at)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_background_jobs_state_created ON wehago_background_jobs(state, created_at)")) def upsert_source_file( conn: Any, path: Path, file_kind: str, year_hint: int | None, sheet_name: str, header: list[str], ) -> tuple[int, bool]: row = conn.execute( text( """ SELECT id, file_hash, modified_ts FROM wehago_source_files WHERE file_path = :file_path """ ), {"file_path": str(path)}, ).mappings().first() file_hash = compute_file_hash(path) modified_ts = path.stat().st_mtime payload = { "file_kind": file_kind, "file_path": str(path), "file_name": path.name, "relative_path": str(path.relative_to(WEHAGO_SOURCE_ROOT)), "year_hint": year_hint, "sheet_name": sheet_name, "file_size": path.stat().st_size, "modified_ts": modified_ts, "file_hash": file_hash, "header_json": str(header), "source_origin": "filesystem", } if row is None: result = conn.execute( text( """ INSERT INTO wehago_source_files ( file_kind, file_path, file_name, relative_path, year_hint, sheet_name, file_size, modified_ts, file_hash, header_json, source_origin ) VALUES ( :file_kind, :file_path, :file_name, :relative_path, :year_hint, :sheet_name, :file_size, :modified_ts, :file_hash, :header_json, :source_origin ) """ ), payload, ) return int(result.lastrowid), True changed = row["file_hash"] != file_hash or float(row["modified_ts"]) != float(modified_ts) conn.execute( text( """ UPDATE wehago_source_files SET file_kind = :file_kind, file_name = :file_name, relative_path = :relative_path, year_hint = :year_hint, sheet_name = :sheet_name, file_size = :file_size, modified_ts = :modified_ts, file_hash = :file_hash, header_json = :header_json, source_origin = :source_origin, imported_at = CURRENT_TIMESTAMP WHERE id = :id """ ), {**payload, "id": row["id"]}, ) return int(row["id"]), changed def build_voucher_signature(values: list[Any], year_hint: int | None) -> str: proof_date = parse_excel_date(values[20] if len(values) > 20 else None) draft_no = clean(values[1] if len(values) > 1 else "") confirmed_no = clean(values[10] if len(values) > 10 else "") fiscal_year = infer_voucher_fiscal_year(confirmed_no, draft_no, proof_date, year_hint) or "" parts = [ clean(values[0] if len(values) > 0 else ""), draft_no, confirmed_no, clean(values[2] if len(values) > 2 else ""), clean(values[3] if len(values) > 3 else ""), f"{parse_amount(values[4] if len(values) > 4 else 0):.2f}", f"{parse_amount(values[5] if len(values) > 5 else 0):.2f}", f"{parse_amount(values[6] if len(values) > 6 else 0):.2f}", f"{parse_amount(values[7] if len(values) > 7 else 0):.2f}", clean(values[8] if len(values) > 8 else ""), clean(values[11] if len(values) > 11 else ""), clean(values[13] if len(values) > 13 else ""), clean(values[15] if len(values) > 15 else ""), clean(values[16] if len(values) > 16 else ""), clean(values[17] if len(values) > 17 else ""), clean(values[18] if len(values) > 18 else ""), clean(values[19] if len(values) > 19 else ""), proof_date or "", clean(values[21] if len(values) > 21 else ""), clean(values[22] if len(values) > 22 else ""), str(fiscal_year), ] return hashlib.sha1("|".join(parts).encode("utf-8", "ignore")).hexdigest() def build_voucher_signature_from_mapping(row: dict[str, Any]) -> str: parts = [ clean(row.get("approval_status")), clean(row.get("draft_no")), clean(row.get("confirmed_no")), clean(row.get("account_code")), clean(row.get("account_name")), f"{parse_amount(row.get('debit_supply')):.2f}", f"{parse_amount(row.get('debit_tax')):.2f}", f"{parse_amount(row.get('credit_supply')):.2f}", f"{parse_amount(row.get('credit_tax')):.2f}", clean(row.get("issue_dept_code")), clean(row.get("support_dept_code")), clean(row.get("cost_dept_code")), clean(row.get("desc1")), clean(row.get("desc2")), clean(row.get("vendor_code")), clean(row.get("vendor_name")), clean(row.get("tax_code")), clean(row.get("proof_date")), clean(row.get("voucher_type")), clean(row.get("management_item")), clean(row.get("fiscal_year")), ] return hashlib.sha1("|".join(parts).encode("utf-8", "ignore")).hexdigest() def import_voucher_rows(conn: Any, source_id: int, sheet_name: str, rows: Iterable[tuple[Any, ...]], year_hint: int | None) -> int: inserted = 0 insert_sql = text( """ INSERT INTO wehago_voucher_rows ( source_file_id, sheet_name, row_number, approval_status, draft_no, confirmed_no, account_code, account_name, debit_supply, debit_tax, credit_supply, credit_tax, issue_dept_code, issue_dept_name, support_dept_code, support_dept_name, cost_dept_code, cost_dept_name, desc1, desc2, vendor_code, vendor_name, tax_code, proof_date, voucher_type, management_item, compare_voucher_no, compare_amount, compare_side, compare_vendor, compare_desc, fiscal_year ) VALUES ( :source_file_id, :sheet_name, :row_number, :approval_status, :draft_no, :confirmed_no, :account_code, :account_name, :debit_supply, :debit_tax, :credit_supply, :credit_tax, :issue_dept_code, :issue_dept_name, :support_dept_code, :support_dept_name, :cost_dept_code, :cost_dept_name, :desc1, :desc2, :vendor_code, :vendor_name, :tax_code, :proof_date, :voucher_type, :management_item, :compare_voucher_no, :compare_amount, :compare_side, :compare_vendor, :compare_desc, :fiscal_year ) """ ) for row_number, row in enumerate(rows, start=2): values = list(row) if not any(item is not None and clean(item) for item in values): continue draft_no = clean(values[1] if len(values) > 1 else "") confirmed_no = clean(values[10] if len(values) > 10 else "") proof_date = parse_excel_date(values[20] if len(values) > 20 else None) fiscal_year = infer_voucher_fiscal_year(confirmed_no, draft_no, proof_date, year_hint) effective_no = choose_effective_erp_voucher_no(confirmed_no, draft_no, year_hint) debit_supply = parse_amount(values[4] if len(values) > 4 else 0) credit_supply = parse_amount(values[6] if len(values) > 6 else 0) conn.execute( insert_sql, { "source_file_id": source_id, "sheet_name": sheet_name, "row_number": row_number, "approval_status": clean(values[0] if len(values) > 0 else ""), "draft_no": draft_no, "confirmed_no": confirmed_no, "account_code": clean(values[2] if len(values) > 2 else ""), "account_name": clean(values[3] if len(values) > 3 else ""), "debit_supply": debit_supply, "debit_tax": parse_amount(values[5] if len(values) > 5 else 0), "credit_supply": credit_supply, "credit_tax": parse_amount(values[7] if len(values) > 7 else 0), "issue_dept_code": clean(values[8] if len(values) > 8 else ""), "issue_dept_name": clean(values[9] if len(values) > 9 else ""), "support_dept_code": clean(values[11] if len(values) > 11 else ""), "support_dept_name": clean(values[12] if len(values) > 12 else ""), "cost_dept_code": clean(values[13] if len(values) > 13 else ""), "cost_dept_name": clean(values[14] if len(values) > 14 else ""), "desc1": clean(values[15] if len(values) > 15 else ""), "desc2": clean(values[16] if len(values) > 16 else ""), "vendor_code": clean(values[17] if len(values) > 17 else ""), "vendor_name": clean(values[18] if len(values) > 18 else ""), "tax_code": clean(values[19] if len(values) > 19 else ""), "proof_date": proof_date, "voucher_type": clean(values[21] if len(values) > 21 else ""), "management_item": clean(values[22] if len(values) > 22 else ""), "compare_voucher_no": normalize_compare_voucher_no(effective_no, proof_date), "compare_amount": debit_supply if debit_supply else credit_supply, "compare_side": "debit" if debit_supply else ("credit" if credit_supply else ""), "compare_vendor": normalize_text(values[18] if len(values) > 18 else ""), "compare_desc": normalize_text( " ".join(clean(values[index]) for index in (15, 16, 22) if index < len(values) and clean(values[index])) ), "fiscal_year": fiscal_year, }, ) inserted += 1 return inserted def import_ledger_rows(conn: Any, source_id: int, sheet_name: str, rows: Iterable[tuple[Any, ...]], year_hint: int | None) -> int: inserted = 0 insert_sql = text( """ INSERT INTO wehago_ledger_rows ( source_file_id, sheet_name, row_number, ledger_date, description, vendor_name, debit, credit, balance, voucher_no, account_code, account_name, compare_voucher_no, compare_amount, compare_side, compare_vendor, compare_desc, fiscal_year ) VALUES ( :source_file_id, :sheet_name, :row_number, :ledger_date, :description, :vendor_name, :debit, :credit, :balance, :voucher_no, :account_code, :account_name, :compare_voucher_no, :compare_amount, :compare_side, :compare_vendor, :compare_desc, :fiscal_year ) """ ) for row_number, row in enumerate(rows, start=2): values = list(row) if len(values) >= len(CONSOLIDATED_LEDGER_PREFIX_HEADERS) + len(LEDGER_HEADERS): values = values[len(CONSOLIDATED_LEDGER_PREFIX_HEADERS) : len(CONSOLIDATED_LEDGER_PREFIX_HEADERS) + len(LEDGER_HEADERS)] if not any(item is not None and clean(item) for item in values): continue normalized_values = {clean(item).replace(" ", "") for item in values} if normalized_values & {"[월계]", "[누계]", "월계", "누계"}: continue ledger_date = parse_excel_date(values[0] if len(values) > 0 else None, default_year=year_hint) fiscal_year = year_from_date_text(ledger_date) or year_hint debit = parse_amount(values[3] if len(values) > 3 else 0) credit = parse_amount(values[4] if len(values) > 4 else 0) conn.execute( insert_sql, { "source_file_id": source_id, "sheet_name": sheet_name, "row_number": row_number, "ledger_date": ledger_date, "description": clean(values[1] if len(values) > 1 else ""), "vendor_name": clean(values[2] if len(values) > 2 else ""), "debit": debit, "credit": credit, "balance": parse_amount(values[5] if len(values) > 5 else 0), "voucher_no": clean(values[6] if len(values) > 6 else ""), "account_code": clean(values[7] if len(values) > 7 else ""), "account_name": clean(values[8] if len(values) > 8 else ""), "compare_voucher_no": normalize_compare_voucher_no(values[6] if len(values) > 6 else "", ledger_date), "compare_amount": debit if debit else credit, "compare_side": "debit" if debit else ("credit" if credit else ""), "compare_vendor": normalize_text(values[2] if len(values) > 2 else ""), "compare_desc": normalize_text(values[1] if len(values) > 1 else ""), "fiscal_year": fiscal_year, }, ) inserted += 1 return inserted def rebuild_comparison_results(conn: Any) -> None: conn.execute(text("DELETE FROM wehago_comparison_results")) conn.execute( text( """ WITH voucher_groups AS ( SELECT COALESCE(fiscal_year, 0) AS fiscal_year, compare_voucher_no AS voucher_no, COUNT(*) AS voucher_row_count, SUM(debit_supply) AS voucher_debit, SUM(credit_supply) AS voucher_credit, GROUP_CONCAT(DISTINCT account_code) AS voucher_accounts, GROUP_CONCAT(DISTINCT vendor_name) AS voucher_vendors FROM wehago_voucher_rows WHERE COALESCE(compare_voucher_no, '') <> '' GROUP BY COALESCE(fiscal_year, 0), compare_voucher_no ), ledger_groups AS ( SELECT COALESCE(fiscal_year, 0) AS fiscal_year, compare_voucher_no AS voucher_no, COUNT(*) AS ledger_row_count, SUM(debit) AS ledger_debit, SUM(credit) AS ledger_credit, GROUP_CONCAT(DISTINCT account_code) AS ledger_accounts, GROUP_CONCAT(DISTINCT vendor_name) AS ledger_vendors FROM wehago_ledger_rows WHERE COALESCE(compare_voucher_no, '') <> '' GROUP BY COALESCE(fiscal_year, 0), compare_voucher_no ), joined AS ( SELECT COALESCE(v.fiscal_year, l.fiscal_year) AS fiscal_year, COALESCE(v.voucher_no, l.voucher_no) AS voucher_no, COALESCE(v.voucher_row_count, 0) AS voucher_row_count, COALESCE(l.ledger_row_count, 0) AS ledger_row_count, COALESCE(v.voucher_debit, 0) AS voucher_debit, COALESCE(v.voucher_credit, 0) AS voucher_credit, COALESCE(l.ledger_debit, 0) AS ledger_debit, COALESCE(l.ledger_credit, 0) AS ledger_credit, COALESCE(v.voucher_accounts, '') AS voucher_accounts, COALESCE(l.ledger_accounts, '') AS ledger_accounts, COALESCE(v.voucher_vendors, '') AS voucher_vendors, COALESCE(l.ledger_vendors, '') AS ledger_vendors FROM voucher_groups v LEFT JOIN ledger_groups l ON l.fiscal_year = v.fiscal_year AND l.voucher_no = v.voucher_no UNION ALL SELECT l.fiscal_year, l.voucher_no, 0, l.ledger_row_count, 0, 0, l.ledger_debit, l.ledger_credit, '', l.ledger_accounts, '', l.ledger_vendors FROM ledger_groups l LEFT JOIN voucher_groups v ON v.fiscal_year = l.fiscal_year AND v.voucher_no = l.voucher_no WHERE v.voucher_no IS NULL ) INSERT INTO wehago_comparison_results ( fiscal_year, voucher_no, voucher_row_count, ledger_row_count, voucher_debit, voucher_credit, ledger_debit, ledger_credit, voucher_accounts, ledger_accounts, voucher_vendors, ledger_vendors, status, notes ) SELECT fiscal_year, voucher_no, voucher_row_count, ledger_row_count, voucher_debit, voucher_credit, ledger_debit, ledger_credit, voucher_accounts, ledger_accounts, voucher_vendors, ledger_vendors, CASE WHEN voucher_row_count > 0 AND ledger_row_count > 0 AND ABS(voucher_debit - ledger_debit) < 0.5 AND ABS(voucher_credit - ledger_credit) < 0.5 THEN 'matched' WHEN voucher_row_count > 0 AND ledger_row_count = 0 THEN 'voucher_only' WHEN voucher_row_count = 0 AND ledger_row_count > 0 THEN 'ledger_only' ELSE 'amount_mismatch' END AS status, CASE WHEN voucher_row_count > 0 AND ledger_row_count > 0 AND (ABS(voucher_debit - ledger_debit) >= 0.5 OR ABS(voucher_credit - ledger_credit) >= 0.5) THEN '전표번호는 같지만 차/대변 합계가 다릅니다.' WHEN voucher_row_count > 0 AND ledger_row_count = 0 THEN 'ERP에는 있으나 WEHAGO에서 찾지 못했습니다.' WHEN voucher_row_count = 0 AND ledger_row_count > 0 THEN 'WEHAGO에는 있으나 ERP에서 찾지 못했습니다.' ELSE '' END AS notes FROM joined """ ) ) boundary_sql = _boundary_excluded_sql("l.ledger_date", "l.description", "l.account_name") conn.execute( text( f""" UPDATE wehago_comparison_results SET status = 'ledger_only', notes = CASE WHEN COALESCE(notes, '') = '' THEN '연초/연말 대체·이월 전표는 매칭 대상에서 제외했습니다.' ELSE notes || ' / 연초/연말 대체·이월 전표는 매칭 대상에서 제외했습니다.' END WHERE EXISTS ( SELECT 1 FROM wehago_ledger_rows l WHERE l.fiscal_year = wehago_comparison_results.fiscal_year AND l.compare_voucher_no = wehago_comparison_results.voucher_no AND {boundary_sql} ) """ ) ) def refresh_wehago_compare_data(engine: Any, source_root: Path | None = None) -> dict[str, int]: source_root = source_root or WEHAGO_SOURCE_ROOT summary = WehagoImportSummary() with engine.begin() as conn: init_wehago_compare_db(engine) for path in iter_source_files(source_root): summary.scanned_files += 1 file_kind, header, sheet_name = detect_file_kind(path) if not file_kind: summary.skipped_files += 1 continue workbook = load_workbook(path, read_only=True, data_only=True) sheet = workbook.worksheets[0] sample_rows = list(sheet.iter_rows(min_row=2, max_row=51, values_only=True)) year_hint = infer_year_hint(path, file_kind, sample_rows) source_id, changed = upsert_source_file(conn, path, file_kind, year_hint, sheet_name, header) if not changed: summary.skipped_files += 1 continue table_name = "wehago_voucher_rows" if file_kind == "voucher" else "wehago_ledger_rows" conn.execute(text(f"DELETE FROM {table_name} WHERE source_file_id = :source_id"), {"source_id": source_id}) rows = sheet.iter_rows(min_row=2, values_only=True) if file_kind == "voucher": inserted = import_voucher_rows(conn, source_id, sheet_name, rows, year_hint) summary.voucher_rows += inserted else: inserted = import_ledger_rows(conn, source_id, sheet_name, rows, year_hint) summary.ledger_rows += inserted conn.execute( text( """ UPDATE wehago_source_files SET row_count = :row_count, imported_at = CURRENT_TIMESTAMP WHERE id = :source_id """ ), {"row_count": inserted, "source_id": source_id}, ) summary.imported_files += 1 rebuild_comparison_results(conn) summary.comparison_rows = int(conn.execute(text("SELECT COUNT(*) FROM wehago_comparison_results")).scalar_one()) _DASHBOARD_CACHE.clear() _SUGGEST_CACHE.clear() _STATUS_ROWS_CACHE.clear() _PAIR_RECOMMEND_CACHE.clear() clear_persisted_pair_recommend_cache(engine) return { "scanned_files": summary.scanned_files, "imported_files": summary.imported_files, "skipped_files": summary.skipped_files, "voucher_rows": summary.voucher_rows, "ledger_rows": summary.ledger_rows, "comparison_rows": summary.comparison_rows, } def fetch_existing_voucher_signatures(conn: Any) -> set[str]: rows = conn.execute( text( """ SELECT approval_status, draft_no, confirmed_no, account_code, account_name, debit_supply, debit_tax, credit_supply, credit_tax, issue_dept_code, support_dept_code, cost_dept_code, desc1, desc2, vendor_code, vendor_name, tax_code, proof_date, voucher_type, management_item, fiscal_year FROM wehago_voucher_rows """ ) ).mappings() return {build_voucher_signature_from_mapping(dict(row)) for row in rows} def count_nonempty_rows(sheet: Any) -> int: count = 0 for row in sheet.iter_rows(min_row=2, values_only=True): values = list(row) if any(item is not None and clean(item) for item in values): count += 1 return count def import_uploaded_erp_voucher_file(engine: Any, upload_path: Path, original_filename: str) -> dict[str, Any]: init_wehago_compare_db(engine) file_kind, header, sheet_name = detect_file_kind(upload_path) if file_kind != "voucher": raise ValueError("선택한 파일이 ERP 전표 형식이 아닙니다.") workbook = load_workbook(upload_path, read_only=True, data_only=True) sheet = workbook.worksheets[0] sample_rows = list(sheet.iter_rows(min_row=2, max_row=51, values_only=True)) year_hint = infer_year_hint(upload_path, "voucher", sample_rows) file_hash = compute_file_hash(upload_path) pseudo_path = f"upload://voucher/{file_hash}" file_size = upload_path.stat().st_size modified_ts = upload_path.stat().st_mtime with engine.begin() as conn: existing_source = conn.execute( text( """ SELECT id, row_count FROM wehago_source_files WHERE file_path = :file_path """ ), {"file_path": pseudo_path}, ).mappings().first() if existing_source is not None: return { "source_id": int(existing_source["id"]), "inserted_rows": 0, "duplicate_rows": count_nonempty_rows(sheet), "file_name": original_filename, "comparison_rows": int(conn.execute(text("SELECT COUNT(*) FROM wehago_comparison_results")).scalar_one()), } existing_signatures = fetch_existing_voucher_signatures(conn) seen_signatures: set[str] = set() insert_sql = text( """ INSERT INTO wehago_voucher_rows ( source_file_id, sheet_name, row_number, approval_status, draft_no, confirmed_no, account_code, account_name, debit_supply, debit_tax, credit_supply, credit_tax, issue_dept_code, issue_dept_name, support_dept_code, support_dept_name, cost_dept_code, cost_dept_name, desc1, desc2, vendor_code, vendor_name, tax_code, proof_date, voucher_type, management_item, compare_voucher_no, compare_amount, compare_side, compare_vendor, compare_desc, fiscal_year ) VALUES ( :source_file_id, :sheet_name, :row_number, :approval_status, :draft_no, :confirmed_no, :account_code, :account_name, :debit_supply, :debit_tax, :credit_supply, :credit_tax, :issue_dept_code, :issue_dept_name, :support_dept_code, :support_dept_name, :cost_dept_code, :cost_dept_name, :desc1, :desc2, :vendor_code, :vendor_name, :tax_code, :proof_date, :voucher_type, :management_item, :compare_voucher_no, :compare_amount, :compare_side, :compare_vendor, :compare_desc, :fiscal_year ) """ ) source_result = conn.execute( text( """ INSERT INTO wehago_source_files ( file_kind, file_path, file_name, relative_path, year_hint, sheet_name, file_size, modified_ts, file_hash, header_json, row_count, source_origin ) VALUES ( 'voucher', :file_path, :file_name, :relative_path, :year_hint, :sheet_name, :file_size, :modified_ts, :file_hash, :header_json, 0, 'upload' ) """ ), { "file_path": pseudo_path, "file_name": original_filename, "relative_path": f"uploaded/{original_filename}", "year_hint": year_hint, "sheet_name": sheet_name, "file_size": file_size, "modified_ts": modified_ts, "file_hash": file_hash, "header_json": str(header), }, ) source_id = int(source_result.lastrowid) inserted = 0 duplicate_rows = 0 for row_number, row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2): values = list(row) if not any(item is not None and clean(item) for item in values): continue signature = build_voucher_signature(values, year_hint) if signature in existing_signatures or signature in seen_signatures: duplicate_rows += 1 continue seen_signatures.add(signature) draft_no = clean(values[1] if len(values) > 1 else "") confirmed_no = clean(values[10] if len(values) > 10 else "") proof_date = parse_excel_date(values[20] if len(values) > 20 else None) fiscal_year = infer_voucher_fiscal_year(confirmed_no, draft_no, proof_date, year_hint) effective_no = choose_effective_erp_voucher_no(confirmed_no, draft_no, year_hint) debit_supply = parse_amount(values[4] if len(values) > 4 else 0) credit_supply = parse_amount(values[6] if len(values) > 6 else 0) conn.execute( insert_sql, { "source_file_id": source_id, "sheet_name": sheet_name, "row_number": row_number, "approval_status": clean(values[0] if len(values) > 0 else ""), "draft_no": draft_no, "confirmed_no": confirmed_no, "account_code": clean(values[2] if len(values) > 2 else ""), "account_name": clean(values[3] if len(values) > 3 else ""), "debit_supply": debit_supply, "debit_tax": parse_amount(values[5] if len(values) > 5 else 0), "credit_supply": credit_supply, "credit_tax": parse_amount(values[7] if len(values) > 7 else 0), "issue_dept_code": clean(values[8] if len(values) > 8 else ""), "issue_dept_name": clean(values[9] if len(values) > 9 else ""), "support_dept_code": clean(values[11] if len(values) > 11 else ""), "support_dept_name": clean(values[12] if len(values) > 12 else ""), "cost_dept_code": clean(values[13] if len(values) > 13 else ""), "cost_dept_name": clean(values[14] if len(values) > 14 else ""), "desc1": clean(values[15] if len(values) > 15 else ""), "desc2": clean(values[16] if len(values) > 16 else ""), "vendor_code": clean(values[17] if len(values) > 17 else ""), "vendor_name": clean(values[18] if len(values) > 18 else ""), "tax_code": clean(values[19] if len(values) > 19 else ""), "proof_date": proof_date, "voucher_type": clean(values[21] if len(values) > 21 else ""), "management_item": clean(values[22] if len(values) > 22 else ""), "compare_voucher_no": normalize_compare_voucher_no(effective_no, proof_date), "compare_amount": debit_supply if debit_supply else credit_supply, "compare_side": "debit" if debit_supply else ("credit" if credit_supply else ""), "compare_vendor": normalize_text(values[18] if len(values) > 18 else ""), "compare_desc": normalize_text( " ".join(clean(values[index]) for index in (15, 16, 22) if index < len(values) and clean(values[index])) ), "fiscal_year": fiscal_year, }, ) inserted += 1 conn.execute( text( """ UPDATE wehago_source_files SET row_count = :row_count, imported_at = CURRENT_TIMESTAMP WHERE id = :source_id """ ), {"row_count": inserted, "source_id": source_id}, ) rebuild_comparison_results(conn) comparison_rows = int(conn.execute(text("SELECT COUNT(*) FROM wehago_comparison_results")).scalar_one()) _DASHBOARD_CACHE.clear() _SUGGEST_CACHE.clear() _STATUS_ROWS_CACHE.clear() _PAIR_RECOMMEND_CACHE.clear() clear_persisted_pair_recommend_cache(engine) return { "source_id": source_id, "inserted_rows": inserted, "duplicate_rows": duplicate_rows, "file_name": original_filename, "comparison_rows": comparison_rows, } def build_year_filter_sql(column_name: str = "fiscal_year") -> str: return ( f"(:start_year IS NULL OR {column_name} >= :start_year) " f"AND (:end_year IS NULL OR {column_name} <= :end_year)" ) def fetch_metric_sections(conn: Any, start_year: int | None, end_year: int | None) -> list[dict[str, Any]]: sections: list[dict[str, Any]] = [] voucher_counts = _voucher_metric_counts_from_db(conn, start_year, end_year) for status_key, label, description in STATUS_META: if status_key in {"voucher_matched", "erp_voucher_matched", "voucher_unmatched"}: count = int(voucher_counts.get(status_key, 0) or 0) else: count = int( conn.execute( text( """ SELECT COUNT(*) FROM wehago_comparison_results WHERE status = :status AND """ + build_year_filter_sql() + """ """ ), {"status": status_key, "start_year": start_year, "end_year": end_year}, ).scalar_one() ) sections.append( { "key": status_key, "label": label, "description": description, "count": count, "columns": DETAIL_COLUMN_MAP[status_key], "rows": [], } ) return sections def fetch_wehago_rows(conn: Any, start_year: int | None, end_year: int | None) -> list[dict[str, Any]]: return [ dict(row._mapping) for row in conn.execute( text( """ SELECT ledger_date, voucher_no, account_code, account_name, vendor_name, description, debit, credit FROM wehago_ledger_rows WHERE """ + build_year_filter_sql() + """ ORDER BY COALESCE(ledger_date, '') DESC, voucher_no DESC, row_number DESC LIMIT 300 """ ), {"start_year": start_year, "end_year": end_year}, ).fetchall() ] def fetch_erp_rows(conn: Any, start_year: int | None, end_year: int | None) -> list[dict[str, Any]]: return [ dict(row._mapping) for row in conn.execute( text( """ SELECT proof_date, confirmed_no, draft_no, account_code, account_name, vendor_name, desc1, desc2, debit_supply, credit_supply FROM wehago_voucher_rows WHERE """ + build_year_filter_sql() + """ ORDER BY COALESCE(proof_date, '') DESC, confirmed_no DESC, draft_no DESC, row_number DESC LIMIT 300 """ ), {"start_year": start_year, "end_year": end_year}, ).fetchall() ] def fetch_latest_upload_meta(conn: Any) -> dict[str, Any] | None: row = conn.execute( text( """ SELECT file_name, imported_at, row_count FROM wehago_source_files WHERE file_kind = 'voucher' AND source_origin = 'upload' ORDER BY imported_at DESC, id DESC LIMIT 1 """ ) ).mappings().first() return dict(row) if row else None def count_rows(conn: Any, table_name: str, start_year: int | None, end_year: int | None) -> int: return int( conn.execute( text( f""" SELECT COUNT(*) FROM {table_name} WHERE {build_year_filter_sql()} """ ), {"start_year": start_year, "end_year": end_year}, ).scalar_one() ) def discover_available_years(source_root: Path | None = None) -> list[int]: source_root = source_root or WEHAGO_SOURCE_ROOT data_download_dir = source_root / "data_download" if not data_download_dir.exists(): return [] years = sorted( [ int(path.name) for path in data_download_dir.iterdir() if path.is_dir() and path.name.isdigit() and len(path.name) == 4 ], reverse=True, ) return years def get_default_year(available_years: list[int]) -> int | None: return available_years[0] if available_years else None def discover_compare_result_bundle(year: int, source_root: Path | None = None) -> dict[str, Path] | None: source_root = source_root or WEHAGO_SOURCE_ROOT data_download_dir = source_root / "data_download" if not data_download_dir.exists(): return None candidates: list[tuple[float, dict[str, Path]]] = [] for folder in data_download_dir.iterdir(): if not folder.is_dir() or (folder.name.isdigit() and len(folder.name) == 4): continue ledger_raw = next(folder.glob(f"ledger_{year}_*.xlsx"), None) voucher_raw = next(folder.glob(f"voucher_{year}_*.xlsx"), None) ledger_result = folder / "ledger_with_matched_voucher_detected_context60_v10_20260422.xlsx" voucher_result = folder / "voucher_with_matched_ledger_detected_context60_v10_20260422.xlsx" if ledger_raw and voucher_raw and ledger_result.exists() and voucher_result.exists(): candidates.append( ( max(ledger_result.stat().st_mtime, voucher_result.stat().st_mtime, folder.stat().st_mtime), { "ledger_result": ledger_result, "voucher_result": voucher_result, }, ) ) if not candidates: return None candidates.sort(key=lambda item: item[0], reverse=True) return candidates[0][1] def _normalize_flag(value: Any) -> bool: text_value = clean(value).strip().lower() return text_value not in {"", "0", "false", "n", "none"} def _append_limited(bucket: list[dict[str, Any]], item: dict[str, Any], limit: int = 200) -> None: if len(bucket) < limit: bucket.append(item) def build_review_key(row: dict[str, Any]) -> str: parts = [ clean(row.get("fiscal_year")), clean(row.get("voucher_no")), clean(row.get("draft_no")), clean(row.get("voucher_account_code")), clean(row.get("voucher_account_name")), clean(row.get("voucher_vendor")), clean(row.get("voucher_desc")), clean(row.get("review_reason")), clean(row.get("review_memo")), f"{parse_amount(row.get('voucher_debit')):.2f}", f"{parse_amount(row.get('voucher_credit')):.2f}", ] return hashlib.sha1("|".join(parts).encode("utf-8", "ignore")).hexdigest() def build_match_identity_key(row: dict[str, Any]) -> str: parts = [ clean(row.get("fiscal_year")), clean(row.get("draft_no")), clean(row.get("voucher_account_code")), clean(row.get("voucher_account_name")), clean(row.get("voucher_vendor")), clean(row.get("voucher_desc")), f"{parse_amount(row.get('voucher_debit')):.2f}", f"{parse_amount(row.get('voucher_credit')):.2f}", ] return hashlib.sha1("|".join(parts).encode("utf-8", "ignore")).hexdigest() def build_ledger_row_key(row: dict[str, Any]) -> str: parts = [ clean(row.get("fiscal_year")), clean(row.get("voucher_no")), clean(row.get("ledger_date")), clean(row.get("ledger_account_code")), clean(row.get("ledger_account_name")), clean(row.get("ledger_vendor")), clean(row.get("ledger_desc")), f"{parse_amount(row.get('ledger_debit')):.2f}", f"{parse_amount(row.get('ledger_credit')):.2f}", ] return hashlib.sha1("|".join(parts).encode("utf-8", "ignore")).hexdigest() def build_voucher_row_key(row: dict[str, Any]) -> str: parts = [ clean(row.get("fiscal_year")), clean(row.get("voucher_no")), clean(row.get("draft_no")), clean(row.get("proof_date")), clean(row.get("voucher_account_code")), clean(row.get("voucher_account_name")), clean(row.get("voucher_vendor")), clean(row.get("voucher_desc")), f"{parse_amount(row.get('voucher_debit')):.2f}", f"{parse_amount(row.get('voucher_credit')):.2f}", ] return hashlib.sha1("|".join(parts).encode("utf-8", "ignore")).hexdigest() def build_manual_pair_key(ledger_row_key: str, voucher_row_key: str) -> str: return hashlib.sha1(f"{clean(ledger_row_key)}|{clean(voucher_row_key)}".encode("utf-8", "ignore")).hexdigest() @lru_cache(maxsize=16) def parse_compare_result_bundle( ledger_path_str: str, ledger_mtime: float, voucher_path_str: str, voucher_mtime: float, year: int, ) -> dict[str, Any]: del ledger_mtime, voucher_mtime ledger_path = Path(ledger_path_str) voucher_path = Path(voucher_path_str) metric_map = { "matched": { "count": 0, "columns": [ ("fiscal_year", "연도"), ("ledger_date", "일자"), ("voucher_no", "전표번호"), ("draft_no", "가전표번호"), ("ledger_account_name", "계정"), ("voucher_account_name", "ERP 계정"), ("ledger_vendor", "거래처"), ("voucher_vendor", "ERP 거래처"), ("ledger_debit", "차변"), ("ledger_credit", "대변"), ("voucher_debit", "ERP 차변"), ("voucher_credit", "ERP 대변"), ("ledger_desc", "WEHAGO 적요"), ("voucher_desc", "ERP 적요"), ], "rows": [], }, "ledger_only": { "count": 0, "columns": [ ("fiscal_year", "연도"), ("voucher_no", "전표번호"), ("ledger_date", "일자"), ("ledger_account_name", "WEHAGO 계정"), ("ledger_vendor", "WEHAGO 거래처"), ("ledger_debit", "차변"), ("ledger_credit", "대변"), ("ledger_desc", "적요"), ], "rows": [], }, "amount_mismatch": { "count": 0, "columns": [ ("fiscal_year", "연도"), ("ledger_date", "일자"), ("voucher_no", "전표번호"), ("draft_no", "가전표번호"), ("ledger_account_name", "계정"), ("voucher_account_name", "ERP 계정"), ("ledger_vendor", "거래처"), ("voucher_vendor", "ERP 거래처"), ("ledger_debit", "차변"), ("ledger_credit", "대변"), ("voucher_debit", "ERP 차변"), ("voucher_credit", "ERP 대변"), ("ledger_desc", "WEHAGO 적요"), ("voucher_desc", "ERP 적요"), ], "rows": [], }, "voucher_only": { "count": 0, "columns": [ ("fiscal_year", "연도"), ("voucher_no", "전표번호"), ("proof_date", "증빙일자"), ("voucher_account_name", "ERP 계정"), ("voucher_vendor", "ERP 거래처"), ("voucher_debit", "차변"), ("voucher_credit", "대변"), ("voucher_desc", "적요"), ], "rows": [], }, } ledger_wb = load_workbook(ledger_path, read_only=True, data_only=True) ledger_ws = ledger_wb.worksheets[0] ledger_header = [clean(v) for v in next(ledger_ws.iter_rows(min_row=1, max_row=1, values_only=True))] ledger_idx = {name: idx for idx, name in enumerate(ledger_header)} ledger_rows: list[tuple[Any, ...]] = list(ledger_ws.iter_rows(min_row=2, values_only=True)) ledger_by_matched_voucher_no: dict[str, dict[str, Any]] = {} for row in ledger_rows: matched_voucher_no = clean(row[ledger_idx["matched_확정전표번호"]]) if "matched_확정전표번호" in ledger_idx else "" if not matched_voucher_no or matched_voucher_no in ledger_by_matched_voucher_no: continue ledger_by_matched_voucher_no[matched_voucher_no] = { "voucher_no": clean(row[ledger_idx["전표번호"]]) if "전표번호" in ledger_idx else "", "ledger_date": clean(row[ledger_idx["일자"]]) if "일자" in ledger_idx else "", "ledger_account_code": clean(row[ledger_idx["계정코드"]]) if "계정코드" in ledger_idx else "", "ledger_account_name": clean(row[ledger_idx["계정명"]]) if "계정명" in ledger_idx else "", "ledger_vendor": clean(row[ledger_idx["거래처"]]) if "거래처" in ledger_idx else "", "ledger_desc": clean(row[ledger_idx["적요"]]) if "적요" in ledger_idx else "", "ledger_debit": parse_amount(row[ledger_idx["차변"]]) if "차변" in ledger_idx else 0, "ledger_credit": parse_amount(row[ledger_idx["대변"]]) if "대변" in ledger_idx else 0, } voucher_wb = load_workbook(voucher_path, read_only=True, data_only=True) voucher_ws = voucher_wb.worksheets[0] voucher_header = [clean(v) for v in next(voucher_ws.iter_rows(min_row=1, max_row=1, values_only=True))] voucher_idx = {name: idx for idx, name in enumerate(voucher_header)} voucher_by_confirmed_no: dict[str, dict[str, Any]] = {} for row in voucher_ws.iter_rows(min_row=2, values_only=True): matched_voucher_no = clean(row[voucher_idx["matched_전표번호"]]) if "matched_전표번호" in voucher_idx else "" review_reason = clean(row[voucher_idx["matched_검증근거"]]) if "matched_검증근거" in voucher_idx else "" review_flag = row[voucher_idx["review_flag"]] if "review_flag" in voucher_idx else "" confirmed_no = clean(row[voucher_idx["확정전표번호"]]) if "확정전표번호" in voucher_idx else "" desc1 = clean(row[voucher_idx["적요1"]]) if "적요1" in voucher_idx else "" matched_ledger = ledger_by_matched_voucher_no.get(confirmed_no, {}) item = { "fiscal_year": year, "voucher_no": ( matched_ledger.get("voucher_no", "") if matched_voucher_no else (confirmed_no or (clean(row[voucher_idx["가전표번호"]]) if "가전표번호" in voucher_idx else "")) ), "confirmed_no": confirmed_no, "proof_date": clean(row[voucher_idx["증빙일자"]]) if "증빙일자" in voucher_idx else "", "ledger_date": matched_ledger.get("ledger_date", ""), "draft_no": clean(row[voucher_idx["가전표번호"]]) if "가전표번호" in voucher_idx else "", "ledger_account_code": matched_ledger.get("ledger_account_code", ""), "ledger_account_name": matched_ledger.get("ledger_account_name", ""), "voucher_account_code": clean(row[voucher_idx["계정코드"]]) if "계정코드" in voucher_idx else "", "voucher_account_name": clean(row[voucher_idx["계정명칭"]]) if "계정명칭" in voucher_idx else "", "ledger_vendor": matched_ledger.get("ledger_vendor", ""), "voucher_vendor": clean(row[voucher_idx["거래처명칭"]]) if "거래처명칭" in voucher_idx else "", "ledger_desc": matched_ledger.get("ledger_desc", ""), "voucher_debit": parse_amount(row[voucher_idx["차변공급가"]]) if "차변공급가" in voucher_idx else 0, "voucher_credit": parse_amount(row[voucher_idx["대변공급가"]]) if "대변공급가" in voucher_idx else 0, "voucher_desc": desc1, "ledger_debit": matched_ledger.get("ledger_debit", 0), "ledger_credit": matched_ledger.get("ledger_credit", 0), "review_reason": review_reason, "review_memo": clean(row[voucher_idx["review_memo"]]) if "review_memo" in voucher_idx else "", "substitution_hint": _build_account_substitution_hint( matched_ledger.get("ledger_account_code", ""), matched_ledger.get("ledger_account_name", ""), clean(row[voucher_idx["계정코드"]]) if "계정코드" in voucher_idx else "", clean(row[voucher_idx["계정명칭"]]) if "계정명칭" in voucher_idx else "", ), } item["review_key"] = build_review_key(item) item["match_identity_key"] = build_match_identity_key(item) item["voucher_row_key"] = build_voucher_row_key(item) if confirmed_no and confirmed_no not in voucher_by_confirmed_no: voucher_by_confirmed_no[confirmed_no] = item if not matched_voucher_no: metric_map["voucher_only"]["count"] += 1 metric_map["voucher_only"]["rows"].append(item) has_wehago_link = bool( item.get("voucher_no") or item.get("ledger_date") or item.get("ledger_account_name") or item.get("ledger_vendor") ) if has_wehago_link and (_normalize_flag(review_flag) or ("REVIEW" in review_reason)): metric_map["amount_mismatch"]["count"] += 1 metric_map["amount_mismatch"]["rows"].append(item) for row in ledger_rows: matched_voucher_no = clean(row[ledger_idx["matched_확정전표번호"]]) if "matched_확정전표번호" in ledger_idx else "" matched_voucher = voucher_by_confirmed_no.get(matched_voucher_no, {}) item = { "fiscal_year": year, "voucher_no": clean(row[ledger_idx["전표번호"]]) if "전표번호" in ledger_idx else "", "ledger_date": clean(row[ledger_idx["일자"]]) if "일자" in ledger_idx else "", "draft_no": matched_voucher.get("draft_no", ""), "ledger_account_code": clean(row[ledger_idx["계정코드"]]) if "계정코드" in ledger_idx else "", "ledger_account_name": clean(row[ledger_idx["계정명"]]) if "계정명" in ledger_idx else "", "voucher_account_code": matched_voucher.get("voucher_account_code") or (clean(row[ledger_idx["matched_계정코드"]]) if "matched_계정코드" in ledger_idx else ""), "voucher_account_name": matched_voucher.get("voucher_account_name") or (clean(row[ledger_idx["matched_계정명칭"]]) if "matched_계정명칭" in ledger_idx else ""), "ledger_vendor": clean(row[ledger_idx["거래처"]]) if "거래처" in ledger_idx else "", "voucher_vendor": matched_voucher.get("voucher_vendor", ""), "voucher_debit": matched_voucher.get("voucher_debit", 0), "voucher_credit": matched_voucher.get("voucher_credit", 0), "ledger_desc": clean(row[ledger_idx["적요"]]) if "적요" in ledger_idx else "", "voucher_desc": matched_voucher.get("voucher_desc", ""), "ledger_debit": parse_amount(row[ledger_idx["차변"]]) if "차변" in ledger_idx else 0, "ledger_credit": parse_amount(row[ledger_idx["대변"]]) if "대변" in ledger_idx else 0, } item["review_key"] = build_review_key(item) item["match_identity_key"] = build_match_identity_key(item) item["ledger_row_key"] = build_ledger_row_key(item) if matched_voucher_no: metric_map["matched"]["count"] += 1 metric_map["matched"]["rows"].append(item) else: metric_map["ledger_only"]["count"] += 1 metric_map["ledger_only"]["rows"].append(item) return _append_substitution_review_rows(metric_map) def build_metric_sections_from_results(start_year: int | None, end_year: int | None) -> list[dict[str, Any]]: if start_year is None or end_year is None: return [] aggregate = { "matched": {"count": 0, "columns": None, "rows": []}, "ledger_only": {"count": 0, "columns": None, "rows": []}, "amount_mismatch": {"count": 0, "columns": None, "rows": []}, "voucher_only": {"count": 0, "columns": None, "rows": []}, } for year in range(start_year, end_year + 1): bundle = discover_compare_result_bundle(year) if not bundle: continue parsed = parse_compare_result_bundle( str(bundle["ledger_result"]), bundle["ledger_result"].stat().st_mtime, str(bundle["voucher_result"]), bundle["voucher_result"].stat().st_mtime, year, ) for key, section in aggregate.items(): section["count"] += parsed[key]["count"] section["columns"] = parsed[key]["columns"] built_sections: list[dict[str, Any]] = [] for status_key, label, description in STATUS_META: section = aggregate.get(status_key, {"count": 0, "columns": None, "rows": []}) built_sections.append( { "key": status_key, "label": label, "description": description, "count": section["count"], "columns": section["columns"] or DETAIL_COLUMN_MAP[status_key], "rows": [], } ) return built_sections def build_metric_sections_with_review_state(conn: Any, start_year: int | None, end_year: int | None) -> list[dict[str, Any]]: if start_year is None or end_year is None: return [] aggregate = { "matched": {"count": 0, "columns": None, "rows": []}, "ledger_only": {"count": 0, "columns": None, "rows": []}, "amount_mismatch": {"count": 0, "columns": None, "rows": []}, "voucher_only": {"count": 0, "columns": None, "rows": []}, } for year in range(start_year, end_year + 1): bundle = discover_compare_result_bundle(year) if not bundle: continue parsed = parse_compare_result_bundle( str(bundle["ledger_result"]), bundle["ledger_result"].stat().st_mtime, str(bundle["voucher_result"]), bundle["voucher_result"].stat().st_mtime, year, ) parsed = apply_saved_recheck_reviews(parsed, get_saved_recheck_review_keys(conn, year, year)) parsed = apply_saved_manual_pair_matches(parsed, get_saved_manual_pair_matches(conn, year, year)) for key, section in aggregate.items(): section["count"] += parsed[key]["count"] section["columns"] = parsed[key]["columns"] built_sections: list[dict[str, Any]] = [] for status_key, label, description in STATUS_META: section = aggregate.get(status_key, {"count": 0, "columns": None, "rows": []}) built_sections.append( { "key": status_key, "label": label, "description": description, "count": section["count"], "columns": section["columns"] or DETAIL_COLUMN_MAP[status_key], "rows": [], } ) return built_sections def build_account_options_from_results(start_year: int | None, end_year: int | None) -> dict[str, list[str]]: wehago_items: set[str] = set() erp_items: set[str] = set() if start_year is None or end_year is None: return {"wehago": [], "erp": []} for year in range(start_year, end_year + 1): bundle = discover_compare_result_bundle(year) if not bundle: continue parsed = parse_compare_result_bundle( str(bundle["ledger_result"]), bundle["ledger_result"].stat().st_mtime, str(bundle["voucher_result"]), bundle["voucher_result"].stat().st_mtime, year, ) for section in parsed.values(): for row in section["rows"]: wehago_code = clean(row.get("ledger_account_code")) wehago_name = clean(row.get("ledger_account_name")) erp_code = clean(row.get("voucher_account_code")) erp_name = clean(row.get("voucher_account_name")) if wehago_code or wehago_name: wehago_items.add(" ".join(part for part in (wehago_code, wehago_name) if part)) if erp_code or erp_name: erp_items.add(" ".join(part for part in (erp_code, erp_name) if part)) return {"wehago": sorted(wehago_items), "erp": sorted(erp_items)} def build_account_options_from_rows(rows_by_status: dict[str, list[dict[str, Any]]]) -> dict[str, list[str]]: wehago_items: set[str] = set() erp_items: set[str] = set() for rows in rows_by_status.values(): for row in rows: wehago_code = clean(row.get("ledger_account_code")) wehago_name = clean(row.get("ledger_account_name")) erp_code = clean(row.get("voucher_account_code")) erp_name = clean(row.get("voucher_account_name")) if wehago_code or wehago_name: wehago_items.add(" ".join(part for part in (wehago_code, wehago_name) if part)) if erp_code or erp_name: erp_items.add(" ".join(part for part in (erp_code, erp_name) if part)) return {"wehago": sorted(wehago_items), "erp": sorted(erp_items)} def build_account_options_from_db(conn: Any, start_year: int | None, end_year: int | None, limit: int = 3000) -> dict[str, list[str]]: wehago_rows = conn.execute( text( f""" SELECT DISTINCT COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name FROM wehago_ledger_rows WHERE {build_year_filter_sql()} AND (COALESCE(account_code, '') <> '' OR COALESCE(account_name, '') <> '') ORDER BY account_code, account_name LIMIT :limit """ ), {"start_year": start_year, "end_year": end_year, "limit": limit}, ).fetchall() erp_rows = conn.execute( text( f""" SELECT DISTINCT COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name FROM wehago_voucher_rows WHERE {build_year_filter_sql()} AND (COALESCE(account_code, '') <> '' OR COALESCE(account_name, '') <> '') ORDER BY account_code, account_name LIMIT :limit """ ), {"start_year": start_year, "end_year": end_year, "limit": limit}, ).fetchall() wehago = sorted({" ".join(part for part in (clean(row[0]), clean(row[1])) if part) for row in wehago_rows}) erp = sorted({" ".join(part for part in (clean(row[0]), clean(row[1])) if part) for row in erp_rows}) return {"wehago": wehago, "erp": erp} def get_saved_recheck_review_keys(conn: Any, start_year: int | None, end_year: int | None) -> set[str]: rows = conn.execute( text( """ SELECT review_key FROM wehago_recheck_reviews WHERE (:start_year IS NULL OR fiscal_year >= :start_year) AND (:end_year IS NULL OR fiscal_year <= :end_year) """ ), {"start_year": start_year, "end_year": end_year}, ).fetchall() return {str(row[0]) for row in rows} def apply_saved_recheck_reviews( sections: dict[str, dict[str, Any]], reviewed_keys: set[str], ) -> dict[str, dict[str, Any]]: if not reviewed_keys: return sections adjusted: dict[str, dict[str, Any]] = {} for key, section in sections.items(): copied = { "count": section["count"], "columns": section["columns"], "rows": list(section["rows"]), } adjusted[key] = copied original_recheck_rows = adjusted["amount_mismatch"]["rows"] remaining_recheck_rows = [row for row in original_recheck_rows if row.get("review_key") not in reviewed_keys] reviewed_rows = [row for row in original_recheck_rows if row.get("review_key") in reviewed_keys] adjusted["amount_mismatch"]["rows"] = remaining_recheck_rows adjusted["amount_mismatch"]["count"] = len(remaining_recheck_rows) matched_rows = adjusted["matched"]["rows"] matched_identity = {row.get("match_identity_key") for row in matched_rows} for row in reviewed_rows: identity = row.get("match_identity_key") if identity in matched_identity: continue matched_identity.add(identity) matched_rows.append(row) adjusted["matched"]["count"] = len(matched_rows) return adjusted def get_saved_manual_pair_matches(conn: Any, start_year: int | None, end_year: int | None) -> list[dict[str, Any]]: rows = conn.execute( text( """ SELECT pair_key, fiscal_year, ledger_row_key, voucher_row_key, pair_note FROM wehago_manual_pair_matches WHERE (:start_year IS NULL OR fiscal_year >= :start_year) AND (:end_year IS NULL OR fiscal_year <= :end_year) """ ), {"start_year": start_year, "end_year": end_year}, ).mappings() return [dict(row) for row in rows] def _build_bundle_signature(start_year: int | None, end_year: int | None) -> str: if start_year is None or end_year is None: return "" parts: list[str] = [] for year in range(start_year, end_year + 1): bundle = discover_compare_result_bundle(year) if not bundle: parts.append(f"{year}:db") continue ledger_mtime = f"{bundle['ledger_result'].stat().st_mtime:.3f}" voucher_mtime = f"{bundle['voucher_result'].stat().st_mtime:.3f}" parts.append(f"{year}:{ledger_mtime}:{voucher_mtime}") return "|".join(parts) def _empty_status_sections() -> dict[str, dict[str, Any]]: return { status_key: { "count": 0, "columns": DETAIL_COLUMN_MAP[status_key], "rows": [], } for status_key, _label, _description in STATUS_META } def _build_db_status_sections(conn: Any, year: int) -> dict[str, dict[str, Any]]: sections = _empty_status_sections() for status_key in sections: payload = _fetch_status_detail_rows_from_db( conn, year, year, status_key, "", "", "", "", "", "", "", "", "", False, 0, 1_000_000, ) rows = payload["rows"] for row in rows: if status_key in {"matched", "ledger_only", "amount_mismatch"}: row["ledger_row_key"] = build_ledger_row_key(row) if status_key in {"matched", "voucher_only", "amount_mismatch"}: row["voucher_row_key"] = build_voucher_row_key(row) row["review_key"] = build_review_key(row) row["match_identity_key"] = build_match_identity_key(row) sections[status_key]["rows"] = rows sections[status_key]["count"] = len(rows) sections = _append_substitution_review_rows(sections) sections = _promote_direct_auto_matches(sections) sections = _apply_previous_year_erp_candidates(conn, year, sections) sections = apply_saved_recheck_reviews(sections, get_saved_recheck_review_keys(conn, year, year)) sections = apply_saved_manual_pair_matches(sections, get_saved_manual_pair_matches(conn, year, year)) return sections def _get_or_create_year_cached_sections(conn: Any, year: int) -> dict[str, dict[str, Any]] | None: bundle = discover_compare_result_bundle(year) if not bundle: return None parse_signature = _current_logic_signature() ledger_path = str(bundle["ledger_result"]) voucher_path = str(bundle["voucher_result"]) ledger_mtime = float(bundle["ledger_result"].stat().st_mtime) voucher_mtime = float(bundle["voucher_result"].stat().st_mtime) cached = conn.execute( text( """ SELECT payload_json FROM wehago_result_row_cache WHERE fiscal_year = :fiscal_year AND ledger_result_path = :ledger_result_path AND ledger_result_mtime = :ledger_result_mtime AND voucher_result_path = :voucher_result_path AND voucher_result_mtime = :voucher_result_mtime AND parse_state_signature = :parse_signature LIMIT 1 """ ), { "fiscal_year": year, "ledger_result_path": ledger_path, "ledger_result_mtime": ledger_mtime, "voucher_result_path": voucher_path, "voucher_result_mtime": voucher_mtime, "parse_signature": parse_signature, }, ).first() if cached and cached[0]: try: payload = json.loads(str(cached[0])) if isinstance(payload, dict): return _append_substitution_review_rows(payload) except Exception: pass parsed = parse_compare_result_bundle( ledger_path, ledger_mtime, voucher_path, voucher_mtime, year, ) conn.execute( text( """ INSERT OR REPLACE INTO wehago_result_row_cache ( fiscal_year, ledger_result_path, ledger_result_mtime, voucher_result_path, voucher_result_mtime, parse_state_signature, payload_json, created_at ) VALUES ( :fiscal_year, :ledger_result_path, :ledger_result_mtime, :voucher_result_path, :voucher_result_mtime, :parse_signature, :payload_json, CURRENT_TIMESTAMP ) """ ), { "fiscal_year": year, "ledger_result_path": ledger_path, "ledger_result_mtime": ledger_mtime, "voucher_result_path": voucher_path, "voucher_result_mtime": voucher_mtime, "parse_signature": parse_signature, "payload_json": json.dumps(parsed, ensure_ascii=False), }, ) conn.execute( text( """ DELETE FROM wehago_result_row_cache WHERE fiscal_year = :fiscal_year AND NOT ( ledger_result_path = :ledger_result_path AND ledger_result_mtime = :ledger_result_mtime AND voucher_result_path = :voucher_result_path AND voucher_result_mtime = :voucher_result_mtime AND parse_state_signature = :parse_signature ) """ ), { "fiscal_year": year, "ledger_result_path": ledger_path, "ledger_result_mtime": ledger_mtime, "voucher_result_path": voucher_path, "voucher_result_mtime": voucher_mtime, "parse_signature": parse_signature, }, ) return parsed def _get_or_create_year_resolved_sections(conn: Any, year: int) -> dict[str, dict[str, Any]] | None: signature = _build_db_state_signature(conn, year, year) cached_payload = _load_year_resolved_sections_cache(conn, year, signature) if cached_payload is not None: return cached_payload parsed_sections = _get_or_create_year_cached_sections(conn, year) if parsed_sections is not None: sections = _apply_boundary_exclusions_to_sections(conn, parsed_sections) sections = apply_saved_recheck_reviews(sections, get_saved_recheck_review_keys(conn, year, year)) sections = apply_saved_manual_pair_matches(sections, get_saved_manual_pair_matches(conn, year, year)) sections = _apply_previous_year_erp_candidates(conn, year, sections) else: sections = _build_db_status_sections(conn, year) _store_year_resolved_sections_cache(conn, year, signature, sections) return sections def _load_year_resolved_sections_cache( conn: Any, year: int, signature: str | None = None, ) -> dict[str, dict[str, Any]] | None: signature = signature or _build_db_state_signature(conn, year, year) cached = conn.execute( text( """ SELECT resolved_payload_json FROM wehago_result_row_cache WHERE fiscal_year = :fiscal_year AND ledger_result_path = 'db' AND voucher_result_path = 'db' AND resolved_state_signature = :signature LIMIT 1 """ ), {"fiscal_year": year, "signature": signature}, ).first() if cached and cached[0]: try: payload = json.loads(str(cached[0])) if isinstance(payload, dict): return payload except Exception: pass return None def _store_year_resolved_sections_cache( conn: Any, year: int, signature: str, sections: dict[str, dict[str, Any]], ) -> None: conn.execute( text( """ INSERT OR REPLACE INTO wehago_result_row_cache ( fiscal_year, ledger_result_path, ledger_result_mtime, voucher_result_path, voucher_result_mtime, payload_json, resolved_state_signature, resolved_payload_json, resolved_created_at, created_at ) VALUES ( :fiscal_year, 'db', 0, 'db', 0, '{}', :signature, :payload_json, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) """ ), { "fiscal_year": year, "signature": signature, "payload_json": json.dumps(sections, ensure_ascii=False), }, ) conn.execute( text( """ DELETE FROM wehago_result_row_cache WHERE fiscal_year = :fiscal_year AND ledger_result_path = 'db' AND voucher_result_path = 'db' AND resolved_state_signature <> :signature """ ), {"fiscal_year": year, "signature": signature}, ) def _build_db_state_signature(conn: Any, start_year: int | None, end_year: int | None) -> str: source = conn.execute( text( """ SELECT COALESCE(MAX(imported_at), ''), COUNT(*) FROM wehago_source_files WHERE (:start_year IS NULL OR year_hint >= :start_year) AND (:end_year IS NULL OR year_hint <= :end_year) """ ), {"start_year": start_year, "end_year": end_year}, ).first() comparison = conn.execute( text( """ SELECT COUNT(*) FROM wehago_comparison_results WHERE (:start_year IS NULL OR fiscal_year >= :start_year) AND (:end_year IS NULL OR fiscal_year <= :end_year) """ ), {"start_year": start_year, "end_year": end_year}, ).first() recheck = conn.execute( text( """ SELECT COALESCE(MAX(reviewed_at), ''), COUNT(*) FROM wehago_recheck_reviews WHERE (:start_year IS NULL OR fiscal_year >= :start_year) AND (:end_year IS NULL OR fiscal_year <= :end_year) """ ), {"start_year": start_year, "end_year": end_year}, ).first() pair = conn.execute( text( """ SELECT COALESCE(MAX(created_at), ''), COUNT(*) FROM wehago_manual_pair_matches WHERE (:start_year IS NULL OR fiscal_year >= :start_year) AND (:end_year IS NULL OR fiscal_year <= :end_year) """ ), {"start_year": start_year, "end_year": end_year}, ).first() return ( f"{_current_logic_signature()}|" f"s:{source[0]}:{source[1]}|c:{comparison[0]}|" f"r:{recheck[0]}:{recheck[1]}|p:{pair[0]}:{pair[1]}" ) def _discover_available_fiscal_years(conn: Any) -> list[int]: rows = conn.execute( text( """ SELECT DISTINCT fiscal_year FROM ( SELECT fiscal_year FROM wehago_ledger_rows UNION SELECT fiscal_year FROM wehago_voucher_rows UNION SELECT fiscal_year FROM wehago_comparison_results ) WHERE fiscal_year IS NOT NULL AND fiscal_year > 0 ORDER BY fiscal_year """ ) ).fetchall() return [int(row[0]) for row in rows if str(row[0] or "").isdigit()] def _get_cached_status_rows_by_range(engine: Any, start_year: int | None, end_year: int | None) -> dict[str, list[dict[str, Any]]]: if start_year is None or end_year is None: return {"matched": [], "ledger_only": [], "amount_mismatch": [], "voucher_only": []} init_wehago_compare_db(engine) with engine.begin() as conn: cache_key = "|".join( [ str(start_year), str(end_year), _build_bundle_signature(start_year, end_year), _build_db_state_signature(conn, start_year, end_year), ] ) now = time.time() cached = _STATUS_ROWS_CACHE.get(cache_key) if cached and (now - float(cached.get("ts", 0))) <= _STATUS_ROWS_CACHE_TTL_SEC: return cached["rows_by_status"] rows_by_status: dict[str, list[dict[str, Any]]] = { "matched": [], "ledger_only": [], "amount_mismatch": [], "voucher_only": [], } for year in range(start_year, end_year + 1): parsed = _get_or_create_year_resolved_sections(conn, year) if not parsed: continue for status_key in rows_by_status: rows_by_status[status_key].extend(parsed[status_key]["rows"]) if start_year != end_year: section_payload = { status_key: { "rows": list(rows), "count": len(rows), "columns": DETAIL_COLUMN_MAP[status_key], } for status_key, rows in rows_by_status.items() } section_payload = _promote_cross_year_auto_matches(section_payload) rows_by_status = { status_key: list(section_payload[status_key]["rows"]) for status_key in rows_by_status } _STATUS_ROWS_CACHE.clear() _STATUS_ROWS_CACHE[cache_key] = {"ts": now, "rows_by_status": rows_by_status} return rows_by_status def _build_voucher_section_cache_key(conn: Any, start_year: int, end_year: int) -> str: return "|".join( [ str(start_year), str(end_year), _build_bundle_signature(start_year, end_year), _build_db_state_signature(conn, start_year, end_year), ] ) def _get_cached_voucher_sections_by_range( engine: Any, start_year: int | None, end_year: int | None, rows_by_status: dict[str, list[dict[str, Any]]] | None = None, ) -> dict[str, list[dict[str, Any]]]: if start_year is None or end_year is None: return { "voucher_matched": [], "erp_voucher_matched": [], "voucher_unmatched": [], "voucher_recheck": [], } init_wehago_compare_db(engine) with engine.begin() as conn: cache_key = _build_voucher_section_cache_key(conn, start_year, end_year) now = time.time() cached = _VOUCHER_SECTION_CACHE.get(cache_key) if cached and (now - float(cached.get("ts", 0))) <= _VOUCHER_SECTION_CACHE_TTL_SEC: return cached["sections"] if rows_by_status is None: rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year) sections = _build_voucher_sections_from_rows_by_status(rows_by_status) _VOUCHER_SECTION_CACHE.clear() _VOUCHER_SECTION_CACHE[cache_key] = {"ts": now, "sections": sections} return sections def _push_action_history(conn: Any, action_type: str, payload: dict[str, Any]) -> int: result = conn.execute( text( """ INSERT INTO wehago_action_history (action_type, payload_json, created_at) VALUES (:action_type, :payload_json, CURRENT_TIMESTAMP) """ ), {"action_type": action_type, "payload_json": json.dumps(payload, ensure_ascii=False)}, ) return int(result.lastrowid) def apply_saved_manual_pair_matches( sections: dict[str, dict[str, Any]], pair_matches: list[dict[str, Any]], ) -> dict[str, dict[str, Any]]: if not pair_matches: return sections adjusted: dict[str, dict[str, Any]] = {} for key, section in sections.items(): adjusted[key] = { "count": section["count"], "columns": section["columns"], "rows": list(section["rows"]), } ledger_rows = adjusted["ledger_only"]["rows"] voucher_rows = adjusted["voucher_only"]["rows"] matched_rows = adjusted["matched"]["rows"] ledger_by_key = {clean(row.get("ledger_row_key")): row for row in ledger_rows if clean(row.get("ledger_row_key"))} voucher_by_key = {clean(row.get("voucher_row_key")): row for row in voucher_rows if clean(row.get("voucher_row_key"))} ledger_remove: set[str] = set() voucher_remove: set[str] = set() matched_pair_keys = {clean(row.get("pair_match_key")) for row in matched_rows if clean(row.get("pair_match_key"))} for pair in pair_matches: ledger_key = clean(pair.get("ledger_row_key")) voucher_key = clean(pair.get("voucher_row_key")) pair_key = clean(pair.get("pair_key")) if not ledger_key and not voucher_key: continue if ledger_key: ledger_remove.add(ledger_key) if voucher_key: voucher_remove.add(voucher_key) ledger_row = ledger_by_key.get(ledger_key) voucher_row = voucher_by_key.get(voucher_key) if not ledger_row or not voucher_row: continue if pair_key and pair_key in matched_pair_keys: continue merged = dict(ledger_row) merged.update( { "proof_date": voucher_row.get("proof_date", ""), "draft_no": voucher_row.get("draft_no", ""), "voucher_account_code": voucher_row.get("voucher_account_code", ""), "voucher_account_name": voucher_row.get("voucher_account_name", ""), "voucher_vendor": voucher_row.get("voucher_vendor", ""), "voucher_debit": voucher_row.get("voucher_debit", 0), "voucher_credit": voucher_row.get("voucher_credit", 0), "voucher_desc": voucher_row.get("voucher_desc", ""), "voucher_row_key": voucher_row.get("voucher_row_key", ""), "review_reason": "수동쌍매칭", "review_memo": clean(pair.get("pair_note")), "pair_match_key": pair_key, "match_identity_key": build_manual_pair_key(ledger_key, voucher_key), } ) matched_rows.append(merged) if pair_key: matched_pair_keys.add(pair_key) if ledger_remove: adjusted["ledger_only"]["rows"] = [row for row in ledger_rows if clean(row.get("ledger_row_key")) not in ledger_remove] if voucher_remove: adjusted["voucher_only"]["rows"] = [row for row in voucher_rows if clean(row.get("voucher_row_key")) not in voucher_remove] adjusted["matched"]["count"] = len(adjusted["matched"]["rows"]) adjusted["ledger_only"]["count"] = len(adjusted["ledger_only"]["rows"]) adjusted["voucher_only"]["count"] = len(adjusted["voucher_only"]["rows"]) return adjusted def save_recheck_review_rows(engine: Any, rows: list[dict[str, Any]]) -> int: init_wehago_compare_db(engine) normalized_rows = [] for row in rows: if not isinstance(row, dict): continue review_key = clean(row.get("review_key")) if not review_key: continue normalized_rows.append( { "review_key": review_key, "fiscal_year": int(row.get("fiscal_year") or 0) or None, "voucher_no": clean(row.get("voucher_no")), "draft_no": clean(row.get("draft_no")), "voucher_account_code": clean(row.get("voucher_account_code")), "voucher_account_name": clean(row.get("voucher_account_name")), "voucher_vendor": clean(row.get("voucher_vendor")), "voucher_desc": clean(row.get("voucher_desc")), "review_reason": clean(row.get("review_reason")), "review_memo": clean(row.get("review_memo")), } ) if not normalized_rows: return 0 saved_keys: list[str] = [] with engine.begin() as conn: for item in normalized_rows: conn.execute( text( """ INSERT INTO wehago_recheck_reviews ( review_key, fiscal_year, voucher_no, draft_no, voucher_account_code, voucher_account_name, voucher_vendor, voucher_desc, review_reason, review_memo, reviewed_at ) VALUES ( :review_key, :fiscal_year, :voucher_no, :draft_no, :voucher_account_code, :voucher_account_name, :voucher_vendor, :voucher_desc, :review_reason, :review_memo, CURRENT_TIMESTAMP ) ON CONFLICT(review_key) DO UPDATE SET fiscal_year = excluded.fiscal_year, voucher_no = excluded.voucher_no, draft_no = excluded.draft_no, voucher_account_code = excluded.voucher_account_code, voucher_account_name = excluded.voucher_account_name, voucher_vendor = excluded.voucher_vendor, voucher_desc = excluded.voucher_desc, review_reason = excluded.review_reason, review_memo = excluded.review_memo, reviewed_at = CURRENT_TIMESTAMP """ ), item, ) saved_keys.append(item["review_key"]) _push_action_history( conn, "recheck_save", { "review_keys": saved_keys, "count": len(saved_keys), }, ) _DASHBOARD_CACHE.clear() _SUGGEST_CACHE.clear() _STATUS_ROWS_CACHE.clear() _PAIR_RECOMMEND_CACHE.clear() clear_persisted_pair_recommend_cache(engine) return len(normalized_rows) def save_manual_pair_matches( engine: Any, ledger_rows: list[dict[str, Any]], voucher_rows: list[dict[str, Any]], pair_meta_by_key: dict[str, dict[str, Any]] | None = None, match_source: str = "manual", ) -> int: init_wehago_compare_db(engine) normalized_ledger = [row for row in ledger_rows if isinstance(row, dict) and clean(row.get("ledger_row_key"))] normalized_voucher = [row for row in voucher_rows if isinstance(row, dict) and clean(row.get("voucher_row_key"))] if not normalized_ledger or not normalized_voucher: return 0 if len(normalized_ledger) != len(normalized_voucher): raise ValueError("WEHAGO/ERP 선택 건수를 동일하게 맞춘 뒤 저장해주세요.") rows_to_save: list[dict[str, Any]] = [] for ledger_row, voucher_row in zip(normalized_ledger, normalized_voucher): ledger_key = clean(ledger_row.get("ledger_row_key")) voucher_key = clean(voucher_row.get("voucher_row_key")) pair_key = build_manual_pair_key(ledger_key, voucher_key) meta = (pair_meta_by_key or {}).get(pair_key, {}) fiscal_year = int(ledger_row.get("fiscal_year") or voucher_row.get("fiscal_year") or 0) or None rows_to_save.append( { "pair_key": pair_key, "fiscal_year": fiscal_year, "ledger_row_key": ledger_key, "voucher_row_key": voucher_key, "ledger_voucher_no": clean(ledger_row.get("voucher_no")), "ledger_account_code": clean(ledger_row.get("ledger_account_code")), "ledger_account_name": clean(ledger_row.get("ledger_account_name")), "ledger_vendor": clean(ledger_row.get("ledger_vendor")), "ledger_debit": parse_amount(ledger_row.get("ledger_debit")), "ledger_credit": parse_amount(ledger_row.get("ledger_credit")), "ledger_desc": clean(ledger_row.get("ledger_desc")), "voucher_no": clean(voucher_row.get("voucher_no")), "draft_no": clean(voucher_row.get("draft_no")), "voucher_account_code": clean(voucher_row.get("voucher_account_code")), "voucher_account_name": clean(voucher_row.get("voucher_account_name")), "voucher_vendor": clean(voucher_row.get("voucher_vendor")), "voucher_debit": parse_amount(voucher_row.get("voucher_debit")), "voucher_credit": parse_amount(voucher_row.get("voucher_credit")), "voucher_desc": clean(voucher_row.get("voucher_desc")), "match_source": clean(meta.get("match_source")) or clean(match_source) or "manual", "confidence_score": float(meta.get("score") or 0), "confidence_level": clean(meta.get("confidence_level")), "match_reason": clean(meta.get("reason")), "pair_note": "", } ) saved_pair_keys: list[str] = [] with engine.begin() as conn: for item in rows_to_save: conn.execute( text( """ INSERT INTO wehago_manual_pair_matches ( pair_key, fiscal_year, ledger_row_key, voucher_row_key, ledger_voucher_no, ledger_account_code, ledger_account_name, ledger_vendor, ledger_debit, ledger_credit, ledger_desc, voucher_no, draft_no, voucher_account_code, voucher_account_name, voucher_vendor, voucher_debit, voucher_credit, voucher_desc, match_source, confidence_score, confidence_level, match_reason, pair_note, created_at ) VALUES ( :pair_key, :fiscal_year, :ledger_row_key, :voucher_row_key, :ledger_voucher_no, :ledger_account_code, :ledger_account_name, :ledger_vendor, :ledger_debit, :ledger_credit, :ledger_desc, :voucher_no, :draft_no, :voucher_account_code, :voucher_account_name, :voucher_vendor, :voucher_debit, :voucher_credit, :voucher_desc, :match_source, :confidence_score, :confidence_level, :match_reason, :pair_note, CURRENT_TIMESTAMP ) ON CONFLICT(pair_key) DO UPDATE SET fiscal_year = excluded.fiscal_year, ledger_row_key = excluded.ledger_row_key, voucher_row_key = excluded.voucher_row_key, ledger_voucher_no = excluded.ledger_voucher_no, ledger_account_code = excluded.ledger_account_code, ledger_account_name = excluded.ledger_account_name, ledger_vendor = excluded.ledger_vendor, ledger_debit = excluded.ledger_debit, ledger_credit = excluded.ledger_credit, ledger_desc = excluded.ledger_desc, voucher_no = excluded.voucher_no, draft_no = excluded.draft_no, voucher_account_code = excluded.voucher_account_code, voucher_account_name = excluded.voucher_account_name, voucher_vendor = excluded.voucher_vendor, voucher_debit = excluded.voucher_debit, voucher_credit = excluded.voucher_credit, voucher_desc = excluded.voucher_desc, match_source = excluded.match_source, confidence_score = excluded.confidence_score, confidence_level = excluded.confidence_level, match_reason = excluded.match_reason, pair_note = excluded.pair_note, created_at = CURRENT_TIMESTAMP """ ), item, ) saved_pair_keys.append(item["pair_key"]) _push_action_history( conn, "pair_save", { "pair_keys": saved_pair_keys, "count": len(saved_pair_keys), "match_source": clean(match_source) or "manual", }, ) _DASHBOARD_CACHE.clear() _SUGGEST_CACHE.clear() _STATUS_ROWS_CACHE.clear() _PAIR_RECOMMEND_CACHE.clear() clear_persisted_pair_recommend_cache(engine) return len(rows_to_save) def get_last_action_summary(engine: Any | None = None, conn: Any | None = None) -> dict[str, Any] | None: if conn is None: if engine is None: return None init_wehago_compare_db(engine) with engine.begin() as local_conn: return get_last_action_summary(conn=local_conn) row = conn.execute( text( """ SELECT id, action_type, payload_json, created_at FROM wehago_action_history ORDER BY id DESC LIMIT 1 """ ) ).mappings().first() if not row: return None payload = {} try: payload = json.loads(row["payload_json"] or "{}") except json.JSONDecodeError: payload = {} count = int(payload.get("count") or 0) return { "id": int(row["id"]), "action_type": clean(row["action_type"]), "count": count, "created_at": clean(row["created_at"]), } def undo_last_action(engine: Any) -> dict[str, Any]: init_wehago_compare_db(engine) with engine.begin() as conn: row = conn.execute( text( """ SELECT id, action_type, payload_json FROM wehago_action_history ORDER BY id DESC LIMIT 1 """ ) ).mappings().first() if not row: return {"undone": False, "message": "되돌릴 작업이 없습니다."} action_type = clean(row["action_type"]) payload = {} try: payload = json.loads(row["payload_json"] or "{}") except json.JSONDecodeError: payload = {} affected = 0 if action_type == "recheck_save": keys = [clean(key) for key in payload.get("review_keys", []) if clean(key)] if keys: placeholders = ", ".join(f":k{i}" for i in range(len(keys))) params = {f"k{i}": key for i, key in enumerate(keys)} result = conn.execute(text(f"DELETE FROM wehago_recheck_reviews WHERE review_key IN ({placeholders})"), params) affected = int(result.rowcount or 0) elif action_type == "pair_save": keys = [clean(key) for key in payload.get("pair_keys", []) if clean(key)] if keys: placeholders = ", ".join(f":k{i}" for i in range(len(keys))) params = {f"k{i}": key for i, key in enumerate(keys)} result = conn.execute(text(f"DELETE FROM wehago_manual_pair_matches WHERE pair_key IN ({placeholders})"), params) affected = int(result.rowcount or 0) conn.execute(text("DELETE FROM wehago_action_history WHERE id = :id"), {"id": row["id"]}) _DASHBOARD_CACHE.clear() _SUGGEST_CACHE.clear() _STATUS_ROWS_CACHE.clear() _PAIR_RECOMMEND_CACHE.clear() clear_persisted_pair_recommend_cache(engine) return {"undone": True, "action_type": action_type, "affected": affected} def _parse_iso_date(value: Any) -> date | None: parsed = parse_excel_date(value) if not parsed: return None try: return datetime.strptime(parsed, "%Y-%m-%d").date() except ValueError: return None def _tokenize_for_similarity(value: Any) -> set[str]: normalized = normalize_text(value) if not normalized: return set() normalized = _normalize_core_similarity_text(normalized) if not normalized: return set() if len(normalized) <= 2: return {normalized} tokens = {normalized[i : i + 2] for i in range(len(normalized) - 1)} tokens.add(normalized) for keyword in _extract_core_keywords(normalized): tokens.add(keyword) return tokens def _normalize_core_similarity_text(value: Any) -> str: text_value = normalize_text(value) replacements = [ "주식회사", "유한회사", "합자회사", "합명회사", "재단법인", "사단법인", "농업회사법인", "회사", "법인", "부가가치세", "전자세금계산서", "세금계산서", ] for token in replacements: text_value = text_value.replace(token, "") return text_value def _extract_core_keywords(value: Any) -> set[str]: text_value = _normalize_core_similarity_text(value) if not text_value: return set() keywords: set[str] = set() candidates = [ "미수금", "매출금", "외상매출금", "매입세액", "부가세대급금", "부가세예수금", "대급금", "예수금", "보통예금", "미지급금", "급여", "퇴직급여", "복리후생", "여비교통", "접대", "통신", "전력", "수도광열", "차량유지", "보험", "교육훈련", "도서인쇄", "사무용품", "소모품", "지급수수료", "수수료", "감가상각", "이자", "배당", "잡이익", "잡손실", ] for keyword in candidates: normalized_keyword = normalize_text(keyword) if normalized_keyword and normalized_keyword in text_value: keywords.add(normalized_keyword) for size in (4, 3): if len(text_value) >= size: keywords.update(text_value[i : i + size] for i in range(len(text_value) - size + 1)) return keywords def _jaccard_similarity(left: Any, right: Any) -> float: left_tokens = _tokenize_for_similarity(left) right_tokens = _tokenize_for_similarity(right) if not left_tokens or not right_tokens: return 0.0 inter = len(left_tokens & right_tokens) union = len(left_tokens | right_tokens) return (inter / union) if union else 0.0 def _jaccard_similarity_tokens(left_tokens: set[str], right_tokens: set[str]) -> float: if not left_tokens or not right_tokens: return 0.0 inter = len(left_tokens & right_tokens) union = len(left_tokens | right_tokens) return (inter / union) if union else 0.0 def _extract_month_tokens(value: Any) -> set[int]: text = str(value or "") tokens: set[int] = set() for raw in re.findall(r"(? bool: left_months = _extract_month_tokens(left) right_months = _extract_month_tokens(right) if not left_months or not right_months: return False return left_months.isdisjoint(right_months) def _has_conflicting_month_token_sets(left_months: set[int], right_months: set[int]) -> bool: if not left_months or not right_months: return False return left_months.isdisjoint(right_months) def _extract_desc_date_tokens(value: Any) -> set[str]: text_value = clean(value) if not text_value: return set() tokens: set[str] = set() for year, month, day in re.findall(r"(? set[int]: months: set[int] = set() for token in tokens: parts = token.split("-") if len(parts) == 3: month_text = parts[1] elif len(parts) == 2 and len(parts[0]) == 4: month_text = parts[1] else: month_text = parts[0] try: month = int(month_text) except ValueError: continue if 1 <= month <= 12: months.add(month) return months def _strip_desc_date_tokens(value: Any) -> str: text_value = clean(value) text_value = re.sub(r"(? bool: left_dates = _extract_desc_date_tokens(left) right_dates = _extract_desc_date_tokens(right) if not left_dates and not right_dates: return True if left_dates == right_dates: return True left_months = _date_token_month_numbers(left_dates) right_months = _date_token_month_numbers(right_dates) return bool(left_months and right_months and left_months == right_months) def _date_tokens_conflict(left: Any, right: Any) -> bool: left_dates = _extract_desc_date_tokens(left) right_dates = _extract_desc_date_tokens(right) return bool(left_dates and right_dates and not _date_tokens_compatible(left, right)) def _core_token_overlap(left: Any, right: Any) -> bool: left_tokens = _extract_core_keywords(left) right_tokens = _extract_core_keywords(right) if left_tokens & right_tokens: return True left_norm = _normalize_core_similarity_text(left) right_norm = _normalize_core_similarity_text(right) if not left_norm or not right_norm: return False if len(left_norm) >= 2 and left_norm in right_norm: return True if len(right_norm) >= 2 and right_norm in left_norm: return True return _jaccard_similarity(left_norm, right_norm) >= 0.55 def _core_token_strong_overlap(left: Any, right: Any) -> bool: left_tokens = _extract_core_keywords(left) right_tokens = _extract_core_keywords(right) if left_tokens and right_tokens and left_tokens & right_tokens: return True left_norm = _normalize_core_similarity_text(left) right_norm = _normalize_core_similarity_text(right) if not left_norm or not right_norm: return False return _jaccard_similarity(left_norm, right_norm) >= 0.72 def _account_names_compatible(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -> bool: ledger_code = clean(ledger_row.get("ledger_account_code")) voucher_code = clean(voucher_row.get("voucher_account_code")) ledger_name = clean(ledger_row.get("ledger_account_name")) voucher_name = clean(voucher_row.get("voucher_account_name")) if ledger_code and voucher_code and ledger_code == voucher_code: return True if normalize_text(ledger_name) and normalize_text(ledger_name) == normalize_text(voucher_name): return True if _build_account_substitution_hint(ledger_code, ledger_name, voucher_code, voucher_name): return True if _account_base_names_compatible(ledger_name, voucher_name): return True ledger_family = _classify_account_family(ledger_code, ledger_name) voucher_family = _classify_account_family(voucher_code, voucher_name) if ledger_family and ledger_family == voucher_family: return True return _jaccard_similarity(ledger_name, voucher_name) >= 0.55 def _account_base_name(value: Any) -> str: text_value = clean(value) text_value = re.sub(r"^\s*원가\)\s*", "", text_value) text_value = text_value.split("(", 1)[0] normalized = normalize_text(text_value) if normalized.startswith("원가"): normalized = normalized[2:] return normalized def _account_base_names_compatible(left: Any, right: Any) -> bool: left_base = _account_base_name(left) right_base = _account_base_name(right) if not left_base or not right_base: return False if left_base == right_base: return True short, long = sorted((left_base, right_base), key=len) return len(short) >= 2 and short in long def _same_or_similar_vendor(row: dict[str, Any]) -> bool: return _core_token_overlap(row.get("ledger_vendor"), row.get("voucher_vendor")) def _same_or_similar_desc(row: dict[str, Any]) -> bool: if not _date_tokens_compatible(row.get("ledger_desc"), row.get("voucher_desc")): return False ledger_desc = _strip_desc_date_tokens(row.get("ledger_desc")) voucher_desc = _strip_desc_date_tokens(row.get("voucher_desc")) return _core_token_strong_overlap(ledger_desc, voucher_desc) def _contained_core_desc_match(left: Any, right: Any) -> bool: if not _date_tokens_compatible(left, right): return False left_norm = _normalize_core_similarity_text(_strip_desc_date_tokens(left)) right_norm = _normalize_core_similarity_text(_strip_desc_date_tokens(right)) if not left_norm or not right_norm: return False short, long = sorted((left_norm, right_norm), key=len) return len(short) >= 2 and short in long def _short_core_desc_fuzzy_match(left: Any, right: Any) -> bool: if not _date_tokens_compatible(left, right): return False left_norm = _normalize_core_similarity_text(_strip_desc_date_tokens(left)) right_norm = _normalize_core_similarity_text(_strip_desc_date_tokens(right)) if not left_norm or not right_norm: return False if max(len(left_norm), len(right_norm)) > 4 or min(len(left_norm), len(right_norm)) < 2: return False return SequenceMatcher(None, left_norm, right_norm).ratio() >= 0.75 def _bank_payable_desc_approved(row: dict[str, Any]) -> bool: ledger_desc = row.get("ledger_desc") voucher_desc = row.get("voucher_desc") return ( _same_or_similar_desc(row) or _contained_core_desc_match(ledger_desc, voucher_desc) or _short_core_desc_fuzzy_match(ledger_desc, voucher_desc) or _shared_strong_keyword(ledger_desc, voucher_desc, {"보증"}) ) def _shared_strong_keyword(left: Any, right: Any, keywords: set[str]) -> bool: left_norm = normalize_text(left) right_norm = normalize_text(right) return any(keyword and keyword in left_norm and keyword in right_norm for keyword in keywords) def _is_same_bank_guarantee_case(row: dict[str, Any]) -> bool: ledger_family = _classify_account_family(row.get("ledger_account_code"), row.get("ledger_account_name")) voucher_family = _classify_account_family(row.get("voucher_account_code"), row.get("voucher_account_name")) return ( ledger_family == "bank" and voucher_family == "bank" and _shared_strong_keyword(row.get("ledger_desc"), row.get("voucher_desc"), {"보증"}) ) def _is_approved_guarantee_account_pair(row: dict[str, Any]) -> bool: if not _shared_strong_keyword(row.get("ledger_desc"), row.get("voucher_desc"), {"보증"}): return False ledger_name = normalize_text(row.get("ledger_account_name")) voucher_name = normalize_text(row.get("voucher_account_name")) approved_pairs = ( ("민사보전금", "임차보증금"), ("세금과공과금", "보험료"), ("선급금", "전도금"), ("보증수수료", "지급수수료"), ) for left_marker, right_marker in approved_pairs: left = normalize_text(left_marker) right = normalize_text(right_marker) if left in ledger_name and right in voucher_name: return True if right in ledger_name and left in voucher_name: return True return False def _account_base_desc_approved(row: dict[str, Any]) -> bool: if not _account_base_names_compatible(row.get("ledger_account_name"), row.get("voucher_account_name")): return False return ( _same_or_similar_desc(row) or _contained_core_desc_match(row.get("ledger_desc"), row.get("voucher_desc")) ) def _has_boundary_substitution_date(row: dict[str, Any]) -> bool: try: fiscal_year = int(row.get("fiscal_year") or 0) except (TypeError, ValueError): fiscal_year = 0 for field in ("ledger_date", "proof_date"): parsed = _parse_iso_date(row.get(field)) if parsed is None: continue if fiscal_year and parsed.year != fiscal_year: continue if (parsed.month, parsed.day) in {(1, 1), (12, 31)}: return True return False def _is_substitution_like_row(row: dict[str, Any]) -> bool: review_text = normalize_text( " ".join( clean(row.get(field)) for field in ("review_reason", "substitution_hint", "ledger_desc", "voucher_desc") ) ) if any(token in review_text for token in ("대체", "substitution")): return True ledger_family = _classify_account_family(row.get("ledger_account_code"), row.get("ledger_account_name")) voucher_family = _classify_account_family(row.get("voucher_account_code"), row.get("voucher_account_name")) return bool(_build_account_substitution_hint( row.get("ledger_account_code"), row.get("ledger_account_name"), row.get("voucher_account_code"), row.get("voucher_account_name"), ) and ledger_family != voucher_family) def _is_bank_payable_case(row: dict[str, Any]) -> bool: ledger_family = _classify_account_family(row.get("ledger_account_code"), row.get("ledger_account_name")) voucher_family = _classify_account_family(row.get("voucher_account_code"), row.get("voucher_account_name")) return {ledger_family, voucher_family} == {"bank", "payable"} def _is_boundary_substitution_row(row: dict[str, Any]) -> bool: return _has_boundary_substitution_date(row) and _is_substitution_like_row(row) def _account_candidate_family_keys(account_code: Any, account_name: Any) -> set[str]: account_code_text = clean(account_code) account_name_norm = normalize_text(account_name) family = _classify_account_family(account_code_text, account_name) keys: set[str] = set() if account_code_text: keys.add(f"code:{account_code_text}") if account_name_norm: keys.add(f"name:{account_name_norm}") if family: keys.add(f"family:{family}") related = { "payable": {"bank"}, "bank": {"payable"}, "receivable": {"sales"}, "sales": {"receivable"}, "vat_input": {"vat_output"}, "vat_output": {"vat_input"}, } for related_family in related.get(family, set()): keys.add(f"family:{related_family}") return keys def _build_amount_account_index( rows: list[dict[str, Any]], *, prefix: str, ) -> tuple[dict[float, list[dict[str, Any]]], dict[tuple[float, str], list[dict[str, Any]]]]: amount_index: dict[float, list[dict[str, Any]]] = {} account_index: dict[tuple[float, str], list[dict[str, Any]]] = {} for row in rows: amount = round(_get_row_match_amount(row, prefix), 2) if amount <= 0: continue amount_index.setdefault(amount, []).append(row) for key in _account_candidate_family_keys( row.get(f"{prefix}_account_code"), row.get(f"{prefix}_account_name"), ): account_index.setdefault((amount, key), []).append(row) return amount_index, account_index def _candidate_rows_for_amount_account( amount: float, ledger_row: dict[str, Any], amount_index: dict[float, list[dict[str, Any]]], account_index: dict[tuple[float, str], list[dict[str, Any]]], *, wide_bucket_limit: int = 80, filtered_bucket_limit: int = 160, ) -> list[dict[str, Any]]: candidates = amount_index.get(amount, []) if len(candidates) <= wide_bucket_limit: return candidates filtered: list[dict[str, Any]] = [] seen_keys: set[int] = set() for key in _account_candidate_family_keys( ledger_row.get("ledger_account_code"), ledger_row.get("ledger_account_name"), ): for candidate in account_index.get((amount, key), []): object_key = id(candidate) if object_key in seen_keys: continue seen_keys.add(object_key) filtered.append(candidate) if len(filtered) > filtered_bucket_limit: return [] return filtered def _is_recheck_row_clear_match(row: dict[str, Any]) -> bool: ledger_amount = _get_row_match_amount(row, "ledger") voucher_amount = _get_row_match_amount(row, "voucher") if ledger_amount <= 0 or abs(ledger_amount - voucher_amount) >= 0.5: return False ledger_side = _determine_primary_side(row) voucher_side = "debit" if parse_amount(row.get("voucher_debit")) > 0 and parse_amount(row.get("voucher_credit")) <= 0 else "credit" if parse_amount(row.get("voucher_credit")) > 0 and parse_amount(row.get("voucher_debit")) <= 0 else "either" if not _nature_compatible( row.get("ledger_account_code"), row.get("ledger_account_name"), ledger_side, row.get("voucher_account_code"), row.get("voucher_account_name"), voucher_side, ): return False if _is_boundary_substitution_row(row): return False vendor_match = _same_or_similar_vendor(row) desc_match = _same_or_similar_desc(row) if _account_base_desc_approved(row): return True if _is_approved_guarantee_account_pair(row): return True if not _account_names_compatible(row, row): return False if _is_same_bank_guarantee_case(row): return True if _is_bank_payable_case(row) and _bank_payable_desc_approved(row): return True if vendor_match and desc_match: return True if vendor_match and not _date_tokens_conflict(row.get("ledger_desc"), row.get("voucher_desc")): return True if desc_match: return True return False def _month_gap(left: date, right: date) -> int: return abs((left.year - right.year) * 12 + (left.month - right.month)) def _normalize_account_family_text(value: Any) -> str: return normalize_text(value) def _classify_account_family(account_code: Any, account_name: Any) -> str: normalized_name = _normalize_account_family_text(account_name) normalized_code = clean(account_code) payable_markers = ("미지급금", "외상미지급금") bank_markers = ("보통예금",) receivable_markers = ("미수금", "외상매출금", "매출채권", "공사미수금") sales_markers = ("매출금", "매출", "용역수입", "수입") vat_input_markers = ("부가세대급금", "매입세액", "부가세매입", "부가가치세대급금") vat_output_markers = ("부가세예수금", "매출세액", "부가세매출", "부가가치세예수금") education_markers = ("교육훈련비", "교육훈련") welfare_markers = ("복리후생비", "복리후생", "회식대") if any(marker in normalized_name for marker in payable_markers): return "payable" if any(marker in normalized_name for marker in bank_markers): return "bank" if any(marker in normalized_name for marker in receivable_markers): return "receivable" if any(marker in normalized_name for marker in sales_markers): return "sales" if any(marker in normalized_name for marker in vat_input_markers): return "vat_input" if any(marker in normalized_name for marker in vat_output_markers): return "vat_output" if any(marker in normalized_name for marker in education_markers): return "education_training" if any(marker in normalized_name for marker in welfare_markers): return "welfare" if normalized_code.startswith(("211", "213")): return "payable" if normalized_code.startswith(("111", "112")): return "bank" if normalized_code.startswith(("108", "120", "112")): return "receivable" if normalized_code.startswith(("401", "411", "412", "413", "414", "415")): return "sales" if normalized_code.startswith(("135", "136")): return "vat_input" if normalized_code.startswith(("255",)): return "vat_output" return "" def _classify_account_category(account_code: Any, account_name: Any) -> str: normalized_name = _normalize_account_family_text(account_name) normalized_code = clean(account_code) asset_markers = ( "보통예금", "예금", "현금", "미수금", "외상매출금", "매출채권", "공사미수금", "선급", "대여금", "가수금환급", "부가세대급금", "매입세액", ) liability_markers = ( "미지급금", "외상미지급금", "외상매입금", "매입채무", "예수금", "부가세예수금", "매출세액", "선수금", "차입금", ) equity_markers = ("자본금", "이익잉여금", "자본잉여금") revenue_markers = ("매출", "용역수입", "수입", "수익") expense_markers = ( "원가", "비용", "차량유지비", "지급임차료", "임차료", "복리후생비", "교육훈련비", "급여", "외주비", "수수료", "소모품비", "여비교통비", ) if any(marker in normalized_name for marker in asset_markers): return "asset" if any(marker in normalized_name for marker in liability_markers): return "liability" if any(marker in normalized_name for marker in equity_markers): return "equity" if any(marker in normalized_name for marker in revenue_markers): return "revenue" if any(marker in normalized_name for marker in expense_markers): return "expense" family = _classify_account_family(account_code, account_name) family_category_map = { "bank": "asset", "receivable": "asset", "vat_input": "asset", "payable": "liability", "vat_output": "liability", "sales": "revenue", "education_training": "expense", "welfare": "expense", } if family in family_category_map: return family_category_map[family] if normalized_code.startswith(("1",)): return "asset" if normalized_code.startswith(("2",)): return "liability" if normalized_code.startswith(("3",)): return "equity" if normalized_code.startswith(("4",)): return "revenue" if normalized_code.startswith(("5", "6", "7", "8", "9")): return "expense" return "" def _account_nature_signature(account_code: Any, account_name: Any, side: str) -> str: category = _classify_account_category(account_code, account_name) if not category or side not in {"debit", "credit"}: return "" if category in {"asset", "expense"}: direction = "increase" if side == "debit" else "decrease" else: direction = "increase" if side == "credit" else "decrease" return f"{category}:{direction}" def _nature_compatible( ledger_code: Any, ledger_name: Any, ledger_side: str, voucher_code: Any, voucher_name: Any, voucher_side: str, ) -> bool: ledger_signature = _account_nature_signature(ledger_code, ledger_name, ledger_side) voucher_signature = _account_nature_signature(voucher_code, voucher_name, voucher_side) if not ledger_signature or not voucher_signature: return True return ledger_signature == voucher_signature def _is_vat_family(family: str) -> bool: return family in {"vat_input", "vat_output"} def _is_income_expense_category(category: str) -> bool: return category in {"revenue", "expense"} def _is_asset_liability_category(category: str) -> bool: return category in {"asset", "liability"} def _account_category_pair_allowed( ledger_code: Any, ledger_name: Any, voucher_code: Any, voucher_name: Any, ) -> bool: ledger_category = _classify_account_category(ledger_code, ledger_name) voucher_category = _classify_account_category(voucher_code, voucher_name) ledger_family = _classify_account_family(ledger_code, ledger_name) voucher_family = _classify_account_family(voucher_code, voucher_name) # VAT rows should be matched only against VAT/tax rows. if _is_vat_family(ledger_family) or _is_vat_family(voucher_family): return _is_vat_family(ledger_family) and _is_vat_family(voucher_family) # Asset/liability rows should not match revenue/expense rows and vice versa. if ( _is_asset_liability_category(ledger_category) and _is_income_expense_category(voucher_category) ) or ( _is_income_expense_category(ledger_category) and _is_asset_liability_category(voucher_category) ): return False return True def _build_account_substitution_hint( ledger_code: Any, ledger_name: Any, voucher_code: Any, voucher_name: Any, ) -> str: ledger_family = _classify_account_family(ledger_code, ledger_name) voucher_family = _classify_account_family(voucher_code, voucher_name) if {ledger_family, voucher_family} == {"payable", "bank"}: return "미지급금/보통예금 대체 검토" if {ledger_family, voucher_family} == {"receivable", "sales"}: return "미수금/매출금 유사 계정" if ledger_family and ledger_family == voucher_family and ledger_family in {"vat_input", "vat_output"}: return "부가세 계정 유사" return "" def _get_row_match_amount(row: dict[str, Any], prefix: str) -> float: debit = parse_amount(row.get(f"{prefix}_debit")) credit = parse_amount(row.get(f"{prefix}_credit")) if debit > 0 and credit <= 0: return debit if credit > 0 and debit <= 0: return credit return max(debit, credit) def _get_side_amount(row: dict[str, Any], prefix: str, side: str) -> float: if side == "debit": return parse_amount(row.get(f"{prefix}_debit")) if side == "credit": return parse_amount(row.get(f"{prefix}_credit")) return _get_row_match_amount(row, prefix) def _build_match_row_features( row: dict[str, Any], *, prefix: str, row_key_field: str, account_code_field: str, account_name_field: str, vendor_field: str, desc_field: str, date_field: str, ) -> MatchRowFeatures: debit_amount = parse_amount(row.get(f"{prefix}_debit")) credit_amount = parse_amount(row.get(f"{prefix}_credit")) if debit_amount > 0 and credit_amount <= 0: match_amount = debit_amount primary_side = "debit" elif credit_amount > 0 and debit_amount <= 0: match_amount = credit_amount primary_side = "credit" else: match_amount = max(debit_amount, credit_amount) primary_side = "either" positive_amounts = tuple( amount for amount in { round(debit_amount, 2), round(credit_amount, 2), } if amount > 0 ) desc_text = clean(row.get(desc_field)) return MatchRowFeatures( row=row, row_key=clean(row.get(row_key_field)), account_code=clean(row.get(account_code_field)), account_name=clean(row.get(account_name_field)), vendor_name=clean(row.get(vendor_field)), desc_text=desc_text, account_tokens=_tokenize_for_similarity(row.get(account_name_field)), vendor_tokens=_tokenize_for_similarity(row.get(vendor_field)), desc_tokens=_tokenize_for_similarity(desc_text), month_tokens=_extract_month_tokens(desc_text), date_value=_parse_iso_date(row.get(date_field)), debit_amount=debit_amount, credit_amount=credit_amount, match_amount=match_amount, primary_side=primary_side, positive_amounts=positive_amounts, ) def _get_feature_side_amount(features: MatchRowFeatures, side: str) -> float: if side == "debit": return features.debit_amount if side == "credit": return features.credit_amount return features.match_amount def _is_strong_substitution_candidate( score_result: dict[str, Any], desc_sim: float, ) -> bool: return bool( score_result.get("substitution_hint") and float(score_result.get("amount_gap", 0)) < 0.5 and desc_sim >= 0.85 ) def _is_strong_text_match( amount_gap: float, vendor_sim: float, desc_sim: float, month_conflict: bool, ) -> bool: return bool( amount_gap < 0.5 and not month_conflict and ( (vendor_sim >= 0.65 and desc_sim >= 0.82) or (vendor_sim >= 0.82 and desc_sim >= 0.55) or (vendor_sim >= 0.55 and desc_sim >= 0.72) ) ) def _is_reviewable_text_match( amount_gap: float, vendor_sim: float, desc_sim: float, month_conflict: bool, ) -> bool: return bool( amount_gap < 0.5 and not month_conflict and (vendor_sim >= 0.4 or desc_sim >= 0.6) ) def _build_substitution_review_rows( ledger_rows: list[dict[str, Any]], voucher_rows: list[dict[str, Any]], existing_pairs: set[tuple[str, str]], ) -> list[dict[str, Any]]: if not ledger_rows or not voucher_rows: return [] voucher_amount_index, voucher_account_index = _build_amount_account_index(voucher_rows, prefix="voucher") built: list[dict[str, Any]] = [] seen_pairs = set(existing_pairs) for ledger_row in ledger_rows: amount = round(_get_row_match_amount(ledger_row, "ledger"), 2) if amount <= 0: continue candidates = _candidate_rows_for_amount_account( amount, ledger_row, voucher_amount_index, voucher_account_index, ) if not candidates: continue for voucher_row in candidates: ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row) voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) pair_key = (ledger_key, voucher_key) if not ledger_key or not voucher_key or pair_key in seen_pairs: continue score_result = _score_pair_match(ledger_row, voucher_row) desc_sim = _jaccard_similarity(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")) if not _is_strong_substitution_candidate(score_result, desc_sim): continue item = { "fiscal_year": ledger_row.get("fiscal_year") or voucher_row.get("fiscal_year"), "voucher_no": ledger_row.get("voucher_no", ""), "ledger_date": ledger_row.get("ledger_date", ""), "draft_no": voucher_row.get("draft_no", ""), "ledger_account_code": ledger_row.get("ledger_account_code", ""), "ledger_account_name": ledger_row.get("ledger_account_name", ""), "voucher_account_code": voucher_row.get("voucher_account_code", ""), "voucher_account_name": voucher_row.get("voucher_account_name", ""), "ledger_vendor": ledger_row.get("ledger_vendor", ""), "voucher_vendor": voucher_row.get("voucher_vendor", ""), "ledger_debit": parse_amount(ledger_row.get("ledger_debit")), "ledger_credit": parse_amount(ledger_row.get("ledger_credit")), "voucher_debit": parse_amount(voucher_row.get("voucher_debit")), "voucher_credit": parse_amount(voucher_row.get("voucher_credit")), "ledger_desc": ledger_row.get("ledger_desc", ""), "voucher_desc": voucher_row.get("voucher_desc", ""), "review_reason": "SUBSTITUTION_RECHECK", "review_memo": "", "substitution_hint": score_result.get("substitution_hint") or "미지급금/보통예금 대체 검토", } item["review_key"] = build_review_key(item) item["match_identity_key"] = build_match_identity_key(item) item["ledger_row_key"] = ledger_key item["voucher_row_key"] = voucher_key built.append(item) seen_pairs.add(pair_key) return built def _build_quality_review_rows( ledger_rows: list[dict[str, Any]], voucher_rows: list[dict[str, Any]], existing_pairs: set[tuple[str, str]], ) -> list[dict[str, Any]]: if not ledger_rows or not voucher_rows: return [] voucher_amount_index, voucher_account_index = _build_amount_account_index(voucher_rows, prefix="voucher") built: list[dict[str, Any]] = [] seen_pairs = set(existing_pairs) for ledger_row in ledger_rows: amount = round(_get_row_match_amount(ledger_row, "ledger"), 2) if amount <= 0: continue best_row: dict[str, Any] | None = None best_score: dict[str, Any] | None = None for voucher_row in _candidate_rows_for_amount_account( amount, ledger_row, voucher_amount_index, voucher_account_index, ): ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row) voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) pair_key = (ledger_key, voucher_key) if not ledger_key or not voucher_key or pair_key in seen_pairs: continue score_result = _score_pair_match(ledger_row, voucher_row) if score_result.get("auto_eligible"): continue if not _is_reviewable_text_match( float(score_result.get("amount_gap", 0)), float(score_result.get("vendor_similarity", 0)), float(score_result.get("desc_similarity", 0)), bool(score_result.get("month_conflict")), ): continue if best_score is None or float(score_result.get("score", 0)) > float(best_score.get("score", 0)): best_row = voucher_row best_score = score_result if best_row is None or best_score is None: continue item = { "fiscal_year": ledger_row.get("fiscal_year") or best_row.get("fiscal_year"), "voucher_no": ledger_row.get("voucher_no", ""), "ledger_date": ledger_row.get("ledger_date", ""), "draft_no": best_row.get("draft_no", ""), "ledger_account_code": ledger_row.get("ledger_account_code", ""), "ledger_account_name": ledger_row.get("ledger_account_name", ""), "voucher_account_code": best_row.get("voucher_account_code", ""), "voucher_account_name": best_row.get("voucher_account_name", ""), "ledger_vendor": ledger_row.get("ledger_vendor", ""), "voucher_vendor": best_row.get("voucher_vendor", ""), "ledger_debit": parse_amount(ledger_row.get("ledger_debit")), "ledger_credit": parse_amount(ledger_row.get("ledger_credit")), "voucher_debit": parse_amount(best_row.get("voucher_debit")), "voucher_credit": parse_amount(best_row.get("voucher_credit")), "ledger_desc": ledger_row.get("ledger_desc", ""), "voucher_desc": best_row.get("voucher_desc", ""), "review_reason": "QUALITY_RECHECK", "review_memo": "", "substitution_hint": best_score.get("substitution_hint", ""), } item["review_key"] = build_review_key(item) item["match_identity_key"] = build_match_identity_key(item) item["ledger_row_key"] = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row) item["voucher_row_key"] = clean(best_row.get("voucher_row_key")) or build_voucher_row_key(best_row) built.append(item) seen_pairs.add((item["ledger_row_key"], item["voucher_row_key"])) return built def _rebalance_matched_rows_for_review( sections: dict[str, dict[str, Any]], ) -> dict[str, dict[str, Any]]: kept_rows: list[dict[str, Any]] = [] moved_rows: list[dict[str, Any]] = [] moved_to_ledger_only: list[dict[str, Any]] = [] for row in sections["matched"]["rows"]: score_result = _score_pair_match(row, row) if _is_boundary_substitution_row(row): recheck_row = dict(row) recheck_row["review_reason"] = "BOUNDARY_SUBSTITUTION_RECHECK" recheck_row.setdefault("review_memo", "") recheck_row["substitution_hint"] = ( score_result.get("substitution_hint") or clean(recheck_row.get("substitution_hint")) or "연초/연말 대체전표 매칭 제외" ) moved_rows.append(recheck_row) continue if not _matched_row_has_voucher_payload(row): ledger_only_row = dict(row) ledger_only_row["review_reason"] = "MISSING_MATCH_TARGET" ledger_only_row["review_memo"] = "" moved_to_ledger_only.append(ledger_only_row) continue if score_result.get("month_conflict") or _date_tokens_conflict(row.get("ledger_desc"), row.get("voucher_desc")): recheck_row = dict(row) recheck_row["review_reason"] = "MONTH_CONFLICT_RECHECK" recheck_row.setdefault("review_memo", "") moved_rows.append(recheck_row) continue if not score_result.get("auto_eligible") and not _is_recheck_row_clear_match(row): recheck_row = dict(row) recheck_row["review_reason"] = "WEAK_MATCH_RECHECK" recheck_row.setdefault("review_memo", "") moved_rows.append(recheck_row) continue kept_rows.append(row) if moved_rows or moved_to_ledger_only: sections["matched"]["rows"] = kept_rows sections["matched"]["count"] = len(kept_rows) sections["amount_mismatch"]["rows"].extend(moved_rows) sections["amount_mismatch"]["count"] = len(sections["amount_mismatch"]["rows"]) sections["ledger_only"]["rows"].extend(moved_to_ledger_only) sections["ledger_only"]["count"] = len(sections["ledger_only"]["rows"]) return sections def _promote_recheck_rows_to_matched( sections: dict[str, dict[str, Any]], ) -> dict[str, dict[str, Any]]: matched_rows = list(sections["matched"]["rows"]) recheck_rows = list(sections["amount_mismatch"]["rows"]) promoted_rows: list[dict[str, Any]] = [] remaining_rows: list[dict[str, Any]] = [] existing_identity_keys = { clean(row.get("match_identity_key")) or build_match_identity_key(row) for row in matched_rows } for row in recheck_rows: score_result = _score_pair_match(row, row) if score_result.get("auto_eligible") or _is_recheck_row_clear_match(row): identity_key = clean(row.get("match_identity_key")) or build_match_identity_key(row) if identity_key not in existing_identity_keys: promoted = dict(row) if _is_bank_payable_case(promoted): promoted["review_reason"] = "BANK_PAYABLE_MATCH" promoted["matched_case"] = "bank_payable" promoted["substitution_hint"] = clean(promoted.get("substitution_hint")) or "보통예금/미지급금 case" else: promoted["review_reason"] = "자동승격매칭" if score_result.get("auto_eligible") else "RECHECK_CLEAR_MATCH" promoted_rows.append(promoted) existing_identity_keys.add(identity_key) else: remaining_rows.append(row) if promoted_rows: sections["matched"]["rows"] = matched_rows + promoted_rows sections["matched"]["count"] = len(sections["matched"]["rows"]) sections["amount_mismatch"]["rows"] = remaining_rows sections["amount_mismatch"]["count"] = len(remaining_rows) return sections def _promote_direct_auto_matches( sections: dict[str, dict[str, Any]], ) -> dict[str, dict[str, Any]]: ledger_rows = list(sections["ledger_only"]["rows"]) voucher_rows = list(sections["voucher_only"]["rows"]) if not ledger_rows or not voucher_rows: return sections amount_index, account_index = _build_amount_account_index(voucher_rows, prefix="voucher") edge_candidates: list[tuple[float, dict[str, Any], dict[str, Any], dict[str, Any]]] = [] for ledger_row in ledger_rows: candidate_amounts = { round(parse_amount(ledger_row.get("ledger_debit")), 2), round(parse_amount(ledger_row.get("ledger_credit")), 2), } seen_voucher_keys: set[str] = set() for amount in candidate_amounts: if amount <= 0: continue for voucher_row in _candidate_rows_for_amount_account( amount, ledger_row, amount_index, account_index, ): voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) if voucher_key in seen_voucher_keys: continue seen_voucher_keys.add(voucher_key) score_result = _score_pair_match(ledger_row, voucher_row) if not score_result.get("auto_eligible"): continue boundary_probe = dict(ledger_row) boundary_probe.update( { "proof_date": voucher_row.get("proof_date", ""), "voucher_account_code": voucher_row.get("voucher_account_code", ""), "voucher_account_name": voucher_row.get("voucher_account_name", ""), "voucher_vendor": voucher_row.get("voucher_vendor", ""), "voucher_desc": voucher_row.get("voucher_desc", ""), } ) if _is_boundary_substitution_row(boundary_probe): continue edge_candidates.append((float(score_result.get("score", 0)), ledger_row, voucher_row, score_result)) if not edge_candidates: return sections edge_candidates.sort(key=lambda item: item[0], reverse=True) matched_ledger_keys: set[str] = set() matched_voucher_keys: set[str] = set() promoted_rows: list[dict[str, Any]] = [] for _score, ledger_row, voucher_row, score_result in edge_candidates: ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row) voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) if ledger_key in matched_ledger_keys or voucher_key in matched_voucher_keys: continue merged = dict(ledger_row) merged.update( { "proof_date": voucher_row.get("proof_date", ""), "draft_no": voucher_row.get("draft_no", ""), "voucher_account_code": voucher_row.get("voucher_account_code", ""), "voucher_account_name": voucher_row.get("voucher_account_name", ""), "voucher_vendor": voucher_row.get("voucher_vendor", ""), "voucher_debit": voucher_row.get("voucher_debit", 0), "voucher_credit": voucher_row.get("voucher_credit", 0), "voucher_desc": voucher_row.get("voucher_desc", ""), "voucher_row_key": voucher_row.get("voucher_row_key", ""), "review_reason": "자동승격매칭", "review_memo": "", "match_identity_key": build_manual_pair_key(ledger_key, voucher_key), } ) promoted_rows.append(merged) matched_ledger_keys.add(ledger_key) matched_voucher_keys.add(voucher_key) if not promoted_rows: return sections sections["matched"]["rows"].extend(promoted_rows) sections["matched"]["count"] = len(sections["matched"]["rows"]) sections["ledger_only"]["rows"] = [ row for row in ledger_rows if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in matched_ledger_keys ] sections["ledger_only"]["count"] = len(sections["ledger_only"]["rows"]) sections["voucher_only"]["rows"] = [ row for row in voucher_rows if (clean(row.get("voucher_row_key")) or build_voucher_row_key(row)) not in matched_voucher_keys ] sections["voucher_only"]["count"] = len(sections["voucher_only"]["rows"]) return sections def _matched_row_has_voucher_payload(row: dict[str, Any]) -> bool: return bool( clean(row.get("draft_no")) or clean(row.get("voucher_account_code")) or clean(row.get("voucher_account_name")) or clean(row.get("voucher_vendor")) or clean(row.get("voucher_desc")) or _get_row_match_amount(row, "voucher") > 0 ) def _promote_cross_year_auto_matches( sections: dict[str, dict[str, Any]], ) -> dict[str, dict[str, Any]]: ledger_rows = list(sections["ledger_only"]["rows"]) voucher_rows = list(sections["voucher_only"]["rows"]) if not ledger_rows or not voucher_rows: return sections amount_index, account_index = _build_amount_account_index(voucher_rows, prefix="voucher") edge_candidates: list[tuple[float, dict[str, Any], dict[str, Any], dict[str, Any]]] = [] for ledger_row in ledger_rows: ledger_year = int(ledger_row.get("fiscal_year") or 0) if not ledger_year: continue candidate_amounts = { round(parse_amount(ledger_row.get("ledger_debit")), 2), round(parse_amount(ledger_row.get("ledger_credit")), 2), } seen_voucher_keys: set[str] = set() for amount in candidate_amounts: if amount <= 0: continue for voucher_row in _candidate_rows_for_amount_account(amount, ledger_row, amount_index, account_index): voucher_year = int(voucher_row.get("fiscal_year") or 0) if abs(ledger_year - voucher_year) != 1: continue voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) if voucher_key in seen_voucher_keys: continue seen_voucher_keys.add(voucher_key) score_result = _score_pair_match(ledger_row, voucher_row) probe = dict(ledger_row) probe.update( { "proof_date": voucher_row.get("proof_date", ""), "draft_no": voucher_row.get("draft_no", ""), "voucher_account_code": voucher_row.get("voucher_account_code", ""), "voucher_account_name": voucher_row.get("voucher_account_name", ""), "voucher_vendor": voucher_row.get("voucher_vendor", ""), "voucher_debit": voucher_row.get("voucher_debit", 0), "voucher_credit": voucher_row.get("voucher_credit", 0), "voucher_desc": voucher_row.get("voucher_desc", ""), } ) if not score_result.get("auto_eligible"): continue if not (_same_or_similar_desc(probe) or _same_or_similar_vendor(probe)): continue edge_candidates.append((float(score_result.get("score", 0)), ledger_row, voucher_row, score_result)) if not edge_candidates: return sections edge_candidates.sort(key=lambda item: item[0], reverse=True) matched_ledger_keys: set[str] = set() matched_voucher_keys: set[str] = set() promoted_rows: list[dict[str, Any]] = [] for _score, ledger_row, voucher_row, _score_result in edge_candidates: ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row) voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) if ledger_key in matched_ledger_keys or voucher_key in matched_voucher_keys: continue merged = dict(ledger_row) merged.update( { "proof_date": voucher_row.get("proof_date", ""), "draft_no": voucher_row.get("draft_no", ""), "voucher_account_code": voucher_row.get("voucher_account_code", ""), "voucher_account_name": voucher_row.get("voucher_account_name", ""), "voucher_vendor": voucher_row.get("voucher_vendor", ""), "voucher_debit": voucher_row.get("voucher_debit", 0), "voucher_credit": voucher_row.get("voucher_credit", 0), "voucher_desc": voucher_row.get("voucher_desc", ""), "voucher_row_key": voucher_row.get("voucher_row_key", ""), "review_reason": "연도교차자동매칭", "matched_case": "cross_year", "review_memo": "", "match_identity_key": build_manual_pair_key(ledger_key, voucher_key), } ) promoted_rows.append(merged) matched_ledger_keys.add(ledger_key) matched_voucher_keys.add(voucher_key) if not promoted_rows: return sections sections["matched"]["rows"].extend(promoted_rows) sections["matched"]["count"] = len(sections["matched"]["rows"]) sections["ledger_only"]["rows"] = [ row for row in ledger_rows if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in matched_ledger_keys ] sections["ledger_only"]["count"] = len(sections["ledger_only"]["rows"]) sections["voucher_only"]["rows"] = [ row for row in voucher_rows if (clean(row.get("voucher_row_key")) or build_voucher_row_key(row)) not in matched_voucher_keys ] sections["voucher_only"]["count"] = len(sections["voucher_only"]["rows"]) return sections def _apply_previous_year_erp_candidates( conn: Any, year: int, sections: dict[str, dict[str, Any]], ) -> dict[str, dict[str, Any]]: if year <= 0: return sections ledger_rows = list(sections["ledger_only"]["rows"]) if not ledger_rows: return sections available_years = set(_discover_available_fiscal_years(conn)) prior_years = sorted((candidate_year for candidate_year in available_years if candidate_year < year), reverse=True) next_years = sorted(candidate_year for candidate_year in available_years if candidate_year > year) candidate_years = prior_years + next_years if not candidate_years: return sections def jan1_row_is_excluded(row: dict[str, Any]) -> bool: return _extract_boundary_month_day(row.get("ledger_date"), row.get("fiscal_year")) == "01-01" and _row_has_earlier_fiscal_year_source(conn, row) adjacent_voucher_rows: list[dict[str, Any]] = [] for candidate_year in candidate_years: payload = _fetch_status_detail_rows_from_db( conn, candidate_year, candidate_year, "voucher_only", "", "", "", "", "", "", "", "", "", False, 0, 1_000_000, ) adjacent_voucher_rows.extend(list(payload.get("rows", []))) if not adjacent_voucher_rows: return sections def remaining_matched_voucher_keys() -> set[str]: return { clean(row.get("voucher_row_key")) or build_voucher_row_key(row) for row in sections["matched"]["rows"] if clean(row.get("voucher_row_key")) or build_voucher_row_key(row) } def remaining_matched_ledger_keys() -> set[str]: return { clean(row.get("ledger_row_key")) or build_ledger_row_key(row) for row in sections["matched"]["rows"] if clean(row.get("ledger_row_key")) or build_ledger_row_key(row) } def apply_adjacent_candidates(eligible_ledger_rows: list[dict[str, Any]], *, boundary_phase: bool) -> list[dict[str, Any]]: if not eligible_ledger_rows: return [] matched_ledger_keys = remaining_matched_ledger_keys() matched_voucher_keys = remaining_matched_voucher_keys() usable_voucher_rows = [ row for row in adjacent_voucher_rows if (clean(row.get("voucher_row_key")) or build_voucher_row_key(row)) not in matched_voucher_keys ] if not usable_voucher_rows: return [] amount_index, account_index = _build_amount_account_index(usable_voucher_rows, prefix="voucher") edge_candidates: list[tuple[float, dict[str, Any], dict[str, Any], dict[str, Any]]] = [] for ledger_row in eligible_ledger_rows: ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row) if not ledger_key or ledger_key in matched_ledger_keys: continue if jan1_row_is_excluded(ledger_row): continue if boundary_phase != _is_boundary_substitution_row(ledger_row): continue candidate_amounts = { round(parse_amount(ledger_row.get("ledger_debit")), 2), round(parse_amount(ledger_row.get("ledger_credit")), 2), } seen_voucher_keys: set[str] = set() for amount in candidate_amounts: if amount <= 0: continue for voucher_row in _candidate_rows_for_amount_account( amount, ledger_row, amount_index, account_index, ): voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) if not voucher_key or voucher_key in matched_voucher_keys or voucher_key in seen_voucher_keys: continue seen_voucher_keys.add(voucher_key) score_result = _score_pair_match(ledger_row, voucher_row) probe = dict(ledger_row) probe.update( { "proof_date": voucher_row.get("proof_date", ""), "draft_no": voucher_row.get("draft_no", ""), "voucher_account_code": voucher_row.get("voucher_account_code", ""), "voucher_account_name": voucher_row.get("voucher_account_name", ""), "voucher_vendor": voucher_row.get("voucher_vendor", ""), "voucher_debit": voucher_row.get("voucher_debit", 0), "voucher_credit": voucher_row.get("voucher_credit", 0), "voucher_desc": voucher_row.get("voucher_desc", ""), } ) if boundary_phase != _is_boundary_substitution_row(probe): continue if not score_result.get("auto_eligible"): continue if not (_same_or_similar_desc(probe) or _same_or_similar_vendor(probe)): continue edge_candidates.append((float(score_result.get("score", 0)), ledger_row, voucher_row, score_result)) if not edge_candidates: return [] edge_candidates.sort(key=lambda item: item[0], reverse=True) phase_matched_ledger_keys: set[str] = set() phase_matched_voucher_keys: set[str] = set() promoted_rows: list[dict[str, Any]] = [] for _score, ledger_row, voucher_row, _score_result in edge_candidates: ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row) voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) if ( ledger_key in matched_ledger_keys or voucher_key in matched_voucher_keys or ledger_key in phase_matched_ledger_keys or voucher_key in phase_matched_voucher_keys ): continue voucher_year = int(voucher_row.get("fiscal_year") or 0) review_reason = "전기ERP자동매칭" if voucher_year < year else "차기ERP자동매칭" matched_case = "previous_year_erp" if voucher_year < year else "next_year_erp" merged = dict(ledger_row) merged.update( { "proof_date": voucher_row.get("proof_date", ""), "draft_no": voucher_row.get("draft_no", ""), "voucher_account_code": voucher_row.get("voucher_account_code", ""), "voucher_account_name": voucher_row.get("voucher_account_name", ""), "voucher_vendor": voucher_row.get("voucher_vendor", ""), "voucher_debit": voucher_row.get("voucher_debit", 0), "voucher_credit": voucher_row.get("voucher_credit", 0), "voucher_desc": voucher_row.get("voucher_desc", ""), "voucher_row_key": voucher_row.get("voucher_row_key", ""), "review_reason": review_reason, "matched_case": matched_case, "review_memo": "", "match_identity_key": build_manual_pair_key(ledger_key, voucher_key), } ) promoted_rows.append(merged) phase_matched_ledger_keys.add(ledger_key) phase_matched_voucher_keys.add(voucher_key) if promoted_rows: sections["matched"]["rows"].extend(promoted_rows) sections["matched"]["count"] = len(sections["matched"]["rows"]) remaining_keys = remaining_matched_ledger_keys() ledger_rows[:] = [ row for row in ledger_rows if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in remaining_keys ] sections["ledger_only"]["rows"] = ledger_rows sections["ledger_only"]["count"] = len(ledger_rows) return promoted_rows def build_adjacent_review_rows(eligible_ledger_rows: list[dict[str, Any]], *, boundary_phase: bool) -> list[dict[str, Any]]: matched_voucher_keys = remaining_matched_voucher_keys() matched_ledger_keys = remaining_matched_ledger_keys() review_voucher_rows = [ row for row in adjacent_voucher_rows if (clean(row.get("voucher_row_key")) or build_voucher_row_key(row)) not in matched_voucher_keys ] review_ledger_rows = [ row for row in eligible_ledger_rows if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in matched_ledger_keys and (_is_boundary_substitution_row(row) == boundary_phase) and not jan1_row_is_excluded(row) ] if not review_ledger_rows or not review_voucher_rows: return [] existing_pairs = { ( clean(row.get("ledger_row_key")) or build_ledger_row_key(row), clean(row.get("voucher_row_key")) or build_voucher_row_key(row), ) for row in sections["amount_mismatch"]["rows"] } review_rows = [] review_rows.extend(_build_substitution_review_rows(review_ledger_rows, review_voucher_rows, existing_pairs)) existing_pairs.update( { ( clean(row.get("ledger_row_key")) or build_ledger_row_key(row), clean(row.get("voucher_row_key")) or build_voucher_row_key(row), ) for row in review_rows } ) review_rows.extend(_build_quality_review_rows(review_ledger_rows, review_voucher_rows, existing_pairs)) return review_rows # 1) 일반 전표를 먼저 인접 연도 ERP와 비교 apply_adjacent_candidates(ledger_rows, boundary_phase=False) normal_review_rows = build_adjacent_review_rows(ledger_rows, boundary_phase=False) if normal_review_rows: review_ledger_keys = { clean(row.get("ledger_row_key")) or build_ledger_row_key(row) for row in normal_review_rows } sections["amount_mismatch"]["rows"].extend(normal_review_rows) sections["amount_mismatch"]["count"] = len(sections["amount_mismatch"]["rows"]) sections["ledger_only"]["rows"] = [ row for row in sections["ledger_only"]["rows"] if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in review_ledger_keys ] sections["ledger_only"]["count"] = len(sections["ledger_only"]["rows"]) ledger_rows = list(sections["ledger_only"]["rows"]) # 2) 연초/연말 대체성 전표는 최후 순서로만 인접 연도 ERP와 비교 apply_adjacent_candidates(ledger_rows, boundary_phase=True) boundary_review_rows = build_adjacent_review_rows(ledger_rows, boundary_phase=True) if boundary_review_rows: review_ledger_keys = { clean(row.get("ledger_row_key")) or build_ledger_row_key(row) for row in boundary_review_rows } sections["amount_mismatch"]["rows"].extend(boundary_review_rows) sections["amount_mismatch"]["count"] = len(sections["amount_mismatch"]["rows"]) sections["ledger_only"]["rows"] = [ row for row in sections["ledger_only"]["rows"] if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in review_ledger_keys ] sections["ledger_only"]["count"] = len(sections["ledger_only"]["rows"]) return sections def _matched_signature_set(rows: list[dict[str, Any]]) -> set[str]: signatures: set[str] = set() for row in rows: parts = [ clean(row.get("voucher_no")), clean(row.get("draft_no")), normalize_text(row.get("ledger_vendor")), normalize_text(row.get("voucher_vendor")), normalize_text(row.get("ledger_desc")), normalize_text(row.get("voucher_desc")), clean(row.get("ledger_account_name")), clean(row.get("voucher_account_name")), f"{_get_row_match_amount(row, 'ledger'):.2f}", f"{_get_row_match_amount(row, 'voucher'):.2f}", ] signatures.add("|".join(parts)) return signatures def _drop_recheck_rows_covered_by_matched( sections: dict[str, dict[str, Any]], ) -> dict[str, dict[str, Any]]: matched_signatures = _matched_signature_set(sections["matched"]["rows"]) matched_ledger_keys = { clean(row.get("ledger_row_key")) or build_ledger_row_key(row) for row in sections["matched"]["rows"] if clean(row.get("ledger_row_key")) or build_ledger_row_key(row) } matched_voucher_keys = { clean(row.get("voucher_row_key")) or build_voucher_row_key(row) for row in sections["matched"]["rows"] if clean(row.get("voucher_row_key")) or build_voucher_row_key(row) } if not matched_signatures and not matched_ledger_keys and not matched_voucher_keys: return sections remaining_rows: list[dict[str, Any]] = [] for row in sections["amount_mismatch"]["rows"]: ledger_key = clean(row.get("ledger_row_key")) or build_ledger_row_key(row) voucher_key = clean(row.get("voucher_row_key")) or build_voucher_row_key(row) if (ledger_key and ledger_key in matched_ledger_keys) or (voucher_key and voucher_key in matched_voucher_keys): continue signature = "|".join( [ clean(row.get("voucher_no")), clean(row.get("draft_no")), normalize_text(row.get("ledger_vendor")), normalize_text(row.get("voucher_vendor")), normalize_text(row.get("ledger_desc")), normalize_text(row.get("voucher_desc")), clean(row.get("ledger_account_name")), clean(row.get("voucher_account_name")), f"{_get_row_match_amount(row, 'ledger'):.2f}", f"{_get_row_match_amount(row, 'voucher'):.2f}", ] ) if signature in matched_signatures: continue remaining_rows.append(row) sections["amount_mismatch"]["rows"] = remaining_rows sections["amount_mismatch"]["count"] = len(remaining_rows) return sections def _append_substitution_review_rows( sections: dict[str, dict[str, Any]], ) -> dict[str, dict[str, Any]]: sections = _rebalance_matched_rows_for_review(sections) sections = _promote_recheck_rows_to_matched(sections) if not ENABLE_GENERATED_RECHECK_CANDIDATES: return _drop_recheck_rows_covered_by_matched(sections) sections = _promote_direct_auto_matches(sections) amount_rows = sections["amount_mismatch"]["rows"] existing_pairs = { ( clean(row.get("ledger_row_key")) or build_ledger_row_key(row), clean(row.get("voucher_row_key")) or build_voucher_row_key(row), ) for row in amount_rows } matched_rows = list(sections["matched"]["rows"]) ledger_only_rows = list(sections["ledger_only"]["rows"]) voucher_only_rows = list(sections["voucher_only"]["rows"]) synthetic_rows = [] synthetic_rows.extend( _build_substitution_review_rows(matched_rows + ledger_only_rows, voucher_only_rows, existing_pairs) ) existing_pairs.update( { ( clean(row.get("ledger_row_key")) or build_ledger_row_key(row), clean(row.get("voucher_row_key")) or build_voucher_row_key(row), ) for row in synthetic_rows } ) synthetic_rows.extend( _build_substitution_review_rows(ledger_only_rows, matched_rows + voucher_only_rows, existing_pairs) ) existing_pairs.update( { ( clean(row.get("ledger_row_key")) or build_ledger_row_key(row), clean(row.get("voucher_row_key")) or build_voucher_row_key(row), ) for row in synthetic_rows } ) synthetic_rows.extend( _build_quality_review_rows(ledger_only_rows, voucher_only_rows, existing_pairs) ) if not synthetic_rows: sections = _promote_recheck_rows_to_matched(sections) return _drop_recheck_rows_covered_by_matched(sections) sections["amount_mismatch"]["rows"].extend(synthetic_rows) sections["amount_mismatch"]["count"] = len(sections["amount_mismatch"]["rows"]) sections = _promote_recheck_rows_to_matched(sections) return _drop_recheck_rows_covered_by_matched(sections) def _numeric_amount_for_side(row: dict[str, Any], side: str) -> float: if side == "debit": return parse_amount(row.get("ledger_debit") if "ledger_debit" in row else row.get("voucher_debit")) if side == "credit": return parse_amount(row.get("ledger_credit") if "ledger_credit" in row else row.get("voucher_credit")) return max( parse_amount(row.get("ledger_debit") if "ledger_debit" in row else row.get("voucher_debit")), parse_amount(row.get("ledger_credit") if "ledger_credit" in row else row.get("voucher_credit")), ) def _determine_primary_side(ledger_row: dict[str, Any]) -> str: debit = parse_amount(ledger_row.get("ledger_debit")) credit = parse_amount(ledger_row.get("ledger_credit")) if debit > 0 and credit <= 0: return "debit" if credit > 0 and debit <= 0: return "credit" return "either" def _score_pair_match(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -> dict[str, Any]: ledger_features = _build_match_row_features( ledger_row, prefix="ledger", row_key_field="ledger_row_key", account_code_field="ledger_account_code", account_name_field="ledger_account_name", vendor_field="ledger_vendor", desc_field="ledger_desc", date_field="ledger_date", ) voucher_features = _build_match_row_features( voucher_row, prefix="voucher", row_key_field="voucher_row_key", account_code_field="voucher_account_code", account_name_field="voucher_account_name", vendor_field="voucher_vendor", desc_field="voucher_desc", date_field="proof_date", ) return _score_pair_match_features(ledger_features, voucher_features) def _score_pair_match_features(ledger: MatchRowFeatures, voucher: MatchRowFeatures) -> dict[str, Any]: score = 0.0 reasons: list[str] = [] hard_pass = True account_supported = False side = ledger.primary_side ledger_amount = _get_feature_side_amount(ledger, side) voucher_same_side_amount = _get_feature_side_amount(voucher, side) voucher_any_side_amount = voucher.match_amount amount_gap_same_side = abs(ledger_amount - voucher_same_side_amount) amount_gap_any_side = abs(ledger_amount - voucher_any_side_amount) amount_gap = min(amount_gap_same_side, amount_gap_any_side) side_flipped_match = amount_gap_any_side < 0.5 and amount_gap_same_side >= 0.5 nature_same_side_ok = _nature_compatible( ledger.account_code, ledger.account_name, side, voucher.account_code, voucher.account_name, side, ) opposite_side = "credit" if side == "debit" else "debit" if side == "credit" else "either" nature_opposite_side_ok = _nature_compatible( ledger.account_code, ledger.account_name, side, voucher.account_code, voucher.account_name, opposite_side, ) if opposite_side in {"debit", "credit"} else False family_pair_allowed = _account_category_pair_allowed( ledger.account_code, ledger.account_name, voucher.account_code, voucher.account_name, ) ledger_family = _classify_account_family(ledger.account_code, ledger.account_name) voucher_family = _classify_account_family(voucher.account_code, voucher.account_name) if amount_gap < 0.5 and ledger_amount > 0: score += 50 reasons.append("금액 일치") if side_flipped_match: reasons.append("차대 방향 보정") elif amount_gap < 5 and ledger_amount > 0: score += 35 reasons.append("금액 근접") elif amount_gap < 100 and ledger_amount > 0: score += 10 reasons.append("금액 유사") else: hard_pass = False if not family_pair_allowed: hard_pass = False score -= 60 reasons.append("계정 성격 교차 불가") if side in {"debit", "credit"}: if amount_gap_same_side < 0.5 and not nature_same_side_ok: hard_pass = False score -= 40 reasons.append("차대 성격 불일치") elif side_flipped_match: # 같은 금액이어도 반대 방향이면 원칙적으로 다른 거래 성격으로 본다. if not nature_opposite_side_ok: hard_pass = False score -= 45 reasons.append("차대 방향 성격 불일치") substitution_hint = _build_account_substitution_hint( ledger.account_code, ledger.account_name, voucher.account_code, voucher.account_name, ) account_sim = _jaccard_similarity_tokens(ledger.account_tokens, voucher.account_tokens) bank_pair = ledger_family == "bank" and voucher_family == "bank" if ledger.account_code and voucher.account_code and ledger.account_code == voucher.account_code: score += 4 if bank_pair else 22 reasons.append("보통예금 코드 일치" if bank_pair else "계정코드 일치") account_supported = True elif ledger.account_code and voucher.account_code and ledger.account_code[:4] == voucher.account_code[:4]: score += 2 if bank_pair else 10 reasons.append("보통예금 대분류 일치" if bank_pair else "계정코드 대분류 일치") account_supported = True elif substitution_hint: score += 12 reasons.append(substitution_hint) account_supported = True else: if account_sim >= 0.8: score += 3 if bank_pair else 16 reasons.append("보통예금 계정명 유사" if bank_pair else "계정명 유사도 높음") account_supported = True elif account_sim >= 0.55: score += 2 if bank_pair else 8 reasons.append("보통예금 계정명 유사" if bank_pair else "계정명 유사") account_supported = True elif _account_base_names_compatible(ledger.account_name, voucher.account_name): score += 2 if bank_pair else 12 reasons.append("보통예금 핵심 일치" if bank_pair else "계정명 핵심 일치") account_supported = True else: if ledger_family and ledger_family == voucher_family: score += 2 if bank_pair else 10 reasons.append("보통예금 계정군 일치" if bank_pair else "계정군 일치") account_supported = True vendor_sim = _jaccard_similarity_tokens(ledger.vendor_tokens, voucher.vendor_tokens) row_for_text = { "ledger_account_code": ledger.account_code, "ledger_account_name": ledger.account_name, "ledger_vendor": ledger.vendor_name, "ledger_desc": ledger.desc_text, "voucher_account_code": voucher.account_code, "voucher_account_name": voucher.account_name, "voucher_vendor": voucher.vendor_name, "voucher_desc": voucher.desc_text, } account_strong = bool( (ledger.account_code and voucher.account_code and ledger.account_code == voucher.account_code) or substitution_hint or account_sim >= 0.8 or _account_names_compatible(row_for_text, row_for_text) ) vendor_core_match = _same_or_similar_vendor(row_for_text) desc_core_match = _same_or_similar_desc(row_for_text) if vendor_sim >= 0.9: score += 16 reasons.append("거래처 일치") elif vendor_sim >= 0.65: score += 10 reasons.append("거래처 유사") elif vendor_sim >= 0.4: score += 4 reasons.append("거래처 일부 유사") else: score -= 8 desc_sim = _jaccard_similarity_tokens(ledger.desc_tokens, voucher.desc_tokens) if desc_sim >= 0.85: score += 10 reasons.append("적요 매우 유사") elif desc_sim >= 0.6: score += 6 reasons.append("적요 유사") elif desc_sim >= 0.35: score += 2 month_conflict = _has_conflicting_month_token_sets( ledger.month_tokens, voucher.month_tokens, ) or _date_tokens_conflict(ledger.desc_text, voucher.desc_text) if substitution_hint and amount_gap < 0.5: if desc_sim >= 0.85: score += 8 reasons.append("대체 적요 일치") elif desc_sim >= 0.6: score += 4 reasons.append("대체 적요 유사") strong_text_match = _is_strong_text_match(amount_gap, vendor_sim, desc_sim, month_conflict) if strong_text_match: score += 10 reasons.append("적요/거래처 강한 일치") elif not account_supported: hard_pass = False if ledger.date_value and voucher.date_value: day_gap = abs((ledger.date_value - voucher.date_value).days) month_gap = _month_gap(ledger.date_value, voucher.date_value) if strong_text_match: if day_gap <= 7: score += 8 reasons.append("일자 근접") elif day_gap <= 31: score += 5 reasons.append("일자 차이 허용") elif month_gap <= PAIR_RECOMMEND_DATE_WINDOW_MONTHS: score += 2 reasons.append("전후 8개월 내 일자 차이") else: score -= 6 reasons.append("전후 8개월 초과") else: if day_gap <= 3: score += 8 reasons.append("일자 근접") elif day_gap <= 10: score += 4 elif month_gap <= PAIR_RECOMMEND_DATE_WINDOW_MONTHS: score += 1 reasons.append("전후 8개월 내 일자 차이") else: score -= 6 reasons.append("전후 8개월 초과") confidence = "low" if score >= 88: confidence = "high" elif score >= 72: confidence = "medium" auto_eligible = bool( hard_pass and amount_gap < 0.5 and not side_flipped_match and not month_conflict and ( (score >= 82 and strong_text_match and vendor_sim >= 0.65) or (score >= 72 and account_strong and vendor_core_match) or (score >= 68 and account_strong and desc_core_match) ) ) return { "score": round(score, 2), "confidence_level": confidence, "reason": ", ".join(reasons[:4]), "auto_eligible": auto_eligible, "hard_pass": hard_pass, "vendor_similarity": round(vendor_sim, 4), "account_similarity": round(account_sim, 4), "desc_similarity": round(desc_sim, 4), "amount_gap": round(amount_gap, 2), "month_conflict": month_conflict, "strong_text_match": strong_text_match, "substitution_hint": substitution_hint, } def _collect_status_rows_for_workbench( engine: Any, start_year: int | None, end_year: int | None, ledger_voucher_no: str = "", ledger_review_reason: str = "", voucher_voucher_no: str = "", voucher_review_reason: str = "", ) -> dict[str, list[dict[str, Any]]]: if start_year is None or end_year is None: return {"ledger_only": [], "voucher_only": []} result: dict[str, list[dict[str, Any]]] = {"ledger_only": [], "voucher_only": []} ledger_voucher_filter = normalize_text(ledger_voucher_no) ledger_reason_filter = normalize_text(ledger_review_reason) voucher_voucher_filter = normalize_text(voucher_voucher_no) voucher_reason_filter = normalize_text(voucher_review_reason) rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year) for row in rows_by_status["ledger_only"]: if _filter_status_row( row, ledger_voucher_filter, "", "", "", "", "", "", "", ledger_reason_filter, ): result["ledger_only"].append(row) for row in rows_by_status["voucher_only"]: if _filter_status_row( row, voucher_voucher_filter, "", "", "", "", "", "", "", voucher_reason_filter, ): result["voucher_only"].append(row) return result def _build_pair_recommend_cache_key( start_year: int | None, end_year: int | None, ledger_voucher_no: str = "", ledger_review_reason: str = "", voucher_voucher_no: str = "", voucher_review_reason: str = "", limit: int = 300, ) -> str: safe_limit = max(min(int(limit or 300), 1000), 1) return "|".join( [ PAIR_RECOMMEND_POLICY_VERSION, str(start_year), str(end_year), normalize_text(ledger_voucher_no), normalize_text(ledger_review_reason), normalize_text(voucher_voucher_no), normalize_text(voucher_review_reason), str(safe_limit), ] ) def _delete_stale_pair_recommend_cache(conn: Any) -> None: conn.execute( text( """ DELETE FROM wehago_pair_recommend_cache WHERE strftime('%s', 'now') - strftime('%s', updated_at) > :ttl """ ), {"ttl": int(_PAIR_RECOMMEND_PERSIST_TTL_SEC)}, ) def _load_persisted_pair_recommend_cache(conn: Any, cache_key: str) -> dict[str, Any] | None: row = conn.execute( text( """ SELECT payload_json FROM wehago_pair_recommend_cache WHERE cache_key = :cache_key AND strftime('%s', 'now') - strftime('%s', updated_at) <= :ttl """ ), {"cache_key": cache_key, "ttl": int(_PAIR_RECOMMEND_PERSIST_TTL_SEC)}, ).mappings().first() if not row: return None try: payload = json.loads(row["payload_json"] or "{}") except json.JSONDecodeError: return None conn.execute( text( """ UPDATE wehago_pair_recommend_cache SET last_accessed_at = CURRENT_TIMESTAMP WHERE cache_key = :cache_key """ ), {"cache_key": cache_key}, ) return payload if isinstance(payload, dict) else None def _store_persisted_pair_recommend_cache( conn: Any, cache_key: str, payload: dict[str, Any], *, start_year: int | None, end_year: int | None, ledger_voucher_no: str, ledger_review_reason: str, voucher_voucher_no: str, voucher_review_reason: str, limit: int, ) -> None: safe_limit = max(min(int(limit or 300), 1000), 1) payload_json = json.dumps(payload, ensure_ascii=False) pair_count = len(payload.get("pairs") or []) conn.execute( text( """ INSERT INTO wehago_pair_recommend_cache ( cache_key, start_year, end_year, ledger_voucher_no, ledger_review_reason, voucher_voucher_no, voucher_review_reason, row_limit, payload_json, pair_count, created_at, updated_at, last_accessed_at ) VALUES ( :cache_key, :start_year, :end_year, :ledger_voucher_no, :ledger_review_reason, :voucher_voucher_no, :voucher_review_reason, :row_limit, :payload_json, :pair_count, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) ON CONFLICT(cache_key) DO UPDATE SET start_year = excluded.start_year, end_year = excluded.end_year, ledger_voucher_no = excluded.ledger_voucher_no, ledger_review_reason = excluded.ledger_review_reason, voucher_voucher_no = excluded.voucher_voucher_no, voucher_review_reason = excluded.voucher_review_reason, row_limit = excluded.row_limit, payload_json = excluded.payload_json, pair_count = excluded.pair_count, updated_at = CURRENT_TIMESTAMP, last_accessed_at = CURRENT_TIMESTAMP """ ), { "cache_key": cache_key, "start_year": start_year, "end_year": end_year, "ledger_voucher_no": clean(ledger_voucher_no), "ledger_review_reason": clean(ledger_review_reason), "voucher_voucher_no": clean(voucher_voucher_no), "voucher_review_reason": clean(voucher_review_reason), "row_limit": safe_limit, "payload_json": payload_json, "pair_count": pair_count, }, ) def clear_persisted_pair_recommend_cache(engine: Any) -> None: init_wehago_compare_db(engine) with engine.begin() as conn: conn.execute(text("DELETE FROM wehago_pair_recommend_cache")) conn.execute(text("DELETE FROM wehago_background_jobs WHERE job_type = 'pair_recommend_precompute'")) def _ensure_pair_recommend_worker(engine: Any) -> None: global _PAIR_RECOMMEND_WORKER_STARTED with _PAIR_RECOMMEND_WORKER_LOCK: if _PAIR_RECOMMEND_WORKER_STARTED: return worker = threading.Thread( target=_pair_recommend_worker_loop, args=(engine,), daemon=True, name="wehago-pair-recommend-worker", ) worker.start() _PAIR_RECOMMEND_WORKER_STARTED = True def enqueue_pair_recommend_precompute( engine: Any, start_year: int | None, end_year: int | None, ledger_voucher_no: str = "", ledger_review_reason: str = "", voucher_voucher_no: str = "", voucher_review_reason: str = "", limit: int = 300, ) -> None: safe_limit = max(min(int(limit or 300), 1000), 1) cache_key = _build_pair_recommend_cache_key( start_year, end_year, ledger_voucher_no=ledger_voucher_no, ledger_review_reason=ledger_review_reason, voucher_voucher_no=voucher_voucher_no, voucher_review_reason=voucher_review_reason, limit=safe_limit, ) payload = { "cache_key": cache_key, "start_year": start_year, "end_year": end_year, "ledger_voucher_no": clean(ledger_voucher_no), "ledger_review_reason": clean(ledger_review_reason), "voucher_voucher_no": clean(voucher_voucher_no), "voucher_review_reason": clean(voucher_review_reason), "limit": safe_limit, } init_wehago_compare_db(engine) with engine.begin() as conn: _delete_stale_pair_recommend_cache(conn) existing_payload = _load_persisted_pair_recommend_cache(conn, cache_key) if existing_payload is not None: return existing_job = conn.execute( text( """ SELECT state FROM wehago_background_jobs WHERE job_key = :job_key AND job_type = 'pair_recommend_precompute' """ ), {"job_key": cache_key}, ).mappings().first() if existing_job and clean(existing_job.get("state")) in {"queued", "running"}: return conn.execute( text( """ INSERT INTO wehago_background_jobs ( job_key, job_type, payload_json, state, error_message, created_at, updated_at ) VALUES ( :job_key, 'pair_recommend_precompute', :payload_json, 'queued', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) ON CONFLICT(job_key) DO UPDATE SET payload_json = excluded.payload_json, state = CASE WHEN wehago_background_jobs.state = 'running' THEN wehago_background_jobs.state ELSE 'queued' END, error_message = '', updated_at = CURRENT_TIMESTAMP """ ), {"job_key": cache_key, "payload_json": json.dumps(payload, ensure_ascii=False)}, ) _ensure_pair_recommend_worker(engine) _PAIR_RECOMMEND_JOB_EVENT.set() def _pair_recommend_worker_loop(engine: Any) -> None: while True: _PAIR_RECOMMEND_JOB_EVENT.wait(timeout=5.0) _PAIR_RECOMMEND_JOB_EVENT.clear() while True: init_wehago_compare_db(engine) with engine.begin() as conn: job = conn.execute( text( """ SELECT job_key, payload_json FROM wehago_background_jobs WHERE job_type = 'pair_recommend_precompute' AND state = 'queued' ORDER BY created_at ASC LIMIT 1 """ ) ).mappings().first() if not job: break conn.execute( text( """ UPDATE wehago_background_jobs SET state = 'running', error_message = '', started_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE job_key = :job_key """ ), {"job_key": job["job_key"]}, ) try: payload = json.loads(job["payload_json"] or "{}") if not isinstance(payload, dict): payload = {} result = _compute_pair_recommendations_payload( engine, start_year=payload.get("start_year"), end_year=payload.get("end_year"), ledger_voucher_no=payload.get("ledger_voucher_no", ""), ledger_review_reason=payload.get("ledger_review_reason", ""), voucher_voucher_no=payload.get("voucher_voucher_no", ""), voucher_review_reason=payload.get("voucher_review_reason", ""), limit=int(payload.get("limit") or 300), ) with engine.begin() as conn: _store_persisted_pair_recommend_cache( conn, payload.get("cache_key") or job["job_key"], result, start_year=payload.get("start_year"), end_year=payload.get("end_year"), ledger_voucher_no=payload.get("ledger_voucher_no", ""), ledger_review_reason=payload.get("ledger_review_reason", ""), voucher_voucher_no=payload.get("voucher_voucher_no", ""), voucher_review_reason=payload.get("voucher_review_reason", ""), limit=int(payload.get("limit") or 300), ) conn.execute( text( """ UPDATE wehago_background_jobs SET state = 'done', error_message = '', finished_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE job_key = :job_key """ ), {"job_key": job["job_key"]}, ) except Exception as exc: with engine.begin() as conn: conn.execute( text( """ UPDATE wehago_background_jobs SET state = 'failed', error_message = :error_message, finished_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE job_key = :job_key """ ), {"job_key": job["job_key"], "error_message": clean(exc)}, ) def _compute_pair_recommendations_payload( engine: Any, start_year: int | None, end_year: int | None, ledger_voucher_no: str = "", ledger_review_reason: str = "", voucher_voucher_no: str = "", voucher_review_reason: str = "", limit: int = 300, ) -> dict[str, Any]: safe_limit = max(min(int(limit or 300), 1000), 1) dataset = _collect_status_rows_for_workbench( engine, start_year, end_year, ledger_voucher_no=ledger_voucher_no, ledger_review_reason=ledger_review_reason, voucher_voucher_no=voucher_voucher_no, voucher_review_reason=voucher_review_reason, ) ledger_rows = dataset["ledger_only"] voucher_rows = dataset["voucher_only"] if not ledger_rows or not voucher_rows: return {"pairs": [], "stats": {"ledger_rows": len(ledger_rows), "voucher_rows": len(voucher_rows), "recommended": 0, "auto_eligible": 0}} ledger_features = [ _build_match_row_features( row, prefix="ledger", row_key_field="ledger_row_key", account_code_field="ledger_account_code", account_name_field="ledger_account_name", vendor_field="ledger_vendor", desc_field="ledger_desc", date_field="ledger_date", ) for row in ledger_rows ] voucher_features = [ _build_match_row_features( row, prefix="voucher", row_key_field="voucher_row_key", account_code_field="voucher_account_code", account_name_field="voucher_account_name", vendor_field="voucher_vendor", desc_field="voucher_desc", date_field="proof_date", ) for row in voucher_rows ] amount_index: dict[float, list[MatchRowFeatures]] = {} for voucher_feature in voucher_features: for amount in voucher_feature.positive_amounts: if amount <= 0: continue amount_index.setdefault(amount, []).append(voucher_feature) edge_candidates: list[dict[str, Any]] = [] for ledger_feature in ledger_features: voucher_candidates: list[MatchRowFeatures] = [] seen_keys: set[str] = set() for amount in ledger_feature.positive_amounts: for voucher_feature in amount_index.get(amount, []): voucher_key = voucher_feature.row_key if voucher_key and voucher_key not in seen_keys: seen_keys.add(voucher_key) voucher_candidates.append(voucher_feature) if not voucher_candidates: continue top_feature: MatchRowFeatures | None = None top_score: dict[str, Any] | None = None second_best_score = -999.0 for voucher_feature in voucher_candidates: score_result = _score_pair_match_features(ledger_feature, voucher_feature) if not score_result["hard_pass"] or score_result["score"] < 72: continue if top_score is None or score_result["score"] > float(top_score["score"]): second_best_score = float(top_score["score"]) if top_score else second_best_score top_feature = voucher_feature top_score = score_result elif score_result["score"] > second_best_score: second_best_score = score_result["score"] if top_feature is None or top_score is None: continue if top_score["score"] - second_best_score < 6: continue edge_candidates.append( { "ledger_row": ledger_feature.row, "voucher_row": top_feature.row, "score": top_score["score"], "confidence_level": top_score["confidence_level"], "reason": top_score["reason"], "auto_eligible": top_score["auto_eligible"], "vendor_similarity": top_score.get("vendor_similarity", 0), "account_similarity": top_score.get("account_similarity", 0), "amount_gap": top_score.get("amount_gap", 0), "substitution_hint": top_score.get("substitution_hint", ""), } ) edge_candidates.sort(key=lambda item: item["score"], reverse=True) matched_ledger: set[str] = set() matched_voucher: set[str] = set() picked: list[dict[str, Any]] = [] for edge in edge_candidates: ledger_key = clean(edge["ledger_row"].get("ledger_row_key")) voucher_key = clean(edge["voucher_row"].get("voucher_row_key")) if not ledger_key or not voucher_key: continue if ledger_key in matched_ledger or voucher_key in matched_voucher: continue matched_ledger.add(ledger_key) matched_voucher.add(voucher_key) pair_key = build_manual_pair_key(ledger_key, voucher_key) picked.append( { "pair_key": pair_key, "ledger_row_key": ledger_key, "voucher_row_key": voucher_key, "score": edge["score"], "confidence_level": edge["confidence_level"], "reason": edge["reason"], "auto_eligible": edge["auto_eligible"], "vendor_similarity": edge.get("vendor_similarity", 0), "account_similarity": edge.get("account_similarity", 0), "amount_gap": edge.get("amount_gap", 0), "substitution_hint": edge.get("substitution_hint", ""), "ledger_row": edge["ledger_row"], "voucher_row": edge["voucher_row"], } ) if len(picked) >= safe_limit: break payload = { "pairs": picked, "stats": { "ledger_rows": len(ledger_rows), "voucher_rows": len(voucher_rows), "recommended": len(picked), "auto_eligible": sum(1 for row in picked if row["auto_eligible"]), "high_confidence": sum(1 for row in picked if row["confidence_level"] == "high"), }, } return payload def recommend_pair_matches( engine: Any, start_year: int | None, end_year: int | None, ledger_voucher_no: str = "", ledger_review_reason: str = "", voucher_voucher_no: str = "", voucher_review_reason: str = "", limit: int = 300, ) -> dict[str, Any]: safe_limit = max(min(int(limit or 300), 1000), 1) cache_key = _build_pair_recommend_cache_key( start_year, end_year, ledger_voucher_no=ledger_voucher_no, ledger_review_reason=ledger_review_reason, voucher_voucher_no=voucher_voucher_no, voucher_review_reason=voucher_review_reason, limit=safe_limit, ) now = time.time() cached = _PAIR_RECOMMEND_CACHE.get(cache_key) if cached and (now - float(cached.get("ts", 0))) <= _PAIR_RECOMMEND_CACHE_TTL_SEC: return cached["payload"] init_wehago_compare_db(engine) with engine.begin() as conn: persisted = _load_persisted_pair_recommend_cache(conn, cache_key) if persisted is not None: _PAIR_RECOMMEND_CACHE.clear() _PAIR_RECOMMEND_CACHE[cache_key] = {"ts": now, "payload": persisted} return persisted payload = _compute_pair_recommendations_payload( engine, start_year=start_year, end_year=end_year, ledger_voucher_no=ledger_voucher_no, ledger_review_reason=ledger_review_reason, voucher_voucher_no=voucher_voucher_no, voucher_review_reason=voucher_review_reason, limit=safe_limit, ) with engine.begin() as conn: _store_persisted_pair_recommend_cache( conn, cache_key, payload, start_year=start_year, end_year=end_year, ledger_voucher_no=ledger_voucher_no, ledger_review_reason=ledger_review_reason, voucher_voucher_no=voucher_voucher_no, voucher_review_reason=voucher_review_reason, limit=safe_limit, ) _PAIR_RECOMMEND_CACHE.clear() _PAIR_RECOMMEND_CACHE[cache_key] = {"ts": now, "payload": payload} return payload def save_recommended_pair_matches( engine: Any, start_year: int | None, end_year: int | None, pair_keys: list[str] | None = None, auto_only: bool = False, ledger_voucher_no: str = "", ledger_review_reason: str = "", voucher_voucher_no: str = "", voucher_review_reason: str = "", ) -> dict[str, Any]: recommended = recommend_pair_matches( engine, start_year=start_year, end_year=end_year, ledger_voucher_no=ledger_voucher_no, ledger_review_reason=ledger_review_reason, voucher_voucher_no=voucher_voucher_no, voucher_review_reason=voucher_review_reason, limit=1000, ) selected_keys = {clean(key) for key in (pair_keys or []) if clean(key)} selected_pairs: list[dict[str, Any]] = [] for pair in recommended["pairs"]: if auto_only and not pair.get("auto_eligible"): continue if selected_keys and clean(pair.get("pair_key")) not in selected_keys: continue selected_pairs.append(pair) ledger_rows = [pair["ledger_row"] for pair in selected_pairs] voucher_rows = [pair["voucher_row"] for pair in selected_pairs] pair_meta_by_key = { clean(pair["pair_key"]): { "score": pair["score"], "confidence_level": pair["confidence_level"], "reason": pair["reason"], "match_source": "auto_recommend" if auto_only else "recommend", } for pair in selected_pairs } saved_count = save_manual_pair_matches( engine, ledger_rows=ledger_rows, voucher_rows=voucher_rows, pair_meta_by_key=pair_meta_by_key, match_source="auto_recommend" if auto_only else "recommend", ) return { "saved_count": saved_count, "selected_count": len(selected_pairs), "recommended_count": recommended["stats"]["recommended"], } def get_individual_pair_recommendations( engine: Any, start_year: int | None, end_year: int | None, source_status: str, source_row_key: str, offset: int = 0, limit: int = 10, ) -> dict[str, Any]: source_status = normalize_text(source_status).lower() if source_status not in {"ledgeronly", "voucheronly", "ledger_only", "voucher_only"}: raise ValueError("개별 추천의 상태 값이 올바르지 않습니다.") normalized_source_status = "ledger_only" if source_status in {"ledgeronly", "ledger_only"} else "voucher_only" normalized_row_key = clean(source_row_key) if not normalized_row_key: raise ValueError("추천할 기준 행 키가 필요합니다.") dataset = _collect_status_rows_for_workbench(engine, start_year, end_year) ledger_rows = dataset["ledger_only"] voucher_rows = dataset["voucher_only"] ledger_features = [ _build_match_row_features( row, prefix="ledger", row_key_field="ledger_row_key", account_code_field="ledger_account_code", account_name_field="ledger_account_name", vendor_field="ledger_vendor", desc_field="ledger_desc", date_field="ledger_date", ) for row in ledger_rows ] voucher_features = [ _build_match_row_features( row, prefix="voucher", row_key_field="voucher_row_key", account_code_field="voucher_account_code", account_name_field="voucher_account_name", vendor_field="voucher_vendor", desc_field="voucher_desc", date_field="proof_date", ) for row in voucher_rows ] source_feature = None if normalized_source_status == "ledger_only": for feature in ledger_features: if feature.row_key == normalized_row_key: source_feature = feature break else: for feature in voucher_features: if feature.row_key == normalized_row_key: source_feature = feature break if source_feature is None: return { "source_status": normalized_source_status, "source_row": None, "rows": [], "total_count": 0, "shown_count": 0, "offset": 0, "limit": limit, "has_more": False, "next_offset": 0, "bank_payable_case_count": 0, } candidates: list[dict[str, Any]] = [] if normalized_source_status == "ledger_only": source_amounts = set(source_feature.positive_amounts) for target_feature in voucher_features: if not (source_amounts & set(target_feature.positive_amounts)): continue score = _score_pair_match_features(source_feature, target_feature) if not score["hard_pass"] or score["score"] < 60: continue candidates.append( { "pair_key": build_manual_pair_key(source_feature.row_key, target_feature.row_key), "score": score["score"], "confidence_level": score["confidence_level"], "amount_gap": score.get("amount_gap", 0), "account_similarity": score.get("account_similarity", 0), "vendor_similarity": score.get("vendor_similarity", 0), "reason": score.get("reason", ""), "target_row": target_feature.row, } ) else: source_amounts = set(source_feature.positive_amounts) for target_feature in ledger_features: if not (source_amounts & set(target_feature.positive_amounts)): continue score = _score_pair_match_features(target_feature, source_feature) if not score["hard_pass"] or score["score"] < 60: continue candidates.append( { "pair_key": build_manual_pair_key(target_feature.row_key, source_feature.row_key), "score": score["score"], "confidence_level": score["confidence_level"], "amount_gap": score.get("amount_gap", 0), "account_similarity": score.get("account_similarity", 0), "vendor_similarity": score.get("vendor_similarity", 0), "reason": score.get("reason", ""), "target_row": target_feature.row, } ) candidates.sort( key=lambda row: ( -float(row.get("score") or 0), float(row.get("amount_gap") or 0), -float(row.get("vendor_similarity") or 0), ) ) safe_offset = max(int(offset or 0), 0) safe_limit = max(min(int(limit or 10), 100), 1) rows = candidates[safe_offset : safe_offset + safe_limit] next_offset = safe_offset + len(rows) return { "source_status": normalized_source_status, "source_row": source_feature.row, "rows": rows, "total_count": len(candidates), "shown_count": len(rows), "offset": safe_offset, "limit": safe_limit, "has_more": next_offset < len(candidates), "next_offset": next_offset, } def _contains_filter(value: Any, keyword: str) -> bool: if not keyword: return True return keyword in normalize_text(value) def _filter_status_row( row: dict[str, Any], voucher_no: str, draft_no: str, wehago_account: str, erp_account: str, wehago_amount: str, erp_amount: str, wehago_vendor: str, erp_vendor: str, desc_keyword: str, ) -> bool: if voucher_no and not _contains_filter(row.get("voucher_no"), voucher_no): return False if draft_no and not _contains_filter(row.get("draft_no"), draft_no): return False wehago_account_candidates = [ row.get("ledger_account_code"), row.get("ledger_account_name"), " ".join(part for part in (clean(row.get("ledger_account_code")), clean(row.get("ledger_account_name"))) if part), ] if wehago_account and not any(_contains_filter(item, wehago_account) for item in wehago_account_candidates): return False erp_account_candidates = [ row.get("voucher_account_code"), row.get("voucher_account_name"), " ".join(part for part in (clean(row.get("voucher_account_code")), clean(row.get("voucher_account_name"))) if part), ] if erp_account and not any(_contains_filter(item, erp_account) for item in erp_account_candidates): return False if wehago_vendor and not _contains_filter(row.get("ledger_vendor"), wehago_vendor): return False if erp_vendor and not _contains_filter(row.get("voucher_vendor"), erp_vendor): return False if wehago_amount: target = parse_amount(wehago_amount) if abs(parse_amount(row.get("ledger_debit")) - target) >= 0.5 and abs(parse_amount(row.get("ledger_credit")) - target) >= 0.5: return False if erp_amount: target = parse_amount(erp_amount) if abs(parse_amount(row.get("voucher_debit")) - target) >= 0.5 and abs(parse_amount(row.get("voucher_credit")) - target) >= 0.5: return False desc_candidates = [ row.get("ledger_desc"), row.get("voucher_desc"), row.get("review_reason"), row.get("review_memo"), ] if desc_keyword and not any(_contains_filter(item, desc_keyword) for item in desc_candidates): return False return True def _is_bank_payable_matched_row(row: dict[str, Any]) -> bool: return clean(row.get("matched_case")) == "bank_payable" or clean(row.get("review_reason")) == "BANK_PAYABLE_MATCH" BOUNDARY_EXCLUSION_KEYWORDS = ( "대체", "이월", "전기", "기초", "기말", "마감", "결산", "손익", "잉여금", ) STRONG_CARRYOVER_KEYWORDS = ( "전기이월", "기초이월", "전기잔액", "기초잔액", ) def _is_boundary_date_value(value: Any, fiscal_year: Any = None) -> bool: date_text = clean(value) if not date_text: return False match = re.search(r"(\d{4})[-./](\d{1,2})[-./](\d{1,2})", date_text) if match: year_value = int(match.group(1)) month_day = f"{int(match.group(2)):02d}-{int(match.group(3)):02d}" target_year = int(fiscal_year or 0) if str(fiscal_year or "").isdigit() else None return month_day in {"01-01", "12-31"} and (not target_year or year_value == target_year) return bool(re.search(r"(^|[^0-9])(?:0?1[-./]0?1|12[-./]31)([^0-9]|$)", date_text)) def _extract_boundary_month_day(value: Any, fiscal_year: Any = None) -> str: date_text = clean(value) if not date_text: return "" match = re.search(r"(\d{4})[-./](\d{1,2})[-./](\d{1,2})", date_text) if match: year_value = int(match.group(1)) month_day = f"{int(match.group(2)):02d}-{int(match.group(3)):02d}" target_year = int(fiscal_year or 0) if str(fiscal_year or "").isdigit() else None if month_day in {"01-01", "12-31"} and (not target_year or year_value == target_year): return month_day return "" loose = re.search(r"(^|[^0-9])(0?1[-./]0?1|12[-./]31)([^0-9]|$)", date_text) if not loose: return "" token = loose.group(2) if token.startswith(("1-1", "01-1", "1/1", "01/1", "1.1", "01.1", "01-01", "01/01", "01.01")): return "01-01" return "12-31" def _row_has_earlier_fiscal_year_source(conn: Any, row: dict[str, Any]) -> bool: try: fiscal_year = int(row.get("fiscal_year") or 0) except (TypeError, ValueError): fiscal_year = 0 if fiscal_year <= 0: return False account_code = clean(row.get("ledger_account_code") or row.get("account_code")) account_name = clean(row.get("ledger_account_name") or row.get("account_name")) if not account_code and not account_name: return False exists = conn.execute( text( """ SELECT 1 FROM wehago_ledger_rows WHERE fiscal_year < :fiscal_year AND ( (:account_code <> '' AND COALESCE(account_code, '') = :account_code) OR (:account_name <> '' AND COALESCE(account_name, '') = :account_name) ) LIMIT 1 """ ), { "fiscal_year": fiscal_year, "account_code": account_code, "account_name": account_name, }, ).first() return bool(exists) def _build_earlier_year_account_lookup(conn: Any) -> dict[int, set[str]]: rows = conn.execute( text( """ SELECT fiscal_year, COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name FROM wehago_ledger_rows WHERE fiscal_year IS NOT NULL AND fiscal_year > 0 """ ) ).mappings() accounts_by_year: dict[int, set[str]] = {} for row in rows: year = int(row["fiscal_year"] or 0) if year <= 0: continue bucket = accounts_by_year.setdefault(year, set()) account_code = clean(row["account_code"]) account_name = clean(row["account_name"]) if account_code: bucket.add(f"code:{account_code}") if account_name: bucket.add(f"name:{account_name}") earlier_lookup: dict[int, set[str]] = {} accumulated: set[str] = set() for year in sorted(accounts_by_year): earlier_lookup[year] = set(accumulated) accumulated.update(accounts_by_year[year]) return earlier_lookup def _row_has_earlier_fiscal_year_source_from_lookup( row: dict[str, Any], earlier_lookup: dict[int, set[str]], ) -> bool: try: fiscal_year = int(row.get("fiscal_year") or 0) except (TypeError, ValueError): fiscal_year = 0 if fiscal_year <= 0: return False earlier_accounts = earlier_lookup.get(fiscal_year, set()) if not earlier_accounts: return False account_code = clean(row.get("ledger_account_code") or row.get("account_code")) account_name = clean(row.get("ledger_account_name") or row.get("account_name")) return ( (bool(account_code) and f"code:{account_code}" in earlier_accounts) or (bool(account_name) and f"name:{account_name}" in earlier_accounts) ) def _has_boundary_exclusion_keyword(*values: Any) -> bool: text_value = normalize_text(" ".join(clean(value) for value in values if clean(value))) return any(keyword in text_value for keyword in BOUNDARY_EXCLUSION_KEYWORDS) def _has_strong_carryover_keyword(*values: Any) -> bool: text_value = normalize_text(" ".join(clean(value) for value in values if clean(value))) compact_value = text_value.replace(" ", "") return any(keyword in compact_value for keyword in STRONG_CARRYOVER_KEYWORDS) def _is_boundary_excluded_unmatched_row( row: dict[str, Any], conn: Any | None = None, earlier_year_account_lookup: dict[int, set[str]] | None = None, ) -> bool: if clean(row.get("boundary_excluded")) == "1" or clean(row.get("matched_case")) == "boundary_excluded": return True month_day = _extract_boundary_month_day(row.get("ledger_date"), row.get("fiscal_year")) text_values = ( row.get("ledger_desc"), row.get("description"), row.get("ledger_account_name"), row.get("account_name"), row.get("review_reason"), row.get("notes"), ) if month_day == "01-01": if earlier_year_account_lookup is not None: return _row_has_earlier_fiscal_year_source_from_lookup(row, earlier_year_account_lookup) return bool(conn is not None and _row_has_earlier_fiscal_year_source(conn, row)) if _has_strong_carryover_keyword(*text_values): return True if not month_day: return False return _has_boundary_exclusion_keyword(*text_values) def _annotate_boundary_excluded_row( row: dict[str, Any], conn: Any | None = None, earlier_year_account_lookup: dict[int, set[str]] | None = None, ) -> dict[str, Any]: if _is_boundary_excluded_unmatched_row(row, conn, earlier_year_account_lookup): row["boundary_excluded"] = "1" row["boundary_excluded_label"] = "연초/연말 대체·이월" row["matched_case"] = "boundary_excluded" return row def _refresh_section_counts(sections: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: for section in sections.values(): rows = section.get("rows", []) if isinstance(rows, list): section["count"] = len(rows) return sections def _apply_boundary_exclusions_to_sections(conn: Any, sections: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: earlier_year_account_lookup = _build_earlier_year_account_lookup(conn) prepared: dict[str, dict[str, Any]] = {} for status_key, section in sections.items(): copied = dict(section) copied["rows"] = [dict(row) for row in section.get("rows", [])] prepared[status_key] = copied ledger_only_rows = prepared.setdefault("ledger_only", {"rows": [], "count": 0, "columns": DETAIL_COLUMN_MAP["ledger_only"]})["rows"] for status_key in ("matched", "amount_mismatch"): kept_rows: list[dict[str, Any]] = [] for row in prepared.get(status_key, {}).get("rows", []): if _is_boundary_excluded_unmatched_row(row, conn, earlier_year_account_lookup): ledger_only_rows.append(_annotate_boundary_excluded_row(row, conn, earlier_year_account_lookup)) else: kept_rows.append(row) if status_key in prepared: prepared[status_key]["rows"] = kept_rows prepared["ledger_only"]["rows"] = [ _annotate_boundary_excluded_row(row, conn, earlier_year_account_lookup) for row in prepared.get("ledger_only", {}).get("rows", []) ] return _refresh_section_counts(prepared) def _boundary_excluded_sql(date_sql: str, *text_sqls: str) -> str: text_expr = " || ' ' || ".join(f"COALESCE({field}, '')" for field in text_sqls) compact_text_expr = f"REPLACE(({text_expr}), ' ', '')" keyword_sql = " OR ".join(f"{text_expr} LIKE '%{keyword}%'" for keyword in BOUNDARY_EXCLUSION_KEYWORDS) strong_keyword_sql = " OR ".join(f"{compact_text_expr} LIKE '%{keyword}%'" for keyword in STRONG_CARRYOVER_KEYWORDS) return f"(({strong_keyword_sql}) OR (substr(COALESCE({date_sql}, ''), 6, 5) IN ('01-01', '12-31') AND ({keyword_sql})))" def _append_like_filter(conditions: list[str], params: dict[str, Any], field_sql: str, param_name: str, value: str) -> None: cleaned = clean(value) if not cleaned: return conditions.append(f"COALESCE({field_sql}, '') LIKE :{param_name}") params[param_name] = f"%{cleaned}%" def _append_amount_filter(conditions: list[str], params: dict[str, Any], fields: list[str], param_name: str, value: str) -> None: cleaned = clean(value) if not cleaned: return amount = parse_amount(cleaned) if abs(amount) < 0.5 and not re.search(r"\d", cleaned): return parts: list[str] = [] for index, field_sql in enumerate(fields): key = f"{param_name}_{index}" parts.append(f"ABS(COALESCE({field_sql}, 0) - :{key}) < 0.5") params[key] = amount conditions.append("(" + " OR ".join(parts) + ")") def _fetch_status_detail_rows_from_db( conn: Any, start_year: int, end_year: int, status: str, voucher_filter: str, draft_filter: str, wehago_account_filter: str, erp_account_filter: str, wehago_amount_filter: str, erp_amount_filter: str, wehago_vendor_filter: str, erp_vendor_filter: str, desc_filter: str, boundary_excluded_filter: bool, offset: int, limit: int, ) -> dict[str, Any]: params: dict[str, Any] = { "start_year": start_year, "end_year": end_year, "status": status, "limit": limit, "offset": offset, } conditions = [ "c.status = :status", "c.fiscal_year >= :start_year", "c.fiscal_year <= :end_year", ] voucher_rep = """ SELECT * FROM ( SELECT fiscal_year, compare_voucher_no, proof_date, confirmed_no, draft_no, account_code, account_name, vendor_name, desc1, desc2, debit_supply, credit_supply, ROW_NUMBER() OVER ( PARTITION BY fiscal_year, compare_voucher_no ORDER BY row_number ) AS rn FROM wehago_voucher_rows WHERE COALESCE(compare_voucher_no, '') <> '' ) WHERE rn = 1 """ if status == "ledger_only": boundary_sql = _boundary_excluded_sql("l.ledger_date", "l.description", "l.account_name", "c.notes") select_sql = """ c.fiscal_year AS fiscal_year, COALESCE(l.voucher_no, c.voucher_no) AS voucher_no, COALESCE(l.ledger_date, '') AS ledger_date, COALESCE(l.account_code, '') AS ledger_account_code, COALESCE(l.account_name, c.ledger_accounts, '') AS ledger_account_name, COALESCE(l.vendor_name, c.ledger_vendors, '') AS ledger_vendor, l.debit AS ledger_debit, l.credit AS ledger_credit, COALESCE(l.description, '') AS ledger_desc, CASE WHEN {boundary_sql} THEN '1' ELSE '' END AS boundary_excluded, CASE WHEN {boundary_sql} THEN '연초/연말 대체·이월' ELSE '' END AS boundary_excluded_label, CASE WHEN {boundary_sql} THEN 'boundary_excluded' ELSE '' END AS matched_case """.format(boundary_sql=boundary_sql) joins = """ JOIN wehago_ledger_rows l ON l.fiscal_year = c.fiscal_year AND l.compare_voucher_no = c.voucher_no """ _append_like_filter(conditions, params, "COALESCE(l.voucher_no, c.voucher_no)", "voucher_no", voucher_filter) _append_like_filter(conditions, params, "COALESCE(l.account_name, c.ledger_accounts)", "wehago_account", wehago_account_filter) _append_like_filter(conditions, params, "COALESCE(l.vendor_name, c.ledger_vendors)", "wehago_vendor", wehago_vendor_filter) _append_like_filter(conditions, params, "l.description", "desc_keyword", desc_filter) _append_amount_filter(conditions, params, ["l.debit", "l.credit"], "wehago_amount", wehago_amount_filter) if boundary_excluded_filter: conditions.append(boundary_sql) elif status == "voucher_only": select_sql = """ c.fiscal_year AS fiscal_year, COALESCE(v.confirmed_no, v.draft_no, c.voucher_no) AS voucher_no, COALESCE(v.proof_date, '') AS proof_date, COALESCE(v.draft_no, '') AS draft_no, COALESCE(v.account_code, '') AS voucher_account_code, COALESCE(v.account_name, c.voucher_accounts, '') AS voucher_account_name, COALESCE(v.vendor_name, c.voucher_vendors, '') AS voucher_vendor, v.debit_supply AS voucher_debit, v.credit_supply AS voucher_credit, TRIM(COALESCE(v.desc1, '') || ' ' || COALESCE(v.desc2, '')) AS voucher_desc """ joins = """ JOIN wehago_voucher_rows v ON v.fiscal_year = c.fiscal_year AND v.compare_voucher_no = c.voucher_no """ _append_like_filter(conditions, params, "COALESCE(v.confirmed_no, v.draft_no, c.voucher_no)", "voucher_no", voucher_filter) _append_like_filter(conditions, params, "v.draft_no", "draft_no", draft_filter) _append_like_filter(conditions, params, "COALESCE(v.account_name, c.voucher_accounts)", "erp_account", erp_account_filter) _append_like_filter(conditions, params, "COALESCE(v.vendor_name, c.voucher_vendors)", "erp_vendor", erp_vendor_filter) _append_like_filter(conditions, params, "TRIM(COALESCE(v.desc1, '') || ' ' || COALESCE(v.desc2, ''))", "desc_keyword", desc_filter) _append_amount_filter(conditions, params, ["v.debit_supply", "v.credit_supply"], "erp_amount", erp_amount_filter) else: select_sql = """ c.fiscal_year AS fiscal_year, COALESCE(l.ledger_date, v.proof_date, '') AS ledger_date, COALESCE(l.voucher_no, v.confirmed_no, v.draft_no, c.voucher_no) AS voucher_no, COALESCE(v.draft_no, '') AS draft_no, COALESCE(l.account_code, '') AS ledger_account_code, COALESCE(l.account_name, c.ledger_accounts, '') AS ledger_account_name, COALESCE(v.account_code, '') AS voucher_account_code, COALESCE(v.account_name, c.voucher_accounts, '') AS voucher_account_name, COALESCE(l.vendor_name, c.ledger_vendors, '') AS ledger_vendor, COALESCE(v.vendor_name, c.voucher_vendors, '') AS voucher_vendor, l.debit AS ledger_debit, l.credit AS ledger_credit, COALESCE(v.debit_supply, 0) AS voucher_debit, COALESCE(v.credit_supply, 0) AS voucher_credit, COALESCE(l.description, '') AS ledger_desc, TRIM(COALESCE(v.desc1, '') || ' ' || COALESCE(v.desc2, '')) AS voucher_desc, c.notes AS review_reason """ joins = f""" JOIN wehago_ledger_rows l ON l.fiscal_year = c.fiscal_year AND l.compare_voucher_no = c.voucher_no LEFT JOIN ({voucher_rep}) v ON v.fiscal_year = c.fiscal_year AND v.compare_voucher_no = c.voucher_no """ _append_like_filter(conditions, params, "COALESCE(l.voucher_no, v.confirmed_no, v.draft_no, c.voucher_no)", "voucher_no", voucher_filter) _append_like_filter(conditions, params, "v.draft_no", "draft_no", draft_filter) _append_like_filter(conditions, params, "COALESCE(l.account_name, c.ledger_accounts)", "wehago_account", wehago_account_filter) _append_like_filter(conditions, params, "COALESCE(v.account_name, c.voucher_accounts)", "erp_account", erp_account_filter) _append_like_filter(conditions, params, "COALESCE(l.vendor_name, c.ledger_vendors)", "wehago_vendor", wehago_vendor_filter) _append_like_filter(conditions, params, "COALESCE(v.vendor_name, c.voucher_vendors)", "erp_vendor", erp_vendor_filter) _append_like_filter( conditions, params, "COALESCE(l.description, '') || ' ' || COALESCE(v.desc1, '') || ' ' || COALESCE(v.desc2, '') || ' ' || COALESCE(c.notes, '')", "desc_keyword", desc_filter, ) _append_amount_filter(conditions, params, ["l.debit", "l.credit"], "wehago_amount", wehago_amount_filter) _append_amount_filter(conditions, params, ["v.debit_supply", "v.credit_supply"], "erp_amount", erp_amount_filter) where_sql = " AND ".join(conditions) from_sql = f"FROM wehago_comparison_results c {joins} WHERE {where_sql}" total_count = int(conn.execute(text(f"SELECT COUNT(*) {from_sql}"), params).scalar_one() or 0) rows = [ {key: clean(value) if isinstance(value, str) else value for key, value in dict(row).items()} for row in conn.execute( text( f""" SELECT {select_sql} {from_sql} ORDER BY c.fiscal_year, c.voucher_no LIMIT :limit OFFSET :offset """ ), params, ).mappings() ] next_offset = offset + len(rows) boundary_excluded_count = 0 if status == "ledger_only": boundary_sql = _boundary_excluded_sql("l.ledger_date", "l.description", "l.account_name", "c.notes") boundary_count_conditions = [ item for item in conditions if item != boundary_sql and item != f"NOT {boundary_sql}" ] boundary_excluded_count = int( conn.execute( text( f""" SELECT COUNT(*) FROM wehago_comparison_results c {joins} WHERE {' AND '.join(boundary_count_conditions + [boundary_sql])} """ ), params, ).scalar_one() or 0 ) return { "columns": DETAIL_COLUMN_MAP[status], "rows": rows, "total_count": total_count, "shown_count": len(rows), "offset": offset, "limit": limit, "has_more": next_offset < total_count, "next_offset": next_offset, "notice": "", "bank_payable_case_count": 0, "boundary_excluded_count": boundary_excluded_count, } def _build_status_detail_response_from_rows( rows_by_status: dict[str, list[dict[str, Any]]], status: str, voucher_filter: str, draft_filter: str, wehago_account_filter: str, erp_account_filter: str, wehago_amount_filter: str, erp_amount_filter: str, wehago_vendor_filter: str, erp_vendor_filter: str, desc_filter: str, boundary_excluded_filter: bool, offset: int, limit: int, ) -> dict[str, Any]: source_rows = [ _annotate_boundary_excluded_row(dict(row)) if status == "ledger_only" else dict(row) for row in rows_by_status.get(status, []) ] filtered_rows = [ row for row in source_rows if _filter_status_row( row, voucher_filter, draft_filter, wehago_account_filter, erp_account_filter, wehago_amount_filter, erp_amount_filter, wehago_vendor_filter, erp_vendor_filter, desc_filter, ) and (not boundary_excluded_filter or _is_boundary_excluded_unmatched_row(row)) ] next_offset = offset + min(limit, max(len(filtered_rows) - offset, 0)) shown_rows = filtered_rows[offset : offset + limit] return { "columns": DETAIL_COLUMN_MAP[status], "rows": shown_rows, "total_count": len(filtered_rows), "shown_count": len(shown_rows), "offset": offset, "limit": limit, "has_more": next_offset < len(filtered_rows), "next_offset": next_offset, "notice": "", "bank_payable_case_count": sum(1 for row in filtered_rows if _is_bank_payable_matched_row(row)), "boundary_excluded_count": sum(1 for row in source_rows if _is_boundary_excluded_unmatched_row(row)) if status == "ledger_only" else 0, } def _build_voucher_sections_from_rows_by_status( rows_by_status: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: grouped: dict[tuple[str, int, str, str], dict[str, Any]] = {} matched_ledger_keys: set[str] = set() matched_voucher_keys: set[str] = set() matched_wehago_group_keys: set[tuple[int, str, str]] = set() def erp_set_key_from_values(fiscal_year: Any, draft_no: Any, voucher_no: Any, proof_date: Any) -> tuple[int, str]: normalized_draft = clean(draft_no) normalized_voucher = clean(voucher_no) normalized_date = clean(proof_date) base = normalized_draft or normalized_voucher if base: base = re.sub(r"-\d+$", "", base) if not base and normalized_date and normalized_voucher: base = f"{normalized_date}|{normalized_voucher}" return (int(fiscal_year or 0), base) def wehago_group_key(row: dict[str, Any]) -> tuple[int, str, str]: return ( int(row.get("fiscal_year") or 0), clean(row.get("voucher_no")), clean(row.get("ledger_date")), ) def erp_group_key(row: dict[str, Any]) -> tuple[int, str]: return erp_set_key_from_values( row.get("fiscal_year"), row.get("draft_no"), row.get("voucher_no"), row.get("proof_date"), ) def row_origin_rank(row: dict[str, Any]) -> int: status_label = clean(row.get("status_label")) if status_label == "Matched": return 0 if status_label == "Unmatched": return 1 if status_label == "ERP Unmatched": return 2 if status_label == "Recheck": return 3 return 4 def ensure_group(status_key: str, row: dict[str, Any]) -> dict[str, Any]: fiscal_year = int(row.get("fiscal_year") or 0) voucher_no = clean(row.get("voucher_no")) draft_no = clean(row.get("draft_no")) ledger_date = clean(row.get("ledger_date")) proof_date = clean(row.get("proof_date")) fallback_voucher_key = clean( row.get("ledger_row_key") or row.get("voucher_row_key") or row.get("review_key") or ledger_date or proof_date or row.get("ledger_desc") or row.get("voucher_desc") ) group_key_parts = [part for part in [voucher_no, draft_no, ledger_date, proof_date] if part] if status_key in {"matched", "ledger_only", "amount_mismatch"}: if voucher_no and ledger_date: group_key = "|".join([str(fiscal_year), ledger_date, voucher_no]) elif voucher_no and proof_date: group_key = "|".join([str(fiscal_year), proof_date, voucher_no]) elif group_key_parts: group_key = "|".join([str(fiscal_year), *group_key_parts]) else: group_key = fallback_voucher_key elif draft_no and proof_date: group_key = "|".join([str(fiscal_year), proof_date, draft_no]) elif voucher_no and proof_date: group_key = "|".join([str(fiscal_year), proof_date, voucher_no]) elif group_key_parts: group_key = "|".join([str(fiscal_year), *group_key_parts]) else: group_key = fallback_voucher_key key = (status_key, fiscal_year, group_key, "") current = grouped.get(key) if current is None: status_label_map = { "matched": "Matched", "ledger_only": "Unmatched", "voucher_only": "ERP Unmatched", "amount_mismatch": "Recheck", } current = { "fiscal_year": fiscal_year, "status_label": status_label_map.get(status_key, status_key), "ledger_date": ledger_date, "proof_date": proof_date, "voucher_no": voucher_no, "draft_no": draft_no, "ledger_row_count": 0, "voucher_row_count": 0, "ledger_debit": 0.0, "ledger_credit": 0.0, "voucher_debit": 0.0, "voucher_credit": 0.0, "ledger_accounts": [], "voucher_accounts": [], "ledger_vendors": [], "voucher_vendors": [], "review_reason": [], "rows": [], "wehago_group_key": wehago_group_key(row), "erp_group_keys": set(), "draft_nos": [], } grouped[key] = current return current def append_unique(target: list[str], value: Any) -> None: text_value = clean(value) if text_value and text_value not in target: target.append(text_value) for status_key in ("matched", "ledger_only", "voucher_only", "amount_mismatch"): for row in rows_by_status.get(status_key, []): current = ensure_group(status_key, row) if not current.get("ledger_date"): current["ledger_date"] = clean(row.get("ledger_date")) if not current.get("proof_date"): current["proof_date"] = clean(row.get("proof_date")) if clean(row.get("ledger_account_name")) or clean(row.get("ledger_desc")): current["ledger_row_count"] += 1 if clean(row.get("voucher_account_name")) or clean(row.get("voucher_desc")): current["voucher_row_count"] += 1 current["ledger_debit"] += parse_amount(row.get("ledger_debit")) current["ledger_credit"] += parse_amount(row.get("ledger_credit")) current["voucher_debit"] += parse_amount(row.get("voucher_debit")) current["voucher_credit"] += parse_amount(row.get("voucher_credit")) append_unique(current["ledger_accounts"], row.get("ledger_account_name")) append_unique(current["voucher_accounts"], row.get("voucher_account_name")) append_unique(current["ledger_vendors"], row.get("ledger_vendor")) append_unique(current["voucher_vendors"], row.get("voucher_vendor")) append_unique(current["review_reason"], row.get("review_reason")) append_unique(current["draft_nos"], row.get("draft_no")) row_payload = dict(row) row_payload.setdefault("status_label", current.get("status_label")) current["rows"].append(row_payload) if status_key in {"matched", "amount_mismatch"}: current["erp_group_keys"].add(erp_group_key(row)) if status_key == "matched": ledger_key = clean(row.get("ledger_row_key")) voucher_key = clean(row.get("voucher_row_key")) if ledger_key: matched_ledger_keys.add(ledger_key) if voucher_key: matched_voucher_keys.add(voucher_key) current["erp_group_keys"].add(erp_group_key(row)) ledger_only_index: dict[tuple[int, str, str], list[dict[str, Any]]] = {} for row in rows_by_status.get("ledger_only", []): ledger_only_index.setdefault(wehago_group_key(row), []).append(dict(row)) voucher_only_index: dict[tuple[int, str], list[dict[str, Any]]] = {} for row in rows_by_status.get("voucher_only", []): voucher_only_index.setdefault(erp_group_key(row), []).append(dict(row)) recheck_by_wehago_index: dict[tuple[int, str, str], list[dict[str, Any]]] = {} recheck_by_erp_index: dict[tuple[int, str], list[dict[str, Any]]] = {} for row in rows_by_status.get("amount_mismatch", []): row_copy = dict(row) recheck_by_wehago_index.setdefault(wehago_group_key(row_copy), []).append(row_copy) recheck_by_erp_index.setdefault(erp_group_key(row_copy), []).append(row_copy) recheck_wehago_group_keys = set(recheck_by_wehago_index.keys()) def recheck_identity(row: dict[str, Any]) -> str: return ( clean(row.get("review_key")) or "|".join( [ clean(row.get("ledger_row_key")), clean(row.get("voucher_row_key")), clean(row.get("voucher_no")), clean(row.get("draft_no")), clean(row.get("ledger_account_name")), clean(row.get("voucher_account_name")), clean(row.get("ledger_desc")), clean(row.get("voucher_desc")), ] ) ) def append_recheck_row(target_rows: list[dict[str, Any]], extra_recheck: dict[str, Any], appended_recheck_ids: set[str]) -> None: identity = recheck_identity(extra_recheck) if identity and identity in appended_recheck_ids: return if identity: appended_recheck_ids.add(identity) recheck_payload = dict(extra_recheck) recheck_payload["status_label"] = "Recheck" target_rows.append(recheck_payload) voucher_sections = { "voucher_matched": [], "erp_voucher_matched": [], "voucher_unmatched": [], "voucher_recheck": [], } for (status_key, _fiscal_year, _voucher_no, _draft_no), row in grouped.items(): if status_key == "matched": matched_wehago_group_keys.add(row.get("wehago_group_key")) for extra_ledger in ledger_only_index.get(row.get("wehago_group_key"), []): ledger_key = clean(extra_ledger.get("ledger_row_key")) if ledger_key and ledger_key in matched_ledger_keys: continue row["rows"].append( { **dict(extra_ledger), "status_label": "Unmatched", "voucher_account_code": "", "voucher_account_name": "", "voucher_vendor": "", "voucher_debit": "", "voucher_credit": "", "voucher_desc": "", "draft_no": "", "proof_date": "", } ) appended_recheck_ids: set[str] = set() for extra_recheck in recheck_by_wehago_index.get(row.get("wehago_group_key"), []): append_recheck_row(row["rows"], extra_recheck, appended_recheck_ids) appended_voucher_keys: set[str] = set() for erp_key in row.get("erp_group_keys", set()): for extra_voucher in voucher_only_index.get(erp_key, []): voucher_key = clean(extra_voucher.get("voucher_row_key")) if voucher_key and voucher_key in matched_voucher_keys: continue if voucher_key and voucher_key in appended_voucher_keys: continue if voucher_key: appended_voucher_keys.add(voucher_key) row["rows"].append( { **dict(extra_voucher), "status_label": "ERP Unmatched", "ledger_account_code": "", "ledger_account_name": "", "ledger_vendor": "", "ledger_debit": "", "ledger_credit": "", "ledger_desc": "", "ledger_date": row.get("ledger_date", ""), "voucher_no": row.get("voucher_no", extra_voucher.get("voucher_no", "")), } ) for extra_recheck in recheck_by_erp_index.get(erp_key, []): append_recheck_row(row["rows"], extra_recheck, appended_recheck_ids) row["rows"].sort( key=lambda item: ( clean(item.get("ledger_date")) or clean(item.get("proof_date")), clean(item.get("voucher_no")), row_origin_rank(item), 0 if (clean(item.get("ledger_account_name")) and clean(item.get("voucher_account_name"))) else 1 if clean(item.get("ledger_account_name")) else 2, clean(item.get("ledger_account_name")) or clean(item.get("voucher_account_name")), clean(item.get("draft_no")), -parse_amount(item.get("ledger_debit") or item.get("voucher_debit")), -parse_amount(item.get("ledger_credit") or item.get("voucher_credit")), clean(item.get("ledger_desc")) or clean(item.get("voucher_desc")), ) ) normalized_summary = dict(row) normalized_summary["ledger_accounts"] = ", ".join(row["ledger_accounts"]) normalized_summary["voucher_accounts"] = ", ".join(row["voucher_accounts"]) normalized_summary["ledger_vendors"] = ", ".join(row["ledger_vendors"]) normalized_summary["voucher_vendors"] = ", ".join(row["voucher_vendors"]) normalized_summary["review_reason"] = " / ".join(row["review_reason"]) normalized_summary["draft_no"] = ", ".join(row["draft_nos"]) normalized_group = { "summary": normalized_summary, "rows": list(row["rows"]), } if status_key == "matched": voucher_sections["voucher_matched"].append(normalized_group) elif status_key == "ledger_only" and row.get("wehago_group_key") not in matched_wehago_group_keys and row.get("wehago_group_key") not in recheck_wehago_group_keys: voucher_sections["voucher_unmatched"].append(normalized_group) elif status_key == "amount_mismatch" and row.get("wehago_group_key") not in matched_wehago_group_keys: for extra_ledger in ledger_only_index.get(row.get("wehago_group_key"), []): ledger_key = clean(extra_ledger.get("ledger_row_key")) if ledger_key and ledger_key in matched_ledger_keys: continue row["rows"].append( { **dict(extra_ledger), "status_label": "Unmatched", "voucher_account_code": "", "voucher_account_name": "", "voucher_vendor": "", "voucher_debit": "", "voucher_credit": "", "voucher_desc": "", "draft_no": "", "proof_date": "", } ) appended_recheck_ids: set[str] = {recheck_identity(existing_row) for existing_row in row["rows"]} appended_voucher_keys: set[str] = set() for erp_key in row.get("erp_group_keys", set()): for extra_voucher in voucher_only_index.get(erp_key, []): voucher_key = clean(extra_voucher.get("voucher_row_key")) if voucher_key and voucher_key in matched_voucher_keys: continue if voucher_key and voucher_key in appended_voucher_keys: continue if voucher_key: appended_voucher_keys.add(voucher_key) row["rows"].append( { **dict(extra_voucher), "status_label": "ERP Unmatched", "ledger_account_code": "", "ledger_account_name": "", "ledger_vendor": "", "ledger_debit": "", "ledger_credit": "", "ledger_desc": "", "ledger_date": row.get("ledger_date", ""), "voucher_no": row.get("voucher_no", extra_voucher.get("voucher_no", "")), } ) for extra_recheck in recheck_by_erp_index.get(erp_key, []): append_recheck_row(row["rows"], extra_recheck, appended_recheck_ids) row["rows"].sort( key=lambda item: ( clean(item.get("ledger_date")) or clean(item.get("proof_date")), clean(item.get("voucher_no")), row_origin_rank(item), 0 if (clean(item.get("ledger_account_name")) and clean(item.get("voucher_account_name"))) else 1 if clean(item.get("ledger_account_name")) else 2, clean(item.get("ledger_account_name")) or clean(item.get("voucher_account_name")), clean(item.get("draft_no")), -parse_amount(item.get("ledger_debit") or item.get("voucher_debit")), -parse_amount(item.get("ledger_credit") or item.get("voucher_credit")), clean(item.get("ledger_desc")) or clean(item.get("voucher_desc")), ) ) normalized_summary = dict(row) normalized_summary["ledger_accounts"] = ", ".join(row["ledger_accounts"]) normalized_summary["voucher_accounts"] = ", ".join(row["voucher_accounts"]) normalized_summary["ledger_vendors"] = ", ".join(row["ledger_vendors"]) normalized_summary["voucher_vendors"] = ", ".join(row["voucher_vendors"]) normalized_summary["review_reason"] = " / ".join(row["review_reason"]) normalized_summary["draft_no"] = ", ".join(row["draft_nos"]) normalized_group = { "summary": normalized_summary, "rows": list(row["rows"]), } voucher_sections["voucher_recheck"].append(normalized_group) erp_grouped: dict[tuple[int, str], dict[str, Any]] = {} for row in rows_by_status.get("matched", []): erp_key = erp_group_key(row) fiscal_year = int(row.get("fiscal_year") or 0) current = erp_grouped.get(erp_key) if current is None: current = { "fiscal_year": fiscal_year, "status_label": "Matched", "ledger_date": clean(row.get("ledger_date")), "proof_date": clean(row.get("proof_date")), "voucher_no": clean(row.get("voucher_no")), "draft_no": clean(row.get("draft_no")), "ledger_row_count": 0, "voucher_row_count": 0, "ledger_debit": 0.0, "ledger_credit": 0.0, "voucher_debit": 0.0, "voucher_credit": 0.0, "ledger_accounts": [], "voucher_accounts": [], "ledger_vendors": [], "voucher_vendors": [], "review_reason": [], "rows": [], "wehago_group_keys": set(), "erp_group_key": erp_key, "voucher_nos": [], "draft_nos": [], } erp_grouped[erp_key] = current if not current.get("ledger_date"): current["ledger_date"] = clean(row.get("ledger_date")) if not current.get("proof_date"): current["proof_date"] = clean(row.get("proof_date")) if not current.get("voucher_no"): current["voucher_no"] = clean(row.get("voucher_no")) if not current.get("draft_no"): current["draft_no"] = clean(row.get("draft_no")) if clean(row.get("ledger_account_name")) or clean(row.get("ledger_desc")): current["ledger_row_count"] += 1 if clean(row.get("voucher_account_name")) or clean(row.get("voucher_desc")): current["voucher_row_count"] += 1 current["ledger_debit"] += parse_amount(row.get("ledger_debit")) current["ledger_credit"] += parse_amount(row.get("ledger_credit")) current["voucher_debit"] += parse_amount(row.get("voucher_debit")) current["voucher_credit"] += parse_amount(row.get("voucher_credit")) append_unique(current["ledger_accounts"], row.get("ledger_account_name")) append_unique(current["voucher_accounts"], row.get("voucher_account_name")) append_unique(current["ledger_vendors"], row.get("ledger_vendor")) append_unique(current["voucher_vendors"], row.get("voucher_vendor")) append_unique(current["review_reason"], row.get("review_reason")) append_unique(current["voucher_nos"], row.get("voucher_no")) append_unique(current["draft_nos"], row.get("draft_no")) current["wehago_group_keys"].add(wehago_group_key(row)) row_payload = dict(row) row_payload.setdefault("status_label", "Matched") current["rows"].append(row_payload) for row in erp_grouped.values(): appended_ledger_keys: set[str] = set() appended_recheck_ids: set[str] = set() for wehago_key in row.get("wehago_group_keys", set()): for extra_ledger in ledger_only_index.get(wehago_key, []): ledger_key = clean(extra_ledger.get("ledger_row_key")) if ledger_key and ledger_key in matched_ledger_keys: continue if ledger_key and ledger_key in appended_ledger_keys: continue if ledger_key: appended_ledger_keys.add(ledger_key) row["rows"].append( { **dict(extra_ledger), "status_label": "Unmatched", "voucher_account_code": "", "voucher_account_name": "", "voucher_vendor": "", "voucher_debit": "", "voucher_credit": "", "voucher_desc": "", "draft_no": row.get("draft_no", ""), "proof_date": row.get("proof_date", ""), } ) for extra_recheck in recheck_by_wehago_index.get(wehago_key, []): append_recheck_row(row["rows"], extra_recheck, appended_recheck_ids) appended_voucher_keys: set[str] = set() for extra_voucher in voucher_only_index.get(row.get("erp_group_key"), []): voucher_key = clean(extra_voucher.get("voucher_row_key")) if voucher_key and voucher_key in matched_voucher_keys: continue if voucher_key and voucher_key in appended_voucher_keys: continue if voucher_key: appended_voucher_keys.add(voucher_key) row["rows"].append( { **dict(extra_voucher), "status_label": "ERP Unmatched", "ledger_account_code": "", "ledger_account_name": "", "ledger_vendor": "", "ledger_debit": "", "ledger_credit": "", "ledger_desc": "", "ledger_date": row.get("ledger_date", ""), "voucher_no": row.get("voucher_no", extra_voucher.get("voucher_no", "")), } ) for extra_recheck in recheck_by_erp_index.get(row.get("erp_group_key"), []): append_recheck_row(row["rows"], extra_recheck, appended_recheck_ids) row["rows"].sort( key=lambda item: ( clean(item.get("proof_date")) or clean(item.get("ledger_date")), clean(item.get("draft_no")), row_origin_rank(item), 0 if (clean(item.get("ledger_account_name")) and clean(item.get("voucher_account_name"))) else 1 if clean(item.get("voucher_account_name")) else 2, clean(item.get("voucher_account_name")) or clean(item.get("ledger_account_name")), clean(item.get("voucher_no")), -parse_amount(item.get("voucher_debit") or item.get("ledger_debit")), -parse_amount(item.get("voucher_credit") or item.get("ledger_credit")), clean(item.get("voucher_desc")) or clean(item.get("ledger_desc")), ) ) normalized_summary = dict(row) normalized_summary["ledger_accounts"] = ", ".join(row["ledger_accounts"]) normalized_summary["voucher_accounts"] = ", ".join(row["voucher_accounts"]) normalized_summary["ledger_vendors"] = ", ".join(row["ledger_vendors"]) normalized_summary["voucher_vendors"] = ", ".join(row["voucher_vendors"]) normalized_summary["review_reason"] = " / ".join(row["review_reason"]) normalized_summary["voucher_no"] = ", ".join(row["voucher_nos"]) normalized_summary["draft_no"] = ", ".join(row["draft_nos"]) voucher_sections["erp_voucher_matched"].append( { "summary": normalized_summary, "rows": list(row["rows"]), } ) for status_key in voucher_sections: voucher_sections[status_key].sort( key=lambda item: ( int(item.get("summary", {}).get("fiscal_year") or 0), clean(item.get("summary", {}).get("proof_date")) or clean(item.get("summary", {}).get("ledger_date")), clean(item.get("summary", {}).get("voucher_no")), clean(item.get("summary", {}).get("draft_no")), ) ) return voucher_sections def _filter_voucher_status_row( row: dict[str, Any], voucher_filter: str, draft_filter: str, wehago_account_filter: str, erp_account_filter: str, wehago_amount_filter: str, erp_amount_filter: str, wehago_vendor_filter: str, erp_vendor_filter: str, desc_filter: str, ) -> bool: summary = row.get("summary", row) if voucher_filter and voucher_filter not in normalize_text(row.get("voucher_no")): if voucher_filter not in normalize_text(summary.get("voucher_no")): return False if draft_filter and draft_filter not in normalize_text(summary.get("draft_no")): return False if wehago_account_filter and wehago_account_filter not in normalize_text(summary.get("ledger_accounts")): return False if erp_account_filter and erp_account_filter not in normalize_text(summary.get("voucher_accounts")): return False if wehago_vendor_filter and wehago_vendor_filter not in normalize_text(summary.get("ledger_vendors")): return False if erp_vendor_filter and erp_vendor_filter not in normalize_text(summary.get("voucher_vendors")): return False if desc_filter: haystack = " ".join( [ clean(summary.get("review_reason")), clean(summary.get("ledger_accounts")), clean(summary.get("voucher_accounts")), clean(summary.get("ledger_vendors")), clean(summary.get("voucher_vendors")), " ".join(clean(item.get("ledger_desc")) for item in row.get("rows", [])), " ".join(clean(item.get("voucher_desc")) for item in row.get("rows", [])), ] ) if desc_filter not in normalize_text(haystack): return False if wehago_amount_filter: amount_text = " ".join( [ str(int(round(parse_amount(summary.get("ledger_debit"))))), str(int(round(parse_amount(summary.get("ledger_credit"))))), ] ) if clean(wehago_amount_filter) not in amount_text: return False if erp_amount_filter: amount_text = " ".join( [ str(int(round(parse_amount(summary.get("voucher_debit"))))), str(int(round(parse_amount(summary.get("voucher_credit"))))), ] ) if clean(erp_amount_filter) not in amount_text: return False return True def _build_voucher_status_detail_response_from_rows( rows_by_status: dict[str, list[dict[str, Any]]], status: str, voucher_filter: str, draft_filter: str, wehago_account_filter: str, erp_account_filter: str, wehago_amount_filter: str, erp_amount_filter: str, wehago_vendor_filter: str, erp_vendor_filter: str, desc_filter: str, offset: int, limit: int, ) -> dict[str, Any]: voucher_sections = _build_voucher_sections_from_rows_by_status(rows_by_status) source_rows = voucher_sections.get(status, []) filtered_rows = [ group for group in source_rows if _filter_voucher_status_row( group, voucher_filter, draft_filter, wehago_account_filter, erp_account_filter, wehago_amount_filter, erp_amount_filter, wehago_vendor_filter, erp_vendor_filter, desc_filter, ) ] next_offset = offset + min(limit, max(len(filtered_rows) - offset, 0)) shown_groups = filtered_rows[offset : offset + limit] return { "columns": DETAIL_COLUMN_MAP[status], "rows": [dict(group.get("summary", {})) for group in shown_groups], "groups": shown_groups, "total_count": len(filtered_rows), "shown_count": len(shown_groups), "offset": offset, "limit": limit, "has_more": next_offset < len(filtered_rows), "next_offset": next_offset, "notice": "", "bank_payable_case_count": 0, "boundary_excluded_count": 0, } def _fetch_voucher_status_detail_rows_from_db( conn: Any, start_year: int, end_year: int, status: str, voucher_filter: str, draft_filter: str, wehago_account_filter: str, erp_account_filter: str, wehago_amount_filter: str, erp_amount_filter: str, wehago_vendor_filter: str, erp_vendor_filter: str, desc_filter: str, offset: int, limit: int, ) -> dict[str, Any]: if status in {"voucher_matched", "erp_voucher_matched"}: status_condition = "c.status = 'matched'" elif status == "voucher_unmatched": status_condition = "c.status = 'ledger_only'" else: status_condition = "c.status = 'amount_mismatch'" account_filter = normalize_text(wehago_account_filter or erp_account_filter) vendor_filter = normalize_text(wehago_vendor_filter or erp_vendor_filter) amount_filter = clean(wehago_amount_filter or erp_amount_filter) params: dict[str, Any] = { "start_year": start_year, "end_year": end_year, "voucher_no": voucher_filter, "voucher_like": f"%{voucher_filter}%", "draft_no": draft_filter, "draft_like": f"%{draft_filter}%", "account_keyword": account_filter, "account_like": f"%{account_filter}%", "vendor_keyword": vendor_filter, "vendor_like": f"%{vendor_filter}%", "desc_keyword": desc_filter, "desc_like": f"%{desc_filter}%", "amount_keyword": amount_filter, "limit": limit, "offset": offset, } conditions = [ build_year_filter_sql("c.fiscal_year"), status_condition, "(:voucher_no = '' OR COALESCE(l.voucher_no, v.confirmed_no, v.draft_no, c.voucher_no) LIKE :voucher_like)", "(:draft_no = '' OR COALESCE(v.draft_no, '') LIKE :draft_like)", "(:account_keyword = '' OR COALESCE(c.ledger_accounts, '') LIKE :account_like OR COALESCE(c.voucher_accounts, '') LIKE :account_like)", "(:vendor_keyword = '' OR COALESCE(c.ledger_vendors, '') LIKE :vendor_like OR COALESCE(c.voucher_vendors, '') LIKE :vendor_like)", """( :desc_keyword = '' OR COALESCE(c.notes, '') LIKE :desc_like OR EXISTS ( SELECT 1 FROM wehago_ledger_rows l2 WHERE l2.fiscal_year = c.fiscal_year AND l2.compare_voucher_no = c.voucher_no AND COALESCE(l2.description, '') LIKE :desc_like ) OR EXISTS ( SELECT 1 FROM wehago_voucher_rows v2 WHERE v2.fiscal_year = c.fiscal_year AND v2.compare_voucher_no = c.voucher_no AND TRIM(COALESCE(v2.desc1, '') || ' ' || COALESCE(v2.desc2, '')) LIKE :desc_like ) )""", """( :amount_keyword = '' OR CAST(ABS(COALESCE(c.ledger_debit, 0)) AS TEXT) LIKE '%' || :amount_keyword || '%' OR CAST(ABS(COALESCE(c.ledger_credit, 0)) AS TEXT) LIKE '%' || :amount_keyword || '%' OR CAST(ABS(COALESCE(c.voucher_debit, 0)) AS TEXT) LIKE '%' || :amount_keyword || '%' OR CAST(ABS(COALESCE(c.voucher_credit, 0)) AS TEXT) LIKE '%' || :amount_keyword || '%' )""", ] voucher_rep = """ SELECT * FROM ( SELECT fiscal_year, compare_voucher_no, proof_date, confirmed_no, draft_no, ROW_NUMBER() OVER ( PARTITION BY fiscal_year, compare_voucher_no ORDER BY row_number ) AS rn FROM wehago_voucher_rows WHERE COALESCE(compare_voucher_no, '') <> '' ) WHERE rn = 1 """ ledger_rep = """ SELECT * FROM ( SELECT fiscal_year, compare_voucher_no, ledger_date, voucher_no, ROW_NUMBER() OVER ( PARTITION BY fiscal_year, compare_voucher_no ORDER BY row_number ) AS rn FROM wehago_ledger_rows WHERE COALESCE(compare_voucher_no, '') <> '' ) WHERE rn = 1 """ from_sql = f""" FROM wehago_comparison_results c LEFT JOIN ({ledger_rep}) l ON l.fiscal_year = c.fiscal_year AND l.compare_voucher_no = c.voucher_no LEFT JOIN ({voucher_rep}) v ON v.fiscal_year = c.fiscal_year AND v.compare_voucher_no = c.voucher_no WHERE {' AND '.join(conditions)} """ select_sql = """ c.fiscal_year AS fiscal_year, CASE c.status WHEN 'matched' THEN 'Matched' WHEN 'ledger_only' THEN 'Unmatched' WHEN 'voucher_only' THEN 'ERP Unmatched' WHEN 'amount_mismatch' THEN 'Recheck' ELSE COALESCE(c.status, '') END AS status_label, COALESCE(l.ledger_date, '') AS ledger_date, COALESCE(v.proof_date, '') AS proof_date, COALESCE(l.voucher_no, v.confirmed_no, v.draft_no, c.voucher_no) AS voucher_no, COALESCE(v.draft_no, '') AS draft_no, COALESCE(c.ledger_row_count, 0) AS ledger_row_count, COALESCE(c.voucher_row_count, 0) AS voucher_row_count, COALESCE(c.ledger_debit, 0) AS ledger_debit, COALESCE(c.ledger_credit, 0) AS ledger_credit, COALESCE(c.voucher_debit, 0) AS voucher_debit, COALESCE(c.voucher_credit, 0) AS voucher_credit, COALESCE(c.ledger_accounts, '') AS ledger_accounts, COALESCE(c.voucher_accounts, '') AS voucher_accounts, COALESCE(c.ledger_vendors, '') AS ledger_vendors, COALESCE(c.voucher_vendors, '') AS voucher_vendors, COALESCE(c.notes, '') AS review_reason """ total_count = int(conn.execute(text(f"SELECT COUNT(*) {from_sql}"), params).scalar_one() or 0) rows = [ {key: clean(value) if isinstance(value, str) else value for key, value in dict(row).items()} for row in conn.execute( text( f""" SELECT {select_sql} {from_sql} ORDER BY c.fiscal_year, c.voucher_no LIMIT :limit OFFSET :offset """ ), params, ).mappings().all() ] next_offset = offset + len(rows) return { "columns": DETAIL_COLUMN_MAP[status], "rows": rows, "total_count": total_count, "shown_count": len(rows), "offset": offset, "limit": limit, "has_more": next_offset < total_count, "next_offset": next_offset, "notice": "", "bank_payable_case_count": 0, "boundary_excluded_count": 0, } def _apply_broad_query_guard( rows: list[dict[str, Any]], voucher_no: str, account_code: str, vendor_name: str, review_reason: str, preview_limit: int = 500, ) -> tuple[list[dict[str, Any]], str]: if voucher_no or account_code or vendor_name or review_reason: return rows, "" if len(rows) <= preview_limit: return rows, "" return rows[:preview_limit], f"조건 없이 조회된 항목이 많아 상위 {preview_limit:,}건만 먼저 표시합니다. 전표번호, 계정코드, 거래처, 검증근거로 좁혀서 전체 항목에 접근할 수 있습니다." def get_status_detail_rows( engine: Any, start_year: int | None, end_year: int | None, status: str, voucher_no: str = "", draft_no: str = "", wehago_account: str = "", erp_account: str = "", wehago_amount: str = "", erp_amount: str = "", wehago_vendor: str = "", erp_vendor: str = "", desc_keyword: str = "", review_reason: str = "", boundary_excluded: str = "", offset: int = 0, limit: int = 200, ) -> dict[str, Any]: if engine is not None: init_wehago_compare_db(engine) if start_year is None or end_year is None: return { "columns": DETAIL_COLUMN_MAP.get(status, []), "rows": [], "total_count": 0, "shown_count": 0, "offset": 0, "limit": limit, "has_more": False, "next_offset": 0, } normalized_status = normalize_text(status).lower() allowed = { "matched": "matched", "ledgeronly": "ledger_only", "amountmismatch": "amount_mismatch", "voucheronly": "voucher_only", "vouchermatched": "voucher_matched", "erpvouchermatched": "erp_voucher_matched", "voucherunmatched": "voucher_unmatched", "voucherrecheck": "voucher_recheck", } if normalized_status not in {"matched", "ledger_only", "amount_mismatch", "voucher_only", "voucher_matched", "erp_voucher_matched", "voucher_unmatched", "voucher_recheck"}: normalized_status = allowed.get(normalized_status, normalized_status) if normalized_status not in {"matched", "ledger_only", "amount_mismatch", "voucher_only", "voucher_matched", "erp_voucher_matched", "voucher_unmatched", "voucher_recheck"}: raise ValueError("상태 값이 올바르지 않습니다.") voucher_filter = normalize_text(voucher_no) draft_filter = normalize_text(draft_no) wehago_account_filter = normalize_text(wehago_account) erp_account_filter = normalize_text(erp_account) wehago_vendor_filter = normalize_text(wehago_vendor) erp_vendor_filter = normalize_text(erp_vendor) desc_filter = normalize_text(desc_keyword or review_reason) boundary_excluded_filter = clean(boundary_excluded).lower() in {"1", "true", "yes", "y", "on"} safe_offset = max(int(offset or 0), 0) safe_limit = max(min(int(limit or 200), 500), 1) with engine.begin() as conn: can_use_resolved_cache = True for year in range(start_year, end_year + 1): cached_sections = _load_year_resolved_sections_cache(conn, year) if cached_sections is None: can_use_resolved_cache = False break if normalized_status in {"voucher_matched", "erp_voucher_matched", "voucher_unmatched", "voucher_recheck"}: rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year) voucher_sections = _get_cached_voucher_sections_by_range(engine, start_year, end_year, rows_by_status) source_rows = voucher_sections.get(normalized_status, []) filtered_rows = [ group for group in source_rows if _filter_voucher_status_row( group, voucher_filter, draft_filter, wehago_account_filter, erp_account_filter, clean(wehago_amount), clean(erp_amount), wehago_vendor_filter, erp_vendor_filter, desc_filter, ) ] next_offset = safe_offset + min(safe_limit, max(len(filtered_rows) - safe_offset, 0)) shown_groups = filtered_rows[safe_offset : safe_offset + safe_limit] return { "columns": DETAIL_COLUMN_MAP[normalized_status], "rows": [dict(group.get("summary", {})) for group in shown_groups], "groups": shown_groups, "total_count": len(filtered_rows), "shown_count": len(shown_groups), "offset": safe_offset, "limit": safe_limit, "has_more": next_offset < len(filtered_rows), "next_offset": next_offset, "notice": "", "bank_payable_case_count": 0, "boundary_excluded_count": 0, } if can_use_resolved_cache: rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year) return _build_status_detail_response_from_rows( rows_by_status, normalized_status, voucher_filter, draft_filter, wehago_account_filter, erp_account_filter, clean(wehago_amount), clean(erp_amount), wehago_vendor_filter, erp_vendor_filter, desc_filter, boundary_excluded_filter, safe_offset, safe_limit, ) warm_status_cache_async(engine, start_year, end_year) warm_metric_counts_async(engine, start_year, end_year) return _fetch_status_detail_rows_from_db( conn, start_year, end_year, normalized_status, voucher_filter, draft_filter, wehago_account_filter, erp_account_filter, clean(wehago_amount), clean(erp_amount), wehago_vendor_filter, erp_vendor_filter, desc_filter, boundary_excluded_filter, safe_offset, safe_limit, ) def get_status_field_suggestions( engine: Any, start_year: int | None, end_year: int | None, status: str, field: str = "voucher_no", keyword: str = "", offset: int = 0, limit: int = 10, ) -> dict[str, Any]: if start_year is None or end_year is None: return {"rows": [], "total_count": 0, "shown_count": 0, "offset": 0, "limit": limit, "has_more": False, "next_offset": 0} normalized_status = normalize_text(status).lower() status_map = { "matched": "matched", "amountmismatch": "amount_mismatch", "ledgeronly": "ledger_only", "voucheronly": "voucher_only", "vouchermatched": "voucher_matched", "erpvouchermatched": "erp_voucher_matched", "voucherunmatched": "voucher_unmatched", "voucherrecheck": "voucher_recheck", "ledger_only": "ledger_only", "voucher_only": "voucher_only", "amount_mismatch": "amount_mismatch", "voucher_matched": "voucher_matched", "erp_voucher_matched": "erp_voucher_matched", "voucher_unmatched": "voucher_unmatched", "voucher_recheck": "voucher_recheck", } normalized_status = status_map.get(normalized_status, normalized_status) if normalized_status not in {"matched", "amount_mismatch", "ledger_only", "voucher_only", "voucher_matched", "erp_voucher_matched", "voucher_unmatched", "voucher_recheck"}: raise ValueError("자동완성 상태 값이 올바르지 않습니다.") normalized_field = normalize_text(field).lower() if normalized_field not in { "voucherno", "draftno", "account", "vendor", "wehagoaccount", "erpaccount", "wehagovendor", "erpvendor", }: raise ValueError("자동완성 필드 값이 올바르지 않습니다.") cache_key = "|".join( [ normalized_status, normalized_field, str(start_year), str(end_year), _current_logic_signature(), _build_bundle_signature(start_year, end_year), normalize_text(keyword), ] ) now = time.time() cached = _SUGGEST_CACHE.get(cache_key) if cached and (now - float(cached.get("ts", 0))) <= _SUGGEST_CACHE_TTL_SEC: all_rows = cached["rows"] elif normalized_status in {"voucher_matched", "erp_voucher_matched", "voucher_unmatched", "voucher_recheck"}: with engine.begin() as conn: can_use_resolved_cache = True for year in range(start_year, end_year + 1): if _load_year_resolved_sections_cache(conn, year) is None: can_use_resolved_cache = False break if can_use_resolved_cache: source_rows = _build_voucher_sections_from_rows_by_status( _get_cached_status_rows_by_range(engine, start_year, end_year) ).get(normalized_status, []) else: source_rows = _fetch_voucher_status_detail_rows_from_db( conn, start_year, end_year, normalized_status, "", "", "", "", "", "", "", "", "", 0, 100000, ).get("rows", []) keyword_norm = normalize_text(keyword) seen: set[str] = set() all_rows = [] for group in source_rows: row = group.get("summary", group) voucher_no = clean(row.get("voucher_no")) draft_no = clean(row.get("draft_no")) if normalized_field == "account": labels = [clean(row.get("ledger_accounts")), clean(row.get("voucher_accounts"))] elif normalized_field == "vendor": labels = [clean(row.get("ledger_vendors")), clean(row.get("voucher_vendors"))] elif normalized_field == "draftno": labels = [draft_no] else: labels = [voucher_no] for label in labels: key = clean(label) if not key or key in seen: continue if keyword_norm and keyword_norm not in normalize_text(key): continue seen.add(key) all_rows.append( { "voucher_no": voucher_no, "code": "", "name": "", "label": key, } ) _SUGGEST_CACHE.clear() _PAIR_RECOMMEND_CACHE.clear() _SUGGEST_CACHE[cache_key] = {"ts": now, "rows": all_rows} else: rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year) keyword_norm = normalize_text(keyword) seen: set[str] = set() all_rows: list[dict[str, Any]] = [] for row in rows_by_status.get(normalized_status, []): code = clean(row.get("voucher_account_code")) name = clean(row.get("voucher_account_name")) voucher_no = clean(row.get("voucher_no")) if normalized_field == "account": if normalized_status == "voucher_only": code = clean(row.get("voucher_account_code")) name = clean(row.get("voucher_account_name")) else: code = clean(row.get("ledger_account_code")) name = clean(row.get("ledger_account_name")) key = "|".join([code, name]) if not code and not name: continue label = " ".join(part for part in [code, name] if part) search_text = normalize_text(label) elif normalized_field == "wehagoaccount": code = clean(row.get("ledger_account_code")) name = clean(row.get("ledger_account_name")) key = "|".join([code, name]) if not code and not name: continue label = " ".join(part for part in [code, name] if part) search_text = normalize_text(label) elif normalized_field == "erpaccount": code = clean(row.get("voucher_account_code")) name = clean(row.get("voucher_account_name")) key = "|".join([code, name]) if not code and not name: continue label = " ".join(part for part in [code, name] if part) search_text = normalize_text(label) elif normalized_field == "vendor": vendor_name = clean(row.get("voucher_vendor")) if normalized_status == "voucher_only" else clean(row.get("ledger_vendor")) key = vendor_name if not vendor_name: continue label = vendor_name search_text = normalize_text(vendor_name) elif normalized_field == "wehagovendor": vendor_name = clean(row.get("ledger_vendor")) key = vendor_name if not vendor_name: continue label = vendor_name search_text = normalize_text(vendor_name) elif normalized_field == "erpvendor": vendor_name = clean(row.get("voucher_vendor")) key = vendor_name if not vendor_name: continue label = vendor_name search_text = normalize_text(vendor_name) elif normalized_field == "draftno": draft_no = clean(row.get("draft_no")) key = draft_no if not draft_no: continue label = draft_no search_text = normalize_text(draft_no) else: key = voucher_no if not voucher_no: continue label = voucher_no search_text = normalize_text(voucher_no) if key in seen: continue if keyword_norm and keyword_norm not in search_text: continue seen.add(key) all_rows.append( { "voucher_no": voucher_no, "code": code, "name": name, "label": label, } ) _SUGGEST_CACHE.clear() _PAIR_RECOMMEND_CACHE.clear() _SUGGEST_CACHE[cache_key] = {"ts": now, "rows": all_rows} safe_offset = max(int(offset or 0), 0) safe_limit = max(min(int(limit or 10), 50), 1) rows = all_rows[safe_offset : safe_offset + safe_limit] next_offset = safe_offset + len(rows) return { "rows": rows, "total_count": len(all_rows), "shown_count": len(rows), "offset": safe_offset, "limit": safe_limit, "has_more": next_offset < len(all_rows), "next_offset": next_offset, } def _fetch_filtered_table_rows( conn: Any, table_name: str, columns: list[tuple[str, str]], start_year: int | None, end_year: int | None, voucher_no: str = "", account_code: str = "", vendor_name: str = "", review_reason: str = "", ) -> dict[str, Any]: del review_reason voucher_filter = f"%{clean(voucher_no)}%" account_filter = f"%{clean(account_code)}%" vendor_filter = f"%{clean(vendor_name)}%" if table_name == "wehago_ledger_rows": filters = [ build_year_filter_sql(), "(:voucher_no = '' OR COALESCE(voucher_no, '') LIKE :voucher_like)", "(:account_code = '' OR COALESCE(account_code, '') LIKE :account_like OR COALESCE(account_name, '') LIKE :account_like)", "(:vendor_name = '' OR COALESCE(vendor_name, '') LIKE :vendor_like OR COALESCE(description, '') LIKE :vendor_like)", ] order_sql = "ORDER BY COALESCE(ledger_date, '') DESC, voucher_no DESC, row_number DESC" else: filters = [ build_year_filter_sql(), "(:voucher_no = '' OR COALESCE(confirmed_no, '') LIKE :voucher_like OR COALESCE(draft_no, '') LIKE :voucher_like)", "(:account_code = '' OR COALESCE(account_code, '') LIKE :account_like OR COALESCE(account_name, '') LIKE :account_like)", "(:vendor_name = '' OR COALESCE(vendor_name, '') LIKE :vendor_like OR COALESCE(desc1, '') LIKE :vendor_like OR COALESCE(desc2, '') LIKE :vendor_like)", ] order_sql = "ORDER BY COALESCE(proof_date, '') DESC, confirmed_no DESC, draft_no DESC, row_number DESC" params = { "start_year": start_year, "end_year": end_year, "voucher_no": clean(voucher_no), "voucher_like": voucher_filter, "account_code": clean(account_code), "account_like": account_filter, "vendor_name": clean(vendor_name), "vendor_like": vendor_filter, } selected_columns = ", ".join(name for name, _ in columns) query = text( f""" SELECT {selected_columns} FROM {table_name} WHERE {' AND '.join(filters)} {order_sql} """ ) rows = [dict(row._mapping) for row in conn.execute(query, params).fetchall()] shown_rows, notice = _apply_broad_query_guard( rows, clean(voucher_no), clean(account_code), clean(vendor_name), "", ) return { "columns": columns, "rows": shown_rows, "total_count": len(rows), "shown_count": len(shown_rows), "notice": notice, } def get_wehago_filtered_rows( engine: Any, start_year: int | None, end_year: int | None, voucher_no: str = "", account_code: str = "", vendor_name: str = "", ) -> dict[str, Any]: init_wehago_compare_db(engine) with engine.begin() as conn: return _fetch_filtered_table_rows( conn, "wehago_ledger_rows", WEHAGO_COLUMNS, start_year, end_year, voucher_no=voucher_no, account_code=account_code, vendor_name=vendor_name, ) def get_erp_filtered_rows( engine: Any, start_year: int | None, end_year: int | None, voucher_no: str = "", account_code: str = "", vendor_name: str = "", ) -> dict[str, Any]: init_wehago_compare_db(engine) with engine.begin() as conn: return _fetch_filtered_table_rows( conn, "wehago_voucher_rows", ERP_COLUMNS, start_year, end_year, voucher_no=voucher_no, account_code=account_code, vendor_name=vendor_name, ) def _warm_status_cache_worker(engine: Any, start_year: int, end_year: int, warm_key: str) -> None: try: rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year) _get_cached_voucher_sections_by_range(engine, start_year, end_year, rows_by_status) except Exception: pass finally: with _STATUS_CACHE_WARMING_LOCK: _STATUS_CACHE_WARMING.discard(warm_key) def warm_status_cache_async(engine: Any, start_year: int | None, end_year: int | None) -> None: if start_year is None or end_year is None: return if start_year > end_year: start_year, end_year = end_year, start_year warm_key = f"{start_year}:{end_year}:{_build_bundle_signature(start_year, end_year)}" with _STATUS_CACHE_WARMING_LOCK: if warm_key in _STATUS_CACHE_WARMING: return _STATUS_CACHE_WARMING.add(warm_key) worker = threading.Thread( target=_warm_status_cache_worker, args=(engine, start_year, end_year, warm_key), daemon=True, ) worker.start() def get_wehago_compare_dashboard( engine: Any, start_year: int | None = None, end_year: int | None = None, *, include_metric_counts: bool = True, warm_caches: bool = True, ) -> dict[str, Any]: init_wehago_compare_db(engine) years = discover_available_years() default_year = get_default_year(years) if start_year is None and end_year is None: start_year = default_year end_year = default_year elif start_year is None: start_year = end_year elif end_year is None: end_year = start_year valid_years = set(years) if start_year not in valid_years: start_year = default_year if end_year not in valid_years: end_year = start_year or default_year if start_year and end_year and start_year > end_year: start_year, end_year = end_year, start_year with engine.begin() as conn: metric_counts = ( get_dashboard_metric_counts(conn, start_year, end_year) if include_metric_counts else {status_key: 0 for status_key, _, _ in STATUS_META} ) account_options = build_account_options_from_db(conn, start_year, end_year) latest_upload = fetch_latest_upload_meta(conn) last_action = get_last_action_summary(conn=conn) metric_sections = [ { "key": status_key, "label": label, "description": description, "count": int(metric_counts.get(status_key, 0) or 0), "columns": DETAIL_COLUMN_MAP[status_key], "rows": [], } for status_key, label, description in STATUS_META ] if warm_caches: warm_metric_counts_async(engine, start_year, end_year) if start_year == end_year: warm_status_cache_async(engine, start_year, end_year) return { "page_title": "전표비교", "source_root": str(WEHAGO_SOURCE_ROOT), "db_file": "data.db", "selected_start_year": start_year, "selected_end_year": end_year, "available_years": years, "metric_sections": metric_sections, "wehago_account_options": account_options["wehago"], "erp_account_options": account_options["erp"], "latest_upload": latest_upload, "last_action": last_action, } def get_wehago_compare_summary( engine: Any, start_year: int | None = None, end_year: int | None = None, ) -> dict[str, Any]: dashboard = get_wehago_compare_dashboard( engine, start_year=start_year, end_year=end_year, include_metric_counts=False, warm_caches=False, ) metric_counts, pending = get_dashboard_metric_counts_nonblocking( engine, dashboard.get("selected_start_year"), dashboard.get("selected_end_year"), ) metric_sections = [ { **section, "count": int(metric_counts.get(section.get("key"), 0) or 0), } for section in dashboard.get("metric_sections", []) ] return { "selected_start_year": dashboard.get("selected_start_year"), "selected_end_year": dashboard.get("selected_end_year"), "metric_sections": metric_sections, "last_action": dashboard.get("last_action"), "pending": pending, } def enqueue_default_pair_recommend_precompute( engine: Any, start_year: int | None = None, end_year: int | None = None, limit: int = 300, ) -> None: years = discover_available_years() default_year = get_default_year(years) resolved_start = start_year resolved_end = end_year if resolved_start is None and resolved_end is None: resolved_start = default_year resolved_end = default_year elif resolved_start is None: resolved_start = resolved_end elif resolved_end is None: resolved_end = resolved_start if resolved_start is None or resolved_end is None: return enqueue_pair_recommend_precompute( engine, start_year=resolved_start, end_year=resolved_end, limit=limit, )