from __future__ import annotations import copy import hashlib from io import BytesIO 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 Workbook, load_workbook from sqlalchemy import text from sqlalchemy.exc import OperationalError WEHAGO_SOURCE_ROOT = Path(Path(__file__).resolve().parent.parent / "WEHAGO_DB") WEHAGO_COMPARE_EXPORT_ROOT = Path(__file__).resolve().parent / "static" / "exports" / "wehago_compare" 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 전표 기준으로 매칭된 결과"), ("bridge_expense_review", "2단계 비교", "보통예금 출금을 미지급금 결제와 원 비용 전표까지 추적한 검토 후보"), ("voucher_unmatched", "WEHAGO Unmatched", "WEHAGO 전표 기준으로 매칭되지 않은 결과"), ("erp_voucher_unmatched", "Voucher Unmatched", "Hanmac ERP 전표 기준으로 매칭되지 않은 결과"), ("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 적요"), ], "bridge_expense_review": [ ("fiscal_year", "연도"), ("ledger_date", "WEHAGO 일자"), ("voucher_no", "WEHAGO 전표"), ("ledger_account_name", "WEHAGO 계정"), ("ledger_credit", "WEHAGO 출금"), ("ledger_desc", "WEHAGO 적요"), ("payment_date", "ERP 지급일"), ("draft_no", "ERP 지급전표"), ("voucher_account_name", "ERP 지급계정"), ("voucher_debit", "ERP 미지급금 차변"), ("voucher_credit", "ERP 보통예금 대변"), ("inferred_expense_accounts", "추정 원 비용계정"), ("expense_source_vouchers", "원 비용전표"), ("expense_source_drafts", "원 비용 가전표"), ("voucher_desc", "ERP 지급 적요"), ("expense_source_desc", "원 비용 적요"), ("bridge_reason", "추적 근거"), ], "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_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", "검토사유"), ], "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 _VOUCHER_RECHECK_CACHE: dict[str, dict[str, Any]] = {} _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() _COMPARE_SNAPSHOT_JOB_EVENT = threading.Event() _COMPARE_SNAPSHOT_WORKER_LOCK = threading.Lock() _COMPARE_SNAPSHOT_WORKER_STARTED = False _COMPARE_BACKGROUND_JOBS_NORMALIZED = False _COMPARE_BACKGROUND_JOBS_NORMALIZE_LOCK = threading.Lock() _COMPARE_BACKGROUND_JOBS_NORMALIZED_AT = 0.0 _COMPARE_BACKGROUND_JOBS_NORMALIZE_TTL_SEC = 60.0 _WEHAGO_COMPARE_DB_READY = False _WEHAGO_COMPARE_DB_LOCK = threading.Lock() _STATUS_EXPORT_CACHE: dict[str, dict[str, Any]] = {} _STATUS_EXPORT_CACHE_TTL_SEC = 300 _SNAPSHOT_STATUS_CACHE: dict[str, dict[str, Any]] = {} _SNAPSHOT_STATUS_CACHE_TTL_SEC = 45.0 _COMPARE_SUMMARY_CACHE: dict[str, dict[str, Any]] = {} _COMPARE_SUMMARY_CACHE_TTL_SEC = 20.0 COMPARE_BACKFILL_HINT_START_YEAR = 2016 COMPARE_ROLLING_UPDATE_START_YEAR = 2026 COMPARE_MONTHLY_EXPECTED_READY_DAY = 10 COMPARE_YEAR_REQUEUE_COOLDOWN_RECENT_SEC = 15 * 60 COMPARE_YEAR_REQUEUE_COOLDOWN_ROLLING_SEC = 60 * 60 COMPARE_YEAR_REQUEUE_COOLDOWN_BACKFILL_SEC = 6 * 60 * 60 COMPARE_JOB_STALE_RUNNING_SEC = 45 * 60 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 BRIDGE_REVIEW_SETTINGS_VERSION = "bridge-review-settings-v1" DEFAULT_BRIDGE_REVIEW_SETTINGS: dict[str, Any] = { "enable_promotion": False, "require_exact_amount": True, "require_expense_nature": True, "require_strong_text": True, "ambiguous_handling": "recheck", "preview_limit": 100, } VOUCHER_EXPORT_STATUS_KEYS = {"voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched"} VOUCHER_EXPORT_LINE_COLUMNS: list[tuple[str, str]] = [ ("fiscal_year", "연도"), ("ledger_date", "WEHAGO 일자"), ("voucher_no", "전표번호"), ("draft_no", "가전표번호"), ("ledger_account_name", "WEHAGO 계정"), ("voucher_account_name", "ERP 계정"), ("ledger_vendor", "WEHAGO 거래처"), ("voucher_vendor", "ERP 거래처"), ("ledger_debit", "WEHAGO 차변"), ("ledger_credit", "WEHAGO 대변"), ("voucher_debit", "ERP 차변"), ("voucher_credit", "ERP 대변"), ("ledger_desc", "WEHAGO 적요"), ("voucher_desc", "ERP 적요"), ] EXPORT_ROW_CACHE_STATUS_KEYS = {"voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched", "voucher_recheck"} @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 _normalize_bridge_review_settings(payload: Any) -> dict[str, Any]: raw = payload if isinstance(payload, dict) else {} ambiguous = clean(raw.get("ambiguous_handling")).lower() if ambiguous not in {"recheck", "review_only"}: ambiguous = clean(DEFAULT_BRIDGE_REVIEW_SETTINGS["ambiguous_handling"]) preview_limit = raw.get("preview_limit", DEFAULT_BRIDGE_REVIEW_SETTINGS["preview_limit"]) try: preview_limit = int(preview_limit) except (TypeError, ValueError): preview_limit = int(DEFAULT_BRIDGE_REVIEW_SETTINGS["preview_limit"]) preview_limit = max(20, min(500, preview_limit)) return { "enable_promotion": bool(raw.get("enable_promotion", DEFAULT_BRIDGE_REVIEW_SETTINGS["enable_promotion"])), "require_exact_amount": bool(raw.get("require_exact_amount", DEFAULT_BRIDGE_REVIEW_SETTINGS["require_exact_amount"])), "require_expense_nature": bool(raw.get("require_expense_nature", DEFAULT_BRIDGE_REVIEW_SETTINGS["require_expense_nature"])), "require_strong_text": bool(raw.get("require_strong_text", DEFAULT_BRIDGE_REVIEW_SETTINGS["require_strong_text"])), "ambiguous_handling": ambiguous, "preview_limit": preview_limit, } def _bridge_review_settings_signature(settings: dict[str, Any]) -> str: normalized = _normalize_bridge_review_settings(settings) return hashlib.sha1(json.dumps(normalized, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest() def _today_local_date() -> date: return datetime.now().date() def _is_after_monthly_expected_ready_day(today: date | None = None) -> bool: resolved_today = today or _today_local_date() return resolved_today.day >= COMPARE_MONTHLY_EXPECTED_READY_DAY def _classify_compare_year_mode(year: int, today: date | None = None) -> str: resolved_today = today or _today_local_date() if year >= COMPARE_ROLLING_UPDATE_START_YEAR: if year > resolved_today.year + 1: return "future" return "rolling" if year >= COMPARE_BACKFILL_HINT_START_YEAR: return "backfill" return "legacy" def _compare_year_requeue_cooldown_seconds(year: int, today: date | None = None) -> int: resolved_today = today or _today_local_date() mode = _classify_compare_year_mode(year, resolved_today) if mode == "rolling": if year == resolved_today.year and _is_after_monthly_expected_ready_day(resolved_today): return COMPARE_YEAR_REQUEUE_COOLDOWN_RECENT_SEC if year >= resolved_today.year - 1: return COMPARE_YEAR_REQUEUE_COOLDOWN_ROLLING_SEC return COMPARE_YEAR_REQUEUE_COOLDOWN_ROLLING_SEC if mode == "backfill": return COMPARE_YEAR_REQUEUE_COOLDOWN_BACKFILL_SEC return COMPARE_YEAR_REQUEUE_COOLDOWN_ROLLING_SEC def _compare_year_job_priority(year: int, today: date | None = None) -> int: resolved_today = today or _today_local_date() mode = _classify_compare_year_mode(year, resolved_today) if mode == "rolling": if year == resolved_today.year: return 10 if _is_after_monthly_expected_ready_day(resolved_today) else 14 if year == resolved_today.year - 1: return 12 if _is_after_monthly_expected_ready_day(resolved_today) else 16 if year == resolved_today.year + 1: return 20 return 30 + abs(resolved_today.year - year) if mode == "backfill": return 90 + max(0, resolved_today.year - year) return 140 + max(0, COMPARE_BACKFILL_HINT_START_YEAR - year) def _compare_range_job_priority(start_year: int | None, end_year: int | None, today: date | None = None) -> int: resolved_today = today or _today_local_date() if start_year is None or end_year is None: return 80 span = max(0, int(end_year) - int(start_year)) if start_year <= resolved_today.year <= end_year and end_year >= COMPARE_ROLLING_UPDATE_START_YEAR: return 25 + min(span, 20) if end_year >= COMPARE_ROLLING_UPDATE_START_YEAR: return 35 + min(span, 20) return 70 + min(span, 50) def _parse_db_timestamp(value: Any) -> datetime | None: text_value = clean(value) if not text_value: return None for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"): try: return datetime.strptime(text_value, fmt) except ValueError: continue return None 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 _apply_cached_adjacent_year_demotion_count_adjustment( conn: Any, counts: dict[str, int], start_year: int | None, end_year: int | None, ) -> dict[str, int]: if start_year is None or end_year is None: return counts adjusted = {status_key: int(counts.get(status_key, 0) or 0) for status_key, _, _ in STATUS_META} for year in range(int(start_year), int(end_year) + 1): sections = _load_latest_year_resolved_sections_cache_any_signature(conn, year) or {} matched_rows = list((sections.get("matched") or {}).get("rows") or []) ledger_only_rows = list((sections.get("ledger_only") or {}).get("rows") or []) if not matched_rows or not ledger_only_rows: continue residual_by_group: dict[tuple[int, str, str], list[dict[str, Any]]] = {} for row in ledger_only_rows: key = (int(row.get("fiscal_year") or 0), clean(row.get("voucher_no")), clean(row.get("ledger_date"))) residual_by_group.setdefault(key, []).append(row) demote_groups: set[tuple[int, str, str]] = set() demote_line_count = 0 important_families = {"vat_input", "vat_output", "payable", "receivable"} for row in matched_rows: if clean(row.get("matched_case")) not in {"previous_year_erp", "next_year_erp"}: continue key = (int(row.get("fiscal_year") or 0), clean(row.get("voucher_no")), clean(row.get("ledger_date"))) residual_rows = residual_by_group.get(key, []) if not residual_rows: continue residual_families = { _classify_account_family(item.get("ledger_account_code"), item.get("ledger_account_name")) for item in residual_rows } residual_families.discard("") if residual_families & important_families: demote_groups.add(key) demote_line_count += 1 if not demote_groups: continue adjusted["matched"] = max(0, adjusted.get("matched", 0) - demote_line_count) adjusted["amount_mismatch"] = adjusted.get("amount_mismatch", 0) + demote_line_count adjusted["voucher_matched"] = max(0, adjusted.get("voucher_matched", 0) - len(demote_groups)) adjusted["voucher_recheck"] = adjusted.get("voucher_recheck", 0) + len(demote_groups) return adjusted def _empty_status_rows_by_status() -> dict[str, list[dict[str, Any]]]: return { "matched": [], "bridge_expense_review": [], "ledger_only": [], "amount_mismatch": [], "voucher_only": [], } def _count_bridge_expense_review_candidates(conn: Any, start_year: int | None, end_year: int | None) -> int: if start_year is None or end_year is None: return 0 return int( conn.execute( text(f"SELECT COUNT(*) FROM ({_bridge_expense_candidates_sql()}) b"), {"start_year": start_year, "end_year": end_year}, ).scalar_one() or 0 ) 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 _load_summary_range_cache( conn: Any, start_year: int | None, end_year: int | None, ) -> dict[str, Any] | None: if start_year is None or end_year is None: return None signature = _metric_counts_signature(conn, start_year, end_year) cached = conn.execute( text( """ SELECT payload_json FROM wehago_summary_range_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])) except Exception: return None return payload if isinstance(payload, dict) else None def _load_latest_summary_range_cache_any_signature( conn: Any, start_year: int | None, end_year: int | None, ) -> dict[str, Any] | None: if start_year is None or end_year is None: return None cached = conn.execute( text( """ SELECT payload_json FROM wehago_summary_range_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])) except Exception: return None return payload if isinstance(payload, dict) else None def _store_summary_range_cache( conn: Any, start_year: int | None, end_year: int | None, counts: dict[str, int], snapshot_state: dict[str, list[int]], ) -> None: if start_year is None or end_year is None: return signature = _metric_counts_signature(conn, start_year, end_year) payload = { "counts": {status_key: int(counts.get(status_key, 0) or 0) for status_key, _, _ in STATUS_META}, "snapshot_state": { "ready": [int(year) for year in snapshot_state.get("ready", [])], "stale": [int(year) for year in snapshot_state.get("stale", [])], "missing": [int(year) for year in snapshot_state.get("missing", [])], }, } conn.execute( text( """ INSERT INTO wehago_summary_range_cache ( start_year, end_year, signature, payload_json, created_at, updated_at ) VALUES ( :start_year, :end_year, :signature, :payload_json, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) ON CONFLICT(start_year, end_year, signature) DO UPDATE SET payload_json = excluded.payload_json, updated_at = CURRENT_TIMESTAMP """ ), { "start_year": start_year, "end_year": end_year, "signature": signature, "payload_json": json.dumps(payload, 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 ) erp_unmatched_count = int( conn.execute( text( """ SELECT COUNT(*) FROM wehago_comparison_results WHERE status = 'voucher_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, "erp_voucher_unmatched": erp_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", [])), } ) voucher_sections = _apply_bridge_expense_promotions_to_voucher_sections(conn, start_year, end_year, voucher_sections) 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["erp_voucher_unmatched"] = len(voucher_sections.get("erp_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["erp_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) voucher_sections = _apply_bridge_expense_promotions_to_voucher_sections(conn, start_year, end_year, voucher_sections) 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["erp_voucher_unmatched"] = len(voucher_sections.get("erp_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["erp_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) counts["bridge_expense_review"] = _count_bridge_expense_review_candidates(conn, start_year, end_year) _store_metric_counts_cache(conn, start_year, end_year, counts) return counts def _merge_bridge_expense_count( conn: Any, counts: dict[str, int], start_year: int | None, end_year: int | None, ) -> dict[str, int]: merged = {status_key: int(counts.get(status_key, 0) or 0) for status_key, _, _ in STATUS_META} if "bridge_expense_review" not in counts: merged["bridge_expense_review"] = _count_bridge_expense_review_candidates(conn, start_year, end_year) return merged def _rebuild_metric_count_caches( conn: Any, start_year: int | None, end_year: int | None, ) -> tuple[dict[str, int], dict[str, list[int]]]: counts = _resolved_metric_counts(conn, start_year, end_year) counts["bridge_expense_review"] = _count_bridge_expense_review_candidates(conn, start_year, end_year) counts = _apply_cached_adjacent_year_demotion_count_adjustment(conn, counts, start_year, end_year) snapshot_state = _get_compare_snapshot_state(conn, start_year, end_year) _store_metric_counts_cache(conn, start_year, end_year, counts) _store_summary_range_cache(conn, start_year, end_year, counts, snapshot_state) return counts, snapshot_state def _restore_metric_counts_from_cached_year_payloads( 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 None counts = _empty_metric_counts() rows_by_status: dict[str, list[dict[str, Any]]] = { "matched": [], "ledger_only": [], "amount_mismatch": [], "voucher_only": [], } for year in range(int(start_year), int(end_year) + 1): signature = _build_db_state_signature(conn, year, year) sections = _load_year_resolved_sections_cache(conn, year, signature) if sections is None: sections = _load_latest_year_resolved_sections_cache_any_signature(conn, year) if sections is None: return None for status_key in rows_by_status: rows = list((sections.get(status_key) or {}).get("rows", [])) counts[status_key] += len(rows) status_row = _load_snapshot_status_map(conn, [year]).get(year) or {} snapshot_signature = clean(status_row.get("snapshot_signature")) or signature grouped = conn.execute( text( """ SELECT status_key, COUNT(DISTINCT group_sort) FROM wehago_compare_export_row_cache WHERE fiscal_year = :fiscal_year AND snapshot_signature = :snapshot_signature AND status_key IN ('voucher_matched', 'erp_voucher_matched', 'voucher_unmatched', 'erp_voucher_unmatched', 'voucher_recheck') GROUP BY status_key """ ), {"fiscal_year": year, "snapshot_signature": snapshot_signature}, ).fetchall() if not grouped: grouped = conn.execute( text( """ SELECT status_key, COUNT(DISTINCT group_sort) FROM wehago_compare_export_row_cache WHERE fiscal_year = :fiscal_year AND status_key IN ('voucher_matched', 'erp_voucher_matched', 'voucher_unmatched', 'erp_voucher_unmatched', 'voucher_recheck') GROUP BY status_key """ ), {"fiscal_year": year}, ).fetchall() local_counts = { "voucher_matched": 0, "erp_voucher_matched": 0, "voucher_unmatched": 0, "erp_voucher_unmatched": 0, "voucher_recheck": 0, } for status_key, value in grouped: if status_key in local_counts: local_counts[status_key] = int(value or 0) for status_key, value in local_counts.items(): counts[status_key] += value counts["bridge_expense_review"] = _count_bridge_expense_review_candidates(conn, start_year, end_year) counts = _apply_cached_adjacent_year_demotion_count_adjustment(conn, counts, start_year, end_year) 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) pending = False years_to_enqueue: list[int] = [] with engine.begin() as conn: cached_summary = _load_summary_range_cache(conn, start_year, end_year) if cached_summary: counts = cached_summary.get("counts") if isinstance(counts, dict): return counts, False cached = _load_metric_counts_cache(conn, start_year, end_year) if cached is not None: return cached, False snapshot_state = _get_compare_snapshot_state(conn, start_year, end_year) pending = bool( (snapshot_state or {}).get("missing") or (snapshot_state or {}).get("stale") or (snapshot_state or {}).get("queued") or (snapshot_state or {}).get("running") ) fallback_summary = _load_latest_summary_range_cache_any_signature(conn, start_year, end_year) fallback_counts = None if fallback_summary and isinstance(fallback_summary.get("counts"), dict): fallback_counts = { status_key: int(fallback_summary["counts"].get(status_key, 0) or 0) for status_key, _, _ in STATUS_META } fallback = fallback_counts or _load_latest_metric_counts_cache_any_signature(conn, start_year, end_year) if fallback is None and start_year is not None and end_year is not None: counts, snapshot_state = _rebuild_metric_count_caches(conn, start_year, end_year) pending = bool( (snapshot_state or {}).get("missing") or (snapshot_state or {}).get("stale") or (snapshot_state or {}).get("queued") or (snapshot_state or {}).get("running") ) return counts, pending # If older fallback caches claim ERP voucher-unmatched is zero while raw voucher-only # rows clearly exist, refresh only the voucher-group counts from cached status rows so # the dashboard card and grouped detail table stay consistent without a heavy rebuild. if ( fallback and int(fallback.get("voucher_only", 0) or 0) > 0 and int(fallback.get("erp_voucher_unmatched", 0) or 0) == 0 and start_year is not None and end_year is not None ): 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) voucher_sections = _apply_bridge_expense_promotions_to_voucher_sections( conn, start_year, end_year, voucher_sections, ) refreshed = dict(fallback) refreshed["voucher_matched"] = len(voucher_sections.get("voucher_matched", [])) refreshed["erp_voucher_matched"] = len(voucher_sections.get("erp_voucher_matched", [])) refreshed["voucher_unmatched"] = len(voucher_sections.get("voucher_unmatched", [])) refreshed["erp_voucher_unmatched"] = len(voucher_sections.get("erp_voucher_unmatched", [])) refreshed["voucher_recheck"] = len(voucher_sections.get("voucher_recheck", [])) refreshed = _merge_bridge_expense_count(conn, refreshed, start_year, end_year) _store_metric_counts_cache(conn, start_year, end_year, refreshed) _store_summary_range_cache(conn, start_year, end_year, refreshed, snapshot_state or {"ready": [], "stale": [], "missing": []}) return refreshed, pending if fallback is None: fallback = _empty_metric_counts() fallback = _merge_bridge_expense_count(conn, fallback, start_year, end_year) years_to_enqueue = [ int(year) for year in ((snapshot_state or {}).get("missing") or []) + ((snapshot_state or {}).get("stale") or []) ] try: if years_to_enqueue: enqueue_year_snapshot_rebuild(engine, years_to_enqueue) enqueue_metric_count_rebuild(engine, start_year, end_year) except Exception: pending = True return fallback or _empty_metric_counts(), pending 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: _rebuild_metric_count_caches(conn, start_year, end_year) 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) try: enqueue_metric_count_rebuild(engine, start_year, end_year) finally: with _METRIC_COUNTS_WARMING_LOCK: _METRIC_COUNTS_WARMING.discard(warm_key) @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, ...] tax_context_months: set[int] tax_context_dates: set[str] vat_sensitive: bool 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: global _WEHAGO_COMPARE_DB_READY if _WEHAGO_COMPARE_DB_READY: return with _WEHAGO_COMPARE_DB_LOCK: if _WEHAGO_COMPARE_DB_READY: return 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 '{}', priority INTEGER NOT NULL DEFAULT 100, 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 '' ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS wehago_summary_range_cache ( start_year INTEGER NOT NULL, end_year INTEGER NOT NULL, signature TEXT NOT NULL, payload_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_snapshot_status ( fiscal_year INTEGER PRIMARY KEY, snapshot_signature TEXT NOT NULL DEFAULT '', state TEXT NOT NULL DEFAULT 'missing', source_kind TEXT NOT NULL DEFAULT 'resolved', row_counts_json TEXT NOT NULL DEFAULT '{}', error_message TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, last_requested_at TEXT NOT NULL DEFAULT '', last_built_at TEXT NOT NULL DEFAULT '' ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS wehago_compare_settings ( setting_key TEXT PRIMARY KEY, setting_json TEXT NOT NULL DEFAULT '{}', updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS wehago_compare_export_jobs ( job_key TEXT PRIMARY KEY, export_key TEXT NOT NULL, status_key TEXT NOT NULL, start_year INTEGER, end_year INTEGER, payload_json TEXT NOT NULL DEFAULT '{}', file_name TEXT NOT NULL DEFAULT '', file_path TEXT NOT NULL DEFAULT '', row_count INTEGER NOT NULL DEFAULT 0, 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 '' ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS wehago_compare_export_row_cache ( fiscal_year INTEGER NOT NULL, status_key TEXT NOT NULL, snapshot_signature TEXT NOT NULL, group_sort INTEGER NOT NULL, row_sort INTEGER NOT NULL, group_voucher_no TEXT NOT NULL DEFAULT '', group_draft_no TEXT NOT NULL DEFAULT '', group_ledger_accounts TEXT NOT NULL DEFAULT '', group_voucher_accounts TEXT NOT NULL DEFAULT '', group_ledger_vendors TEXT NOT NULL DEFAULT '', group_voucher_vendors TEXT NOT NULL DEFAULT '', group_ledger_debit REAL NOT NULL DEFAULT 0, group_ledger_credit REAL NOT NULL DEFAULT 0, group_voucher_debit REAL NOT NULL DEFAULT 0, group_voucher_credit REAL NOT NULL DEFAULT 0, ledger_date TEXT NOT NULL DEFAULT '', voucher_no TEXT NOT NULL DEFAULT '', draft_no TEXT NOT NULL DEFAULT '', ledger_account_name TEXT NOT NULL DEFAULT '', voucher_account_name TEXT NOT NULL DEFAULT '', ledger_vendor TEXT NOT NULL DEFAULT '', voucher_vendor TEXT NOT NULL DEFAULT '', ledger_debit REAL NOT NULL DEFAULT 0, ledger_credit REAL NOT NULL DEFAULT 0, voucher_debit REAL NOT NULL DEFAULT 0, voucher_credit REAL NOT NULL DEFAULT 0, ledger_desc TEXT NOT NULL DEFAULT '', voucher_desc TEXT NOT NULL DEFAULT '', PRIMARY KEY (fiscal_year, status_key, snapshot_signature, group_sort, row_sort) ) """ ) ) 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 ''") ensure_column(conn, "wehago_background_jobs", "priority", "INTEGER NOT NULL DEFAULT 100") 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, priority, created_at)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_summary_range_cache_range ON wehago_summary_range_cache(start_year, end_year, updated_at)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_snapshot_status_state_updated ON wehago_snapshot_status(state, updated_at)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_compare_export_jobs_lookup ON wehago_compare_export_jobs(export_key, state, updated_at)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_compare_export_row_cache_lookup ON wehago_compare_export_row_cache(status_key, fiscal_year, snapshot_signature, group_sort, row_sort)")) bridge_review_exists = conn.execute( text( """ SELECT 1 FROM wehago_compare_settings WHERE setting_key = 'bridge_review' LIMIT 1 """ ) ).first() if not bridge_review_exists: try: conn.execute( text( """ INSERT INTO wehago_compare_settings (setting_key, setting_json, updated_at) VALUES ('bridge_review', :setting_json, CURRENT_TIMESTAMP) ON CONFLICT(setting_key) DO NOTHING """ ), {"setting_json": json.dumps(DEFAULT_BRIDGE_REVIEW_SETTINGS, ensure_ascii=False)}, ) except OperationalError: pass _try_normalize_compare_background_jobs(conn) _WEHAGO_COMPARE_DB_READY = True 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() _VOUCHER_RECHECK_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() _VOUCHER_RECHECK_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", "erp_voucher_unmatched"}: count = int(voucher_counts.get(status_key, 0) or 0) elif status_key == "bridge_expense_review": count = _count_bridge_expense_review_candidates(conn, start_year, end_year) 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 = _repair_invalid_cross_category_matches(parsed) 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)) parsed = _repair_invalid_cross_category_matches(parsed) 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 = _repair_invalid_cross_category_matches(sections) 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)) sections = _repair_invalid_cross_category_matches(sections) 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 = _repair_invalid_cross_category_matches(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 = _repair_invalid_cross_category_matches(sections) 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): payload = _repair_invalid_cross_category_matches(payload) return _demote_incomplete_adjacent_year_matches(payload) except Exception: pass return None def _load_latest_year_resolved_sections_cache_any_signature( conn: Any, year: int, ) -> dict[str, dict[str, Any]] | None: 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 COALESCE(resolved_payload_json, '') <> '' ORDER BY COALESCE(resolved_created_at, created_at) DESC, created_at DESC LIMIT 1 """ ), {"fiscal_year": year}, ).first() if cached and cached[0]: try: payload = json.loads(str(cached[0])) if isinstance(payload, dict): payload = _repair_invalid_cross_category_matches(payload) return _demote_incomplete_adjacent_year_matches(payload) except Exception: pass return None def _status_row_counts_from_sections(sections: dict[str, dict[str, Any]] | None) -> dict[str, int]: counts = { "matched": 0, "ledger_only": 0, "amount_mismatch": 0, "voucher_only": 0, } if not sections: return counts for status_key in counts: counts[status_key] = len(sections.get(status_key, {}).get("rows", [])) return counts def _upsert_snapshot_status( conn: Any, year: int, *, signature: str, state: str, source_kind: str = "resolved", row_counts: dict[str, int] | None = None, error_message: str = "", touch_requested: bool = False, built_now: bool = False, ) -> None: row_counts_json = json.dumps(row_counts or {}, ensure_ascii=False) conn.execute( text( """ INSERT INTO wehago_snapshot_status ( fiscal_year, snapshot_signature, state, source_kind, row_counts_json, error_message, created_at, updated_at, last_requested_at, last_built_at ) VALUES ( :fiscal_year, :snapshot_signature, :state, :source_kind, :row_counts_json, :error_message, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CASE WHEN :touch_requested = 1 THEN CURRENT_TIMESTAMP ELSE '' END, CASE WHEN :built_now = 1 THEN CURRENT_TIMESTAMP ELSE '' END ) ON CONFLICT(fiscal_year) DO UPDATE SET snapshot_signature = excluded.snapshot_signature, state = excluded.state, source_kind = excluded.source_kind, row_counts_json = excluded.row_counts_json, error_message = excluded.error_message, updated_at = CURRENT_TIMESTAMP, last_requested_at = CASE WHEN :touch_requested = 1 THEN CURRENT_TIMESTAMP ELSE wehago_snapshot_status.last_requested_at END, last_built_at = CASE WHEN :built_now = 1 THEN CURRENT_TIMESTAMP ELSE wehago_snapshot_status.last_built_at END """ ), { "fiscal_year": year, "snapshot_signature": signature, "state": state, "source_kind": source_kind, "row_counts_json": row_counts_json, "error_message": clean(error_message), "touch_requested": 1 if touch_requested else 0, "built_now": 1 if built_now else 0, }, ) def _get_compare_snapshot_state( conn: Any, start_year: int | None, end_year: int | None, ) -> dict[str, list[int]]: state = {"missing": [], "stale": [], "ready": [], "queued": [], "running": [], "failed": []} if start_year is None or end_year is None: return state years = list(range(start_year, end_year + 1)) status_map = _load_snapshot_status_map(conn, years) for year in years: row = status_map.get(year) or {} persisted_state = clean(row.get("state")).lower() if persisted_state in {"queued", "running", "failed"}: state[persisted_state].append(year) continue signature = _build_db_state_signature(conn, year, year) stored_signature = clean(row.get("snapshot_signature")) if stored_signature and stored_signature == clean(signature): state["ready"].append(year) elif stored_signature: state["stale"].append(year) else: state["missing"].append(year) return state 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 _refresh_year_resolved_sections(conn: Any, year: int) -> dict[str, dict[str, Any]] | None: signature = _build_db_state_signature(conn, year, year) 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 = _repair_invalid_cross_category_matches(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 = _repair_invalid_cross_category_matches(sections) 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) rows_by_status = { "matched": list((sections or {}).get("matched", {}).get("rows", [])), "ledger_only": list((sections or {}).get("ledger_only", {}).get("rows", [])), "amount_mismatch": list((sections or {}).get("amount_mismatch", {}).get("rows", [])), "voucher_only": list((sections or {}).get("voucher_only", {}).get("rows", [])), } voucher_sections = _build_voucher_sections_with_bridge_promotions(conn, year, year, rows_by_status) _store_year_export_row_cache(conn, year, signature, voucher_sections) _upsert_snapshot_status( conn, year, signature=signature, state="ready", row_counts=_status_row_counts_from_sections(sections), built_now=True, ) return sections 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() bridge_settings_row = conn.execute( text( """ SELECT setting_json, updated_at FROM wehago_compare_settings WHERE setting_key = 'bridge_review' """ ) ).mappings().first() bridge_payload: dict[str, Any] = {} if bridge_settings_row: try: decoded = json.loads(clean((bridge_settings_row or {}).get("setting_json")) or "{}") if isinstance(decoded, dict): bridge_payload = decoded except Exception: bridge_payload = {} bridge_settings = _normalize_bridge_review_settings(bridge_payload or DEFAULT_BRIDGE_REVIEW_SETTINGS) bridge_settings_sig = _bridge_review_settings_signature(bridge_settings) 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]}|" f"b:{bridge_settings_sig}:{clean((bridge_settings_row or {}).get('updated_at'))}" ) def _build_year_snapshot_job_key(year: int, signature: str) -> str: return f"year_snapshot:{year}:{signature}" def _build_metric_rebuild_job_key(start_year: int | None, end_year: int | None, signature: str) -> str: return f"metric_counts:{start_year}:{end_year}:{signature}" def _compare_job_scope(job_type: str, payload: dict[str, Any]) -> tuple[Any, ...] | None: if job_type == "year_snapshot_rebuild": year = int(payload.get("fiscal_year") or 0) return ("year_snapshot_rebuild", year) if year > 0 else None if job_type == "metric_counts_rebuild": return ("metric_counts_rebuild", payload.get("start_year"), payload.get("end_year")) return None def _prune_duplicate_queued_compare_jobs( conn: Any, *, job_type: str, scope: tuple[Any, ...] | None, keep_job_key: str, ) -> None: if scope is None: return rows = conn.execute( text( """ SELECT job_key, payload_json FROM wehago_background_jobs WHERE job_type = :job_type AND state = 'queued' """ ), {"job_type": job_type}, ).mappings().all() delete_keys: list[str] = [] for row in rows: job_key = clean(row.get("job_key")) if not job_key or job_key == keep_job_key: continue try: payload = json.loads(clean(row.get("payload_json")) or "{}") except Exception: payload = {} if not isinstance(payload, dict): payload = {} if _compare_job_scope(job_type, payload) == scope: delete_keys.append(job_key) for job_key in delete_keys: conn.execute( text("DELETE FROM wehago_background_jobs WHERE job_key = :job_key"), {"job_key": job_key}, ) def _normalize_compare_background_jobs(conn: Any) -> None: rows = conn.execute( text( """ SELECT job_key, job_type, payload_json, state, created_at, updated_at, started_at FROM wehago_background_jobs WHERE job_type IN ('year_snapshot_rebuild', 'metric_counts_rebuild', 'status_export_xlsx') AND state IN ('queued', 'running') ORDER BY updated_at DESC, created_at DESC """ ) ).mappings().all() now = datetime.now() queued_keep: dict[tuple[Any, ...], str] = {} queued_drop: list[str] = [] stale_running: list[str] = [] for row in rows: job_key = clean(row.get("job_key")) job_type = clean(row.get("job_type")) try: payload = json.loads(clean(row.get("payload_json")) or "{}") except Exception: payload = {} if not isinstance(payload, dict): payload = {} scope = _compare_job_scope(job_type, payload) state = clean(row.get("state")).lower() if state == "running": started_at = _parse_db_timestamp(row.get("started_at")) or _parse_db_timestamp(row.get("updated_at")) or _parse_db_timestamp(row.get("created_at")) if started_at and (now - started_at).total_seconds() >= COMPARE_JOB_STALE_RUNNING_SEC: stale_running.append(job_key) continue if state != "queued" or scope is None: continue if scope in queued_keep: queued_drop.append(job_key) continue queued_keep[scope] = job_key for job_key in stale_running: conn.execute( text( """ UPDATE wehago_background_jobs SET state = 'failed', error_message = 'abandoned stale running job cleaned during startup', finished_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE job_key = :job_key """ ), {"job_key": job_key}, ) for job_key in queued_drop: conn.execute( text("DELETE FROM wehago_background_jobs WHERE job_key = :job_key"), {"job_key": job_key}, ) def _normalize_compare_background_jobs_once(conn: Any, *, force: bool = False) -> None: global _COMPARE_BACKGROUND_JOBS_NORMALIZED, _COMPARE_BACKGROUND_JOBS_NORMALIZED_AT with _COMPARE_BACKGROUND_JOBS_NORMALIZE_LOCK: now = time.time() if ( not force and _COMPARE_BACKGROUND_JOBS_NORMALIZED and (now - float(_COMPARE_BACKGROUND_JOBS_NORMALIZED_AT or 0.0)) < _COMPARE_BACKGROUND_JOBS_NORMALIZE_TTL_SEC ): return _normalize_compare_background_jobs(conn) _COMPARE_BACKGROUND_JOBS_NORMALIZED = True _COMPARE_BACKGROUND_JOBS_NORMALIZED_AT = now def _try_normalize_compare_background_jobs(conn: Any, *, force: bool = False) -> None: try: _normalize_compare_background_jobs_once(conn, force=force) except OperationalError: return except Exception: return def _ensure_compare_snapshot_worker(engine: Any) -> None: global _COMPARE_SNAPSHOT_WORKER_STARTED with _COMPARE_SNAPSHOT_WORKER_LOCK: if _COMPARE_SNAPSHOT_WORKER_STARTED: return worker = threading.Thread( target=_compare_snapshot_worker_loop, args=(engine,), daemon=True, name="wehago-compare-snapshot-worker", ) worker.start() _COMPARE_SNAPSHOT_WORKER_STARTED = True def enqueue_year_snapshot_rebuild( engine: Any, years: Iterable[int], ) -> None: years = sorted({int(year) for year in years if int(year or 0) > 0}) if not years: return init_wehago_compare_db(engine) with engine.begin() as conn: status_map = _load_snapshot_status_map(conn, years) for year in years: signature = _build_db_state_signature(conn, year, year) if not _should_enqueue_year_snapshot(conn, year, signature, status_map.get(year)): continue job_key = _build_year_snapshot_job_key(year, signature) priority = _compare_year_job_priority(year) conn.execute( text( """ INSERT INTO wehago_background_jobs ( job_key, job_type, payload_json, priority, state, error_message, created_at, updated_at ) VALUES ( :job_key, 'year_snapshot_rebuild', :payload_json, :priority, 'queued', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) ON CONFLICT(job_key) DO UPDATE SET state = CASE WHEN wehago_background_jobs.state = 'running' THEN wehago_background_jobs.state ELSE 'queued' END, priority = excluded.priority, payload_json = excluded.payload_json, error_message = '', updated_at = CURRENT_TIMESTAMP """ ), { "job_key": job_key, "priority": priority, "payload_json": json.dumps({"fiscal_year": year, "signature": signature}, ensure_ascii=False), }, ) _upsert_snapshot_status( conn, year, signature=signature, state="queued", touch_requested=True, ) _prune_duplicate_queued_compare_jobs( conn, job_type="year_snapshot_rebuild", scope=("year_snapshot_rebuild", year), keep_job_key=job_key, ) _ensure_compare_snapshot_worker(engine) _COMPARE_SNAPSHOT_JOB_EVENT.set() def enqueue_metric_count_rebuild( engine: Any, start_year: int | None, end_year: int | None, ) -> None: if start_year is None or end_year is None: return init_wehago_compare_db(engine) with engine.begin() as conn: signature = _metric_counts_signature(conn, start_year, end_year) job_key = _build_metric_rebuild_job_key(start_year, end_year, signature) # metric 집계는 연도 스냅샷이 먼저 최신화된 뒤에 도는 편이 # 카드/상세 수치 수렴이 훨씬 안정적이다. priority = max(140, _compare_range_job_priority(start_year, end_year) + 120) conn.execute( text( """ INSERT INTO wehago_background_jobs ( job_key, job_type, payload_json, priority, state, error_message, created_at, updated_at ) VALUES ( :job_key, 'metric_counts_rebuild', :payload_json, :priority, 'queued', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) ON CONFLICT(job_key) DO UPDATE SET state = CASE WHEN wehago_background_jobs.state = 'running' THEN wehago_background_jobs.state ELSE 'queued' END, priority = excluded.priority, payload_json = excluded.payload_json, error_message = '', updated_at = CURRENT_TIMESTAMP """ ), { "job_key": job_key, "priority": priority, "payload_json": json.dumps( { "start_year": start_year, "end_year": end_year, "signature": signature, }, ensure_ascii=False, ), }, ) _prune_duplicate_queued_compare_jobs( conn, job_type="metric_counts_rebuild", scope=("metric_counts_rebuild", start_year, end_year), keep_job_key=job_key, ) _ensure_compare_snapshot_worker(engine) _COMPARE_SNAPSHOT_JOB_EVENT.set() def _compare_snapshot_worker_loop(engine: Any) -> None: while True: _COMPARE_SNAPSHOT_JOB_EVENT.wait(timeout=5.0) _COMPARE_SNAPSHOT_JOB_EVENT.clear() while True: init_wehago_compare_db(engine) with engine.begin() as conn: _normalize_compare_background_jobs(conn) job = conn.execute( text( """ SELECT job_key, job_type, payload_json FROM wehago_background_jobs WHERE job_type IN ('year_snapshot_rebuild', 'metric_counts_rebuild', 'status_export_xlsx') AND state = 'queued' ORDER BY priority ASC, 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"]}, ) if clean(job.get("job_type")) == 'status_export_xlsx': conn.execute( text( """ UPDATE wehago_compare_export_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.get("payload_json") or "{}") if not isinstance(payload, dict): payload = {} with engine.begin() as conn: if job["job_type"] == "year_snapshot_rebuild": year = int(payload.get("fiscal_year") or 0) if year > 0: _refresh_year_resolved_sections(conn, year) elif job["job_type"] == "metric_counts_rebuild": start_year = payload.get("start_year") end_year = payload.get("end_year") counts = _resolved_metric_counts(conn, start_year, end_year) _store_metric_counts_cache(conn, start_year, end_year, counts) _store_summary_range_cache( conn, start_year, end_year, counts, _get_compare_snapshot_state(conn, start_year, end_year), ) elif job["job_type"] == "status_export_xlsx": WEHAGO_COMPARE_EXPORT_ROOT.mkdir(parents=True, exist_ok=True) normalized_status = _normalize_voucher_export_status_key(clean(payload.get("status"))) start_year = payload.get("start_year") end_year = payload.get("end_year") file_name, file_bytes, row_count = export_wehago_status_rows_xlsx( engine, start_year=start_year, end_year=end_year, status=normalized_status, voucher_no=clean(payload.get("voucher_no")), draft_no=clean(payload.get("draft_no")), wehago_account=clean(payload.get("wehago_account")), erp_account=clean(payload.get("erp_account")), wehago_amount=clean(payload.get("wehago_amount")), erp_amount=clean(payload.get("erp_amount")), wehago_vendor=clean(payload.get("wehago_vendor")), erp_vendor=clean(payload.get("erp_vendor")), desc_keyword=clean(payload.get("desc_keyword")), ) file_path = WEHAGO_COMPARE_EXPORT_ROOT / file_name file_path.write_bytes(file_bytes) export_job = _load_status_export_job(conn, clean(job.get("job_key"))) or {} _store_status_export_job_file( conn, clean(job.get("job_key")), file_name, file_path, row_count, ) _cleanup_old_compare_export_files(keep_paths={str(file_path)}) 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: payload = {} try: payload = json.loads(job.get("payload_json") or "{}") except Exception: payload = {} if job["job_type"] == "year_snapshot_rebuild": year = int(payload.get("fiscal_year") or 0) if year > 0: _upsert_snapshot_status( conn, year, signature=clean(payload.get("signature")), state="failed", error_message=clean(exc), ) 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 _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 _load_snapshot_status_map(conn: Any, years: Iterable[int]) -> dict[int, dict[str, Any]]: year_list = sorted({int(year) for year in years if int(year or 0) > 0}) if not year_list: return {} placeholders = ", ".join(f":year_{idx}" for idx in range(len(year_list))) params = {f"year_{idx}": year for idx, year in enumerate(year_list)} rows = conn.execute( text( f""" SELECT fiscal_year, snapshot_signature, state, row_counts_json, error_message, created_at, updated_at, last_requested_at, last_built_at FROM wehago_snapshot_status WHERE fiscal_year IN ({placeholders}) """ ), params, ).mappings().all() return {int(row["fiscal_year"]): dict(row) for row in rows} def _should_enqueue_year_snapshot( conn: Any, year: int, signature: str, status_row: dict[str, Any] | None = None, *, force: bool = False, today: date | None = None, ) -> bool: if year <= 0: return False if force: return True row = status_row or {} if clean(row.get("snapshot_signature")) == clean(signature) and clean(row.get("state")) == "ready": return False cooldown = _compare_year_requeue_cooldown_seconds(year, today) state_text = clean(row.get("state")).lower() recent_requested_at = _parse_db_timestamp(row.get("last_requested_at")) if state_text in {"queued", "running"} and recent_requested_at: if (datetime.now() - recent_requested_at).total_seconds() < cooldown: return False if recent_requested_at and (datetime.now() - recent_requested_at).total_seconds() < cooldown: return False return True def _build_compare_snapshot_policy( start_year: int | None, end_year: int | None, available_years: Iterable[int] | None = None, ) -> dict[str, Any]: year_list = sorted({int(year) for year in (available_years or []) if int(year or 0) > 0}) if start_year is not None and end_year is not None and not year_list: year_list = list(range(start_year, end_year + 1)) resolved_today = _today_local_date() return { "backfill_hint_start_year": COMPARE_BACKFILL_HINT_START_YEAR, "rolling_update_start_year": COMPARE_ROLLING_UPDATE_START_YEAR, "monthly_expected_ready_day": COMPARE_MONTHLY_EXPECTED_READY_DAY, "range_start_year": start_year, "range_end_year": end_year, "year_modes": [ {"year": int(year), "mode": _classify_compare_year_mode(int(year), resolved_today)} for year in year_list if start_year is None or end_year is None or start_year <= int(year) <= end_year ], } def _compute_year_snapshot_runtime_state( conn: Any, year: int, status_row: dict[str, Any] | None = None, ) -> dict[str, Any]: signature = _build_db_state_signature(conn, year, year) row = status_row or {} persisted_state = clean(row.get("state")).lower() stored_signature = clean(row.get("snapshot_signature")) signature_matches = bool(stored_signature) and stored_signature == clean(signature) if persisted_state in {"queued", "running", "failed"}: display_state = persisted_state cache_state = "stale" if stored_signature else "missing" elif signature_matches: display_state = "ready" cache_state = "exact" elif stored_signature: display_state = "stale" cache_state = "stale" else: display_state = "missing" cache_state = "missing" row_counts: dict[str, int] = {} try: parsed_counts = json.loads(clean(row.get("row_counts_json")) or "{}") if isinstance(parsed_counts, dict): row_counts = {str(key): int(value or 0) for key, value in parsed_counts.items()} except Exception: row_counts = {} return { "fiscal_year": int(year), "mode": _classify_compare_year_mode(year), "state": display_state, "cache_state": cache_state, "signature_matches": signature_matches, "row_counts": row_counts, "last_requested_at": clean(row.get("last_requested_at")), "last_built_at": clean(row.get("last_built_at")), "updated_at": clean(row.get("updated_at")), "error_message": clean(row.get("error_message")), } def _load_recent_compare_jobs( conn: Any, start_year: int | None, end_year: int | None, limit: int = 20, ) -> list[dict[str, Any]]: rows = conn.execute( text( """ SELECT job_key, job_type, payload_json, priority, state, error_message, created_at, updated_at, started_at, finished_at FROM wehago_background_jobs WHERE job_type IN ('year_snapshot_rebuild', 'metric_counts_rebuild', 'status_export_xlsx') ORDER BY updated_at DESC, created_at DESC LIMIT :limit """ ), {"limit": int(max(limit, 1))}, ).mappings().all() filtered: list[dict[str, Any]] = [] for row in rows: payload = {} try: payload = json.loads(clean(row.get("payload_json")) or "{}") except Exception: payload = {} job_type = clean(row.get("job_type")) include = True if job_type == "year_snapshot_rebuild": year = int(payload.get("fiscal_year") or 0) if start_year is not None and year < start_year: include = False if end_year is not None and year > end_year: include = False elif job_type == "metric_counts_rebuild": payload_start = payload.get("start_year") payload_end = payload.get("end_year") if start_year is not None and end_year is not None: include = payload_start == start_year and payload_end == end_year if not include: continue filtered.append( { "job_key": clean(row.get("job_key")), "job_type": job_type, "priority": int(row.get("priority") or 0), "state": clean(row.get("state")), "error_message": clean(row.get("error_message")), "created_at": clean(row.get("created_at")), "updated_at": clean(row.get("updated_at")), "started_at": clean(row.get("started_at")), "finished_at": clean(row.get("finished_at")), "payload": payload if isinstance(payload, dict) else {}, } ) return filtered def get_compare_snapshot_status( engine: Any, start_year: int | None, end_year: int | None, *, force: bool = False, ) -> dict[str, Any]: return _get_cached_snapshot_status_payload(engine, start_year, end_year, force=force) def request_compare_snapshot_rebuild( engine: Any, start_year: int | None, end_year: int | None, *, include_metric_counts: bool = True, ) -> dict[str, Any]: if start_year is None or end_year is None: raise ValueError("재생성할 기간이 필요합니다.") years = list(range(min(start_year, end_year), max(start_year, end_year) + 1)) enqueue_year_snapshot_rebuild(engine, years) if include_metric_counts: enqueue_metric_count_rebuild(engine, start_year, end_year) return { "queued_years": years, "include_metric_counts": bool(include_metric_counts), } def _clear_compare_runtime_caches() -> None: global _COMPARE_BACKGROUND_JOBS_NORMALIZED, _COMPARE_BACKGROUND_JOBS_NORMALIZED_AT _DASHBOARD_CACHE.clear() _SUGGEST_CACHE.clear() _STATUS_ROWS_CACHE.clear() _VOUCHER_SECTION_CACHE.clear() _VOUCHER_RECHECK_CACHE.clear() _SNAPSHOT_STATUS_CACHE.clear() _COMPARE_SUMMARY_CACHE.clear() _COMPARE_BACKGROUND_JOBS_NORMALIZED = False _COMPARE_BACKGROUND_JOBS_NORMALIZED_AT = 0.0 def _load_bridge_review_settings_from_conn(conn: Any) -> dict[str, Any]: row = conn.execute( text( """ SELECT setting_json, updated_at FROM wehago_compare_settings WHERE setting_key = 'bridge_review' """ ) ).mappings().first() parsed: dict[str, Any] = {} if row: try: candidate = json.loads(clean(row.get("setting_json")) or "{}") if isinstance(candidate, dict): parsed = candidate except Exception: parsed = {} settings = _normalize_bridge_review_settings(parsed) settings["updated_at"] = clean((row or {}).get("updated_at")) settings["settings_version"] = BRIDGE_REVIEW_SETTINGS_VERSION return settings def load_bridge_review_settings(engine: Any) -> dict[str, Any]: init_wehago_compare_db(engine) with engine.begin() as conn: return _load_bridge_review_settings_from_conn(conn) def save_bridge_review_settings(engine: Any, payload: dict[str, Any]) -> dict[str, Any]: init_wehago_compare_db(engine) settings = _normalize_bridge_review_settings(payload) settings_json = json.dumps(settings, ensure_ascii=False, sort_keys=True) with engine.begin() as conn: conn.execute( text( """ INSERT INTO wehago_compare_settings (setting_key, setting_json, updated_at) VALUES ('bridge_review', :setting_json, CURRENT_TIMESTAMP) ON CONFLICT(setting_key) DO UPDATE SET setting_json = excluded.setting_json, updated_at = CURRENT_TIMESTAMP """ ), {"setting_json": settings_json}, ) _clear_compare_runtime_caches() return load_bridge_review_settings(engine) def _collect_cached_status_rows_by_range( conn: Any, start_year: int | None, end_year: int | None, ) -> tuple[dict[str, list[dict[str, Any]]], dict[str, list[int]]]: rows_by_status = _empty_status_rows_by_status() snapshot_state = {"missing": [], "stale": [], "ready": []} if start_year is None or end_year is None: return rows_by_status, snapshot_state for year in range(start_year, end_year + 1): signature = _build_db_state_signature(conn, year, year) sections = _load_year_resolved_sections_cache(conn, year, signature) state_name = "ready" if sections is None: sections = _load_latest_year_resolved_sections_cache_any_signature(conn, year) state_name = "stale" if sections is not None else "missing" snapshot_state[state_name].append(year) if not sections: continue for status_key in rows_by_status: rows_by_status[status_key].extend(sections.get(status_key, {}).get("rows", [])) return rows_by_status, snapshot_state 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 _empty_status_rows_by_status() 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) cache_ttl = float(cached.get("ttl", _STATUS_ROWS_CACHE_TTL_SEC)) if cached else _STATUS_ROWS_CACHE_TTL_SEC if cached and (now - float(cached.get("ts", 0))) <= cache_ttl: return cached["rows_by_status"] rows_by_status, snapshot_state = _collect_cached_status_rows_by_range(conn, start_year, end_year) 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 } pending_years = snapshot_state["missing"] + snapshot_state["stale"] ttl_seconds = 15 if pending_years else _STATUS_ROWS_CACHE_TTL_SEC _STATUS_ROWS_CACHE.clear() _VOUCHER_RECHECK_CACHE.clear() _STATUS_ROWS_CACHE[cache_key] = {"ts": now, "rows_by_status": rows_by_status, "ttl": ttl_seconds} if pending_years: try: enqueue_year_snapshot_rebuild(engine, pending_years) except Exception: pass return 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) with engine.begin() as conn: sections = _build_voucher_sections_from_rows_by_status(rows_by_status) sections = _apply_bridge_expense_promotions_to_voucher_sections(conn, start_year, end_year, sections) _VOUCHER_SECTION_CACHE.clear() _VOUCHER_RECHECK_CACHE.clear() _VOUCHER_SECTION_CACHE[cache_key] = {"ts": now, "sections": sections} return sections def _build_voucher_recheck_groups_from_rows_by_status( rows_by_status: dict[str, list[dict[str, Any]]], ) -> 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(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 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 key = ("amount_mismatch", fiscal_year, group_key, "") current = grouped.get(key) if current is None: current = { "fiscal_year": fiscal_year, "status_label": row_status_label, "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 row in rows_by_status.get("matched", []): matched_wehago_group_keys.add(wehago_group_key(row)) 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) for row in rows_by_status.get("amount_mismatch", []): current = ensure_group(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) 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_erp_index: dict[tuple[int, str], list[dict[str, Any]]] = {} for row in rows_by_status.get("amount_mismatch", []): recheck_by_erp_index.setdefault(erp_group_key(row), []).append(dict(row)) 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) groups: list[dict[str, Any]] = [] for (_status_key, _fiscal_year, _group_key, _), row in grouped.items(): if row.get("wehago_group_key") in matched_wehago_group_keys: continue 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"]) groups.append({"summary": normalized_summary, "rows": list(row["rows"])}) groups.sort( key=lambda group: ( int(group.get("summary", {}).get("fiscal_year") or 0), clean(group.get("summary", {}).get("ledger_date")) or clean(group.get("summary", {}).get("proof_date")), clean(group.get("summary", {}).get("voucher_no")), clean(group.get("summary", {}).get("draft_no")), ) ) return groups def _get_cached_voucher_recheck_groups_by_range( engine: Any, start_year: int | None, end_year: int | None, ) -> list[dict[str, Any]]: if start_year is None or end_year is None: return [] 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 = _VOUCHER_RECHECK_CACHE.get(cache_key) if cached and (now - float(cached.get("ts", 0))) <= _VOUCHER_SECTION_CACHE_TTL_SEC: return cached["groups"] rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year) groups = _build_voucher_recheck_groups_from_rows_by_status(rows_by_status) _VOUCHER_RECHECK_CACHE.clear() _VOUCHER_RECHECK_CACHE[cache_key] = {"ts": now, "groups": groups} return groups def _build_voucher_sections_with_bridge_promotions( conn: Any, start_year: int | None, end_year: int | None, rows_by_status: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: sections = _build_voucher_sections_from_rows_by_status(rows_by_status) return _apply_bridge_expense_promotions_to_voucher_sections(conn, start_year, end_year, 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 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 not _account_category_pair_allowed( ledger_row.get("ledger_account_code"), ledger_row.get("ledger_account_name"), voucher_row.get("voucher_account_code"), voucher_row.get("voucher_account_name"), ): continue if pair_key and pair_key in matched_pair_keys: continue if ledger_key: ledger_remove.add(ledger_key) if voucher_key: voucher_remove.add(voucher_key) 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 and not clean(row.get("voucher_no")): 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")), "ledger_date": clean(row.get("ledger_date")), "proof_date": clean(row.get("proof_date")), "ledger_account_name": clean(row.get("ledger_account_name")), "voucher_account_code": clean(row.get("voucher_account_code")), "voucher_account_name": clean(row.get("voucher_account_name")), "ledger_vendor": clean(row.get("ledger_vendor")), "voucher_vendor": clean(row.get("voucher_vendor")), "ledger_desc": clean(row.get("ledger_desc")), "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 selected_by_year: dict[int, list[dict[str, Any]]] = {} for row in normalized_rows: fiscal_year = int(row.get("fiscal_year") or 0) if fiscal_year <= 0: continue selected_by_year.setdefault(fiscal_year, []).append(row) expanded_rows_by_key: dict[str, dict[str, Any]] = {} saved_keys: list[str] = [] with engine.begin() as conn: for year, year_rows in selected_by_year.items(): sections = _get_or_create_year_resolved_sections(conn, year) or {} recheck_rows = list((sections.get("amount_mismatch") or {}).get("rows") or []) if not recheck_rows: continue recheck_by_key = { clean(item.get("review_key")): item for item in recheck_rows if clean(item.get("review_key")) } for selected in year_rows: selected_key = clean(selected.get("review_key")) source_row = recheck_by_key.get(selected_key) if selected_key else None if not source_row: source_row = next( ( item for item in recheck_rows if int(item.get("fiscal_year") or 0) == year and clean(item.get("voucher_no")) == clean(selected.get("voucher_no")) and ( not clean(selected.get("draft_no")) or clean(item.get("draft_no")) == clean(selected.get("draft_no")) ) and ( not clean(selected.get("ledger_date")) or clean(item.get("ledger_date")) == clean(selected.get("ledger_date")) ) and ( not clean(selected.get("ledger_account_name")) or clean(item.get("ledger_account_name")) == clean(selected.get("ledger_account_name")) ) and ( not clean(selected.get("voucher_account_name")) or clean(item.get("voucher_account_name")) == clean(selected.get("voucher_account_name")) ) ), None, ) if not source_row: wehago_key = _wehago_group_key_for_row(selected) erp_key = _erp_group_key_for_row(selected) for candidate in recheck_rows: if ( _wehago_group_key_for_row(candidate) == wehago_key or _erp_group_key_for_row(candidate) == erp_key ): candidate_key = clean(candidate.get("review_key")) if not candidate_key: continue expanded_rows_by_key[candidate_key] = { "review_key": candidate_key, "fiscal_year": int(candidate.get("fiscal_year") or 0) or None, "voucher_no": clean(candidate.get("voucher_no")), "draft_no": clean(candidate.get("draft_no")), "voucher_account_code": clean(candidate.get("voucher_account_code")), "voucher_account_name": clean(candidate.get("voucher_account_name")), "voucher_vendor": clean(candidate.get("voucher_vendor")), "voucher_desc": clean(candidate.get("voucher_desc")), "review_reason": clean(candidate.get("review_reason")) or "수동매칭승인", "review_memo": clean(candidate.get("review_memo")), } continue wehago_key = _wehago_group_key_for_row(source_row) erp_key = _erp_group_key_for_row(source_row) for candidate in recheck_rows: if ( _wehago_group_key_for_row(candidate) == wehago_key or _erp_group_key_for_row(candidate) == erp_key ): candidate_key = clean(candidate.get("review_key")) if not candidate_key: continue expanded_rows_by_key[candidate_key] = { "review_key": candidate_key, "fiscal_year": int(candidate.get("fiscal_year") or 0) or None, "voucher_no": clean(candidate.get("voucher_no")), "draft_no": clean(candidate.get("draft_no")), "voucher_account_code": clean(candidate.get("voucher_account_code")), "voucher_account_name": clean(candidate.get("voucher_account_name")), "voucher_vendor": clean(candidate.get("voucher_vendor")), "voucher_desc": clean(candidate.get("voucher_desc")), "review_reason": clean(candidate.get("review_reason")) or "수동매칭승인", "review_memo": clean(candidate.get("review_memo")), } if expanded_rows_by_key: normalized_rows = list(expanded_rows_by_key.values()) 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() _VOUCHER_RECHECK_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) if not _account_category_pair_allowed( ledger_row.get("ledger_account_code"), ledger_row.get("ledger_account_name"), voucher_row.get("voucher_account_code"), voucher_row.get("voucher_account_name"), ): raise ValueError("자산/부채/비용/수익 계열이 다른 계정끼리는 수동 매칭할 수 없습니다.") 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() _VOUCHER_RECHECK_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() _VOUCHER_RECHECK_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 _row_has_tax_signal( account_code: Any, account_name: Any, desc_text: Any = None, *, extra_text: Any = None, tax_code: Any = None, ) -> bool: family = _classify_account_family(account_code, account_name) if _is_vat_family(family): return True normalized_desc = normalize_text(desc_text) normalized_extra = normalize_text(extra_text) normalized_tax_code = normalize_text(tax_code) search_text = " ".join( part for part in (normalized_desc, normalized_extra, normalized_tax_code) if part ) return any(token in search_text for token in ( normalize_text('세금계산서'), normalize_text('전자세금계산서'), normalize_text('계산서'), normalize_text('매입세액'), normalize_text('매출세액'), normalize_text('부가세'), normalize_text('세액'), normalize_text('공급가'), normalize_text('과세'), )) def _group_voucher_context_key(row: dict[str, Any], prefix: str) -> tuple[str, str, str]: fiscal_year = clean(row.get('fiscal_year')) if prefix == 'ledger': return (fiscal_year, clean(row.get('ledger_date')), clean(row.get('voucher_no'))) voucher_no = clean(row.get('voucher_no')) draft_no = clean(row.get('draft_no')) or voucher_no # ERP 전표는 같은 가전표 안에서도 일부 행만 증빙일자가 들어오는 경우가 있어 # 증빙일자를 그룹 키에 넣으면 VAT/세금계산서 컨텍스트가 전표 전체로 퍼지지 못한다. return (fiscal_year, draft_no, '') def _collect_row_tax_context(row: dict[str, Any], prefix: str) -> tuple[bool, set[int], set[str]]: account_code = row.get(f'{prefix}_account_code') account_name = row.get(f'{prefix}_account_name') desc_text = clean(row.get(f'{prefix}_desc')) extra_text = "" tax_code = "" if prefix == 'voucher': extra_text = " ".join( clean(part) for part in ( row.get('management_item'), row.get('desc2'), ) if clean(part) ) tax_code = clean(row.get('tax_code')) source_text = " ".join(part for part in (desc_text, extra_text) if part) vat_sensitive = _row_has_tax_signal( account_code, account_name, desc_text, extra_text=extra_text, tax_code=tax_code, ) date_field = 'proof_date' if prefix == 'voucher' else 'ledger_date' date_text = parse_excel_date(row.get(date_field), default_year=int(row.get('fiscal_year') or 0) or None) or '' desc_date_tokens = _extract_desc_date_tokens(source_text) months = _extract_month_tokens(source_text) | _date_token_month_numbers(desc_date_tokens) dates = set(desc_date_tokens) if date_text and vat_sensitive: dates.add(date_text) months.add(int(date_text[5:7])) dates.add(date_text[:7]) return vat_sensitive, months, dates def _format_int_set(values: set[int]) -> str: return ','.join(str(int(value)) for value in sorted({int(v) for v in values if int(v) > 0})) def _format_text_set(values: set[str]) -> str: return '|'.join(sorted({clean(v) for v in values if clean(v)})) def _parse_int_set_text(value: Any) -> set[int]: text_value = clean(value) if not text_value: return set() built: set[int] = set() for token in re.split(r'[|,]', text_value): token = clean(token) if not token: continue try: month = int(token) except ValueError: continue if 1 <= month <= 12: built.add(month) return built def _parse_text_set_text(value: Any) -> set[str]: text_value = clean(value) if not text_value: return set() return {clean(token) for token in text_value.split('|') if clean(token)} def _extract_exact_tax_dates(tokens: set[str]) -> set[str]: return { clean(token) for token in tokens if re.fullmatch(r"(?:19|20)\d{2}-\d{2}-\d{2}", clean(token)) } def _annotate_tax_context_to_sections(sections: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: ledger_group_context: dict[tuple[str, str, str], dict[str, Any]] = {} voucher_group_context: dict[tuple[str, str, str], dict[str, Any]] = {} for section in sections.values(): for row in section.get('rows', []): ledger_sensitive, ledger_months, ledger_dates = _collect_row_tax_context(row, 'ledger') voucher_sensitive, voucher_months, voucher_dates = _collect_row_tax_context(row, 'voucher') row['_ledger_vat_sensitive'] = ledger_sensitive row['_ledger_tax_months'] = set(ledger_months) row['_ledger_tax_dates'] = set(ledger_dates) row['_voucher_vat_sensitive'] = voucher_sensitive row['_voucher_tax_months'] = set(voucher_months) row['_voucher_tax_dates'] = set(voucher_dates) ledger_key = _group_voucher_context_key(row, 'ledger') voucher_key = _group_voucher_context_key(row, 'voucher') if ledger_sensitive and ledger_key[-1]: bucket = ledger_group_context.setdefault(ledger_key, {'months': set(), 'dates': set(), 'sensitive': False}) bucket['months'].update(ledger_months) bucket['dates'].update(ledger_dates) bucket['sensitive'] = True if voucher_sensitive and voucher_key[1]: bucket = voucher_group_context.setdefault(voucher_key, {'months': set(), 'dates': set(), 'sensitive': False}) bucket['months'].update(voucher_months) bucket['dates'].update(voucher_dates) bucket['sensitive'] = True for section in sections.values(): for row in section.get('rows', []): ledger_key = _group_voucher_context_key(row, 'ledger') voucher_key = _group_voucher_context_key(row, 'voucher') ledger_ctx = ledger_group_context.get(ledger_key) voucher_ctx = voucher_group_context.get(voucher_key) if ledger_ctx: row['_ledger_vat_sensitive'] = bool(row.get('_ledger_vat_sensitive')) or bool(ledger_ctx.get('sensitive')) row['_ledger_tax_months'] = set(row.get('_ledger_tax_months') or set()) | set(ledger_ctx.get('months') or set()) row['_ledger_tax_dates'] = set(row.get('_ledger_tax_dates') or set()) | set(ledger_ctx.get('dates') or set()) if voucher_ctx: row['_voucher_vat_sensitive'] = bool(row.get('_voucher_vat_sensitive')) or bool(voucher_ctx.get('sensitive')) row['_voucher_tax_months'] = set(row.get('_voucher_tax_months') or set()) | set(voucher_ctx.get('months') or set()) row['_voucher_tax_dates'] = set(row.get('_voucher_tax_dates') or set()) | set(voucher_ctx.get('dates') or set()) row['ledger_tax_context_months'] = _format_int_set(set(row.get('_ledger_tax_months') or set())) row['ledger_tax_context_dates'] = _format_text_set(set(row.get('_ledger_tax_dates') or set())) row['ledger_vat_sensitive'] = 1 if row.get('_ledger_vat_sensitive') else 0 row['voucher_tax_context_months'] = _format_int_set(set(row.get('_voucher_tax_months') or set())) row['voucher_tax_context_dates'] = _format_text_set(set(row.get('_voucher_tax_dates') or set())) row['voucher_vat_sensitive'] = 1 if row.get('_voucher_vat_sensitive') else 0 for key in ['_ledger_tax_months','_ledger_tax_dates','_voucher_tax_months','_voucher_tax_dates','_ledger_vat_sensitive','_voucher_vat_sensitive']: row.pop(key, None) return sections 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 not _account_category_pair_allowed(ledger_code, ledger_name, voucher_code, voucher_name): return False 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"}, "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: score_result = _score_pair_match(row, row) if score_result.get('month_conflict'): return False 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 if not _account_category_pair_allowed( row.get("ledger_account_code"), row.get("ledger_account_name"), row.get("voucher_account_code"), row.get("voucher_account_name"), ): 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 if _is_clear_vat_match_row(row): return True 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 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 = ("미수금", "외상매출금", "매출채권", "공사미수금") advance_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 advance_markers): return "advance" 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(("113", "114", "117")): return "advance" 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", "advance": "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_clear_vat_match_row(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")) if not ledger_family or ledger_family != voucher_family or not _is_vat_family(ledger_family): return False 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_date = _parse_iso_date(row.get("ledger_date")) voucher_date = _parse_iso_date(row.get("proof_date")) if ledger_date and voucher_date: return ledger_date == voucher_date return True 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) # Line-level bank/payable, receivable/payable, advance/payable pairs are not direct-match targets. incompatible_families = {frozenset({"bank", "payable"}), frozenset({"receivable", "payable"}), frozenset({"advance", "payable"}), frozenset({"bank", "receivable"}), frozenset({"bank", "advance"})} if frozenset({ledger_family, voucher_family}) in incompatible_families: return False strict_categories = {"asset", "liability", "revenue", "expense"} if ( ledger_category in strict_categories and voucher_category in strict_categories and ledger_category != voucher_category ): return False # 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 _matched_row_passes_account_pair_rule(row: dict[str, Any]) -> bool: return _account_category_pair_allowed( row.get("ledger_account_code"), row.get("ledger_account_name"), row.get("voucher_account_code"), row.get("voucher_account_name"), ) def _restore_ledger_only_row_from_match(row: dict[str, Any]) -> dict[str, Any]: restored = dict(row) for field in ( "proof_date", "draft_no", "voucher_account_code", "voucher_account_name", "voucher_vendor", "voucher_debit", "voucher_credit", "voucher_desc", "voucher_row_key", "pair_match_key", "match_identity_key", "matched_case", "substitution_hint", ): restored.pop(field, None) restored["review_reason"] = "" restored["review_memo"] = "" restored["ledger_row_key"] = clean(restored.get("ledger_row_key")) or build_ledger_row_key(restored) restored["review_key"] = build_review_key(restored) return restored def _restore_voucher_only_row_from_match(row: dict[str, Any]) -> dict[str, Any]: restored = dict(row) for field in ( "ledger_row_key", "ledger_date", "voucher_no", "ledger_account_code", "ledger_account_name", "ledger_vendor", "ledger_debit", "ledger_credit", "ledger_desc", "pair_match_key", "match_identity_key", "matched_case", "substitution_hint", ): restored.pop(field, None) restored["review_reason"] = "" restored["review_memo"] = "" restored["voucher_row_key"] = clean(restored.get("voucher_row_key")) or build_voucher_row_key(restored) restored["review_key"] = build_review_key(restored) return restored def _erp_group_key_for_row(row: dict[str, Any]) -> tuple[int, str]: fiscal_year = int(row.get("fiscal_year") or 0) normalized_draft = clean(row.get("draft_no")) normalized_voucher = clean(row.get("voucher_no")) normalized_date = clean(row.get("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 (fiscal_year, base) def _wehago_group_key_for_row(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 _release_invalid_cross_category_matches( sections: dict[str, dict[str, Any]], ) -> tuple[dict[str, dict[str, Any]], bool]: matched_rows = list((sections or {}).get("matched", {}).get("rows", [])) if not matched_rows: return sections, False kept_rows: list[dict[str, Any]] = [] restored_ledger_rows: list[dict[str, Any]] = [] restored_voucher_rows: list[dict[str, Any]] = [] changed = False for row in matched_rows: if _matched_row_passes_account_pair_rule(row): kept_rows.append(row) continue changed = True restored_ledger_rows.append(_restore_ledger_only_row_from_match(row)) restored_voucher_rows.append(_restore_voucher_only_row_from_match(row)) if not changed: return sections, False ledger_rows = list((sections or {}).get("ledger_only", {}).get("rows", [])) voucher_rows = list((sections or {}).get("voucher_only", {}).get("rows", [])) ledger_seen = { clean(item.get("ledger_row_key")) or build_ledger_row_key(item) for item in ledger_rows } voucher_seen = { clean(item.get("voucher_row_key")) or build_voucher_row_key(item) for item in voucher_rows } for row in restored_ledger_rows: row_key = clean(row.get("ledger_row_key")) or build_ledger_row_key(row) if row_key and row_key not in ledger_seen: ledger_rows.append(row) ledger_seen.add(row_key) for row in restored_voucher_rows: row_key = clean(row.get("voucher_row_key")) or build_voucher_row_key(row) if row_key and row_key not in voucher_seen: voucher_rows.append(row) voucher_seen.add(row_key) sections["matched"]["rows"] = kept_rows sections["matched"]["count"] = len(kept_rows) sections["ledger_only"]["rows"] = ledger_rows sections["ledger_only"]["count"] = len(ledger_rows) sections["voucher_only"]["rows"] = voucher_rows sections["voucher_only"]["count"] = len(voucher_rows) return sections, True def _release_invalid_cross_category_recheck_rows( sections: dict[str, dict[str, Any]], ) -> tuple[dict[str, dict[str, Any]], bool]: recheck_rows = list((sections or {}).get("amount_mismatch", {}).get("rows", [])) if not recheck_rows: return sections, False kept_rows: list[dict[str, Any]] = [] restored_ledger_rows: list[dict[str, Any]] = [] restored_voucher_rows: list[dict[str, Any]] = [] changed = False for row in recheck_rows: if _matched_row_passes_account_pair_rule(row): kept_rows.append(row) continue changed = True restored_ledger_rows.append(_restore_ledger_only_row_from_match(row)) restored_voucher_rows.append(_restore_voucher_only_row_from_match(row)) if not changed: return sections, False ledger_rows = list((sections or {}).get("ledger_only", {}).get("rows", [])) voucher_rows = list((sections or {}).get("voucher_only", {}).get("rows", [])) ledger_seen = { clean(item.get("ledger_row_key")) or build_ledger_row_key(item) for item in ledger_rows } voucher_seen = { clean(item.get("voucher_row_key")) or build_voucher_row_key(item) for item in voucher_rows } for row in restored_ledger_rows: row_key = clean(row.get("ledger_row_key")) or build_ledger_row_key(row) if row_key and row_key not in ledger_seen: ledger_rows.append(row) ledger_seen.add(row_key) for row in restored_voucher_rows: row_key = clean(row.get("voucher_row_key")) or build_voucher_row_key(row) if row_key and row_key not in voucher_seen: voucher_rows.append(row) voucher_seen.add(row_key) sections["amount_mismatch"]["rows"] = kept_rows sections["amount_mismatch"]["count"] = len(kept_rows) sections["ledger_only"]["rows"] = ledger_rows sections["ledger_only"]["count"] = len(ledger_rows) sections["voucher_only"]["rows"] = voucher_rows sections["voucher_only"]["count"] = len(voucher_rows) return sections, True def _repair_invalid_cross_category_matches( sections: dict[str, dict[str, Any]], ) -> dict[str, dict[str, Any]]: sections, changed_matched = _release_invalid_cross_category_matches(sections) sections, changed_recheck = _release_invalid_cross_category_recheck_rows(sections) if not (changed_matched or changed_recheck): return sections return _promote_direct_auto_matches(sections) 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 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, tax_context_months=_parse_int_set_text(row.get(f'{prefix}_tax_context_months')), tax_context_dates=_parse_text_set_text(row.get(f'{prefix}_tax_context_dates')), vat_sensitive=bool(int(row.get(f'{prefix}_vat_sensitive') or 0)), ) 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", ""), "voucher_tax_context_months": voucher_row.get("voucher_tax_context_months", ""), "voucher_tax_context_dates": voucher_row.get("voucher_tax_context_dates", ""), "voucher_vat_sensitive": voucher_row.get("voucher_vat_sensitive", 0), "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 score_result.get("review_conflict"): recheck_row = dict(row) recheck_row["review_reason"] = "PROOF_DATE_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) 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 _voucher_group_tax_context_compatible(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -> bool: ledger_months = _parse_int_set_text(ledger_row.get('ledger_tax_context_months')) voucher_months = _parse_int_set_text(voucher_row.get('voucher_tax_context_months')) ledger_dates = _parse_text_set_text(ledger_row.get('ledger_tax_context_dates')) voucher_dates = _parse_text_set_text(voucher_row.get('voucher_tax_context_dates')) ledger_year = int(ledger_row.get("fiscal_year") or 0) voucher_year = int(voucher_row.get("fiscal_year") or 0) ledger_sensitive = bool(ledger_months or ledger_dates or ledger_row.get("_ledger_vat_sensitive")) voucher_sensitive = bool(voucher_months or voucher_dates or voucher_row.get("_voucher_vat_sensitive")) if not (ledger_sensitive or voucher_sensitive): return True if ledger_year and voucher_year and ledger_year != voucher_year: return False ledger_exact_date = parse_excel_date(ledger_row.get("ledger_date"), default_year=ledger_year or None) or "" voucher_exact_date = parse_excel_date(voucher_row.get("proof_date"), default_year=voucher_year or None) or "" if ledger_exact_date or voucher_exact_date: return bool(ledger_exact_date and voucher_exact_date and ledger_exact_date == voucher_exact_date) ledger_exact_dates = _extract_exact_tax_dates(ledger_dates) voucher_exact_dates = _extract_exact_tax_dates(voucher_dates) if ledger_exact_dates or voucher_exact_dates: return bool(ledger_exact_dates and voucher_exact_dates and not ledger_exact_dates.isdisjoint(voucher_exact_dates)) if ledger_dates and voucher_dates and ledger_dates.isdisjoint(voucher_dates): return False if ledger_months and voucher_months and ledger_months.isdisjoint(voucher_months): return False return True 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 if not _voucher_group_tax_context_compatible(ledger_row, voucher_row): 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", ""), "voucher_tax_context_months": voucher_row.get("voucher_tax_context_months", ""), "voucher_tax_context_dates": voucher_row.get("voucher_tax_context_dates", ""), "voucher_vat_sensitive": voucher_row.get("voucher_vat_sensitive", 0), "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) if not _voucher_group_tax_context_compatible(ledger_row, voucher_row): continue 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 tax_seed_sections = _annotate_tax_context_to_sections({ "matched": {"rows": [], "count": 0, "columns": DETAIL_COLUMN_MAP["matched"]}, "ledger_only": {"rows": list(ledger_rows), "count": len(ledger_rows), "columns": DETAIL_COLUMN_MAP["ledger_only"]}, "amount_mismatch": {"rows": [], "count": 0, "columns": DETAIL_COLUMN_MAP["amount_mismatch"]}, "voucher_only": {"rows": list(adjacent_voucher_rows), "count": len(adjacent_voucher_rows), "columns": DETAIL_COLUMN_MAP["voucher_only"]}, }) ledger_rows = list(tax_seed_sections["ledger_only"]["rows"]) adjacent_voucher_rows = list(tax_seed_sections["voucher_only"]["rows"]) 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 _voucher_group_tax_context_compatible(ledger_row, voucher_row): 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"]) sections = _demote_incomplete_adjacent_year_matches(sections) return sections def _demote_incomplete_adjacent_year_matches( sections: dict[str, dict[str, Any]], ) -> dict[str, dict[str, Any]]: matched_rows = list(sections["matched"]["rows"]) ledger_only_rows = list(sections["ledger_only"]["rows"]) if not matched_rows or not ledger_only_rows: return sections residual_by_group: dict[tuple[int, str, str], list[dict[str, Any]]] = {} for row in ledger_only_rows: key = ( int(row.get("fiscal_year") or 0), clean(row.get("voucher_no")), clean(row.get("ledger_date")), ) residual_by_group.setdefault(key, []).append(row) demote_groups: set[tuple[int, str, str]] = set() important_families = {"vat_input", "vat_output", "payable", "receivable"} for row in matched_rows: if clean(row.get("matched_case")) not in {"previous_year_erp", "next_year_erp"}: continue key = ( int(row.get("fiscal_year") or 0), clean(row.get("voucher_no")), clean(row.get("ledger_date")), ) residual_rows = residual_by_group.get(key, []) if not residual_rows: continue residual_families = { _classify_account_family(item.get("ledger_account_code"), item.get("ledger_account_name")) for item in residual_rows } residual_families.discard("") if residual_families & important_families: demote_groups.add(key) if not demote_groups: return sections kept_matched: list[dict[str, Any]] = [] demoted_rows: list[dict[str, Any]] = [] for row in matched_rows: key = ( int(row.get("fiscal_year") or 0), clean(row.get("voucher_no")), clean(row.get("ledger_date")), ) if key in demote_groups and clean(row.get("matched_case")) in {"previous_year_erp", "next_year_erp"}: demoted = dict(row) demoted["review_reason"] = "전표단위 재검토" demoted["matched_case"] = "voucher_level_recheck" demoted_rows.append(demoted) else: kept_matched.append(row) if not demoted_rows: return sections sections["matched"]["rows"] = kept_matched sections["matched"]["count"] = len(kept_matched) sections["amount_mismatch"]["rows"].extend(demoted_rows) sections["amount_mismatch"]["count"] = len(sections["amount_mismatch"]["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) sections = _promote_direct_auto_matches(sections) if not ENABLE_GENERATED_RECHECK_CANDIDATES: return _drop_recheck_rows_covered_by_matched(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) vat_family_pair = bool( ledger_family and ledger_family == voucher_family and _is_vat_family(ledger_family) ) 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 if vat_family_pair and amount_gap < 0.5: score += 14 reasons.append("VAT 계정군 일치") 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) tax_sensitive = bool( ledger.vat_sensitive or voucher.vat_sensitive or ledger.tax_context_months or voucher.tax_context_months or ledger.tax_context_dates or voucher.tax_context_dates ) ledger_tax_year = int(ledger.row.get("fiscal_year") or 0) voucher_tax_year = int(voucher.row.get("fiscal_year") or 0) ledger_exact_date = ledger.date_value.isoformat() if ledger.date_value else "" voucher_exact_date = voucher.date_value.isoformat() if voucher.date_value else "" tax_year_conflict = bool( tax_sensitive and ledger_tax_year and voucher_tax_year and ledger_tax_year != voucher_tax_year ) tax_month_conflict = False tax_date_missing = False tax_date_conflict = False tax_day_review_conflict = False if tax_sensitive: if ledger_exact_date or voucher_exact_date: if not ledger_exact_date or not voucher_exact_date: tax_date_missing = True elif ledger_exact_date == voucher_exact_date: pass elif ledger_exact_date[:7] == voucher_exact_date[:7]: tax_day_review_conflict = True else: tax_date_conflict = True elif ledger.tax_context_months and voucher.tax_context_months and ledger.tax_context_months.isdisjoint(voucher.tax_context_months): tax_month_conflict = True if tax_year_conflict: hard_pass = False score -= 75 reasons.append('세금계산서 연도 불일치') elif tax_date_missing: hard_pass = False score -= 60 reasons.append('세금계산서 일자 미확인') elif tax_date_conflict: hard_pass = False score -= 65 reasons.append('세금계산서 일자 불일치') elif tax_month_conflict: hard_pass = False score -= 55 reasons.append('세금계산서 월 불일치') elif tax_day_review_conflict: hard_pass = False score -= 18 reasons.append('세금계산서 일자 재검토') else: if ledger_exact_date and voucher_exact_date: score += 18 reasons.append('세금계산서 일자 일치') elif ledger.tax_context_months and voucher.tax_context_months: score += 10 reasons.append('세금계산서 월 일치') month_conflict = month_conflict or tax_year_conflict or tax_month_conflict or tax_date_missing or tax_date_conflict review_conflict = tax_day_review_conflict 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 day_gap = None month_gap = None 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 vat_family_pair and day_gap == 0 and amount_gap < 0.5: score += 10 reasons.append("VAT 일자 일치") 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개월 초과") # 계정군이 명백히 다른데 기간 차이까지 크면 자동매칭 대상이 아니다. explicit_family_mismatch = bool( ledger_family and voucher_family and ledger_family != voucher_family and not substitution_hint ) if ( explicit_family_mismatch and month_gap is not None and month_gap >= 2 and account_sim < 0.35 and not bank_pair ): hard_pass = False score -= 42 reasons.append("계정 상이/기간 차이") 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 not review_conflict and ( (vat_family_pair and ledger.date_value and voucher.date_value and ledger.date_value == voucher.date_value) or (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, "review_conflict": review_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 _bridge_expense_candidates_sql() -> str: compact_desc_sql = """ lower( replace(replace(replace(replace(replace( COALESCE(desc1, '') || COALESCE(desc2, ''), ' ', '' ), '(', ''), ')', ''), '/', ''), '-', '') ) """ ledger_desc_sql = """ lower( replace(replace(replace(replace(replace( COALESCE(description, ''), ' ', '' ), '(', ''), ')', ''), '/', ''), '-', '') ) """ bank_sql = "(account_name LIKE '%보통예금%' OR account_code LIKE '101105%' OR account_code IN ('103'))" payable_sql = "(account_name LIKE '%미지급금%' OR account_name LIKE '%외상매입금%' OR account_code LIKE '201%')" ledger_expense_sql = """ ( account_code LIKE '5%' OR account_code LIKE '6%' OR account_name LIKE '%급여%' OR account_name LIKE '%접대%' OR account_name LIKE '%수수료%' OR account_name LIKE '%임차료%' OR account_name LIKE '%복리후생%' OR account_name LIKE '%교육훈련%' OR account_name LIKE '%보험료%' OR account_name LIKE '%도서인쇄%' OR account_name LIKE '%소모품%' OR account_name LIKE '%외주비%' ) """ expense_sql = """ ( account_code LIKE '5%' OR account_code LIKE '6%' OR account_name LIKE '%급여%' OR account_name LIKE '%접대%' OR account_name LIKE '%수수료%' OR account_name LIKE '%임차료%' OR account_name LIKE '%복리후생%' OR account_name LIKE '%교육훈련%' OR account_name LIKE '%보험료%' OR account_name LIKE '%도서인쇄%' OR account_name LIKE '%소모품%' OR account_name LIKE '%외주비%' ) """ return f""" WITH ledger_groups AS ( SELECT fiscal_year, voucher_no, ledger_date, SUM(CASE WHEN {bank_sql} THEN COALESCE(credit, 0) ELSE 0 END) AS bank_credit, GROUP_CONCAT(DISTINCT CASE WHEN {ledger_expense_sql} THEN account_code || ' ' || account_name END) AS ledger_accounts, GROUP_CONCAT(DISTINCT CASE WHEN {ledger_expense_sql} THEN vendor_name END) AS ledger_vendors, GROUP_CONCAT(DISTINCT description) AS ledger_desc, MIN({ledger_desc_sql}) AS ledger_desc_norm FROM wehago_ledger_rows WHERE fiscal_year >= :start_year AND fiscal_year <= :end_year AND COALESCE(voucher_no, '') <> '' GROUP BY fiscal_year, voucher_no, ledger_date HAVING bank_credit > 0 AND COALESCE(ledger_accounts, '') <> '' ), payment_groups AS ( SELECT fiscal_year, rtrim(rtrim(COALESCE(draft_no, confirmed_no), '0123456789'), '-') AS payment_group_key, MIN(CASE WHEN length(COALESCE(confirmed_no, '')) >= 11 THEN substr(confirmed_no, 4, 4) || '-' || substr(confirmed_no, 8, 2) || '-' || substr(confirmed_no, 10, 2) ELSE proof_date END) AS payment_date, SUM(CASE WHEN {bank_sql} THEN COALESCE(credit_supply, 0) ELSE 0 END) AS bank_credit, SUM(CASE WHEN {payable_sql} THEN COALESCE(debit_supply, 0) ELSE 0 END) AS payable_debit, GROUP_CONCAT(DISTINCT CASE WHEN {payable_sql} THEN vendor_name END) AS payable_vendors, GROUP_CONCAT(DISTINCT account_code || ' ' || account_name) AS payment_accounts, GROUP_CONCAT(DISTINCT confirmed_no) AS payment_vouchers, GROUP_CONCAT(DISTINCT draft_no) AS payment_drafts, GROUP_CONCAT(DISTINCT COALESCE(desc1, '') || COALESCE(desc2, '')) AS payment_desc, MIN({compact_desc_sql}) AS payment_desc_norm FROM wehago_voucher_rows WHERE fiscal_year >= :start_year AND fiscal_year <= :end_year AND COALESCE(draft_no, confirmed_no, '') <> '' GROUP BY fiscal_year, payment_group_key HAVING bank_credit > 0 AND payable_debit > 0 ), expense_sources AS ( SELECT fiscal_year, MIN(CASE WHEN length(COALESCE(confirmed_no, '')) >= 11 THEN substr(confirmed_no, 4, 4) || '-' || substr(confirmed_no, 8, 2) || '-' || substr(confirmed_no, 10, 2) ELSE proof_date END) AS source_date, {compact_desc_sql} AS source_desc_norm, GROUP_CONCAT(DISTINCT CASE WHEN {expense_sql} THEN account_code || ' ' || account_name END) AS expense_accounts, GROUP_CONCAT(DISTINCT CASE WHEN {expense_sql} THEN vendor_name END) AS expense_vendors, GROUP_CONCAT(DISTINCT CASE WHEN {expense_sql} THEN confirmed_no END) AS expense_vouchers, GROUP_CONCAT(DISTINCT CASE WHEN {expense_sql} THEN draft_no END) AS expense_drafts, GROUP_CONCAT(DISTINCT COALESCE(desc1, '') || COALESCE(desc2, '')) AS expense_desc, SUM(CASE WHEN {expense_sql} THEN COALESCE(debit_supply, 0) ELSE 0 END) AS expense_debit, SUM(CASE WHEN {payable_sql} THEN COALESCE(credit_supply, 0) ELSE 0 END) AS payable_credit FROM wehago_voucher_rows WHERE fiscal_year >= :start_year AND fiscal_year <= :end_year GROUP BY fiscal_year, source_desc_norm HAVING expense_debit > 0 AND payable_credit > 0 AND COALESCE(expense_accounts, '') <> '' ) SELECT l.fiscal_year AS fiscal_year, l.ledger_date AS ledger_date, l.voucher_no AS voucher_no, COALESCE(l.ledger_accounts, '') AS ledger_account_name, COALESCE(l.ledger_vendors, '') AS ledger_vendor, 0 AS ledger_debit, l.bank_credit AS ledger_credit, COALESCE(l.ledger_desc, '') AS ledger_desc, p.payment_date AS payment_date, p.payment_group_key AS draft_no, COALESCE(p.payment_accounts, '') AS voucher_account_name, COALESCE(p.payable_vendors, '') AS voucher_vendor, p.payable_debit AS voucher_debit, p.bank_credit AS voucher_credit, COALESCE(p.payment_desc, '') AS voucher_desc, COALESCE(s.expense_accounts, '') AS inferred_expense_accounts, COALESCE(s.expense_vendors, '') AS inferred_expense_vendors, COALESCE(s.expense_vouchers, '') AS expense_source_vouchers, COALESCE(s.expense_drafts, '') AS expense_source_drafts, COALESCE(s.expense_desc, '') AS expense_source_desc, COALESCE(s.expense_debit, 0) AS expense_source_debit, COALESCE(s.payable_credit, 0) AS expense_source_payable_credit, '보통예금 출금액=ERP 보통예금 지급액, ERP 지급전표는 미지급금 결제, 같은 핵심 적요의 원 비용전표 존재' AS bridge_reason, 'bridge_expense' AS matched_case FROM ledger_groups l JOIN payment_groups p ON p.fiscal_year = l.fiscal_year AND ABS(p.bank_credit - l.bank_credit) < 0.5 AND ( l.ledger_date = p.payment_date OR ABS(julianday(l.ledger_date) - julianday(p.payment_date)) <= 30 ) AND ( instr(l.ledger_desc_norm, p.payment_desc_norm) > 0 OR instr(p.payment_desc_norm, l.ledger_desc_norm) > 0 ) JOIN expense_sources s ON s.fiscal_year = p.fiscal_year AND s.source_desc_norm = p.payment_desc_norm AND ( s.source_date IS NULL OR p.payment_date IS NULL OR julianday(s.source_date) <= julianday(p.payment_date) + 1 ) """ def _fetch_bridge_expense_review_rows_from_db( conn: Any, start_year: int, end_year: int, 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]: params: dict[str, Any] = { "start_year": start_year, "end_year": end_year, "limit": limit, "offset": offset, } conditions = ["1 = 1"] _append_like_filter(conditions, params, "b.voucher_no", "voucher_no", voucher_filter) _append_like_filter(conditions, params, "b.draft_no || ' ' || b.payment_vouchers || ' ' || b.expense_source_vouchers", "draft_no", draft_filter) _append_like_filter(conditions, params, "b.ledger_account_name", "wehago_account", wehago_account_filter) _append_like_filter(conditions, params, "b.voucher_account_name || ' ' || b.inferred_expense_accounts", "erp_account", erp_account_filter) _append_like_filter(conditions, params, "b.ledger_vendor", "wehago_vendor", wehago_vendor_filter) _append_like_filter(conditions, params, "b.voucher_vendor || ' ' || b.inferred_expense_vendors", "erp_vendor", erp_vendor_filter) _append_like_filter( conditions, params, "b.ledger_desc || ' ' || b.voucher_desc || ' ' || b.expense_source_desc || ' ' || b.bridge_reason", "desc_keyword", desc_filter, ) _append_amount_filter(conditions, params, ["b.ledger_credit"], "wehago_amount", wehago_amount_filter) _append_amount_filter(conditions, params, ["b.voucher_debit", "b.voucher_credit", "b.expense_source_debit"], "erp_amount", erp_amount_filter) base_sql = _bridge_expense_candidates_sql() where_sql = " AND ".join(conditions) from_sql = f"FROM ({base_sql}) b 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 * {from_sql} ORDER BY fiscal_year, ledger_date, voucher_no, draft_no LIMIT :limit OFFSET :offset """ ), params, ).mappings() ] next_offset = offset + len(rows) return { "columns": DETAIL_COLUMN_MAP["bridge_expense_review"], "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 _fetch_bridge_expense_candidate_rows( conn: Any, start_year: int, end_year: int, ) -> list[dict[str, Any]]: base_sql = _bridge_expense_candidates_sql() rows = conn.execute( text( f""" SELECT * FROM ({base_sql}) b ORDER BY fiscal_year, ledger_date, voucher_no, draft_no """ ), {"start_year": start_year, "end_year": end_year}, ).mappings().all() return [ {key: clean(value) if isinstance(value, str) else value for key, value in dict(row).items()} for row in rows ] def _split_bridge_account_entries(value: Any) -> list[tuple[str, str]]: entries: list[tuple[str, str]] = [] for chunk in [part.strip() for part in clean(value).split(",") if clean(part)]: match = re.match(r"^(\S+)\s+(.+)$", chunk) if match: entries.append((clean(match.group(1)), clean(match.group(2)))) else: entries.append(("", clean(chunk))) return entries def _bridge_candidate_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 _bridge_candidate_has_expense_nature(row: dict[str, Any]) -> bool: entries = _split_bridge_account_entries(row.get("inferred_expense_accounts")) if not entries: return False valid_count = 0 for code, name in entries: category = _classify_account_category(code, name) if category == "expense": valid_count += 1 elif category: return False return valid_count > 0 def _bridge_candidate_is_eligible_for_promotion(row: dict[str, Any], settings: dict[str, Any]) -> bool: if not settings.get("enable_promotion"): return False amount_gap_1 = abs(parse_amount(row.get("ledger_credit")) - parse_amount(row.get("voucher_credit"))) amount_gap_2 = abs(parse_amount(row.get("ledger_credit")) - parse_amount(row.get("expense_source_debit"))) amount_gap_3 = abs(parse_amount(row.get("voucher_debit")) - parse_amount(row.get("expense_source_payable_credit"))) if settings.get("require_exact_amount") and max(amount_gap_1, amount_gap_2, amount_gap_3) >= 0.5: return False if settings.get("require_expense_nature") and not _bridge_candidate_has_expense_nature(row): return False if settings.get("require_strong_text"): bridge_probe = { "ledger_desc": clean(row.get("ledger_desc")), "voucher_desc": clean(row.get("expense_source_desc")) or clean(row.get("voucher_desc")), "ledger_vendor": clean(row.get("ledger_vendor")), "voucher_vendor": clean(row.get("inferred_expense_vendors")) or clean(row.get("voucher_vendor")), } if not (_same_or_similar_desc(bridge_probe) or _same_or_similar_vendor(bridge_probe)): return False return True def _bridge_candidate_to_voucher_matched_group(row: dict[str, Any]) -> dict[str, Any]: draft_display = clean(row.get("expense_source_drafts")) or clean(row.get("draft_no")) voucher_display = clean(row.get("expense_source_vouchers")) or clean(row.get("draft_no")) proof_date = clean(row.get("payment_date")) review_reason = "2단계자동매칭" review_detail = clean(row.get("bridge_reason")) if voucher_display: review_detail = f"{review_reason} / 원전표 {voucher_display}" if draft_display and draft_display not in review_detail: review_detail = f"{review_detail} / 원가전표 {draft_display}" row_payload = { "fiscal_year": int(row.get("fiscal_year") or 0), "ledger_date": clean(row.get("ledger_date")), "proof_date": proof_date, "voucher_no": clean(row.get("voucher_no")), "draft_no": draft_display, "ledger_account_code": "", "ledger_account_name": clean(row.get("ledger_account_name")), "voucher_account_code": "", "voucher_account_name": clean(row.get("inferred_expense_accounts")), "ledger_vendor": clean(row.get("ledger_vendor")), "voucher_vendor": clean(row.get("inferred_expense_vendors")) or clean(row.get("voucher_vendor")), "ledger_debit": parse_amount(row.get("ledger_debit")), "ledger_credit": parse_amount(row.get("ledger_credit")), "voucher_debit": parse_amount(row.get("expense_source_debit")), "voucher_credit": 0, "ledger_desc": clean(row.get("ledger_desc")), "voucher_desc": clean(row.get("expense_source_desc")) or clean(row.get("voucher_desc")), "status_label": "Matched", "review_reason": review_reason, "review_memo": review_detail, "matched_case": "bridge_expense_promoted", "ledger_row_key": f"bridge-ledger:{clean(row.get('fiscal_year'))}:{clean(row.get('ledger_date'))}:{clean(row.get('voucher_no'))}", "voucher_row_key": f"bridge-voucher:{clean(row.get('fiscal_year'))}:{clean(voucher_display)}:{clean(draft_display)}", "match_identity_key": f"bridge:{clean(row.get('fiscal_year'))}:{clean(row.get('voucher_no'))}:{clean(row.get('ledger_date'))}:{clean(voucher_display)}", } summary = { "fiscal_year": row_payload["fiscal_year"], "status_label": "Matched", "ledger_date": row_payload["ledger_date"], "proof_date": row_payload["proof_date"], "voucher_no": row_payload["voucher_no"], "draft_no": row_payload["draft_no"], "ledger_row_count": 1, "voucher_row_count": 1, "ledger_debit": parse_amount(row_payload["ledger_debit"]), "ledger_credit": parse_amount(row_payload["ledger_credit"]), "voucher_debit": parse_amount(row_payload["voucher_debit"]), "voucher_credit": parse_amount(row_payload["voucher_credit"]), "ledger_accounts": clean(row_payload["ledger_account_name"]), "voucher_accounts": clean(row_payload["voucher_account_name"]), "ledger_vendors": clean(row_payload["ledger_vendor"]), "voucher_vendors": clean(row_payload["voucher_vendor"]), "review_reason": review_detail, "matched_case": row_payload["matched_case"], } return {"summary": summary, "rows": [row_payload]} def _bridge_candidates_to_voucher_recheck_group(group_key: tuple[int, str, str], candidates: list[dict[str, Any]]) -> dict[str, Any]: primary = candidates[0] if candidates else {} review_reason = "2단계복수후보" voucher_labels = [clean(candidate.get("expense_source_vouchers")) or clean(candidate.get("draft_no")) for candidate in candidates] voucher_labels = [label for label in voucher_labels if label] review_detail = review_reason if voucher_labels: review_detail = f"{review_reason} / 후보 {', '.join(dict.fromkeys(voucher_labels))}" rows: list[dict[str, Any]] = [] for index, candidate in enumerate(candidates, start=1): voucher_display = clean(candidate.get("expense_source_vouchers")) or clean(candidate.get("draft_no")) draft_display = clean(candidate.get("expense_source_drafts")) or clean(candidate.get("draft_no")) rows.append( { "fiscal_year": int(candidate.get("fiscal_year") or 0), "ledger_date": clean(candidate.get("ledger_date")), "proof_date": clean(candidate.get("payment_date")), "voucher_no": clean(candidate.get("voucher_no")), "draft_no": draft_display, "ledger_account_code": "", "ledger_account_name": clean(candidate.get("ledger_account_name")), "voucher_account_code": "", "voucher_account_name": clean(candidate.get("inferred_expense_accounts")), "ledger_vendor": clean(candidate.get("ledger_vendor")), "voucher_vendor": clean(candidate.get("inferred_expense_vendors")) or clean(candidate.get("voucher_vendor")), "ledger_debit": parse_amount(candidate.get("ledger_debit")), "ledger_credit": parse_amount(candidate.get("ledger_credit")), "voucher_debit": parse_amount(candidate.get("expense_source_debit")), "voucher_credit": 0, "ledger_desc": clean(candidate.get("ledger_desc")), "voucher_desc": clean(candidate.get("expense_source_desc")) or clean(candidate.get("voucher_desc")), "status_label": "Recheck", "review_reason": review_reason, "review_memo": f"{review_detail} / 후보 {index}", "matched_case": "bridge_expense_recheck", "ledger_row_key": f"bridge-recheck-ledger:{clean(candidate.get('fiscal_year'))}:{clean(candidate.get('ledger_date'))}:{clean(candidate.get('voucher_no'))}:{index}", "voucher_row_key": f"bridge-recheck-voucher:{clean(candidate.get('fiscal_year'))}:{clean(voucher_display)}:{clean(draft_display)}:{index}", "match_identity_key": f"bridge-recheck:{clean(candidate.get('fiscal_year'))}:{clean(candidate.get('voucher_no'))}:{clean(candidate.get('ledger_date'))}:{index}", } ) summary = { "fiscal_year": int(primary.get("fiscal_year") or group_key[0] or 0), "status_label": "Recheck", "ledger_date": clean(primary.get("ledger_date")) or clean(group_key[2]), "proof_date": clean(primary.get("payment_date")), "voucher_no": clean(primary.get("voucher_no")) or clean(group_key[1]), "draft_no": ", ".join(dict.fromkeys(clean(candidate.get("expense_source_drafts")) or clean(candidate.get("draft_no")) for candidate in candidates if clean(candidate.get("expense_source_drafts")) or clean(candidate.get("draft_no")))), "ledger_row_count": 1 if primary else 0, "voucher_row_count": len(rows), "ledger_debit": parse_amount(primary.get("ledger_debit")), "ledger_credit": parse_amount(primary.get("ledger_credit")), "voucher_debit": sum(parse_amount(candidate.get("expense_source_debit")) for candidate in candidates), "voucher_credit": 0, "ledger_accounts": clean(primary.get("ledger_account_name")), "voucher_accounts": ", ".join(dict.fromkeys(clean(candidate.get("inferred_expense_accounts")) for candidate in candidates if clean(candidate.get("inferred_expense_accounts")))), "ledger_vendors": clean(primary.get("ledger_vendor")), "voucher_vendors": ", ".join(dict.fromkeys((clean(candidate.get("inferred_expense_vendors")) or clean(candidate.get("voucher_vendor"))) for candidate in candidates if (clean(candidate.get("inferred_expense_vendors")) or clean(candidate.get("voucher_vendor"))))), "review_reason": review_detail, "matched_case": "bridge_expense_recheck", } return {"summary": summary, "rows": rows} def _apply_bridge_expense_promotions_to_voucher_sections( conn: Any, start_year: int | None, end_year: int | None, voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: if start_year is None or end_year is None: return voucher_sections settings = _load_bridge_review_settings_from_conn(conn) if not settings.get("enable_promotion"): return voucher_sections candidates = _fetch_bridge_expense_candidate_rows(conn, start_year, end_year) if not candidates: return voucher_sections existing_wehago_keys = { ( int(group.get("summary", {}).get("fiscal_year") or 0), clean(group.get("summary", {}).get("voucher_no")), clean(group.get("summary", {}).get("ledger_date")), ) for group in voucher_sections.get("voucher_matched", []) } eligible_by_group: dict[tuple[int, str, str], list[dict[str, Any]]] = {} for candidate in candidates: group_key = _bridge_candidate_group_key(candidate) if group_key in existing_wehago_keys: continue if not _bridge_candidate_is_eligible_for_promotion(candidate, settings): continue eligible_by_group.setdefault(group_key, []).append(candidate) promoted_groups: list[dict[str, Any]] = [] recheck_groups: list[dict[str, Any]] = [] for group_key, group_candidates in eligible_by_group.items(): if len(group_candidates) != 1: if settings.get("ambiguous_handling") == "recheck": recheck_groups.append(_bridge_candidates_to_voucher_recheck_group(group_key, group_candidates)) continue promoted_groups.append(_bridge_candidate_to_voucher_matched_group(group_candidates[0])) if not promoted_groups and not recheck_groups: return voucher_sections if promoted_groups: voucher_sections["voucher_matched"].extend(promoted_groups) voucher_sections["voucher_matched"].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")), ) ) if recheck_groups: voucher_sections["voucher_recheck"].extend(recheck_groups) voucher_sections["voucher_recheck"].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 _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]: if status == "bridge_expense_review": return _fetch_bridge_expense_review_rows_from_db( conn, start_year, end_year, voucher_filter, draft_filter, wehago_account_filter, erp_account_filter, wehago_amount_filter, erp_amount_filter, wehago_vendor_filter, erp_vendor_filter, desc_filter, offset, limit, ) 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() matched_erp_group_keys: set[tuple[int, 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 == "voucher_only": current["erp_group_keys"].add(erp_group_key(row)) if status_key == "matched": matched_erp_group_keys.add(erp_group_key(row)) 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()) recheck_erp_group_keys = set(recheck_by_erp_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": [], "erp_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"]), } ) erp_unmatched_grouped: dict[tuple[int, str], dict[str, Any]] = {} for row in rows_by_status.get("voucher_only", []): erp_key = erp_group_key(row) if not erp_key[1] or erp_key in matched_erp_group_keys or erp_key in recheck_erp_group_keys: continue fiscal_year = int(row.get("fiscal_year") or 0) current = erp_unmatched_grouped.get(erp_key) if current is None: current = { "fiscal_year": fiscal_year, "status_label": "ERP Unmatched", "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": [], "voucher_nos": [], "draft_nos": [], } erp_unmatched_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")) row_payload = dict(row) row_payload.setdefault("status_label", "ERP Unmatched") current["rows"].append(row_payload) for row in erp_unmatched_grouped.values(): row["rows"].sort( key=lambda item: ( clean(item.get("proof_date")) or clean(item.get("ledger_date")), clean(item.get("draft_no")), clean(item.get("voucher_no")), clean(item.get("voucher_account_name")) or clean(item.get("ledger_account_name")), -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_unmatched"].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'" elif status == "erp_voucher_unmatched": status_condition = "c.status = 'voucher_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", "bridgeexpensereview": "bridge_expense_review", "ledgeronly": "ledger_only", "amountmismatch": "amount_mismatch", "voucheronly": "voucher_only", "vouchermatched": "voucher_matched", "erpvouchermatched": "erp_voucher_matched", "voucherunmatched": "voucher_unmatched", "erpvoucherunmatched": "erp_voucher_unmatched", "voucherrecheck": "voucher_recheck", } if normalized_status not in {"matched", "bridge_expense_review", "ledger_only", "amount_mismatch", "voucher_only", "voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched", "voucher_recheck"}: normalized_status = allowed.get(normalized_status, normalized_status) if normalized_status not in {"matched", "bridge_expense_review", "ledger_only", "amount_mismatch", "voucher_only", "voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_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) if normalized_status == "bridge_expense_review": with engine.begin() as conn: return _fetch_bridge_expense_review_rows_from_db( conn, start_year, end_year, voucher_filter, draft_filter, wehago_account_filter, erp_account_filter, clean(wehago_amount), clean(erp_amount), wehago_vendor_filter, erp_vendor_filter, desc_filter, safe_offset, safe_limit, ) with engine.begin() as conn: snapshot_state = _get_compare_snapshot_state(conn, start_year, end_year) pending_years = snapshot_state["missing"] + snapshot_state["stale"] if pending_years: try: enqueue_year_snapshot_rebuild(engine, pending_years) except Exception: pass notice = "" if pending_years: year_text = ", ".join(str(year) for year in pending_years[:5]) suffix = "..." if len(pending_years) > 5 else "" notice = f"최신 전표 스냅샷을 갱신 중입니다. ({year_text}{suffix})" if normalized_status == "voucher_recheck": with engine.begin() as conn: shown_groups, total_count = _fetch_cached_export_group_page( 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, safe_offset, safe_limit, ) next_offset = safe_offset + len(shown_groups) return { "columns": DETAIL_COLUMN_MAP[normalized_status], "rows": [dict(group.get("summary", {})) for group in shown_groups], "groups": shown_groups, "total_count": total_count, "shown_count": len(shown_groups), "offset": safe_offset, "limit": safe_limit, "has_more": next_offset < total_count, "next_offset": next_offset, "notice": notice, "bank_payable_case_count": 0, "boundary_excluded_count": 0, } if normalized_status in {"voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched"}: with engine.begin() as conn: shown_groups, total_count = _fetch_cached_export_group_page( 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, safe_offset, safe_limit, ) next_offset = safe_offset + len(shown_groups) return { "columns": DETAIL_COLUMN_MAP[normalized_status], "rows": [dict(group.get("summary", {})) for group in shown_groups], "groups": shown_groups, "total_count": total_count, "shown_count": len(shown_groups), "offset": safe_offset, "limit": safe_limit, "has_more": next_offset < total_count, "next_offset": next_offset, "notice": notice, "bank_payable_case_count": 0, "boundary_excluded_count": 0, } rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year) payload = _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, ) payload["notice"] = notice or clean(payload.get("notice")) return payload def _normalize_voucher_export_status_key(status: str) -> str: normalized_status = normalize_text(status).lower() status_map = { "vouchermatched": "voucher_matched", "erpvouchermatched": "erp_voucher_matched", "voucherunmatched": "voucher_unmatched", "erpvoucherunmatched": "erp_voucher_unmatched", "voucher_matched": "voucher_matched", "erp_voucher_matched": "erp_voucher_matched", "voucher_unmatched": "voucher_unmatched", "erp_voucher_unmatched": "erp_voucher_unmatched", } normalized_status = status_map.get(normalized_status, normalized_status) if normalized_status not in VOUCHER_EXPORT_STATUS_KEYS: raise ValueError("엑셀 다운로드는 WEHAGO, Hanmac ERP, WEHAGO Unmatched 표에서만 지원됩니다.") return normalized_status def _get_filtered_voucher_export_groups( 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 = "", ) -> tuple[str, list[dict[str, Any]]]: normalized_status = _normalize_voucher_export_status_key(status) 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) 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) wehago_amount_filter = clean(wehago_amount) erp_amount_filter = clean(erp_amount) 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, wehago_amount_filter, erp_amount_filter, wehago_vendor_filter, erp_vendor_filter, desc_filter, ) ] return normalized_status, filtered_rows def _safe_compare_export_segment(value: Any) -> str: raw = re.sub(r"[^0-9A-Za-z가-힣._-]+", "-", clean(value)) return raw.strip("-")[:80] or "all" def _build_status_export_job_key( normalized_status: str, start_year: int | None, end_year: int | None, 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, ) -> tuple[str, str]: export_key = _build_voucher_export_cache_key( start_year, end_year, normalized_status, voucher_no, draft_no, wehago_account, erp_account, wehago_amount, erp_amount, wehago_vendor, erp_vendor, desc_keyword, ) digest = hashlib.sha1(export_key.encode('utf-8')).hexdigest() return export_key, f"status_export_xlsx:{digest}" def _compare_range_cache_key(start_year: int | None, end_year: int | None) -> str: return f"{int(start_year or 0)}:{int(end_year or 0)}" def _get_ttl_cached_payload( cache: dict[str, dict[str, Any]], key: str, ttl_seconds: float, ) -> dict[str, Any] | None: entry = cache.get(key) if not entry: return None expires_at = float(entry.get("expires_at") or 0.0) if time.monotonic() >= expires_at: cache.pop(key, None) return None payload = entry.get("payload") return copy.deepcopy(payload) if isinstance(payload, dict) else None def _store_ttl_cached_payload( cache: dict[str, dict[str, Any]], key: str, payload: dict[str, Any], ttl_seconds: float, ) -> dict[str, Any]: cache[key] = { "expires_at": time.monotonic() + max(float(ttl_seconds or 0.0), 1.0), "payload": copy.deepcopy(payload), } return copy.deepcopy(payload) def _aggregate_snapshot_state_counts(snapshot_state: dict[str, Any] | None) -> dict[str, int]: state = snapshot_state if isinstance(snapshot_state, dict) else {} return { "ready": len(state.get("ready") or []), "queued": len(state.get("queued") or []), "running": len(state.get("running") or []), "stale": len(state.get("stale") or []), "failed": len(state.get("failed") or []), "missing": len(state.get("missing") or []), } def _build_snapshot_status_payload( conn: Any, start_year: int | None, end_year: int | None, ) -> dict[str, Any]: available_years = _discover_available_fiscal_years(conn) selected_years = [ int(year) for year in available_years if (start_year is None or int(year) >= start_year) and (end_year is None or int(year) <= end_year) ] status_map = _load_snapshot_status_map(conn, selected_years) years = [ _compute_year_snapshot_runtime_state(conn, year, status_map.get(year)) for year in selected_years ] exact_summary = _load_summary_range_cache(conn, start_year, end_year) latest_summary = _load_latest_summary_range_cache_any_signature(conn, start_year, end_year) recent_jobs = _load_recent_compare_jobs(conn, start_year, end_year, limit=20) snapshot_state = _get_compare_snapshot_state(conn, start_year, end_year) return { "range_start_year": start_year, "range_end_year": end_year, "snapshot_state": snapshot_state, "snapshot_policy": _build_compare_snapshot_policy(start_year, end_year, available_years), "aggregate": _aggregate_snapshot_state_counts(snapshot_state), "years": years, "summary_cache": { "exact": bool(exact_summary), "latest_available": bool(latest_summary), "counts": (exact_summary or latest_summary or {}).get("counts", {}), "snapshot_state": (exact_summary or latest_summary or {}).get("snapshot_state", {}), }, "recent_jobs": recent_jobs, } def _get_cached_snapshot_status_payload( engine: Any, start_year: int | None, end_year: int | None, *, force: bool = False, ) -> dict[str, Any]: key = _compare_range_cache_key(start_year, end_year) if not force: cached = _get_ttl_cached_payload(_SNAPSHOT_STATUS_CACHE, key, _SNAPSHOT_STATUS_CACHE_TTL_SEC) if cached is not None: return cached init_wehago_compare_db(engine) with engine.begin() as conn: payload = _build_snapshot_status_payload(conn, start_year, end_year) return _store_ttl_cached_payload(_SNAPSHOT_STATUS_CACHE, key, payload, _SNAPSHOT_STATUS_CACHE_TTL_SEC) def _cleanup_old_compare_export_files(keep_paths: set[str] | None = None, older_than_sec: int = 24 * 60 * 60) -> None: keep = {str(Path(item)) for item in (keep_paths or set())} root = WEHAGO_COMPARE_EXPORT_ROOT if not root.exists(): return threshold = time.time() - older_than_sec for path in root.glob('*.xlsx'): try: if str(path) in keep: continue if path.stat().st_mtime >= threshold: continue path.unlink(missing_ok=True) except Exception: continue def _load_status_export_job(conn: Any, job_key: str) -> dict[str, Any] | None: row = conn.execute( text( """ SELECT job_key, export_key, status_key, start_year, end_year, payload_json, file_name, file_path, row_count, state, error_message, created_at, updated_at, started_at, finished_at FROM wehago_compare_export_jobs WHERE job_key = :job_key LIMIT 1 """ ), {"job_key": job_key}, ).mappings().first() if not row: return None payload = {} try: payload = json.loads(clean(row.get('payload_json')) or '{}') except Exception: payload = {} return { 'job_key': clean(row.get('job_key')), 'export_key': clean(row.get('export_key')), 'status_key': clean(row.get('status_key')), 'start_year': row.get('start_year'), 'end_year': row.get('end_year'), 'payload': payload if isinstance(payload, dict) else {}, 'file_name': clean(row.get('file_name')), 'file_path': clean(row.get('file_path')), 'row_count': int(row.get('row_count') or 0), 'state': clean(row.get('state')), 'error_message': clean(row.get('error_message')), 'created_at': clean(row.get('created_at')), 'updated_at': clean(row.get('updated_at')), 'started_at': clean(row.get('started_at')), 'finished_at': clean(row.get('finished_at')), } def _store_status_export_job_file( conn: Any, job_key: str, file_name: str, file_path: Path, row_count: int, ) -> None: conn.execute( text( """ UPDATE wehago_compare_export_jobs SET file_name = :file_name, file_path = :file_path, row_count = :row_count, state = 'ready', error_message = '', finished_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE job_key = :job_key """ ), { 'job_key': job_key, 'file_name': file_name, 'file_path': str(file_path), 'row_count': int(row_count), }, ) def _mark_status_export_job_failed(conn: Any, job_key: str, error_message: str) -> None: conn.execute( text( """ UPDATE wehago_compare_export_jobs SET state = 'failed', error_message = :error_message, finished_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE job_key = :job_key """ ), {'job_key': job_key, 'error_message': clean(error_message)[:500]}, ) def request_status_export_xlsx( 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 = '', ) -> dict[str, Any]: normalized_status = _normalize_voucher_export_status_key(status) export_key, job_key = _build_status_export_job_key( normalized_status, start_year, end_year, voucher_no, draft_no, wehago_account, erp_account, wehago_amount, erp_amount, wehago_vendor, erp_vendor, desc_keyword, ) init_wehago_compare_db(engine) payload = { 'status': normalized_status, 'start_year': start_year, 'end_year': end_year, 'voucher_no': voucher_no, 'draft_no': draft_no, 'wehago_account': wehago_account, 'erp_account': erp_account, 'wehago_amount': wehago_amount, 'erp_amount': erp_amount, 'wehago_vendor': wehago_vendor, 'erp_vendor': erp_vendor, 'desc_keyword': desc_keyword, } def _read_existing_job(conn: Any) -> dict[str, Any] | None: existing = conn.execute( text( """ SELECT job_key, state, file_name, file_path, row_count, error_message, created_at, updated_at, started_at, finished_at FROM wehago_compare_export_jobs WHERE export_key = :export_key ORDER BY updated_at DESC, created_at DESC LIMIT 1 """ ), {'export_key': export_key}, ).mappings().first() if not existing: return None existing_state = clean(existing.get('state')) existing_job_key = clean(existing.get('job_key')) existing_file_path = clean(existing.get('file_path')) if existing_state == 'ready' and existing_file_path and Path(existing_file_path).exists(): return { 'job_key': existing_job_key, 'state': 'ready', 'download_url': f"/wehago-compare/api/status-export-download/{existing_job_key}", 'row_count': int(existing.get('row_count') or 0), 'file_name': clean(existing.get('file_name')), } if existing_state in {'queued', 'running'}: started_at = _parse_db_timestamp(existing.get('started_at')) or _parse_db_timestamp(existing.get('updated_at')) or _parse_db_timestamp(existing.get('created_at')) if existing_state == 'running' and started_at and (datetime.now() - started_at).total_seconds() >= COMPARE_JOB_STALE_RUNNING_SEC: try: conn.execute( text( """ UPDATE wehago_background_jobs SET state = 'queued', error_message = '', updated_at = CURRENT_TIMESTAMP WHERE job_key = :job_key AND job_type = 'status_export_xlsx' """ ), {'job_key': existing_job_key}, ) conn.execute( text( """ UPDATE wehago_compare_export_jobs SET state = 'queued', error_message = '', updated_at = CURRENT_TIMESTAMP, started_at = '', finished_at = '' WHERE job_key = :job_key """ ), {'job_key': existing_job_key}, ) existing_state = 'queued' except OperationalError: existing_state = 'running' return { 'job_key': existing_job_key, 'state': existing_state, 'download_url': '', 'row_count': int(existing.get('row_count') or 0), 'file_name': clean(existing.get('file_name')), } return None last_error: Exception | None = None for attempt in range(4): try: with engine.begin() as conn: _try_normalize_compare_background_jobs(conn, force=True) existing_payload = _read_existing_job(conn) if existing_payload: return existing_payload conn.execute( text( """ INSERT INTO wehago_background_jobs ( job_key, job_type, payload_json, priority, state, error_message, created_at, updated_at ) VALUES ( :job_key, 'status_export_xlsx', :payload_json, :priority, 'queued', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) ON CONFLICT(job_key) DO UPDATE SET state = CASE WHEN wehago_background_jobs.state = 'running' THEN wehago_background_jobs.state ELSE 'queued' END, payload_json = excluded.payload_json, priority = excluded.priority, error_message = '', updated_at = CURRENT_TIMESTAMP """ ), { 'job_key': job_key, 'payload_json': json.dumps(payload, ensure_ascii=False, sort_keys=True), 'priority': max(5, _compare_range_job_priority(start_year, end_year) - 25), }, ) conn.execute( text( """ INSERT INTO wehago_compare_export_jobs ( job_key, export_key, status_key, start_year, end_year, payload_json, file_name, file_path, row_count, state, error_message, created_at, updated_at, started_at, finished_at ) VALUES ( :job_key, :export_key, :status_key, :start_year, :end_year, :payload_json, '', '', 0, 'queued', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, '', '' ) ON CONFLICT(job_key) DO UPDATE SET export_key = excluded.export_key, status_key = excluded.status_key, start_year = excluded.start_year, end_year = excluded.end_year, payload_json = excluded.payload_json, state = CASE WHEN wehago_compare_export_jobs.state = 'running' THEN wehago_compare_export_jobs.state ELSE 'queued' END, error_message = '', updated_at = CURRENT_TIMESTAMP """ ), { 'job_key': job_key, 'export_key': export_key, 'status_key': normalized_status, 'start_year': start_year, 'end_year': end_year, 'payload_json': json.dumps(payload, ensure_ascii=False, sort_keys=True), }, ) _ensure_compare_snapshot_worker(engine) _COMPARE_SNAPSHOT_JOB_EVENT.set() return {'job_key': job_key, 'state': 'queued', 'download_url': '', 'row_count': 0, 'file_name': ''} except OperationalError as exc: last_error = exc time.sleep(0.15 * (attempt + 1)) with engine.begin() as conn: existing_payload = _read_existing_job(conn) if existing_payload: return existing_payload if last_error is not None: raise last_error return {'job_key': job_key, 'state': 'queued', 'download_url': '', 'row_count': 0, 'file_name': ''} def get_status_export_job(engine: Any, job_key: str) -> dict[str, Any]: init_wehago_compare_db(engine) with engine.begin() as conn: _try_normalize_compare_background_jobs(conn, force=True) job = _load_status_export_job(conn, job_key) if not job: raise ValueError('엑셀 준비 작업을 찾을 수 없습니다.') file_path = clean(job.get('file_path')) ready = job.get('state') == 'ready' and file_path and Path(file_path).exists() return { **job, 'download_url': f"/wehago-compare/api/status-export-download/{job_key}" if ready else '', } def _store_year_export_row_cache( conn: Any, year: int, signature: str, voucher_sections: dict[str, list[dict[str, Any]]], ) -> None: conn.execute( text( """ DELETE FROM wehago_compare_export_row_cache WHERE fiscal_year = :fiscal_year """ ), {"fiscal_year": year}, ) insert_sql = text( """ INSERT INTO wehago_compare_export_row_cache ( fiscal_year, status_key, snapshot_signature, group_sort, row_sort, group_voucher_no, group_draft_no, group_ledger_accounts, group_voucher_accounts, group_ledger_vendors, group_voucher_vendors, group_ledger_debit, group_ledger_credit, group_voucher_debit, group_voucher_credit, ledger_date, voucher_no, draft_no, ledger_account_name, voucher_account_name, ledger_vendor, voucher_vendor, ledger_debit, ledger_credit, voucher_debit, voucher_credit, ledger_desc, voucher_desc ) VALUES ( :fiscal_year, :status_key, :snapshot_signature, :group_sort, :row_sort, :group_voucher_no, :group_draft_no, :group_ledger_accounts, :group_voucher_accounts, :group_ledger_vendors, :group_voucher_vendors, :group_ledger_debit, :group_ledger_credit, :group_voucher_debit, :group_voucher_credit, :ledger_date, :voucher_no, :draft_no, :ledger_account_name, :voucher_account_name, :ledger_vendor, :voucher_vendor, :ledger_debit, :ledger_credit, :voucher_debit, :voucher_credit, :ledger_desc, :voucher_desc ) """ ) payload_rows: list[dict[str, Any]] = [] for status_key in EXPORT_ROW_CACHE_STATUS_KEYS: groups = voucher_sections.get(status_key, []) for group_index, group in enumerate(groups): summary = group.get("summary", {}) group_payload = { "fiscal_year": year, "status_key": status_key, "snapshot_signature": signature, "group_sort": group_index, "group_voucher_no": clean(summary.get("voucher_no")), "group_draft_no": clean(summary.get("draft_no")), "group_ledger_accounts": clean(summary.get("ledger_accounts")), "group_voucher_accounts": clean(summary.get("voucher_accounts")), "group_ledger_vendors": clean(summary.get("ledger_vendors")), "group_voucher_vendors": clean(summary.get("voucher_vendors")), "group_ledger_debit": parse_amount(summary.get("ledger_debit")), "group_ledger_credit": parse_amount(summary.get("ledger_credit")), "group_voucher_debit": parse_amount(summary.get("voucher_debit")), "group_voucher_credit": parse_amount(summary.get("voucher_credit")), } for row_index, row in enumerate(group.get("rows", [])): payload_rows.append( { **group_payload, "row_sort": row_index, "ledger_date": clean(row.get("ledger_date")), "voucher_no": clean(row.get("voucher_no")), "draft_no": clean(row.get("draft_no")), "ledger_account_name": clean(row.get("ledger_account_name")), "voucher_account_name": clean(row.get("voucher_account_name")), "ledger_vendor": clean(row.get("ledger_vendor")), "voucher_vendor": clean(row.get("voucher_vendor")), "ledger_debit": parse_amount(row.get("ledger_debit")), "ledger_credit": parse_amount(row.get("ledger_credit")), "voucher_debit": parse_amount(row.get("voucher_debit")), "voucher_credit": parse_amount(row.get("voucher_credit")), "ledger_desc": clean(row.get("ledger_desc")), "voucher_desc": clean(row.get("voucher_desc")), } ) if payload_rows: conn.execute(insert_sql, payload_rows) def _ensure_year_export_row_cache(conn: Any, year: int) -> str: signature = _build_db_state_signature(conn, year, year) exists = conn.execute( text( """ SELECT 1 FROM wehago_compare_export_row_cache WHERE fiscal_year = :fiscal_year AND snapshot_signature = :signature LIMIT 1 """ ), {"fiscal_year": year, "signature": signature}, ).first() if exists: return signature latest_cached_signature = clean( conn.execute( text( """ SELECT snapshot_signature FROM wehago_compare_export_row_cache WHERE fiscal_year = :fiscal_year ORDER BY rowid DESC LIMIT 1 """ ), {"fiscal_year": year}, ).scalar() ) if latest_cached_signature: return latest_cached_signature stale_signature = clean( conn.execute( text( """ SELECT snapshot_signature FROM wehago_snapshot_status WHERE fiscal_year = :fiscal_year ORDER BY updated_at DESC LIMIT 1 """ ), {"fiscal_year": year}, ).scalar() ) if stale_signature: stale_exists = conn.execute( text( """ SELECT 1 FROM wehago_compare_export_row_cache WHERE fiscal_year = :fiscal_year AND snapshot_signature = :signature LIMIT 1 """ ), {"fiscal_year": year, "signature": stale_signature}, ).first() if stale_exists: return stale_signature sections = _load_year_resolved_sections_cache(conn, year, signature) if sections is None and stale_signature: stale_sections = _load_year_resolved_sections_cache(conn, year, stale_signature) if stale_sections is not None: sections = stale_sections signature = stale_signature if sections is None: sections = _refresh_year_resolved_sections(conn, year) signature = _build_db_state_signature(conn, year, year) rows_by_status = { "matched": list((sections or {}).get("matched", {}).get("rows", [])), "ledger_only": list((sections or {}).get("ledger_only", {}).get("rows", [])), "amount_mismatch": list((sections or {}).get("amount_mismatch", {}).get("rows", [])), "voucher_only": list((sections or {}).get("voucher_only", {}).get("rows", [])), } voucher_sections = _build_voucher_sections_with_bridge_promotions(conn, year, year, rows_by_status) _store_year_export_row_cache(conn, year, signature, voucher_sections) return signature def _iter_cached_export_rows( conn: Any, start_year: int | None, end_year: int | None, normalized_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, ) -> tuple[list[dict[str, Any]], int]: if start_year is None or end_year is None: return [], 0 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) wehago_amount_filter = clean(wehago_amount) erp_amount_filter = clean(erp_amount) selected_years = list(range(int(start_year), int(end_year) + 1)) signatures = {year: _ensure_year_export_row_cache(conn, year) for year in selected_years} all_rows: list[dict[str, Any]] = [] for year in selected_years: signature = signatures.get(year) or '' if not signature: continue rows = conn.execute( text( """ SELECT fiscal_year, ledger_date, voucher_no, draft_no, ledger_account_name, voucher_account_name, ledger_vendor, voucher_vendor, ledger_debit, ledger_credit, voucher_debit, voucher_credit, ledger_desc, voucher_desc, group_voucher_no, group_draft_no, group_ledger_accounts, group_voucher_accounts, group_ledger_vendors, group_voucher_vendors, group_ledger_debit, group_ledger_credit, group_voucher_debit, group_voucher_credit FROM wehago_compare_export_row_cache WHERE fiscal_year = :fiscal_year AND status_key = :status_key AND snapshot_signature = :signature ORDER BY group_sort ASC, row_sort ASC """ ), {"fiscal_year": year, "status_key": normalized_status, "signature": signature}, ).mappings().all() for row in rows: voucher_match_target = normalize_text(clean(row.get("voucher_no")) or clean(row.get("group_voucher_no"))) if voucher_filter and voucher_filter not in voucher_match_target: continue if draft_filter and draft_filter not in normalize_text(row.get("group_draft_no")): continue if wehago_account_filter and wehago_account_filter not in normalize_text(row.get("group_ledger_accounts")): continue if erp_account_filter and erp_account_filter not in normalize_text(row.get("group_voucher_accounts")): continue if wehago_vendor_filter and wehago_vendor_filter not in normalize_text(row.get("group_ledger_vendors")): continue if erp_vendor_filter and erp_vendor_filter not in normalize_text(row.get("group_voucher_vendors")): continue if desc_filter: haystack = " ".join( [ clean(row.get("group_ledger_accounts")), clean(row.get("group_voucher_accounts")), clean(row.get("group_ledger_vendors")), clean(row.get("group_voucher_vendors")), clean(row.get("ledger_desc")), clean(row.get("voucher_desc")), ] ) if desc_filter not in normalize_text(haystack): continue if wehago_amount_filter: amount_text = " ".join( [ str(int(round(parse_amount(row.get("group_ledger_debit"))))), str(int(round(parse_amount(row.get("group_ledger_credit"))))), ] ) if wehago_amount_filter not in amount_text: continue if erp_amount_filter: amount_text = " ".join( [ str(int(round(parse_amount(row.get("group_voucher_debit"))))), str(int(round(parse_amount(row.get("group_voucher_credit"))))), ] ) if erp_amount_filter not in amount_text: continue all_rows.append(dict(row)) return all_rows, len(all_rows) def _build_voucher_groups_from_cached_export_rows( rows: list[dict[str, Any]], normalized_status: str, ) -> list[dict[str, Any]]: status_label_map = { "voucher_matched": "Matched", "erp_voucher_matched": "Matched", "voucher_unmatched": "Unmatched", "erp_voucher_unmatched": "ERP Unmatched", "voucher_recheck": "Recheck", } row_status_label = status_label_map.get(normalized_status, "Recheck") groups: list[dict[str, Any]] = [] current_group: dict[str, Any] | None = None current_key: tuple[int, str, str, str] | None = None for row in rows: fiscal_year = int(row.get("fiscal_year") or 0) ledger_date = clean(row.get("ledger_date")) group_voucher_no = clean(row.get("group_voucher_no") or row.get("voucher_no")) group_draft_no = clean(row.get("group_draft_no") or row.get("draft_no")) group_key = (fiscal_year, group_voucher_no, group_draft_no, ledger_date) if current_key != group_key: summary = { "fiscal_year": fiscal_year, "status_label": row_status_label, "ledger_date": ledger_date, "proof_date": "", "voucher_no": group_voucher_no, "draft_no": group_draft_no, "ledger_row_count": 0, "voucher_row_count": 0, "ledger_debit": parse_amount(row.get("group_ledger_debit")), "ledger_credit": parse_amount(row.get("group_ledger_credit")), "voucher_debit": parse_amount(row.get("group_voucher_debit")), "voucher_credit": parse_amount(row.get("group_voucher_credit")), "ledger_accounts": clean(row.get("group_ledger_accounts")), "voucher_accounts": clean(row.get("group_voucher_accounts")), "ledger_vendors": clean(row.get("group_ledger_vendors")), "voucher_vendors": clean(row.get("group_voucher_vendors")), "review_reason": "", } current_group = {"summary": summary, "rows": []} groups.append(current_group) current_key = group_key if current_group is None: continue row_payload = { "fiscal_year": fiscal_year, "status_label": row_status_label, "ledger_date": ledger_date, "proof_date": "", "voucher_no": clean(row.get("voucher_no") or group_voucher_no), "draft_no": clean(row.get("draft_no") or group_draft_no), "ledger_account_name": clean(row.get("ledger_account_name")), "voucher_account_name": clean(row.get("voucher_account_name")), "ledger_vendor": clean(row.get("ledger_vendor")), "voucher_vendor": clean(row.get("voucher_vendor")), "ledger_debit": parse_amount(row.get("ledger_debit")), "ledger_credit": parse_amount(row.get("ledger_credit")), "voucher_debit": parse_amount(row.get("voucher_debit")), "voucher_credit": parse_amount(row.get("voucher_credit")), "ledger_desc": clean(row.get("ledger_desc")), "voucher_desc": clean(row.get("voucher_desc")), } if row_payload["ledger_account_name"] or row_payload["ledger_desc"]: current_group["summary"]["ledger_row_count"] += 1 if row_payload["voucher_account_name"] or row_payload["voucher_desc"]: current_group["summary"]["voucher_row_count"] += 1 current_group["rows"].append(row_payload) return groups def _fetch_cached_export_group_page( conn: Any, start_year: int | None, end_year: int | None, normalized_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, offset: int, limit: int, ) -> tuple[list[dict[str, Any]], int]: if start_year is None or end_year is None: return [], 0 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) wehago_amount_filter = clean(wehago_amount) erp_amount_filter = clean(erp_amount) selected_years = list(range(int(start_year), int(end_year) + 1)) signatures = {year: _ensure_year_export_row_cache(conn, year) for year in selected_years} where_sql = """ status_key = :status_key AND fiscal_year = :fiscal_year AND snapshot_signature = :signature AND (:voucher_keyword = '' OR COALESCE(voucher_no, group_voucher_no, '') LIKE :voucher_like OR COALESCE(group_voucher_no, '') LIKE :voucher_like) AND (:draft_keyword = '' OR COALESCE(group_draft_no, '') LIKE :draft_like) AND (:wehago_account_keyword = '' OR COALESCE(group_ledger_accounts, '') LIKE :wehago_account_like) AND (:erp_account_keyword = '' OR COALESCE(group_voucher_accounts, '') LIKE :erp_account_like) AND (:wehago_vendor_keyword = '' OR COALESCE(group_ledger_vendors, '') LIKE :wehago_vendor_like) AND (:erp_vendor_keyword = '' OR COALESCE(group_voucher_vendors, '') LIKE :erp_vendor_like) AND ( :desc_keyword = '' OR ( COALESCE(group_ledger_accounts, '') || ' ' || COALESCE(group_voucher_accounts, '') || ' ' || COALESCE(group_ledger_vendors, '') || ' ' || COALESCE(group_voucher_vendors, '') || ' ' || COALESCE(ledger_desc, '') || ' ' || COALESCE(voucher_desc, '') ) LIKE :desc_like ) AND ( :wehago_amount_keyword = '' OR CAST(ABS(COALESCE(group_ledger_debit, 0)) AS TEXT) LIKE '%' || :wehago_amount_keyword || '%' OR CAST(ABS(COALESCE(group_ledger_credit, 0)) AS TEXT) LIKE '%' || :wehago_amount_keyword || '%' ) AND ( :erp_amount_keyword = '' OR CAST(ABS(COALESCE(group_voucher_debit, 0)) AS TEXT) LIKE '%' || :erp_amount_keyword || '%' OR CAST(ABS(COALESCE(group_voucher_credit, 0)) AS TEXT) LIKE '%' || :erp_amount_keyword || '%' ) """ header_params_base = { "status_key": normalized_status, "voucher_keyword": voucher_filter, "voucher_like": f"%{voucher_filter}%", "draft_keyword": draft_filter, "draft_like": f"%{draft_filter}%", "wehago_account_keyword": wehago_account_filter, "wehago_account_like": f"%{wehago_account_filter}%", "erp_account_keyword": erp_account_filter, "erp_account_like": f"%{erp_account_filter}%", "wehago_vendor_keyword": wehago_vendor_filter, "wehago_vendor_like": f"%{wehago_vendor_filter}%", "erp_vendor_keyword": erp_vendor_filter, "erp_vendor_like": f"%{erp_vendor_filter}%", "desc_keyword": desc_filter, "desc_like": f"%{desc_filter}%", "wehago_amount_keyword": wehago_amount_filter, "erp_amount_keyword": erp_amount_filter, } header_rows: list[dict[str, Any]] = [] for year in selected_years: signature = signatures.get(year) or "" if not signature: continue params = { **header_params_base, "fiscal_year": year, "signature": signature, } year_headers = conn.execute( text( f""" SELECT DISTINCT fiscal_year, group_sort, COALESCE(ledger_date, '') AS ledger_date, COALESCE(group_voucher_no, '') AS group_voucher_no, COALESCE(group_draft_no, '') AS group_draft_no FROM wehago_compare_export_row_cache WHERE {where_sql} ORDER BY fiscal_year ASC, group_sort ASC """ ), params, ).mappings().all() header_rows.extend(dict(row) for row in year_headers) total_count = len(header_rows) if total_count == 0: return [], 0 selected_headers = header_rows[offset : offset + limit] if not selected_headers: return [], total_count group_sorts_by_year: dict[int, set[int]] = {} for header in selected_headers: fiscal_year = int(header.get("fiscal_year") or 0) group_sort = int(header.get("group_sort") or 0) if fiscal_year <= 0: continue group_sorts_by_year.setdefault(fiscal_year, set()).add(group_sort) detail_rows: list[dict[str, Any]] = [] for year in selected_years: selected_group_sorts = sorted(group_sorts_by_year.get(year) or []) if not selected_group_sorts: continue signature = signatures.get(year) or "" if not signature: continue placeholders = ", ".join(f":group_sort_{index}" for index, _ in enumerate(selected_group_sorts)) params = { **header_params_base, "fiscal_year": year, "signature": signature, } for index, group_sort in enumerate(selected_group_sorts): params[f"group_sort_{index}"] = int(group_sort) rows = conn.execute( text( f""" SELECT fiscal_year, ledger_date, voucher_no, draft_no, ledger_account_name, voucher_account_name, ledger_vendor, voucher_vendor, ledger_debit, ledger_credit, voucher_debit, voucher_credit, ledger_desc, voucher_desc, group_voucher_no, group_draft_no, group_ledger_accounts, group_voucher_accounts, group_ledger_vendors, group_voucher_vendors, group_ledger_debit, group_ledger_credit, group_voucher_debit, group_voucher_credit, group_sort, row_sort FROM wehago_compare_export_row_cache WHERE {where_sql} AND group_sort IN ({placeholders}) ORDER BY fiscal_year ASC, group_sort ASC, row_sort ASC """ ), params, ).mappings().all() detail_rows.extend(dict(row) for row in rows) groups = _build_voucher_groups_from_cached_export_rows(detail_rows, normalized_status) return groups, total_count def _build_voucher_export_cache_key( start_year: int | None, end_year: int | None, normalized_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, ) -> str: return "|".join( [ normalized_status, str(start_year or "all"), str(end_year or "all"), _build_bundle_signature(start_year, end_year) if start_year is not None and end_year is not None else "no-range", normalize_text(voucher_no), normalize_text(draft_no), normalize_text(wehago_account), normalize_text(erp_account), clean(wehago_amount), clean(erp_amount), normalize_text(wehago_vendor), normalize_text(erp_vendor), normalize_text(desc_keyword), _current_logic_signature(), ] ) def _coerce_voucher_export_cell(field: str, raw: Any) -> Any: if field == "fiscal_year": return clean(raw) if field in {"ledger_debit", "ledger_credit", "voucher_debit", "voucher_credit"}: amount = parse_amount(raw) return "" if abs(amount) < 0.000001 else amount return clean(raw) def export_wehago_status_rows_xlsx( 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 = "", ) -> tuple[str, bytes, int]: normalized_status = _normalize_voucher_export_status_key(status) cache_key = _build_voucher_export_cache_key( start_year, end_year, normalized_status, voucher_no, draft_no, wehago_account, erp_account, wehago_amount, erp_amount, wehago_vendor, erp_vendor, desc_keyword, ) cached = _STATUS_EXPORT_CACHE.get(cache_key) now = time.time() if cached and (now - float(cached.get("ts", 0))) <= _STATUS_EXPORT_CACHE_TTL_SEC: return cached["file_name"], cached["content"], int(cached.get("row_count") or 0) with engine.begin() as conn: export_rows, row_count = _iter_cached_export_rows( conn, start_year, end_year, normalized_status, voucher_no, draft_no, wehago_account, erp_account, wehago_amount, erp_amount, wehago_vendor, erp_vendor, desc_keyword, ) workbook = Workbook(write_only=True) status_labels = {key: label for key, label, _ in STATUS_META} worksheet = workbook.create_sheet(title=status_labels.get(normalized_status, normalized_status)[:31]) headers = [label for _, label in VOUCHER_EXPORT_LINE_COLUMNS] worksheet.append(headers) for row in export_rows: worksheet.append([_coerce_voucher_export_cell(field, row.get(field)) for field, _ in VOUCHER_EXPORT_LINE_COLUMNS]) buffer = BytesIO() workbook.save(buffer) content = buffer.getvalue() file_name = f"wehago_compare_{normalized_status}_{start_year or 'all'}_{end_year or 'all'}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" _STATUS_EXPORT_CACHE.clear() _STATUS_EXPORT_CACHE[cache_key] = {"ts": now, "file_name": file_name, "content": content, "row_count": row_count} return file_name, content, row_count 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", "bridgeexpensereview": "bridge_expense_review", "amountmismatch": "amount_mismatch", "ledgeronly": "ledger_only", "voucheronly": "voucher_only", "vouchermatched": "voucher_matched", "erpvouchermatched": "erp_voucher_matched", "voucherunmatched": "voucher_unmatched", "erpvoucherunmatched": "erp_voucher_unmatched", "voucherrecheck": "voucher_recheck", "ledger_only": "ledger_only", "bridge_expense_review": "bridge_expense_review", "voucher_only": "voucher_only", "amount_mismatch": "amount_mismatch", "voucher_matched": "voucher_matched", "erp_voucher_matched": "erp_voucher_matched", "voucher_unmatched": "voucher_unmatched", "erp_voucher_unmatched": "erp_voucher_unmatched", "voucher_recheck": "voucher_recheck", } normalized_status = status_map.get(normalized_status, normalized_status) if normalized_status not in {"matched", "bridge_expense_review", "amount_mismatch", "ledger_only", "voucher_only", "voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_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", "erp_voucher_unmatched", "voucher_recheck"}: rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year) with engine.begin() as conn: snapshot_state = _get_compare_snapshot_state(conn, start_year, end_year) pending_years = snapshot_state["missing"] + snapshot_state["stale"] if pending_years: try: enqueue_year_snapshot_rebuild(engine, pending_years) except Exception: pass source_rows = _get_cached_voucher_sections_by_range(engine, start_year, end_year, rows_by_status).get(normalized_status, []) 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: try: enqueue_year_snapshot_rebuild(engine, range(start_year, end_year + 1)) enqueue_metric_count_rebuild(engine, start_year, end_year) 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) with engine.begin() as conn: years = sorted(_discover_available_fiscal_years(conn) or discover_available_years(), reverse=True) status_map = _load_snapshot_status_map(conn, years) ready_years = [ int(year) for year in years if clean((status_map.get(int(year)) or {}).get("state")) == "ready" ] current_year = date.today().year completed_ready_years = [int(year) for year in ready_years if int(year) < current_year] default_year = ( completed_ready_years[0] if completed_ready_years else ready_years[0] if ready_years else 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 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"), ) cache_key = _compare_range_cache_key( dashboard.get("selected_start_year"), dashboard.get("selected_end_year"), ) cached_summary = _get_ttl_cached_payload(_COMPARE_SUMMARY_CACHE, cache_key, _COMPARE_SUMMARY_CACHE_TTL_SEC) if cached_summary is None: with engine.begin() as conn: available_years = _discover_available_fiscal_years(conn) latest_summary = _load_latest_summary_range_cache_any_signature( conn, dashboard.get("selected_start_year"), dashboard.get("selected_end_year"), ) snapshot_state = (latest_summary or {}).get("snapshot_state") or {"ready": [], "stale": [], "missing": []} snapshot_policy = _build_compare_snapshot_policy( dashboard.get("selected_start_year"), dashboard.get("selected_end_year"), available_years, ) cached_summary = _store_ttl_cached_payload( _COMPARE_SUMMARY_CACHE, cache_key, { "snapshot_state": snapshot_state, "snapshot_policy": snapshot_policy, "snapshot_aggregate": _aggregate_snapshot_state_counts(snapshot_state), }, _COMPARE_SUMMARY_CACHE_TTL_SEC, ) 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, "snapshot_state": cached_summary.get("snapshot_state", {"ready": [], "stale": [], "missing": []}), "snapshot_policy": cached_summary.get("snapshot_policy", {}), "snapshot_aggregate": cached_summary.get("snapshot_aggregate", {}), "snapshot_status_payload": None, } def enqueue_default_pair_recommend_precompute( engine: Any, start_year: int | None = None, end_year: int | None = None, limit: int = 300, ) -> None: with engine.begin() as conn: years = sorted(_discover_available_fiscal_years(conn) or discover_available_years(), reverse=True) status_map = _load_snapshot_status_map(conn, years) ready_years = [ int(year) for year in years if clean((status_map.get(int(year)) or {}).get("state")) == "ready" ] current_year = date.today().year completed_ready_years = [int(year) for year in ready_years if int(year) < current_year] default_year = ( completed_ready_years[0] if completed_ready_years else ready_years[0] if ready_years else 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, )