from __future__ import annotations import copy import hashlib from io import BytesIO import json import re import sqlite3 import subprocess import sys import threading import time from dataclasses import dataclass from datetime import date, datetime, timedelta from difflib import SequenceMatcher from functools import lru_cache from pathlib import Path from typing import Any, Callable, Iterable from openpyxl import Workbook, load_workbook from openpyxl.cell import WriteOnlyCell from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from sqlalchemy import text from sqlalchemy.exc import OperationalError from runtime_config import WEHAGO_COMPARE_EXPORT_ROOT, WEHAGO_SOURCE_ROOT 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 Voucher", "WEHAGO 전표 기준으로 매칭된 결과"), ("voucher_unmatched", "WEHAGO Unmatched", "WEHAGO 전표 기준으로 매칭되지 않은 결과"), ("voucher_recheck", "WEHAGO Recheck", "WEHAGO 전표 기준으로 재검토가 필요한 결과"), ("voucher_excepted", "WEHAGO Excepted", "WEHAGO 전표 중 전기이월 및 회계감사 목적 대체 전표"), ("hanmac_unconnected", "Hanmac unconnected", "Hanmac 전표 중 WEHAGO Voucher/Recheck에 연결되지 않은 전표"), ("erp_voucher_matched", "HANMAC Voucher", "Hanmac ERP 전표 기준으로 매칭된 결과"), ("erp_voucher_unmatched", "HANMAC Unmatched", "Hanmac ERP 전표 기준으로 매칭되지 않은 결과"), ("bridge_expense_review", "2단계 비교", "보통예금 출금을 미지급금 결제와 원 비용 전표까지 추적한 검토 후보"), ] QUERY_PROJECTION_VERSION = "compare-query-v14-strict-erp-draft-year-scope" QUERY_VOUCHER_STATUS_KEYS = ( "voucher_matched", "voucher_unmatched", "voucher_recheck", "voucher_excepted", "hanmac_unconnected", "erp_voucher_matched", "erp_voucher_unmatched", ) QUERY_STANDARD_ROW_STATUS_KEYS = ( "matched", "ledger_only", "amount_mismatch", "voucher_only", ) QUERY_PAGE_CACHEABLE_STATUS_KEYS = QUERY_VOUCHER_STATUS_KEYS + QUERY_STANDARD_ROW_STATUS_KEYS VOUCHER_GROUP_LINE_COLUMNS = [ ("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 적요"), ] 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": VOUCHER_GROUP_LINE_COLUMNS, "voucher_unmatched": VOUCHER_GROUP_LINE_COLUMNS, "voucher_recheck": VOUCHER_GROUP_LINE_COLUMNS, "voucher_excepted": VOUCHER_GROUP_LINE_COLUMNS, "hanmac_unconnected": VOUCHER_GROUP_LINE_COLUMNS, "erp_voucher_unmatched": VOUCHER_GROUP_LINE_COLUMNS, "erp_voucher_matched": VOUCHER_GROUP_LINE_COLUMNS, } 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]] = {} _HANMAC_UNCONNECTED_CACHE: dict[str, dict[str, Any]] = {} _HANMAC_UNCONNECTED_CACHE_TTL_SEC = 60 _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_SNAPSHOT_WORKER_THREAD: threading.Thread | None = None _COMPARE_EXPORT_JOB_EVENT = threading.Event() _COMPARE_EXPORT_WORKER_LOCK = threading.Lock() _COMPARE_EXPORT_WORKER_STARTED = False _COMPARE_EXPORT_WORKER_THREAD: threading.Thread | None = None _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 _STATUS_DETAIL_RESPONSE_CACHE: dict[str, dict[str, Any]] = {} _STATUS_DETAIL_RESPONSE_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 = 3 * 60 * 60 ENABLE_COMPARE_QUERY_BACKGROUND_REBUILD = False ENABLE_GENERATED_RECHECK_CANDIDATES = False RECHECK_RESOLUTION_POLICY_VERSION = "recheck-v31-strict-erp-draft-year-scope" METRIC_COUNT_CACHE_VERSION = "voucher-summary-v13-strict-erp-draft-year-scope" SUMMARY_CACHE_POLICY_VERSION = "dashboard-summary-v1-nonblocking" _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-2m-name-conflict-gate-v2" PAIR_RECOMMEND_DATE_WINDOW_MONTHS = 2 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_recheck", "voucher_excepted", "hanmac_unconnected"} 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 적요"), ] VOUCHER_EXPORT_COLUMN_WIDTHS: dict[str, float] = { "fiscal_year": 8, "ledger_date": 12, "voucher_no": 13, "draft_no": 15, "ledger_account_name": 18, "voucher_account_name": 18, "ledger_vendor": 20, "voucher_vendor": 20, "ledger_debit": 14, "ledger_credit": 14, "voucher_debit": 14, "voucher_credit": 14, "ledger_desc": 32, "voucher_desc": 32, } VOUCHER_EXPORT_HEADER_FILL = PatternFill(fill_type="solid", fgColor="F8FAFC") VOUCHER_EXPORT_BODY_FILL = PatternFill(fill_type="solid", fgColor="FFFFFF") VOUCHER_EXPORT_HEADER_FONT = Font(name="Calibri", size=10, bold=True, color="111827") VOUCHER_EXPORT_BODY_FONT = Font(name="Calibri", size=10, bold=False, color="111827") VOUCHER_EXPORT_HEADER_ALIGNMENT = Alignment(horizontal="left", vertical="center", wrap_text=False) VOUCHER_EXPORT_TEXT_ALIGNMENT = Alignment(horizontal="left", vertical="center", wrap_text=False) VOUCHER_EXPORT_AMOUNT_ALIGNMENT = Alignment(horizontal="right", vertical="center", wrap_text=False) VOUCHER_EXPORT_HEADER_BORDER = Border(bottom=Side(style="thin", color="E5E7EB")) VOUCHER_EXPORT_BODY_BORDER = Border(bottom=Side(style="thin", color="E5E7EB")) VOUCHER_EXPORT_GROUP_START_BORDER = Border(top=Side(style="medium", color="94A3B8"), bottom=Side(style="thin", color="E5E7EB")) EXPORT_ROW_CACHE_STATUS_KEYS = {"voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched", "voucher_recheck", "voucher_excepted", "hanmac_unconnected"} @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: return "|".join(_current_logic_signature_parts()) def _current_logic_signature_parts() -> tuple[str, str, str, str]: return ( METRIC_COUNT_CACHE_VERSION, RECHECK_RESOLUTION_POLICY_VERSION, PAIR_RECOMMEND_POLICY_VERSION, "generated-recheck-on" if ENABLE_GENERATED_RECHECK_CANDIDATES else "generated-recheck-off", ) def _looks_like_legacy_file_signature(part: str) -> bool: return bool(re.fullmatch(r"[0-9a-f]{40}", clean(part).lower())) def _split_snapshot_signature(signature: Any) -> tuple[list[str], list[str]]: parts = clean(signature).split("|") logic_len = len(_current_logic_signature_parts()) if len(parts) < logic_len: return parts, [] logic_parts = parts[:logic_len] state_start = logic_len if len(parts) > logic_len and _looks_like_legacy_file_signature(parts[logic_len]): state_start += 1 return logic_parts, parts[state_start:] def _snapshot_signature_equivalent(signature: Any, current_signature: Any) -> bool: signature_logic, signature_state = _split_snapshot_signature(signature) current_logic, current_state = _split_snapshot_signature(current_signature) return bool(signature_logic and signature_logic == current_logic and signature_state == current_state) 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: 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 COALESCE(MAX(id), 0), COUNT(*) FROM wehago_comparison_results WHERE (:start_year IS NULL OR fiscal_year >= :start_year) AND (:end_year IS NULL OR fiscal_year <= :end_year) """ ), {"start_year": start_year, "end_year": end_year}, ).first() recheck = conn.execute( text( """ SELECT COALESCE(MAX(reviewed_at), ''), COUNT(*) FROM wehago_recheck_reviews WHERE (:start_year IS NULL OR fiscal_year >= :start_year) AND (:end_year IS NULL OR fiscal_year <= :end_year) """ ), {"start_year": start_year, "end_year": end_year}, ).first() pair = conn.execute( text( """ SELECT COALESCE(MAX(created_at), ''), COUNT(*) FROM wehago_manual_pair_matches WHERE (:start_year IS NULL OR fiscal_year >= :start_year) AND (:end_year IS NULL OR fiscal_year <= :end_year) """ ), {"start_year": start_year, "end_year": end_year}, ).first() return "|".join( [ METRIC_COUNT_CACHE_VERSION, SUMMARY_CACHE_POLICY_VERSION, _build_bundle_signature(start_year, end_year), f"s:{source[0]}:{source[1]}", f"c:{comparison[0]}:{comparison[1]}", f"r:{recheck[0]}:{recheck[1]}", f"p:{pair[0]}:{pair[1]}", ] ) 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 _query_projection_signature(conn: Any, start_year: int | None, end_year: int | None) -> str: return "|".join( [ QUERY_PROJECTION_VERSION, _metric_counts_signature(conn, start_year, end_year), ] ) def _load_query_metric_projection( 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 = _query_projection_signature(conn, start_year, end_year) row = conn.execute( text( """ SELECT counts_json, snapshot_state_json, source_state_json FROM wehago_compare_query_metrics 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 row: return None try: counts = json.loads(str(row[0] or "{}")) snapshot_state = json.loads(str(row[1] or "{}")) source_state = json.loads(str(row[2] or "{}")) except Exception: return None if not isinstance(counts, dict): return None return { "counts": {status_key: int(counts.get(status_key, 0) or 0) for status_key, _, _ in STATUS_META}, "snapshot_state": snapshot_state if isinstance(snapshot_state, dict) else {}, "source_state": source_state if isinstance(source_state, dict) else {}, } def _sqlite_db_path_from_engine(engine: Any) -> str | None: try: path = getattr(getattr(engine, "url", None), "database", None) except Exception: path = None return str(path) if path else None def _find_best_query_projection_scope( conn: Any, table_name: str, start_year: int | None, end_year: int | None, status_key: str | None = None, ) -> tuple[int, int, str] | None: if start_year is None or end_year is None: return None sql = f""" SELECT start_year, end_year, signature, MAX(updated_at) AS max_updated_at FROM {table_name} WHERE start_year <= :start_year AND end_year >= :end_year AND signature LIKE :signature_like """ params: dict[str, Any] = { "start_year": int(start_year), "end_year": int(end_year), "signature_like": f"{QUERY_PROJECTION_VERSION}|%", } if status_key: sql += " AND status_key = :status_key" params["status_key"] = clean(status_key) sql += """ GROUP BY start_year, end_year, signature ORDER BY CASE WHEN start_year = :start_year AND end_year = :end_year THEN 0 ELSE 1 END ASC, (end_year - start_year) ASC, max_updated_at DESC LIMIT 1 """ row = conn.execute(text(sql), params).first() if not row: fallback_sql = f""" SELECT start_year, end_year, signature, MAX(updated_at) AS max_updated_at FROM {table_name} WHERE start_year <= :start_year AND end_year >= :end_year """ fallback_params: dict[str, Any] = { "start_year": int(start_year), "end_year": int(end_year), } if status_key: fallback_sql += " AND status_key = :status_key" fallback_params["status_key"] = clean(status_key) fallback_sql += """ GROUP BY start_year, end_year, signature ORDER BY CASE WHEN start_year = :start_year AND end_year = :end_year THEN 0 ELSE 1 END ASC, (end_year - start_year) ASC, max_updated_at DESC LIMIT 1 """ row = conn.execute(text(fallback_sql), fallback_params).first() if not row: return None return int(row[0] or 0), int(row[1] or 0), clean(row[2]) def _load_broader_query_projection_counts_from_groups( engine: 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 db_path = _sqlite_db_path_from_engine(engine) if not db_path: return None try: conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=1.0) conn.row_factory = sqlite3.Row except Exception: return None try: current_signature_prefix = f"{QUERY_PROJECTION_VERSION}|%" scope_row = conn.execute( """ SELECT start_year, end_year, signature, COUNT(DISTINCT status_key) AS status_count, MAX(updated_at) AS max_updated_at FROM wehago_compare_query_groups WHERE start_year <= ? AND end_year >= ? AND signature LIKE ? GROUP BY start_year, end_year, signature ORDER BY CASE WHEN signature LIKE ? THEN 0 ELSE 1 END ASC, status_count DESC, CASE WHEN start_year = ? AND end_year = ? THEN 0 ELSE 1 END ASC, (end_year - start_year) ASC, max_updated_at DESC LIMIT 1 """, ( int(start_year), int(end_year), current_signature_prefix, current_signature_prefix, int(start_year), int(end_year), ), ).fetchone() if not scope_row: scope_row = conn.execute( """ SELECT start_year, end_year, signature, MAX(updated_at) AS max_updated_at FROM wehago_compare_query_groups WHERE start_year <= ? AND end_year >= ? AND status_key = ? GROUP BY start_year, end_year, signature ORDER BY CASE WHEN start_year = ? AND end_year = ? THEN 0 ELSE 1 END ASC, (end_year - start_year) ASC, max_updated_at DESC LIMIT 1 """, ( int(start_year), int(end_year), normalized_status, int(start_year), int(end_year), ), ).fetchone() if not scope_row: return None proj_start = int(scope_row["start_year"] or 0) proj_end = int(scope_row["end_year"] or 0) signature = clean(scope_row["signature"]) counts = _empty_metric_counts() for row in conn.execute( """ SELECT status_key, COUNT(*) FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ? AND fiscal_year BETWEEN ? AND ? GROUP BY status_key """, (proj_start, proj_end, signature, int(start_year), int(end_year)), ): status_key = clean(row[0]) if status_key in counts: counts[status_key] = int(row[1] or 0) for row in conn.execute( """ SELECT status_key, COUNT(*) FROM wehago_compare_query_rows WHERE start_year = ? AND end_year = ? AND signature = ? AND fiscal_year BETWEEN ? AND ? AND status_key IN ('matched', 'ledger_only', 'amount_mismatch', 'voucher_only') GROUP BY status_key """, (proj_start, proj_end, signature, int(start_year), int(end_year)), ): status_key = clean(row[0]) if status_key in counts: counts[status_key] = int(row[1] or 0) if any(counts.get(status_key, 0) for status_key in QUERY_PAGE_CACHEABLE_STATUS_KEYS): return counts return None finally: conn.close() def _fast_sqlite_query_group_page( engine: Any, start_year: int | None, end_year: int | None, normalized_status: str, offset: int, limit: int, *, cursor: str = "", known_total_count: int | None = None, ) -> tuple[list[dict[str, Any]], int, str] | None: if ( start_year is None or end_year is None or normalized_status not in QUERY_VOUCHER_STATUS_KEYS ): return None db_path = _sqlite_db_path_from_engine(engine) if not db_path: return None try: conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=1.0) conn.row_factory = sqlite3.Row except Exception: return None try: current_signature_prefix = f"{QUERY_PROJECTION_VERSION}|%" scope_row = conn.execute( """ SELECT start_year, end_year, signature, MAX(updated_at) AS max_updated_at FROM wehago_compare_query_groups WHERE start_year <= ? AND end_year >= ? AND status_key = ? AND signature LIKE ? GROUP BY start_year, end_year, signature ORDER BY CASE WHEN signature LIKE ? THEN 0 ELSE 1 END ASC, CASE WHEN start_year = ? AND end_year = ? THEN 0 ELSE 1 END ASC, (end_year - start_year) ASC, max_updated_at DESC LIMIT 1 """, ( int(start_year), int(end_year), normalized_status, current_signature_prefix, current_signature_prefix, int(start_year), int(end_year), ), ).fetchone() if not scope_row: scope_row = conn.execute( """ SELECT start_year, end_year, signature, MAX(updated_at) AS max_updated_at FROM wehago_compare_query_groups WHERE start_year <= ? AND end_year >= ? AND status_key = ? GROUP BY start_year, end_year, signature ORDER BY CASE WHEN start_year = ? AND end_year = ? THEN 0 ELSE 1 END ASC, (end_year - start_year) ASC, max_updated_at DESC LIMIT 1 """, ( int(start_year), int(end_year), normalized_status, int(start_year), int(end_year), ), ).fetchone() if not scope_row: return None projection_start_year = int(scope_row["start_year"] or 0) projection_end_year = int(scope_row["end_year"] or 0) signature = clean(scope_row["signature"]) total_count = ( max(int(known_total_count or 0), 0) if known_total_count is not None else int( conn.execute( """ SELECT COUNT(*) FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND status_key = ? AND signature = ? AND fiscal_year BETWEEN ? AND ? """, ( projection_start_year, projection_end_year, normalized_status, signature, int(start_year), int(end_year), ), ).fetchone()[0] or 0 ) ) if total_count <= 0: return [], 0, "" cursor_year = 0 cursor_group = -1 cursor_text = clean(cursor) if cursor_text: try: raw_year, raw_group = cursor_text.split(":", 1) cursor_year = int(raw_year or 0) cursor_group = int(raw_group or -1) except Exception: cursor_text = "" cursor_year = 0 cursor_group = -1 if cursor_text: group_rows = conn.execute( """ SELECT * FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND status_key = ? AND signature = ? AND fiscal_year BETWEEN ? AND ? AND ( fiscal_year > ? OR (fiscal_year = ? AND group_index > ?) ) ORDER BY fiscal_year ASC, group_index ASC LIMIT ? """, ( projection_start_year, projection_end_year, normalized_status, signature, int(start_year), int(end_year), cursor_year, cursor_year, cursor_group, int(limit), ), ).fetchall() else: group_rows = conn.execute( """ SELECT * FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND status_key = ? AND signature = ? AND fiscal_year BETWEEN ? AND ? ORDER BY fiscal_year ASC, group_index ASC LIMIT ? OFFSET ? """, ( projection_start_year, projection_end_year, normalized_status, signature, int(start_year), int(end_year), int(limit), int(offset), ), ).fetchall() if not group_rows: return [], total_count, "" selected_indices = [int(row["group_index"] or 0) for row in group_rows] placeholders = ", ".join("?" for _ in selected_indices) detail_rows = conn.execute( f""" SELECT * FROM wehago_compare_query_rows WHERE start_year = ? AND end_year = ? AND status_key = ? AND signature = ? AND fiscal_year BETWEEN ? AND ? AND group_index IN ({placeholders}) ORDER BY group_index ASC, row_index ASC """, ( projection_start_year, projection_end_year, normalized_status, signature, int(start_year), int(end_year), *selected_indices, ), ).fetchall() rows_by_group: dict[int, list[dict[str, Any]]] = {} for row in detail_rows: payload = dict(row) rows_by_group.setdefault(int(payload.get("group_index") or 0), []).append(payload) groups: list[dict[str, Any]] = [] for raw_row in group_rows: row = dict(raw_row) group_index = int(row.get("group_index") or 0) summary = { "fiscal_year": int(row.get("fiscal_year") or 0), "status_label": "Matched" if normalized_status in {"voucher_matched", "erp_voucher_matched"} else "Recheck" if normalized_status == "voucher_recheck" else "Excepted" if normalized_status == "voucher_excepted" else "Hanmac unconnected" if normalized_status == "hanmac_unconnected" else "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": int(row.get("ledger_row_count") or 0), "voucher_row_count": int(row.get("voucher_row_count") or 0), "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_accounts": clean(row.get("ledger_accounts")), "voucher_accounts": clean(row.get("voucher_accounts")), "ledger_vendors": clean(row.get("ledger_vendors")), "voucher_vendors": clean(row.get("voucher_vendors")), "review_reason": clean(row.get("review_reason")), } groups.append({ "summary": summary, "rows": _sanitize_voucher_group_rows(rows_by_group.get(group_index, [])), }) last_row = dict(group_rows[-1]) next_cursor = ( f"{int(last_row.get('fiscal_year') or 0)}:{int(last_row.get('group_index') or 0)}" if len(group_rows) >= int(limit or 0) else "" ) return groups, total_count, next_cursor finally: conn.close() def _store_query_metric_projection( conn: Any, start_year: int | None, end_year: int | None, counts: dict[str, int], snapshot_state: dict[str, Any], source_state: dict[str, Any] | None = None, ) -> None: if start_year is None or end_year is None: return signature = _query_projection_signature(conn, start_year, end_year) payload_counts = {status_key: int(counts.get(status_key, 0) or 0) for status_key, _, _ in STATUS_META} payload_snapshot = snapshot_state if isinstance(snapshot_state, dict) else {} payload_source = source_state if isinstance(source_state, dict) else {} try: conn.execute( text( """ INSERT INTO wehago_compare_query_metrics ( start_year, end_year, signature, counts_json, snapshot_state_json, source_state_json, created_at, updated_at ) VALUES ( :start_year, :end_year, :signature, :counts_json, :snapshot_state_json, :source_state_json, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) ON CONFLICT(start_year, end_year, signature) DO UPDATE SET counts_json = excluded.counts_json, snapshot_state_json = excluded.snapshot_state_json, source_state_json = excluded.source_state_json, updated_at = CURRENT_TIMESTAMP """ ), { "start_year": start_year, "end_year": end_year, "signature": signature, "counts_json": json.dumps(payload_counts, ensure_ascii=False), "snapshot_state_json": json.dumps(payload_snapshot, ensure_ascii=False), "source_state_json": json.dumps(payload_source, ensure_ascii=False), }, ) except OperationalError as exc: logger.warning("Skipped query metric projection write for %s-%s due to lock: %s", start_year, end_year, exc) 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() current_signature = _metric_counts_signature(conn, start_year, end_year) cached = conn.execute( text( """ SELECT signature, 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[1]: return None if clean(cached[0]) != current_signature: return None try: payload = json.loads(str(cached[1])) 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) try: 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), }, ) except OperationalError as exc: logger.warning("Skipped metric count cache write for %s-%s due to lock: %s", start_year, end_year, exc) 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 current_signature = _metric_counts_signature(conn, start_year, end_year) cached = conn.execute( text( """ SELECT signature, 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[1]: return None if clean(cached[0]) != current_signature: return None try: payload = json.loads(str(cached[1])) 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", [])], }, } try: 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), }, ) except OperationalError as exc: logger.warning("Skipped summary range cache write for %s-%s due to lock: %s", start_year, end_year, exc) def _store_query_group_projection( conn: Any, start_year: int | None, end_year: int | None, voucher_sections: dict[str, list[dict[str, Any]]], ) -> None: if start_year is None or end_year is None: return signature = _query_projection_signature(conn, start_year, end_year) try: delete_params = {"start_year": start_year, "end_year": end_year, "signature": signature} voucher_status_placeholders = ", ".join(f":status_key_{index}" for index, _ in enumerate(QUERY_VOUCHER_STATUS_KEYS)) for index, status_key in enumerate(QUERY_VOUCHER_STATUS_KEYS): delete_params[f"status_key_{index}"] = status_key conn.execute( text( f""" DELETE FROM wehago_compare_query_rows WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature AND status_key IN ({voucher_status_placeholders}) """ ), delete_params, ) conn.execute( text( f""" DELETE FROM wehago_compare_query_groups WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature AND status_key IN ({voucher_status_placeholders}) """ ), delete_params, ) for status_key in QUERY_VOUCHER_STATUS_KEYS: groups = list(voucher_sections.get(status_key, []) or []) for group_index, group in enumerate(groups): summary = dict(group.get("summary", {}) or {}) rows = _sanitize_voucher_group_rows(list(group.get("rows", []) or [])) search_text = " ".join( filter( None, [ clean(summary.get("voucher_no")), clean(summary.get("draft_no")), clean(summary.get("ledger_accounts")), clean(summary.get("voucher_accounts")), clean(summary.get("ledger_vendors")), clean(summary.get("voucher_vendors")), clean(summary.get("review_reason")), " ".join(clean(row.get("ledger_desc")) for row in rows), " ".join(clean(row.get("voucher_desc")) for row in rows), ], ) ) conn.execute( text( """ INSERT INTO wehago_compare_query_groups ( start_year, end_year, status_key, signature, group_index, fiscal_year, ledger_date, proof_date, voucher_no, draft_no, ledger_row_count, voucher_row_count, ledger_debit, ledger_credit, voucher_debit, voucher_credit, ledger_accounts, voucher_accounts, ledger_vendors, voucher_vendors, review_reason, search_text, created_at, updated_at ) VALUES ( :start_year, :end_year, :status_key, :signature, :group_index, :fiscal_year, :ledger_date, :proof_date, :voucher_no, :draft_no, :ledger_row_count, :voucher_row_count, :ledger_debit, :ledger_credit, :voucher_debit, :voucher_credit, :ledger_accounts, :voucher_accounts, :ledger_vendors, :voucher_vendors, :review_reason, :search_text, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) """ ), { "start_year": start_year, "end_year": end_year, "status_key": status_key, "signature": signature, "group_index": group_index, "fiscal_year": int(summary.get("fiscal_year") or 0), "ledger_date": clean(summary.get("ledger_date")), "proof_date": clean(summary.get("proof_date")), "voucher_no": clean(summary.get("voucher_no")), "draft_no": clean(summary.get("draft_no")), "ledger_row_count": int(summary.get("ledger_row_count") or 0), "voucher_row_count": int(summary.get("voucher_row_count") or 0), "ledger_debit": parse_amount(summary.get("ledger_debit")), "ledger_credit": parse_amount(summary.get("ledger_credit")), "voucher_debit": parse_amount(summary.get("voucher_debit")), "voucher_credit": parse_amount(summary.get("voucher_credit")), "ledger_accounts": clean(summary.get("ledger_accounts")), "voucher_accounts": clean(summary.get("voucher_accounts")), "ledger_vendors": clean(summary.get("ledger_vendors")), "voucher_vendors": clean(summary.get("voucher_vendors")), "review_reason": clean(summary.get("review_reason")), "search_text": search_text, }, ) for row_index, row in enumerate(rows): conn.execute( text( """ INSERT INTO wehago_compare_query_rows ( start_year, end_year, status_key, signature, group_index, row_index, fiscal_year, status_label, ledger_date, proof_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, review_reason, matched_case, ledger_row_key, voucher_row_key, match_identity_key, created_at, updated_at ) VALUES ( :start_year, :end_year, :status_key, :signature, :group_index, :row_index, :fiscal_year, :status_label, :ledger_date, :proof_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, :review_reason, :matched_case, :ledger_row_key, :voucher_row_key, :match_identity_key, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) """ ), { "start_year": start_year, "end_year": end_year, "status_key": status_key, "signature": signature, "group_index": group_index, "row_index": row_index, "fiscal_year": int(row.get("fiscal_year") or summary.get("fiscal_year") or 0), "status_label": clean(row.get("status_label") or summary.get("status_label")), "ledger_date": clean(row.get("ledger_date") or summary.get("ledger_date")), "proof_date": clean(row.get("proof_date") or summary.get("proof_date")), "voucher_no": clean(row.get("voucher_no") or summary.get("voucher_no")), "draft_no": clean(row.get("draft_no") or summary.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")), "review_reason": clean(row.get("review_reason")), "matched_case": clean(row.get("matched_case")), "ledger_row_key": clean(row.get("ledger_row_key")), "voucher_row_key": clean(row.get("voucher_row_key")), "match_identity_key": clean(row.get("match_identity_key")), }, ) except OperationalError as exc: logger.warning("Skipped query group projection write for %s-%s due to lock: %s", start_year, end_year, exc) def _store_query_row_projection( conn: Any, start_year: int | None, end_year: int | None, rows_by_status: dict[str, list[dict[str, Any]]], ) -> None: if start_year is None or end_year is None: return signature = _query_projection_signature(conn, start_year, end_year) try: delete_params = {"start_year": start_year, "end_year": end_year, "signature": signature} row_status_placeholders = ", ".join(f":row_status_key_{index}" for index, _ in enumerate(QUERY_STANDARD_ROW_STATUS_KEYS)) for index, status_key in enumerate(QUERY_STANDARD_ROW_STATUS_KEYS): delete_params[f"row_status_key_{index}"] = status_key conn.execute( text( f""" DELETE FROM wehago_compare_query_rows WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature AND status_key IN ({row_status_placeholders}) """ ), delete_params, ) conn.execute( text( f""" DELETE FROM wehago_compare_query_groups WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature AND status_key IN ({row_status_placeholders}) """ ), delete_params, ) for status_key in QUERY_STANDARD_ROW_STATUS_KEYS: rows = list(rows_by_status.get(status_key, []) or []) for item_index, row in enumerate(rows): conn.execute( text( """ INSERT INTO wehago_compare_query_rows ( start_year, end_year, status_key, signature, group_index, row_index, fiscal_year, status_label, ledger_date, proof_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, review_reason, matched_case, ledger_row_key, voucher_row_key, match_identity_key, created_at, updated_at ) VALUES ( :start_year, :end_year, :status_key, :signature, :group_index, 0, :fiscal_year, :status_label, :ledger_date, :proof_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, :review_reason, :matched_case, :ledger_row_key, :voucher_row_key, :match_identity_key, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) """ ), { "start_year": start_year, "end_year": end_year, "status_key": status_key, "signature": signature, "group_index": item_index, "fiscal_year": int(row.get("fiscal_year") or 0), "status_label": clean(row.get("status_label")), "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_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")), "review_reason": clean(row.get("review_reason")), "matched_case": clean(row.get("matched_case") or row.get("boundary_excluded")), "ledger_row_key": clean(row.get("ledger_row_key")), "voucher_row_key": clean(row.get("voucher_row_key")), "match_identity_key": clean(row.get("match_identity_key")), }, ) except OperationalError as exc: logger.warning("Skipped query row projection write for %s-%s due to lock: %s", start_year, end_year, exc) def _load_query_page_projection_cache( conn: Any, start_year: int | None, end_year: int | None, status_key: str, query_hash: str, ) -> dict[str, Any] | None: if ( start_year is None or end_year is None or status_key not in QUERY_PAGE_CACHEABLE_STATUS_KEYS or not clean(query_hash) ): return None signature = _query_projection_signature(conn, start_year, end_year) row = conn.execute( text( """ SELECT payload_json FROM wehago_compare_query_page_cache WHERE start_year = :start_year AND end_year = :end_year AND status_key = :status_key AND signature = :signature AND query_hash = :query_hash LIMIT 1 """ ), { "start_year": start_year, "end_year": end_year, "status_key": status_key, "signature": signature, "query_hash": clean(query_hash), }, ).scalar_one_or_none() if not row: return None try: payload = json.loads(str(row)) except Exception: return None return payload if isinstance(payload, dict) else None def _load_latest_query_page_projection_cache_any_signature( conn: Any, start_year: int | None, end_year: int | None, status_key: str, query_hash: str, ) -> dict[str, Any] | None: if ( start_year is None or end_year is None or status_key not in QUERY_PAGE_CACHEABLE_STATUS_KEYS or not clean(query_hash) ): return None row = conn.execute( text( """ SELECT payload_json FROM wehago_compare_query_page_cache WHERE start_year = :start_year AND end_year = :end_year AND status_key = :status_key AND query_hash = :query_hash AND signature LIKE :signature_like ORDER BY updated_at DESC, created_at DESC LIMIT 1 """ ), { "start_year": start_year, "end_year": end_year, "status_key": status_key, "query_hash": clean(query_hash), "signature_like": f"%{QUERY_PROJECTION_VERSION}%", }, ).scalar_one_or_none() if not row: return None try: payload = json.loads(str(row)) except Exception: return None return payload if isinstance(payload, dict) else None def _store_query_page_projection_cache( conn: Any, start_year: int | None, end_year: int | None, status_key: str, query_hash: str, payload: dict[str, Any], ) -> None: if ( start_year is None or end_year is None or status_key not in QUERY_PAGE_CACHEABLE_STATUS_KEYS or not clean(query_hash) or not isinstance(payload, dict) or payload.get("pending") ): return signature = _query_projection_signature(conn, start_year, end_year) cache_payload = copy.deepcopy(payload) cache_payload.pop("notice", None) cache_payload.pop("pending", None) cache_payload.pop("ready_years", None) cache_payload.pop("pending_years", None) try: conn.execute( text( """ INSERT INTO wehago_compare_query_page_cache ( start_year, end_year, status_key, signature, query_hash, payload_json, created_at, updated_at ) VALUES ( :start_year, :end_year, :status_key, :signature, :query_hash, :payload_json, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) ON CONFLICT(start_year, end_year, status_key, signature, query_hash) DO UPDATE SET payload_json = excluded.payload_json, updated_at = CURRENT_TIMESTAMP """ ), { "start_year": start_year, "end_year": end_year, "status_key": status_key, "signature": signature, "query_hash": clean(query_hash), "payload_json": json.dumps( cache_payload, ensure_ascii=False, default=lambda value: sorted(value) if isinstance(value, set) else clean(value), ), }, ) except OperationalError as exc: logger.warning( "Skipped query page projection cache write for %s-%s %s due to lock: %s", start_year, end_year, status_key, exc, ) def _prewarm_query_page_projection_cache( conn: Any, start_year: int | None, end_year: int | None, rows_by_status: dict[str, list[dict[str, Any]]], voucher_sections: dict[str, list[dict[str, Any]]], ) -> None: if start_year is None or end_year is None: return for status_key in QUERY_VOUCHER_STATUS_KEYS: payload = _build_voucher_status_detail_response_from_sections( voucher_sections, status_key, "", "", "", "", "", "", "", "", "", 0, 24, ) _store_query_page_projection_cache( conn, start_year, end_year, status_key, _status_detail_response_cache_key( start_year, end_year, status=status_key, voucher_no="", draft_no="", wehago_account="", erp_account="", wehago_amount="", erp_amount="", wehago_vendor="", erp_vendor="", desc_keyword="", review_reason="", boundary_excluded=False, offset=0, limit=24, cursor="", ), payload, ) for status_key in QUERY_STANDARD_ROW_STATUS_KEYS: payload = _build_status_detail_response_from_rows( rows_by_status, status_key, "", "", "", "", "", "", "", "", "", False, 0, 60, ) _store_query_page_projection_cache( conn, start_year, end_year, status_key, _status_detail_response_cache_key( start_year, end_year, status=status_key, voucher_no="", draft_no="", wehago_account="", erp_account="", wehago_amount="", erp_amount="", wehago_vendor="", erp_vendor="", desc_keyword="", review_reason="", boundary_excluded=False, offset=0, limit=60, cursor="", ), payload, ) def _load_query_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, cursor: str = "", known_total_count: int | None = None, ) -> tuple[list[dict[str, Any]], int, str] | None: if start_year is None or end_year is None or normalized_status not in QUERY_VOUCHER_STATUS_KEYS: return None projection_scope = _find_best_query_projection_scope( conn, "wehago_compare_query_groups", start_year, end_year, normalized_status, ) if not projection_scope: return None projection_start_year, projection_end_year, signature = projection_scope voucher_filter = clean(voucher_no) draft_filter = clean(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) params = { "projection_start_year": projection_start_year, "projection_end_year": projection_end_year, "selected_start_year": int(start_year), "selected_end_year": int(end_year), "status_key": normalized_status, "signature": signature, "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, "limit": int(limit), "offset": int(offset), } where_sql = """ start_year = :projection_start_year AND end_year = :projection_end_year AND status_key = :status_key AND signature = :signature AND fiscal_year BETWEEN :selected_start_year AND :selected_end_year AND (:voucher_keyword = '' OR COALESCE(voucher_no, '') LIKE :voucher_like) AND (:draft_keyword = '' OR COALESCE(draft_no, '') LIKE :draft_like) AND (:wehago_account_keyword = '' OR COALESCE(ledger_accounts, '') LIKE :wehago_account_like) AND (:erp_account_keyword = '' OR COALESCE(voucher_accounts, '') LIKE :erp_account_like) AND (:wehago_vendor_keyword = '' OR COALESCE(ledger_vendors, '') LIKE :wehago_vendor_like) AND (:erp_vendor_keyword = '' OR COALESCE(voucher_vendors, '') LIKE :erp_vendor_like) AND (:desc_keyword = '' OR COALESCE(search_text, '') LIKE :desc_like) AND ( :wehago_amount_keyword = '' OR CAST(ABS(COALESCE(ledger_debit, 0)) AS TEXT) LIKE '%' || :wehago_amount_keyword || '%' OR CAST(ABS(COALESCE(ledger_credit, 0)) AS TEXT) LIKE '%' || :wehago_amount_keyword || '%' ) AND ( :erp_amount_keyword = '' OR CAST(ABS(COALESCE(voucher_debit, 0)) AS TEXT) LIKE '%' || :erp_amount_keyword || '%' OR CAST(ABS(COALESCE(voucher_credit, 0)) AS TEXT) LIKE '%' || :erp_amount_keyword || '%' ) """ if known_total_count is not None: total_count = max(int(known_total_count or 0), 0) else: total_count = int( conn.execute( text(f"SELECT COUNT(*) FROM wehago_compare_query_groups WHERE {where_sql}"), params, ).scalar_one() or 0 ) if total_count <= 0: return [], 0, "" cursor_year = 0 cursor_group_index = -1 cursor_text = clean(cursor) if cursor_text: try: raw_year, raw_group = cursor_text.split(":", 1) cursor_year = int(raw_year or 0) cursor_group_index = int(raw_group or -1) except Exception: cursor_year = 0 cursor_group_index = -1 cursor_text = "" query_sql = f""" SELECT * FROM wehago_compare_query_groups WHERE {where_sql} """ if cursor_text: query_sql += """ AND ( fiscal_year > :cursor_year OR (fiscal_year = :cursor_year AND group_index > :cursor_group_index) ) """ params["cursor_year"] = cursor_year params["cursor_group_index"] = cursor_group_index query_sql += """ ORDER BY fiscal_year ASC, group_index ASC LIMIT :limit """ if not cursor_text: query_sql = query_sql.replace("LIMIT :limit", "LIMIT :limit OFFSET :offset") group_rows = conn.execute(text(query_sql), params).mappings().all() if not group_rows: return [], total_count, "" selected_indices = [int(row.get("group_index") or 0) for row in group_rows] placeholders = ", ".join(f":group_index_{index}" for index, _ in enumerate(selected_indices)) row_params = { "projection_start_year": projection_start_year, "projection_end_year": projection_end_year, "status_key": normalized_status, "signature": signature, } for index, value in enumerate(selected_indices): row_params[f"group_index_{index}"] = value detail_rows = conn.execute( text( f""" SELECT * FROM wehago_compare_query_rows WHERE start_year = :projection_start_year AND end_year = :projection_end_year AND status_key = :status_key AND signature = :signature AND group_index IN ({placeholders}) ORDER BY group_index ASC, row_index ASC """ ), row_params, ).mappings().all() rows_by_group: dict[int, list[dict[str, Any]]] = {} for row in detail_rows: rows_by_group.setdefault(int(row.get("group_index") or 0), []).append(dict(row)) groups: list[dict[str, Any]] = [] for row in group_rows: group_index = int(row.get("group_index") or 0) summary = { "fiscal_year": int(row.get("fiscal_year") or 0), "status_label": "Matched" if normalized_status in {"voucher_matched", "erp_voucher_matched"} else "Recheck" if normalized_status == "voucher_recheck" else "Excepted" if normalized_status == "voucher_excepted" else "Hanmac unconnected" if normalized_status == "hanmac_unconnected" else "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": int(row.get("ledger_row_count") or 0), "voucher_row_count": int(row.get("voucher_row_count") or 0), "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_accounts": clean(row.get("ledger_accounts")), "voucher_accounts": clean(row.get("voucher_accounts")), "ledger_vendors": clean(row.get("ledger_vendors")), "voucher_vendors": clean(row.get("voucher_vendors")), "review_reason": clean(row.get("review_reason")), } groups.append({"summary": summary, "rows": _sanitize_voucher_group_rows(rows_by_group.get(group_index, []))}) last_row = group_rows[-1] next_cursor = f"{int(last_row.get('fiscal_year') or 0)}:{int(last_row.get('group_index') or 0)}" if len(group_rows) >= int(limit or 0) else "" return groups, total_count, next_cursor def _load_query_row_page( conn: Any, start_year: int | None, end_year: int | None, normalized_status: str, offset: int, limit: int, ) -> dict[str, Any] | None: if start_year is None or end_year is None or normalized_status not in QUERY_STANDARD_ROW_STATUS_KEYS: return None projection_scope = _find_best_query_projection_scope( conn, "wehago_compare_query_rows", start_year, end_year, normalized_status, ) if not projection_scope: return None projection_start_year, projection_end_year, signature = projection_scope total_count = int( conn.execute( text( """ SELECT COUNT(*) FROM wehago_compare_query_rows WHERE start_year = :projection_start_year AND end_year = :projection_end_year AND status_key = :status_key AND signature = :signature AND fiscal_year BETWEEN :selected_start_year AND :selected_end_year """ ), { "projection_start_year": projection_start_year, "projection_end_year": projection_end_year, "selected_start_year": int(start_year), "selected_end_year": int(end_year), "status_key": normalized_status, "signature": signature, }, ).scalar_one() or 0 ) if total_count <= 0: return { "columns": DETAIL_COLUMN_MAP[normalized_status], "rows": [], "total_count": 0, "shown_count": 0, "offset": int(offset), "limit": int(limit), "has_more": False, "next_offset": int(offset), "notice": "", "bank_payable_case_count": 0, "boundary_excluded_count": 0, } rows = [ {key: clean(value) if isinstance(value, str) else value for key, value in dict(row).items()} for row in conn.execute( text( """ SELECT * FROM wehago_compare_query_rows WHERE start_year = :projection_start_year AND end_year = :projection_end_year AND status_key = :status_key AND signature = :signature AND fiscal_year BETWEEN :selected_start_year AND :selected_end_year ORDER BY fiscal_year ASC, group_index ASC LIMIT :limit OFFSET :offset """ ), { "projection_start_year": projection_start_year, "projection_end_year": projection_end_year, "selected_start_year": int(start_year), "selected_end_year": int(end_year), "status_key": normalized_status, "signature": signature, "limit": int(limit), "offset": int(offset), }, ).mappings().all() ] bank_payable_case_count = 0 if normalized_status == "matched": bank_payable_case_count = int( conn.execute( text( """ SELECT COUNT(*) FROM wehago_compare_query_rows WHERE start_year = :projection_start_year AND end_year = :projection_end_year AND status_key = :status_key AND signature = :signature AND fiscal_year BETWEEN :selected_start_year AND :selected_end_year AND review_reason LIKE '%BANK_PAYABLE_MATCH%' """ ), { "projection_start_year": projection_start_year, "projection_end_year": projection_end_year, "selected_start_year": int(start_year), "selected_end_year": int(end_year), "status_key": normalized_status, "signature": signature, }, ).scalar_one() or 0 ) boundary_excluded_count = 0 if normalized_status == "ledger_only": boundary_excluded_count = int( conn.execute( text( """ SELECT COUNT(*) FROM wehago_compare_query_rows WHERE start_year = :projection_start_year AND end_year = :projection_end_year AND status_key = :status_key AND signature = :signature AND fiscal_year BETWEEN :selected_start_year AND :selected_end_year AND matched_case = 'boundary_excluded' """ ), { "projection_start_year": projection_start_year, "projection_end_year": projection_end_year, "selected_start_year": int(start_year), "selected_end_year": int(end_year), "status_key": normalized_status, "signature": signature, }, ).scalar_one() or 0 ) next_offset = int(offset) + len(rows) return { "columns": DETAIL_COLUMN_MAP[normalized_status], "rows": rows, "total_count": total_count, "shown_count": len(rows), "offset": int(offset), "limit": int(limit), "has_more": next_offset < total_count, "next_offset": next_offset, "notice": "", "bank_payable_case_count": bank_payable_case_count, "boundary_excluded_count": boundary_excluded_count, } 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, "voucher_excepted": 0, "hanmac_unconnected": 0, } def _voucher_metric_counts_from_export_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 None counts = { "voucher_matched": 0, "erp_voucher_matched": 0, "voucher_unmatched": 0, "erp_voucher_unmatched": 0, "voucher_recheck": 0, "voucher_excepted": 0, "hanmac_unconnected": 0, } found_cache = False for year in range(int(start_year), int(end_year) + 1): signature = _get_fast_year_export_row_cache_signature(conn, year) if not signature: continue found_cache = True rows = conn.execute( text( """ SELECT status_key, COUNT(*) AS group_count FROM wehago_compare_export_row_cache WHERE fiscal_year = :fiscal_year AND snapshot_signature = :signature AND row_sort = 0 AND status_key IN ('voucher_matched', 'erp_voucher_matched', 'voucher_unmatched', 'erp_voucher_unmatched', 'voucher_recheck', 'voucher_excepted', 'hanmac_unconnected') GROUP BY status_key """ ), {"fiscal_year": year, "signature": signature}, ).mappings().all() for row in rows: status_key = clean(row.get("status_key")) if status_key in counts and status_key != "hanmac_unconnected": counts[status_key] += int(row.get("group_count") or 0) counts["hanmac_unconnected"] += _count_hanmac_unconnected_export_groups(conn, year, signature) return counts if found_cache else None def _merge_export_cache_voucher_counts( conn: Any, counts: dict[str, int], start_year: int | None, end_year: int | None, ) -> dict[str, int]: export_counts = _voucher_metric_counts_from_export_cache(conn, start_year, end_year) if not export_counts: return counts merged = dict(counts) for status_key, value in export_counts.items(): if status_key in {"voucher_unmatched", "voucher_excepted"}: continue if status_key == "hanmac_unconnected": merged[status_key] = int(value or 0) continue merged[status_key] = max(int(merged.get(status_key, 0) or 0), int(value or 0)) return merged def _count_distinct_group_summary_tokens( groups: Iterable[dict[str, Any]], summary_key: str, ) -> int: values: set[str] = set() for group in groups: summary = dict(group.get("summary", {}) or {}) raw_value = clean(summary.get(summary_key)) if not raw_value: continue for token in raw_value.split(","): normalized = clean(token) if normalized: values.add(normalized) return len(values) def _count_distinct_row_field_tokens( rows: Iterable[dict[str, Any]], field_name: str, ) -> int: values: set[str] = set() for row in rows: raw_value = clean(row.get(field_name)) if not raw_value: continue for token in raw_value.split(","): normalized = clean(token) if normalized: values.add(normalized) return len(values) def _derive_voucher_projection_counts( rows_by_status: dict[str, list[dict[str, Any]]], voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, int]: matched_groups = list(voucher_sections.get("voucher_matched", []) or []) unmatched_groups = list(voucher_sections.get("voucher_unmatched", []) or []) recheck_groups = list(voucher_sections.get("voucher_recheck", []) or []) excepted_groups = list(voucher_sections.get("voucher_excepted", []) or []) hanmac_unconnected_groups = list(voucher_sections.get("hanmac_unconnected", []) or []) return { "voucher_matched": len(matched_groups), "erp_voucher_matched": len(voucher_sections.get("erp_voucher_matched", []) or []), "voucher_unmatched": len(unmatched_groups), "erp_voucher_unmatched": len(voucher_sections.get("erp_voucher_unmatched", []) or []), "voucher_recheck": len(recheck_groups), "voucher_excepted": len(excepted_groups), "hanmac_unconnected": len(hanmac_unconnected_groups), } def _resolve_fast_projection_counts_from_cached_rows( engine: Any, 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 rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year) if not any(rows_by_status.get(status_key) for status_key in ("matched", "ledger_only", "amount_mismatch", "voucher_only")): return None counts = _empty_metric_counts() for status_key in ("matched", "ledger_only", "amount_mismatch", "voucher_only"): counts[status_key] = len(rows_by_status.get(status_key, []) or []) voucher_sections = _get_cached_voucher_sections_by_range(engine, start_year, end_year, rows_by_status) counts.update(_derive_voucher_projection_counts(rows_by_status, voucher_sections)) counts["bridge_expense_review"] = _count_bridge_expense_review_candidates(conn, start_year, end_year) return counts 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.update(_derive_voucher_projection_counts(rows_by_status, voucher_sections)) 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 not counts["hanmac_unconnected"] 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.update(_derive_voucher_projection_counts(rows_by_status, voucher_sections)) 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 not counts["hanmac_unconnected"] 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 _rebuild_compare_query_projection( engine: Any, conn: Any, start_year: int | None, end_year: int | None, ) -> tuple[dict[str, int], dict[str, list[int]]]: if start_year is None or end_year is None: counts, snapshot_state = _rebuild_metric_count_caches(conn, start_year, end_year) return counts, snapshot_state snapshot_state = _get_compare_snapshot_state(conn, start_year, end_year) rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year) source_row_count = sum( len(rows_by_status.get(status_key, []) or []) for status_key in ("matched", "ledger_only", "voucher_only", "amount_mismatch") ) if source_row_count <= 0 and any(snapshot_state.get(key) for key in ("missing", "stale", "queued", "running", "failed")): existing_projection = _load_query_metric_projection(conn, start_year, end_year) if existing_projection and isinstance(existing_projection.get("counts"), dict): return { str(key): int(value or 0) for key, value in dict(existing_projection.get("counts") or {}).items() }, snapshot_state try: from scripts.project_export_cache_ranges import project_range db_path = _sqlite_db_path_from_engine(engine) if db_path: sqlite_conn = sqlite3.connect(db_path) sqlite_conn.row_factory = sqlite3.Row try: export_counts = project_range(sqlite_conn, int(start_year), int(end_year)) finally: sqlite_conn.close() counts = _restore_metric_counts_from_cached_year_payloads( conn, start_year, end_year, lightweight=True, ) or _empty_metric_counts() for key, value in export_counts.items(): counts[key] = int(value or 0) _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 except Exception as exc: logger.warning("Current export row cache projection failed for %s-%s: %s", start_year, end_year, exc) raise RuntimeError("현재 로직 전표 원본 스냅샷이 준비되지 않아 조회 캐시를 갱신하지 않았습니다.") voucher_sections = _get_cached_voucher_sections_by_range(engine, start_year, end_year, rows_by_status) counts = _empty_metric_counts() for status_key in ("matched", "ledger_only", "voucher_only", "amount_mismatch"): counts[status_key] = len(rows_by_status.get(status_key, []) or []) counts.update(_derive_voucher_projection_counts(rows_by_status, voucher_sections)) 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) _store_metric_counts_cache(conn, start_year, end_year, counts) _store_summary_range_cache(conn, start_year, end_year, counts, snapshot_state) _store_query_metric_projection( conn, start_year, end_year, counts, snapshot_state, source_state={ "projection_signature": _query_projection_signature(conn, start_year, end_year), "status_keys": list(QUERY_VOUCHER_STATUS_KEYS + QUERY_STANDARD_ROW_STATUS_KEYS), }, ) _store_query_group_projection(conn, start_year, end_year, voucher_sections) _store_query_row_projection(conn, start_year, end_year, rows_by_status) _prewarm_query_page_projection_cache(conn, start_year, end_year, rows_by_status, voucher_sections) return counts, snapshot_state def _restore_metric_counts_from_cached_year_payloads( conn: Any, start_year: int | None, end_year: int | None, *, lightweight: bool = False, selected_years: Iterable[int] | None = None, ) -> dict[str, int] | None: if start_year is None or end_year is None: return None counts = _empty_metric_counts() year_list = ( sorted({int(year) for year in selected_years if int(year or 0) > 0}) if selected_years is not None else list(range(int(start_year), int(end_year) + 1)) ) if not year_list: return None status_map = _load_snapshot_status_map(conn, year_list) for year in year_list: signature = _build_db_state_signature(conn, year, year) year_metric_cache = _load_latest_metric_counts_cache_any_signature(conn, year, year) if year_metric_cache is not None: for status_key, _, _ in STATUS_META: counts[status_key] += int(year_metric_cache.get(status_key, 0) or 0) continue status_row = status_map.get(year) or {} row_counts_payload: dict[str, Any] = {} try: parsed = json.loads(clean(status_row.get("row_counts_json")) or "{}") if isinstance(parsed, dict): row_counts_payload = parsed except Exception: row_counts_payload = {} if row_counts_payload: for status_key in ( "matched", "ledger_only", "amount_mismatch", "voucher_only", "voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched", "voucher_recheck", "voucher_excepted", "hanmac_unconnected", ): counts[status_key] += int(row_counts_payload.get(status_key, 0) or 0) if all( status_key in row_counts_payload for status_key in ("voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched", "voucher_recheck", "voucher_excepted", "hanmac_unconnected") ): continue else: 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 ("matched", "ledger_only", "amount_mismatch", "voucher_only"): rows = list((sections.get(status_key) or {}).get("rows", [])) counts[status_key] += len(rows) 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', 'voucher_excepted', 'hanmac_unconnected') 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', 'voucher_excepted', 'hanmac_unconnected') 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, "voucher_excepted": 0, "hanmac_unconnected": 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 if not lightweight: 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: 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") ) fast_projection_counts = _resolve_fast_projection_counts_from_cached_rows( engine, conn, start_year, end_year, ) query_projection = _load_query_metric_projection(conn, start_year, end_year) if fast_projection_counts is not None and ( pending or not query_projection or not isinstance(query_projection.get("counts"), dict) ): try: _store_metric_counts_cache(conn, start_year, end_year, fast_projection_counts) _store_summary_range_cache( conn, start_year, end_year, fast_projection_counts, snapshot_state or {"ready": [], "stale": [], "missing": []}, ) except OperationalError: pass return _merge_export_cache_voucher_counts(conn, fast_projection_counts, start_year, end_year), pending if query_projection and isinstance(query_projection.get("counts"), dict): query_counts = query_projection["counts"] if fast_projection_counts is not None: voucher_like_keys = ( "voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched", "voucher_recheck", "hanmac_unconnected", ) projection_total = sum(int(query_counts.get(key, 0) or 0) for key in voucher_like_keys) fast_total = sum(int(fast_projection_counts.get(key, 0) or 0) for key in voucher_like_keys) # Prefer the reconstructed counts when the stored projection is clearly lagging behind # the cached status rows. This keeps the dashboard from getting stuck on stale low values # after matching rules change or a projection rebuild is still queued. if fast_total > 0 and ( projection_total <= 0 or projection_total * 2 < fast_total ): try: _store_metric_counts_cache(conn, start_year, end_year, fast_projection_counts) _store_summary_range_cache( conn, start_year, end_year, fast_projection_counts, snapshot_state or {"ready": [], "stale": [], "missing": []}, ) except OperationalError: pass return _merge_export_cache_voucher_counts(conn, fast_projection_counts, start_year, end_year), pending return _merge_export_cache_voucher_counts(conn, query_counts, start_year, end_year), pending if fast_projection_counts is not None: try: _store_metric_counts_cache(conn, start_year, end_year, fast_projection_counts) _store_summary_range_cache( conn, start_year, end_year, fast_projection_counts, snapshot_state or {"ready": [], "stale": [], "missing": []}, ) except OperationalError: pass return _merge_export_cache_voucher_counts(conn, fast_projection_counts, start_year, end_year), pending cached_summary = _load_summary_range_cache(conn, start_year, end_year) if cached_summary: counts = cached_summary.get("counts") if isinstance(counts, dict): return _merge_export_cache_voucher_counts(conn, counts, start_year, end_year), False cached = _load_metric_counts_cache(conn, start_year, end_year) if cached is not None: return _merge_export_cache_voucher_counts(conn, cached, start_year, end_year), False 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) ready_years = [ int(year) for year in ((snapshot_state or {}).get("ready") or []) if start_year is None or end_year is None or int(start_year) <= int(year) <= int(end_year) ] if pending and ready_years: ready_only_counts = _restore_metric_counts_from_cached_year_payloads( conn, start_year, end_year, lightweight=True, selected_years=ready_years, ) if ready_only_counts is not None and any(int(ready_only_counts.get(status_key, 0) or 0) for status_key, _, _ in STATUS_META): ready_only_counts = _merge_export_cache_voucher_counts(conn, ready_only_counts, min(ready_years), max(ready_years)) fallback = ready_only_counts if fallback is None and start_year is not None and end_year is not None: fallback = _restore_metric_counts_from_cached_year_payloads( conn, start_year, end_year, lightweight=True, ) if fallback is not None: fallback.setdefault("bridge_expense_review", 0) try: _store_metric_counts_cache(conn, start_year, end_year, fallback) _store_summary_range_cache( conn, start_year, end_year, fallback, snapshot_state or {"ready": [], "stale": [], "missing": []}, ) except OperationalError: pass if ( (fallback is None or not any(int(fallback.get(status_key, 0) or 0) for status_key, _, _ in STATUS_META)) and start_year is not None and end_year is not None ): direct_counts = _empty_metric_counts() direct_counts.update(_fallback_metric_counts_from_db(conn, start_year, end_year)) direct_counts.update(_voucher_metric_counts_from_db(conn, start_year, end_year)) direct_counts["bridge_expense_review"] = _count_bridge_expense_review_candidates(conn, start_year, end_year) direct_counts = _apply_cached_adjacent_year_demotion_count_adjustment(conn, direct_counts, start_year, end_year) fallback = direct_counts try: _store_metric_counts_cache(conn, start_year, end_year, direct_counts) _store_summary_range_cache( conn, start_year, end_year, direct_counts, snapshot_state or {"ready": [], "stale": [], "missing": []}, ) except OperationalError: pass # 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 int(fallback.get("hanmac_unconnected", 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["voucher_excepted"] = len(voucher_sections.get("voucher_excepted", [])) refreshed["hanmac_unconnected"] = len(voucher_sections.get("hanmac_unconnected", [])) 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_export_cache_voucher_counts(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 []) ] if not years_to_enqueue and start_year is not None and end_year is not None and not pending: years_to_enqueue = list(range(int(start_year), int(end_year) + 1)) pending = True try: enqueue_query_projection_rebuild(engine, start_year, end_year) enqueue_metric_count_rebuild(engine, start_year, end_year) has_fallback_data = any(int((fallback or {}).get(status_key, 0) or 0) for status_key, _, _ in STATUS_META) if years_to_enqueue and not has_fallback_data: enqueue_year_snapshot_rebuild(engine, years_to_enqueue) 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 "" compare_match = re.match(r"^(\d{8})-(\d{5})$", voucher_no) if compare_match: return voucher_no 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 normalize_wehago_display_date(value: Any, default_year: int | None = None) -> str: parsed = parse_excel_date(value, default_year=default_year) if parsed: return parsed[5:] return clean(value) def normalize_wehago_voucher_identity( fiscal_year: Any, voucher_no: Any, ledger_date: Any, fallback: Any = "", ) -> str: year = int(fiscal_year or 0) parsed_date = parse_excel_date(ledger_date, default_year=year or None) normalized = normalize_compare_voucher_no(voucher_no, parsed_date or ledger_date) if normalized: return normalized fallback_text = clean(fallback) if fallback_text: return fallback_text display_date = normalize_wehago_display_date(ledger_date, year or None) return "|".join(part for part in [str(year) if year else "", display_date] if part) 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_recheck_row_changes ( change_key TEXT PRIMARY KEY, change_type TEXT NOT NULL DEFAULT 'match', fiscal_year INTEGER, voucher_no TEXT NOT NULL DEFAULT '', draft_no TEXT NOT NULL DEFAULT '', ledger_date TEXT NOT NULL DEFAULT '', proof_date TEXT NOT NULL DEFAULT '', ledger_account_name TEXT NOT NULL DEFAULT '', voucher_account_name 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 '', changed_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) ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS wehago_compare_query_metrics ( start_year INTEGER NOT NULL, end_year INTEGER NOT NULL, signature TEXT NOT NULL, counts_json TEXT NOT NULL DEFAULT '{}', snapshot_state_json TEXT NOT NULL DEFAULT '{}', source_state_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_compare_query_groups ( start_year INTEGER NOT NULL, end_year INTEGER NOT NULL, status_key TEXT NOT NULL, signature TEXT NOT NULL, group_index INTEGER NOT NULL, fiscal_year INTEGER NOT NULL DEFAULT 0, ledger_date TEXT NOT NULL DEFAULT '', proof_date TEXT NOT NULL DEFAULT '', voucher_no TEXT NOT NULL DEFAULT '', draft_no TEXT NOT NULL DEFAULT '', ledger_row_count INTEGER NOT NULL DEFAULT 0, voucher_row_count INTEGER NOT NULL DEFAULT 0, 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_accounts TEXT NOT NULL DEFAULT '', voucher_accounts TEXT NOT NULL DEFAULT '', ledger_vendors TEXT NOT NULL DEFAULT '', voucher_vendors TEXT NOT NULL DEFAULT '', review_reason TEXT NOT NULL DEFAULT '', search_text 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, status_key, signature, group_index) ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS wehago_compare_query_rows ( start_year INTEGER NOT NULL, end_year INTEGER NOT NULL, status_key TEXT NOT NULL, signature TEXT NOT NULL, group_index INTEGER NOT NULL, row_index INTEGER NOT NULL, fiscal_year INTEGER NOT NULL DEFAULT 0, status_label TEXT NOT NULL DEFAULT '', ledger_date TEXT NOT NULL DEFAULT '', proof_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 '', review_reason TEXT NOT NULL DEFAULT '', matched_case TEXT NOT NULL DEFAULT '', ledger_row_key TEXT NOT NULL DEFAULT '', voucher_row_key TEXT NOT NULL DEFAULT '', match_identity_key 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, status_key, signature, group_index, row_index) ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS wehago_compare_query_page_cache ( start_year INTEGER NOT NULL, end_year INTEGER NOT NULL, status_key TEXT NOT NULL, signature TEXT NOT NULL, query_hash 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, status_key, signature, query_hash) ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS wehago_raw_erp_trace_candidate_cache ( id INTEGER PRIMARY KEY AUTOINCREMENT, candidate_key TEXT NOT NULL DEFAULT '', fiscal_year INTEGER NOT NULL, source_mode TEXT NOT NULL DEFAULT '', source_signature TEXT NOT NULL DEFAULT '', logic_version TEXT NOT NULL DEFAULT '', status_key TEXT NOT NULL DEFAULT '', group_index INTEGER NOT NULL DEFAULT 0, row_index INTEGER NOT NULL DEFAULT 0, ledger_date TEXT NOT NULL DEFAULT '', voucher_no TEXT NOT NULL DEFAULT '', ledger_account_name TEXT NOT NULL DEFAULT '', ledger_vendor TEXT NOT NULL DEFAULT '', ledger_desc TEXT NOT NULL DEFAULT '', ledger_amount REAL NOT NULL DEFAULT 0, erp_draft_no TEXT NOT NULL DEFAULT '', erp_confirmed_no TEXT NOT NULL DEFAULT '', erp_account_name TEXT NOT NULL DEFAULT '', erp_vendor TEXT NOT NULL DEFAULT '', erp_desc TEXT NOT NULL DEFAULT '', erp_amount REAL NOT NULL DEFAULT 0, amount_field TEXT NOT NULL DEFAULT '', score REAL NOT NULL DEFAULT 0, matched_case TEXT NOT NULL DEFAULT '', review_reason TEXT NOT NULL DEFAULT '', candidate_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """ ) ) 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") ensure_column(conn, "wehago_raw_erp_trace_candidate_cache", "candidate_key", "TEXT NOT NULL DEFAULT ''") conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_voucher_source ON wehago_voucher_rows(source_file_id)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_voucher_year_vno ON wehago_voucher_rows(fiscal_year, compare_voucher_no)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_ledger_source ON wehago_ledger_rows(source_file_id)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_ledger_year_vno ON wehago_ledger_rows(fiscal_year, compare_voucher_no)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_compare_year_status ON wehago_comparison_results(fiscal_year, status)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_source_origin_kind ON wehago_source_files(source_origin, file_kind)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_recheck_reviews_year ON wehago_recheck_reviews(fiscal_year, reviewed_at)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_recheck_row_changes_year ON wehago_recheck_row_changes(fiscal_year, change_type, changed_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)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_compare_export_row_cache_header ON wehago_compare_export_row_cache(status_key, fiscal_year, snapshot_signature, row_sort, group_sort)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_compare_export_row_cache_group_filter ON wehago_compare_export_row_cache(status_key, fiscal_year, snapshot_signature, group_voucher_no, group_draft_no, group_sort, row_sort)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_compare_export_row_cache_year_signature ON wehago_compare_export_row_cache(fiscal_year, snapshot_signature)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_compare_query_metrics_updated ON wehago_compare_query_metrics(start_year, end_year, updated_at)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_compare_query_groups_lookup ON wehago_compare_query_groups(status_key, start_year, end_year, signature, fiscal_year, group_index)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_compare_query_groups_filter ON wehago_compare_query_groups(status_key, start_year, end_year, signature, voucher_no, draft_no)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_compare_query_groups_range_signature ON wehago_compare_query_groups(start_year, end_year, signature, fiscal_year, status_key)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_compare_query_rows_lookup ON wehago_compare_query_rows(status_key, start_year, end_year, signature, group_index, row_index)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_compare_query_rows_group_fetch ON wehago_compare_query_rows(start_year, end_year, status_key, signature, group_index, row_index, fiscal_year)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_compare_query_rows_range_signature ON wehago_compare_query_rows(start_year, end_year, signature, fiscal_year, status_key)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_compare_query_page_cache_lookup ON wehago_compare_query_page_cache(status_key, start_year, end_year, signature, updated_at)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_raw_trace_candidate_source ON wehago_raw_erp_trace_candidate_cache(fiscal_year, source_mode, source_signature, logic_version, status_key, group_index)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_raw_trace_candidate_voucher ON wehago_raw_erp_trace_candidate_cache(fiscal_year, ledger_date, voucher_no)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_raw_trace_candidate_score ON wehago_raw_erp_trace_candidate_cache(fiscal_year, score DESC)")) conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_wehago_raw_trace_candidate_key ON wehago_raw_erp_trace_candidate_cache(candidate_key) WHERE candidate_key <> ''")) 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() _HANMAC_UNCONNECTED_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() _HANMAC_UNCONNECTED_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", "hanmac_unconnected"}: 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 _restrict_erp_rows_to_fiscal_year(sections, year) 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, ) try: 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, }, ) except OperationalError as exc: logger.warning("Skipped parse cache write for year %s due to lock: %s", year, exc) 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) sections = _repair_invalid_cross_category_matches(sections) else: sections = _build_db_status_sections(conn, year) sections = _restrict_erp_rows_to_fiscal_year(sections, 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) payload = _demote_incomplete_adjacent_year_matches(payload) return _restrict_erp_rows_to_fiscal_year(payload, year) except Exception: pass return None def _signature_has_same_source_state(signature: Any, current_signature: Any) -> bool: _signature_logic, signature_state = _split_snapshot_signature(signature) _current_logic, current_state = _split_snapshot_signature(current_signature) return bool(signature_state and signature_state == current_state) def _load_latest_year_resolved_sections_cache_any_signature( conn: Any, year: int, *, current_signature: str | None = None, allow_legacy_same_state: bool = False, ) -> dict[str, dict[str, Any]] | None: cached = conn.execute( text( """ SELECT resolved_payload_json, resolved_state_signature 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] and ( _signature_uses_current_logic(cached[1]) or ( allow_legacy_same_state and current_signature and _signature_has_same_source_state(cached[1], current_signature) ) ): try: payload = json.loads(str(cached[0])) if isinstance(payload, dict): payload = _repair_invalid_cross_category_matches(payload) payload = _demote_incomplete_adjacent_year_matches(payload) return _restrict_erp_rows_to_fiscal_year(payload, year) except Exception: pass return None def _signature_uses_current_logic(signature: Any) -> bool: signature_text = clean(signature) if not signature_text: return False signature_parts, _state_parts = _split_snapshot_signature(signature_text) current_parts = list(_current_logic_signature_parts()) return signature_parts == current_parts def _status_row_counts_from_sections( sections: dict[str, dict[str, Any]] | None, voucher_sections: dict[str, list[dict[str, Any]]] | None = None, ) -> dict[str, int]: counts = { "matched": 0, "ledger_only": 0, "amount_mismatch": 0, "voucher_only": 0, "voucher_matched": 0, "erp_voucher_matched": 0, "voucher_unmatched": 0, "erp_voucher_unmatched": 0, "voucher_recheck": 0, "voucher_excepted": 0, "hanmac_unconnected": 0, } if not sections: return counts for status_key in ("matched", "ledger_only", "amount_mismatch", "voucher_only"): counts[status_key] = len(sections.get(status_key, {}).get("rows", [])) if voucher_sections: for status_key in ("voucher_matched", "voucher_unmatched", "voucher_recheck", "voucher_excepted", "hanmac_unconnected", "erp_voucher_matched", "erp_voucher_unmatched"): counts[status_key] = len(voucher_sections.get(status_key, [])) 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) or _snapshot_signature_equivalent(stored_signature, 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: try: 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}, ) except OperationalError as exc: logger.warning("Skipped resolved sections cache write for year %s due to lock: %s", year, exc) 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) sections = _repair_invalid_cross_category_matches(sections) else: sections = _build_db_status_sections(conn, year) sections = _restrict_erp_rows_to_fiscal_year(sections, 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, voucher_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 _build_query_projection_rebuild_job_key(start_year: int | None, end_year: int | None, signature: str) -> str: return f"query_projection:{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")) if job_type == "query_projection_rebuild": return ("query_projection_rebuild", payload.get("start_year"), payload.get("end_year")) return None def _has_active_compare_job_for_scope( conn: Any, *, job_type: str, scope: tuple[Any, ...] | None, signature: str = "", ) -> bool: if scope is None: return False rows = conn.execute( text( """ SELECT payload_json FROM wehago_background_jobs WHERE job_type = :job_type AND state IN ('queued', 'running') """ ), {"job_type": job_type}, ).mappings().all() for row in rows: 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: continue if signature and clean(payload.get("signature")) != signature: continue return True return False 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', 'query_projection_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 _run_compare_write_transaction( engine: Any, operation: Callable[[Any], Any], *, attempts: int = 8, base_delay: float = 0.15, ) -> Any: last_error: OperationalError | None = None total_attempts = max(int(attempts or 1), 1) for attempt in range(total_attempts): try: with engine.begin() as conn: return operation(conn) except OperationalError as exc: if "database is locked" not in str(exc).lower(): raise last_error = exc time.sleep(base_delay * (attempt + 1)) if last_error is not None: raise last_error raise RuntimeError("compare write transaction failed without a captured error") def _ensure_compare_snapshot_worker(engine: Any) -> None: global _COMPARE_SNAPSHOT_WORKER_STARTED, _COMPARE_SNAPSHOT_WORKER_THREAD with _COMPARE_SNAPSHOT_WORKER_LOCK: if _COMPARE_SNAPSHOT_WORKER_STARTED and _COMPARE_SNAPSHOT_WORKER_THREAD and _COMPARE_SNAPSHOT_WORKER_THREAD.is_alive(): 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 _COMPARE_SNAPSHOT_WORKER_THREAD = worker def _ensure_compare_export_worker(engine: Any) -> None: global _COMPARE_EXPORT_WORKER_STARTED, _COMPARE_EXPORT_WORKER_THREAD with _COMPARE_EXPORT_WORKER_LOCK: if _COMPARE_EXPORT_WORKER_STARTED and _COMPARE_EXPORT_WORKER_THREAD and _COMPARE_EXPORT_WORKER_THREAD.is_alive(): return worker = threading.Thread( target=_compare_export_worker_loop, args=(engine,), daemon=True, name="wehago-compare-export-worker", ) worker.start() _COMPARE_EXPORT_WORKER_STARTED = True _COMPARE_EXPORT_WORKER_THREAD = worker 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) def _operation(conn: Any) -> None: status_map = _load_snapshot_status_map(conn, years) for year in years: scope = ("year_snapshot_rebuild", year) signature = _build_db_state_signature(conn, year, year) if not _should_enqueue_year_snapshot(conn, year, signature, status_map.get(year)): continue if _has_active_compare_job_for_scope( conn, job_type="year_snapshot_rebuild", scope=scope, signature=signature, ): 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=scope, keep_job_key=job_key, ) _run_compare_write_transaction(engine, _operation) _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 not ENABLE_COMPARE_QUERY_BACKGROUND_REBUILD: return if start_year is None or end_year is None: return init_wehago_compare_db(engine) def _operation(conn: Any) -> None: scope = ("metric_counts_rebuild", start_year, end_year) signature = _metric_counts_signature(conn, start_year, end_year) if _has_active_compare_job_for_scope( conn, job_type="metric_counts_rebuild", scope=scope, signature=signature, ): return 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=scope, keep_job_key=job_key, ) _run_compare_write_transaction(engine, _operation) _ensure_compare_snapshot_worker(engine) _COMPARE_SNAPSHOT_JOB_EVENT.set() def enqueue_query_projection_rebuild( engine: Any, start_year: int | None, end_year: int | None, ) -> None: if not ENABLE_COMPARE_QUERY_BACKGROUND_REBUILD: return if start_year is None or end_year is None: return init_wehago_compare_db(engine) def _operation(conn: Any) -> None: scope = ("query_projection_rebuild", start_year, end_year) signature = _query_projection_signature(conn, start_year, end_year) if _has_active_compare_job_for_scope( conn, job_type="query_projection_rebuild", scope=scope, signature=signature, ): return job_key = _build_query_projection_rebuild_job_key(start_year, end_year, signature) priority = max(95, _compare_range_job_priority(start_year, end_year) + 60) 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, 'query_projection_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="query_projection_rebuild", scope=scope, keep_job_key=job_key, ) _run_compare_write_transaction(engine, _operation) _ensure_compare_snapshot_worker(engine) _COMPARE_SNAPSHOT_JOB_EVENT.set() def _run_compare_background_job_subprocess(job_type: str, payload: dict[str, Any]) -> None: payload_json = json.dumps(payload, ensure_ascii=False) script = r""" import json import fcntl import sys from pathlib import Path from main import engine from wehago_compare import ( _get_compare_snapshot_state, _rebuild_compare_query_projection, _refresh_year_resolved_sections, _resolved_metric_counts, _store_metric_counts_cache, _store_summary_range_cache, ) job_type = sys.argv[1] payload = json.loads(sys.argv[2] or "{}") lock_path = Path("/tmp/wehago_compare_compute.lock") lock_path.parent.mkdir(parents=True, exist_ok=True) with lock_path.open("w") as lock_file: fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: if job_type == "year_snapshot_rebuild": year = int(payload.get("fiscal_year") or 0) if year > 0: _refresh_year_resolved_sections(conn, year) elif job_type == "query_projection_rebuild": start_year = payload.get("start_year") end_year = payload.get("end_year") _rebuild_compare_query_projection(engine, conn, start_year, end_year) elif job_type == "metric_counts_rebuild": start_year = payload.get("start_year") end_year = payload.get("end_year") _rebuild_compare_query_projection(engine, conn, start_year, end_year) else: raise ValueError(f"Unsupported compare background job: {job_type}") """ subprocess.run( [sys.executable, "-c", script, job_type, payload_json], cwd=str(Path(__file__).resolve().parent), check=True, ) def _claim_next_compare_background_job( conn: Any, *, allowed_job_types: tuple[str, ...], ) -> dict[str, Any] | None: placeholders = ", ".join(f":job_type_{index}" for index, _ in enumerate(allowed_job_types)) params = {f"job_type_{index}": job_type for index, job_type in enumerate(allowed_job_types)} while True: job = conn.execute( text( f""" SELECT job_key, job_type, payload_json FROM wehago_background_jobs WHERE job_type IN ({placeholders}) AND state = 'queued' ORDER BY priority ASC, created_at ASC LIMIT 1 """ ), params, ).mappings().first() if not job: return None job_dict = dict(job) payload: dict[str, Any] = {} try: payload = json.loads(job_dict.get("payload_json") or "{}") except Exception: payload = {} signature = clean(payload.get("signature")) job_type = clean(job_dict.get("job_type")) obsolete = False if signature: if job_type == "query_projection_rebuild": obsolete = not signature.startswith(QUERY_PROJECTION_VERSION) elif job_type in {"metric_counts_rebuild", "year_snapshot_rebuild"}: obsolete = not signature.startswith(METRIC_COUNT_CACHE_VERSION) if obsolete: conn.execute( text( """ UPDATE wehago_background_jobs SET state = 'failed', error_message = 'obsolete compare job skipped', finished_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE job_key = :job_key """ ), {"job_key": clean(job_dict.get("job_key"))}, ) continue conn.execute( text( """ UPDATE wehago_background_jobs SET state = 'running', error_message = '', started_at = CURRENT_TIMESTAMP, finished_at = '', updated_at = CURRENT_TIMESTAMP WHERE job_key = :job_key """ ), {"job_key": job["job_key"]}, ) if job_type == "year_snapshot_rebuild": year = int(payload.get("fiscal_year") or 0) if year > 0: _upsert_snapshot_status( conn, year, signature=signature, state="running", ) if 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"]}, ) return job_dict def _mark_compare_background_job_done(conn: Any, job_key: str) -> None: 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_key}, ) def _mark_compare_background_job_failed(conn: Any, job: dict[str, Any], exc: Exception) -> None: payload = {} try: payload = json.loads(job.get("payload_json") or "{}") except Exception: payload = {} if clean(job.get("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.get("job_key"), "error_message": clean(exc)}, ) def _run_status_export_background_job(engine: Any, payload: dict[str, Any], job_key: str) -> None: 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) with engine.begin() as conn: _store_status_export_job_file( conn, job_key, file_name, file_path, row_count, ) _cleanup_old_compare_export_files(keep_paths={str(file_path)}) 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 = _claim_next_compare_background_job( conn, allowed_job_types=("year_snapshot_rebuild", "query_projection_rebuild", "metric_counts_rebuild"), ) if not job: break try: payload = json.loads(job.get("payload_json") or "{}") if not isinstance(payload, dict): payload = {} if job["job_type"] == "year_snapshot_rebuild": _run_compare_background_job_subprocess("year_snapshot_rebuild", payload) elif job["job_type"] == "query_projection_rebuild": _run_compare_background_job_subprocess("query_projection_rebuild", payload) elif job["job_type"] == "metric_counts_rebuild": _run_compare_background_job_subprocess("metric_counts_rebuild", payload) with engine.begin() as conn: _mark_compare_background_job_done(conn, clean(job.get("job_key"))) except Exception as exc: with engine.begin() as conn: _mark_compare_background_job_failed(conn, job, exc) def _compare_export_worker_loop(engine: Any) -> None: while True: _COMPARE_EXPORT_JOB_EVENT.wait(timeout=5.0) _COMPARE_EXPORT_JOB_EVENT.clear() while True: init_wehago_compare_db(engine) with engine.begin() as conn: _normalize_compare_background_jobs(conn) job = _claim_next_compare_background_job( conn, allowed_job_types=("status_export_xlsx",), ) if not job: break try: payload = json.loads(job.get("payload_json") or "{}") if not isinstance(payload, dict): payload = {} _run_status_export_background_job(engine, payload, clean(job.get("job_key"))) with engine.begin() as conn: _mark_compare_background_job_done(conn, clean(job.get("job_key"))) except Exception as exc: with engine.begin() as conn: _mark_status_export_job_failed(conn, clean(job.get("job_key")), clean(exc)) _mark_compare_background_job_failed(conn, job, 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 {} stored_signature = clean(row.get("snapshot_signature")) if ( stored_signature and clean(row.get("state")) == "ready" and ( stored_signature == clean(signature) or _snapshot_signature_equivalent(stored_signature, signature) ) ): 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) or _snapshot_signature_equivalent(stored_signature, 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', 'query_projection_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 elif job_type == "query_projection_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() _STATUS_DETAIL_RESPONSE_CACHE.clear() _VOUCHER_SECTION_CACHE.clear() _VOUCHER_RECHECK_CACHE.clear() _HANMAC_UNCONNECTED_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, selected_years: Iterable[int] | None = 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 year_range = ( sorted({int(year) for year in selected_years if int(year or 0) > 0}) if selected_years is not None else list(range(start_year, end_year + 1)) ) for year in year_range: 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_query_projection_rebuild(engine, start_year, end_year) except Exception: pass if not any(rows_by_status.get(status_key) for status_key in rows_by_status): 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( [ QUERY_PROJECTION_VERSION, 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": [], "voucher_excepted": [], "hanmac_unconnected": [], "erp_voucher_unmatched": [], } 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_with_bridge_promotions(conn, start_year, end_year, rows_by_status) _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]: fiscal_year = int(row.get("fiscal_year") or 0) return ( fiscal_year, normalize_wehago_voucher_identity( fiscal_year, row.get("voucher_no"), row.get("ledger_date"), row.get("ledger_row_key") or row.get("review_key"), ), "", ) 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")) matched_erp_key = erp_group_key(row)[1] 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 and matched_erp_key: group_key = "|".join([str(fiscal_year), ledger_date, voucher_no, matched_erp_key]) elif voucher_no and proof_date and matched_erp_key: group_key = "|".join([str(fiscal_year), proof_date, voucher_no, matched_erp_key]) elif 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) sections = _expand_erp_unmatched_groups_from_raw_rows(conn, start_year, end_year, sections) sections = _apply_bridge_expense_promotions_to_voucher_sections(conn, start_year, end_year, sections) sections = _apply_raw_erp_trace_promotions(conn, start_year, end_year, sections) return _dedupe_wehago_sections_to_single_status(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: review_key = clean(row.get("match_identity_key")) if not review_key: review_key = clean(row.get("ledger_row_key")) or clean(row.get("voucher_row_key")) if not review_key: review_key = "|".join( [ "manual-recheck", clean(row.get("fiscal_year")), clean(row.get("ledger_date")), 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_debit")), clean(row.get("ledger_credit")), clean(row.get("voucher_debit")), clean(row.get("voucher_credit")), ] ) 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 saved_keys: list[str] = [] with engine.begin() as conn: normalized_rows = [item for item in normalized_rows if clean(item.get("review_key"))] if not normalized_rows: return 0 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() _HANMAC_UNCONNECTED_CACHE.clear() _PAIR_RECOMMEND_CACHE.clear() clear_persisted_pair_recommend_cache(engine) return len(normalized_rows) def _build_recheck_change_key(row: dict[str, Any], change_type: str) -> str: explicit_key = clean(row.get("review_key")) or clean(row.get("match_identity_key")) if explicit_key: return f"{change_type}:{explicit_key}" return "|".join( [ change_type, clean(row.get("fiscal_year")), clean(row.get("ledger_date")), 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_debit")), clean(row.get("ledger_credit")), clean(row.get("voucher_debit")), clean(row.get("voucher_credit")), clean(row.get("ledger_desc")), clean(row.get("voucher_desc")), ] ) def save_recheck_change_rows( engine: Any, match_rows: list[dict[str, Any]], split_rows: list[dict[str, Any]], ) -> dict[str, int]: init_wehago_compare_db(engine) saved_match_count = save_recheck_review_rows(engine, match_rows) if match_rows else 0 normalized_change_rows: list[dict[str, Any]] = [] def append_change_rows(rows: list[dict[str, Any]], change_type: str) -> None: for row in rows or []: if not isinstance(row, dict): continue change_key = _build_recheck_change_key(row, change_type) if not clean(change_key): continue normalized_change_rows.append( { "change_key": change_key, "change_type": change_type, "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_name": clean(row.get("voucher_account_name")), "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")), } ) append_change_rows(match_rows, "match") append_change_rows(split_rows, "split") saved_split_count = 0 if normalized_change_rows: seen_change_keys: set[str] = set() normalized_change_rows = [ item for item in normalized_change_rows if not (item["change_key"] in seen_change_keys or seen_change_keys.add(item["change_key"])) ] for item in normalized_change_rows: if item.get("change_type") == "split": saved_split_count += 1 if normalized_change_rows: with engine.begin() as conn: for item in normalized_change_rows: conn.execute( text( """ INSERT INTO wehago_recheck_row_changes ( change_key, change_type, fiscal_year, voucher_no, draft_no, ledger_date, proof_date, ledger_account_name, voucher_account_name, ledger_debit, ledger_credit, voucher_debit, voucher_credit, ledger_desc, voucher_desc, changed_at ) VALUES ( :change_key, :change_type, :fiscal_year, :voucher_no, :draft_no, :ledger_date, :proof_date, :ledger_account_name, :voucher_account_name, :ledger_debit, :ledger_credit, :voucher_debit, :voucher_credit, :ledger_desc, :voucher_desc, CURRENT_TIMESTAMP ) ON CONFLICT(change_key) DO UPDATE SET change_type = excluded.change_type, fiscal_year = excluded.fiscal_year, voucher_no = excluded.voucher_no, draft_no = excluded.draft_no, ledger_date = excluded.ledger_date, proof_date = excluded.proof_date, ledger_account_name = excluded.ledger_account_name, voucher_account_name = excluded.voucher_account_name, ledger_debit = excluded.ledger_debit, ledger_credit = excluded.ledger_credit, voucher_debit = excluded.voucher_debit, voucher_credit = excluded.voucher_credit, ledger_desc = excluded.ledger_desc, voucher_desc = excluded.voucher_desc, changed_at = CURRENT_TIMESTAMP """ ), item, ) _push_action_history( conn, "recheck_change", { "match_count": saved_match_count, "split_count": saved_split_count, "count": saved_match_count + saved_split_count, }, ) _DASHBOARD_CACHE.clear() _SUGGEST_CACHE.clear() _STATUS_ROWS_CACHE.clear() _VOUCHER_RECHECK_CACHE.clear() _HANMAC_UNCONNECTED_CACHE.clear() _PAIR_RECOMMEND_CACHE.clear() clear_persisted_pair_recommend_cache(engine) return {"match_count": saved_match_count, "split_count": saved_split_count, "count": saved_match_count + saved_split_count} 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() _HANMAC_UNCONNECTED_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() _HANMAC_UNCONNECTED_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 _parse_row_date_with_year(row: dict[str, Any], field: str) -> date | None: parsed = _parse_iso_date(row.get(field)) if parsed: return parsed raw = clean(row.get(field)) match = re.fullmatch(r"(\d{1,2})[-./](\d{1,2})", raw) if not match: return None year = int(row.get("fiscal_year") or 0) if year <= 0: return None try: return date(year, int(match.group(1)), int(match.group(2))) except ValueError: return None def _extract_dates_from_text(value: Any) -> list[date]: text_value = clean(value) if not text_value: return [] dates: list[date] = [] seen: set[date] = set() for match in re.finditer(r"(? list[date]: cached_values = entry.get("_raw_entry_dates") if cached_values: parsed_values: list[date] = [] for value in cached_values: if isinstance(value, date): parsed_values.append(value) else: parsed = _parse_iso_date(value) if parsed: parsed_values.append(parsed) if parsed_values: return parsed_values values: list[date] = [] seen: set[date] = set() for parsed in ( _parse_row_date_with_year(entry, "proof_date"), *_extract_dates_from_text(entry.get("management_item")), ): if parsed and parsed not in seen: seen.add(parsed) values.append(parsed) return values 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 _extract_named_reference_tokens(value: Any) -> set[str]: text_value = clean(value) if not text_value: return set() tokens: set[str] = set() for inner in re.findall(r"\(([^()]*)\)", text_value): normalized_inner = normalize_text(inner) if len(normalized_inner) >= 2: tokens.add(normalized_inner) for piece in re.split(r"[\\/,\s·ㆍ]+", inner): normalized_piece = normalize_text(piece) if len(normalized_piece) >= 2: tokens.add(normalized_piece) return tokens def _has_named_reference_conflict(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -> bool: left_tokens = ( _extract_named_reference_tokens(ledger_row.get("ledger_desc")) | _extract_named_reference_tokens(ledger_row.get("ledger_vendor")) ) right_tokens = ( _extract_named_reference_tokens(voucher_row.get("voucher_desc")) | _extract_named_reference_tokens(voucher_row.get("voucher_vendor")) ) return bool(left_tokens and right_tokens and left_tokens.isdisjoint(right_tokens)) 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 _is_recheck_row_obvious_same_voucher_match(row: dict[str, Any]) -> bool: score_result = _score_pair_match(row, row) if score_result.get("month_conflict") or score_result.get("review_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 not _account_names_compatible(row, row): return False ledger_date = _parse_iso_date(row.get("ledger_date")) voucher_date = _infer_compare_row_proof_date(row) same_day = bool(ledger_date and voucher_date and ledger_date == voucher_date) same_month = bool(ledger_date and voucher_date and ledger_date.year == voucher_date.year and ledger_date.month == voucher_date.month) vendor_match = _same_or_similar_vendor(row) desc_match = _same_or_similar_desc(row) or _contained_core_desc_match(row.get("ledger_desc"), row.get("voucher_desc")) if same_day and (vendor_match or desc_match): return True if same_month and vendor_match and desc_match: return True if score_result.get("auto_eligible"): return True return False def _infer_compare_row_proof_date(row: dict[str, Any]) -> date | None: parsed = _parse_iso_date(row.get("proof_date")) if parsed is not None: return parsed draft_no = clean(row.get("draft_no")) match = re.search(r"(20\d{2})(\d{2})(\d{2})", draft_no) if not match: return None try: return date(int(match.group(1)), int(match.group(2)), int(match.group(3))) except ValueError: return None def _compare_row_month_gap(row: dict[str, Any]) -> int | None: ledger_date = _parse_iso_date(row.get("ledger_date")) voucher_date = _infer_compare_row_proof_date(row) if ledger_date is None or voucher_date is None: return None return _month_gap(ledger_date, voucher_date) def _is_obvious_recheck_business_row(row: dict[str, Any]) -> bool: if not clean(row.get("ledger_account_name")) or not clean(row.get("voucher_account_name")): return False 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 {ledger_family, voucher_family} & {"bank", "payable"}: if ledger_family != voucher_family: return False if not _account_base_desc_approved(row): return False return _is_recheck_row_clear_match(row) or _is_recheck_row_obvious_same_voucher_match(row) 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 = ("복리후생비", "복리후생", "회식대") # VAT output names such as "매출세액" contain "매출"; classify them before generic 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 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 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_forbidden_direct_account_family_pair(ledger_family: str, voucher_family: str) -> bool: return frozenset({ledger_family, voucher_family}) in { frozenset({"advance", "payable"}), } 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: if ledger_date == voucher_date: return True if ledger_date.year == voucher_date.year and ledger_date.month == voucher_date.month: return not _has_named_reference_conflict(row, row) 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) if _is_forbidden_direct_account_family_pair(ledger_family, voucher_family): return False # 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: 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 = _effective_row_account_side(row, "ledger") voucher_side = _effective_row_account_side(row, "voucher") return _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, ) def _effective_row_account_side(row: dict[str, Any], prefix: str) -> str: net_debit = parse_amount(row.get(f"{prefix}_debit")) - parse_amount(row.get(f"{prefix}_credit")) if net_debit > 0.0001: return "debit" if net_debit < -0.0001: return "credit" return "either" def _safe_float(value: Any) -> float: try: return float(parse_amount(value)) except Exception: return 0.0 def _row_passes_user_defined_match_basis(row: dict[str, Any]) -> bool: if not _matched_row_passes_account_pair_rule(row): 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 score_result = _score_pair_match(row, row) if score_result.get("month_conflict") and not _is_clear_vat_match_row(row): return False if score_result.get("review_conflict") and not _is_clear_vat_match_row(row): return False return bool( score_result.get("auto_eligible") or _is_recheck_row_clear_match(row) or _is_recheck_row_obvious_same_voucher_match(row) ) 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["status_label"] = "Unmatched" restored["review_reason"] = "DIRECT_PAIR_CONTEXT_ONLY" 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["status_label"] = "ERP Unmatched" restored["review_reason"] = "DIRECT_PAIR_CONTEXT_ONLY" 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 _split_forbidden_direct_pair_row(row: dict[str, Any]) -> list[dict[str, Any]]: if _matched_row_passes_account_pair_rule(row): return [dict(row)] ledger_context = 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", ): ledger_context[field] = "" if field.endswith(("_code", "_name", "_vendor", "_desc", "_row_key")) or field in {"proof_date", "draft_no"} else 0 voucher_context = dict(row) for field in ( "ledger_date", "ledger_account_code", "ledger_account_name", "ledger_vendor", "ledger_debit", "ledger_credit", "ledger_desc", "ledger_row_key", ): voucher_context[field] = "" if field.endswith(("_code", "_name", "_vendor", "_desc", "_row_key")) or field == "ledger_date" else 0 return [ledger_context, voucher_context] def _sanitize_voucher_group_rows(rows: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: sanitized: list[dict[str, Any]] = [] seen: set[tuple[Any, ...]] = set() for row in rows: for split_row in _split_forbidden_direct_pair_row(dict(row)): if ( not clean(split_row.get("ledger_account_name")) and clean(split_row.get("draft_no")) and re.match(r"^11-20\d{6}-", clean(split_row.get("voucher_no"))) ): split_row["voucher_no"] = "" if not ( clean(split_row.get("ledger_account_name")) or clean(split_row.get("voucher_account_name")) or clean(split_row.get("ledger_desc")) or clean(split_row.get("voucher_desc")) or abs(_safe_float(split_row.get("ledger_debit"))) > 0.0001 or abs(_safe_float(split_row.get("ledger_credit"))) > 0.0001 or abs(_safe_float(split_row.get("voucher_debit"))) > 0.0001 or abs(_safe_float(split_row.get("voucher_credit"))) > 0.0001 ): continue dedupe_key = ( int(split_row.get("fiscal_year") or 0), clean(split_row.get("status_label")), clean(split_row.get("ledger_date")), clean(split_row.get("proof_date")), clean(split_row.get("voucher_no")), clean(split_row.get("draft_no")), clean(split_row.get("ledger_account_name")), clean(split_row.get("voucher_account_name")), clean(split_row.get("ledger_vendor")), clean(split_row.get("voucher_vendor")), _safe_float(split_row.get("ledger_debit")), _safe_float(split_row.get("ledger_credit")), _safe_float(split_row.get("voucher_debit")), _safe_float(split_row.get("voucher_credit")), clean(split_row.get("ledger_desc")), clean(split_row.get("voucher_desc")), clean(split_row.get("ledger_row_key")), clean(split_row.get("voucher_row_key")), clean(split_row.get("match_identity_key")), ) if dedupe_key in seen: continue seen.add(dedupe_key) sanitized.append(split_row) return sanitized 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) sections, changed_scope = _release_invalid_multi_voucher_matches(sections) if not (changed_matched or changed_recheck or changed_scope): return sections return _promote_direct_auto_matches(sections) def _release_invalid_multi_voucher_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 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]: 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 (int(row.get("fiscal_year") or 0), base) wehago_group_capacities = _multi_voucher_capacity_by_group( {"matched": matched_rows}, group_key_builder=wehago_group_key, prefix="ledger", ) erp_group_capacities = _multi_voucher_capacity_by_group( {"matched": matched_rows}, group_key_builder=erp_group_key, prefix="voucher", ) wehago_to_erp: dict[tuple[int, str, str], set[tuple[int, str]]] = {} erp_to_wehago: dict[tuple[int, str], set[tuple[int, str, str]]] = {} for row in matched_rows: w_key = wehago_group_key(row) e_key = erp_group_key(row) wehago_to_erp.setdefault(w_key, set()).add(e_key) erp_to_wehago.setdefault(e_key, set()).add(w_key) invalid_wehago_keys = { key for key, erp_keys in wehago_to_erp.items() if len(erp_keys) > wehago_group_capacities.get(key, 1) } invalid_erp_keys = { key for key, wehago_keys in erp_to_wehago.items() if len(wehago_keys) > erp_group_capacities.get(key, 1) } kept_rows: list[dict[str, Any]] = [] recheck_rows = list((sections or {}).get("amount_mismatch", {}).get("rows", [])) existing_review_keys = { 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")), ] ) for row in recheck_rows } changed = False for row in matched_rows: w_key = wehago_group_key(row) e_key = erp_group_key(row) ledger_date = _parse_iso_date(row.get("ledger_date")) voucher_date = _parse_iso_date(row.get("proof_date")) month_gap = _month_gap(ledger_date, voucher_date) if ledger_date and voucher_date else None invalid_scope = ( w_key in invalid_wehago_keys or e_key in invalid_erp_keys or (month_gap is not None and month_gap >= 8) ) if invalid_scope and _row_passes_user_defined_match_basis(row): kept_rows.append(row) continue if not invalid_scope: kept_rows.append(row) continue changed = True demoted = dict(row) demoted["review_reason"] = "전표단위 재검토" demoted["matched_case"] = "voucher_level_recheck" review_identity = clean(demoted.get("review_key")) or "|".join( [ clean(demoted.get("ledger_row_key")), clean(demoted.get("voucher_row_key")), clean(demoted.get("voucher_no")), clean(demoted.get("draft_no")), ] ) if review_identity not in existing_review_keys: recheck_rows.append(demoted) existing_review_keys.add(review_identity) if not changed: return sections, False sections["matched"]["rows"] = kept_rows sections["matched"]["count"] = len(kept_rows) sections["amount_mismatch"]["rows"] = recheck_rows sections["amount_mismatch"]["count"] = len(recheck_rows) return sections, True def _build_account_substitution_hint( ledger_code: Any, ledger_name: Any, voucher_code: Any, voucher_name: Any, ) -> str: ledger_family = _classify_account_family(ledger_code, ledger_name) voucher_family = _classify_account_family(voucher_code, voucher_name) if ledger_family 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]] = [] moved_to_voucher_only: list[dict[str, Any]] = [] for row in sections["matched"]["rows"]: if not _matched_row_passes_account_pair_rule(row): moved_to_ledger_only.append(_restore_ledger_only_row_from_match(row)) moved_to_voucher_only.append(_restore_voucher_only_row_from_match(row)) continue 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 or moved_to_voucher_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"]) sections["voucher_only"]["rows"].extend(moved_to_voucher_only) sections["voucher_only"]["count"] = len(sections["voucher_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) or _is_recheck_row_obvious_same_voucher_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_OBVIOUS_MATCH" if _is_recheck_row_obvious_same_voucher_match(row) 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), } ) if not _matched_row_passes_account_pair_rule(merged): continue 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), } ) if not _matched_row_passes_account_pair_rule(merged): continue 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]]: # A selected period must only expose and match Hanmac ERP vouchers whose # draft voucher date belongs to that period. Pulling older ERP candidates # into the current year made unrelated Hanmac rows appear as current data. return sections 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) ) exact_same_day_vat_match = bool( vat_family_pair and ledger.date_value and voucher.date_value and ledger.date_value == voucher.date_value and amount_gap_any_side < 0.5 and ledger_amount > 0 ) same_month_vat_match = bool( vat_family_pair and ledger.date_value and voucher.date_value and ledger.date_value.year == voucher.date_value.year and ledger.date_value.month == voucher.date_value.month and amount_gap_any_side < 0.5 and ledger_amount > 0 ) 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 if _is_forbidden_direct_account_family_pair(ledger_family, voucher_family): reasons.append("전도금/외상매입금 직접매칭 불가") else: reasons.append("계정 성격 교차 불가") if side in {"debit", "credit"}: if amount_gap_same_side < 0.5 and not nature_same_side_ok: if exact_same_day_vat_match: reasons.append("VAT 동일일자 금액 일치") else: hard_pass = False score -= 40 reasons.append("차대 성격 불일치") elif side_flipped_match: # 같은 금액이어도 반대 방향이면 원칙적으로 다른 거래 성격으로 본다. if exact_same_day_vat_match: reasons.append("VAT 동일일자 금액 일치") elif 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 named_reference_conflict = _has_named_reference_conflict(ledger.row, voucher.row) if named_reference_conflict: hard_pass = False score -= 55 reasons.append("이름/식별자 상충") 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: if same_month_vat_match and (vendor_core_match or desc_core_match or account_strong): score += 8 reasons.append('VAT 같은 월 일자 허용') else: 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 = bool(tax_day_review_conflict and not (same_month_vat_match and (vendor_core_match or desc_core_match or account_strong))) 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 일자 일치") elif same_month_vat_match and amount_gap < 0.5: score += 6 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("전후 2개월 내 일자 차이") else: score -= 6 reasons.append("전후 2개월 초과") 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("전후 2개월 내 일자 차이") else: score -= 6 reasons.append("전후 2개월 초과") transfer_like = any( marker in normalize_text( " ".join( [ clean(ledger.account_name), clean(voucher.account_name), clean(ledger.desc_text), clean(voucher.desc_text), clean(ledger.vendor_name), clean(voucher.vendor_name), ] ) ) for marker in ("이체", "cma", "예금", "통장") ) if transfer_like and month_gap is not None and month_gap > 1: hard_pass = False score -= 45 reasons.append("자금이동 일자 차이") # 계정군이 명백히 다른데 기간 차이까지 크면 자동매칭 대상이 아니다. 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 or exact_same_day_vat_match) and not month_conflict and not review_conflict and ( exact_same_day_vat_match or (same_month_vat_match and account_strong and (vendor_core_match or desc_core_match)) 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 CASE WHEN {bank_sql} THEN account_code || ' ' || account_name END) AS bank_accounts, GROUP_CONCAT(DISTINCT CASE WHEN {bank_sql} THEN vendor_name END) AS bank_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, GROUP_CONCAT(DISTINCT CASE WHEN {payable_sql} THEN account_code || ' ' || account_name END) AS payable_accounts, GROUP_CONCAT(DISTINCT CASE WHEN {payable_sql} THEN vendor_name END) AS payable_vendors, 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, COALESCE(l.bank_accounts, '') AS ledger_bank_accounts, COALESCE(l.bank_vendors, '') AS ledger_bank_vendors, 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_accounts, '') AS expense_source_payable_accounts, COALESCE(s.payable_vendors, '') AS expense_source_payable_vendors, 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_identity = f"bridge:{clean(row.get('fiscal_year'))}:{clean(row.get('voucher_no'))}:{clean(row.get('ledger_date'))}:{clean(voucher_display)}" expense_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-expense:{clean(row.get('fiscal_year'))}:{clean(row.get('ledger_date'))}:{clean(row.get('voucher_no'))}", "voucher_row_key": f"bridge-voucher-expense:{clean(row.get('fiscal_year'))}:{clean(voucher_display)}:{clean(draft_display)}", "match_identity_key": row_identity, } ledger_bank_context_row = { "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_bank_accounts")) or "보통예금", "voucher_account_code": "", "voucher_account_name": "", "ledger_vendor": clean(row.get("ledger_bank_vendors")), "voucher_vendor": "", "ledger_debit": 0, "ledger_credit": parse_amount(row.get("ledger_credit")), "voucher_debit": 0, "voucher_credit": 0, "ledger_desc": clean(row.get("ledger_desc")), "voucher_desc": "", "status_label": "Matched", "review_reason": review_reason, "review_memo": review_detail, "matched_case": "bridge_expense_promoted", "ledger_row_key": f"bridge-ledger-bank:{clean(row.get('fiscal_year'))}:{clean(row.get('ledger_date'))}:{clean(row.get('voucher_no'))}", "voucher_row_key": "", "match_identity_key": row_identity, } voucher_payable_context_row = { "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": "", "voucher_account_code": "", "voucher_account_name": clean(row.get("expense_source_payable_accounts")) or "미지급금", "ledger_vendor": "", "voucher_vendor": clean(row.get("expense_source_payable_vendors")) or clean(row.get("inferred_expense_vendors")) or clean(row.get("voucher_vendor")), "ledger_debit": 0, "ledger_credit": 0, "voucher_debit": 0, "voucher_credit": parse_amount(row.get("expense_source_payable_credit")), "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": "", "voucher_row_key": f"bridge-voucher-payable:{clean(row.get('fiscal_year'))}:{clean(voucher_display)}:{clean(draft_display)}", "match_identity_key": row_identity, } row_payloads = [expense_row_payload, ledger_bank_context_row, voucher_payable_context_row] summary = { "fiscal_year": expense_row_payload["fiscal_year"], "status_label": "Matched", "ledger_date": expense_row_payload["ledger_date"], "proof_date": expense_row_payload["proof_date"], "voucher_no": expense_row_payload["voucher_no"], "draft_no": expense_row_payload["draft_no"], "ledger_row_count": 2, "voucher_row_count": 2, "ledger_debit": sum(parse_amount(item.get("ledger_debit")) for item in row_payloads), "ledger_credit": sum(parse_amount(item.get("ledger_credit")) for item in row_payloads), "voucher_debit": sum(parse_amount(item.get("voucher_debit")) for item in row_payloads), "voucher_credit": sum(parse_amount(item.get("voucher_credit")) for item in row_payloads), "ledger_accounts": ", ".join(filter(None, dict.fromkeys(clean(item.get("ledger_account_name")) for item in row_payloads))), "voucher_accounts": ", ".join(filter(None, dict.fromkeys(clean(item.get("voucher_account_name")) for item in row_payloads))), "ledger_vendors": ", ".join(filter(None, dict.fromkeys(clean(item.get("ledger_vendor")) for item in row_payloads))), "voucher_vendors": ", ".join(filter(None, dict.fromkeys(clean(item.get("voucher_vendor")) for item in row_payloads))), "review_reason": review_detail, "matched_case": expense_row_payload["matched_case"], } return {"summary": summary, "rows": row_payloads} 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]] = [] promoted_original_ids: set[int] = set() 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, known_total_count: int | None = None, ) -> 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}" if known_total_count is None: total_count = int(conn.execute(text(f"SELECT COUNT(*) {from_sql}"), params).scalar_one() or 0) else: total_count = max(int(known_total_count or 0), 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 _count_vat_rows_by_voucher_group( rows_by_status: dict[str, list[dict[str, Any]]], *, group_key_builder: Any, prefix: str, ) -> dict[Any, int]: counts: dict[Any, int] = {} seen: set[tuple[Any, str]] = set() for rows in rows_by_status.values(): for row in rows: account_family = _classify_account_family( row.get(f"{prefix}_account_code"), row.get(f"{prefix}_account_name"), ) if not _is_vat_family(account_family): continue group_key = group_key_builder(row) row_key = clean(row.get(f"{prefix}_row_key")) or ( build_ledger_row_key(row) if prefix == "ledger" else build_voucher_row_key(row) ) identity = (group_key, row_key) if identity in seen: continue seen.add(identity) counts[group_key] = counts.get(group_key, 0) + 1 return counts def _multi_voucher_exception_bucket(account_code: Any, account_name: Any) -> str: family = _classify_account_family(account_code, account_name) if _is_vat_family(family): return "vat" category = _classify_account_category(account_code, account_name) return category if category in {"expense", "asset"} else "" def _multi_voucher_capacity_by_group( rows_by_status: dict[str, list[dict[str, Any]]], *, group_key_builder: Any, prefix: str, ) -> dict[Any, int]: bucket_rows: dict[Any, dict[str, set[str]]] = {} for rows in rows_by_status.values(): for row in rows: bucket = _multi_voucher_exception_bucket( row.get(f"{prefix}_account_code"), row.get(f"{prefix}_account_name"), ) if not bucket: continue group_key = group_key_builder(row) row_key = clean(row.get(f"{prefix}_row_key")) or ( build_ledger_row_key(row) if prefix == "ledger" else build_voucher_row_key(row) ) bucket_rows.setdefault(group_key, {}).setdefault(bucket, set()).add(row_key) return { group_key: max(1, *(len(row_keys) for row_keys in buckets.values())) for group_key, buckets in bucket_rows.items() } 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"), ) wehago_group_capacities = _multi_voucher_capacity_by_group( rows_by_status, group_key_builder=wehago_group_key, prefix="ledger", ) erp_group_capacities = _multi_voucher_capacity_by_group( rows_by_status, group_key_builder=erp_group_key, prefix="voucher", ) multi_match_wehago_keys = {key for key, capacity in wehago_group_capacities.items() if capacity >= 2} multi_match_erp_keys = {key for key, capacity in erp_group_capacities.items() if capacity >= 2} def has_multi_match_wehago_exception(row: dict[str, Any]) -> bool: return row.get("wehago_group_key") in multi_match_wehago_keys def has_multi_match_erp_exception(row: dict[str, Any]) -> bool: return bool(set(row.get("erp_group_keys") or set()) & multi_match_erp_keys) 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 = normalize_wehago_display_date(row.get("ledger_date"), fiscal_year or None) proof_date = normalize_wehago_display_date(row.get("proof_date"), fiscal_year or None) matched_erp_key = erp_group_key(row)[1] 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", "amount_mismatch", "ledger_only"}: group_key = normalize_wehago_voucher_identity( fiscal_year, voucher_no, row.get("ledger_date"), fallback_voucher_key, ) if not group_key and group_key_parts: group_key = "|".join([str(fiscal_year), *group_key_parts]) 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) def ledger_month_day(value: Any, fiscal_year: int | None = None) -> str: normalized = normalize_wehago_display_date(value, fiscal_year) matched = re.search(r"(\d{1,2})[-./](\d{1,2})$", clean(normalized)) if not matched: matched = re.search(r"\d{4}[-./](\d{1,2})[-./](\d{1,2})", clean(value)) if not matched: return "" return f"{int(matched.group(1)):02d}-{int(matched.group(2)):02d}" def is_wehago_excepted_group(group: dict[str, Any]) -> tuple[bool, str]: summary = group.get("summary") or {} rows = list(group.get("rows") or []) fiscal_year = int(summary.get("fiscal_year") or 0) or None date_candidates = [summary.get("ledger_date")] + [row.get("ledger_date") for row in rows] month_days = {ledger_month_day(value, fiscal_year) for value in date_candidates if clean(value)} month_days.discard("") haystack = normalize_text( " ".join( [ clean(summary.get("ledger_accounts")), clean(summary.get("ledger_vendors")), clean(summary.get("review_reason")), " ".join(clean(row.get("ledger_account_name")) for row in rows), " ".join(clean(row.get("ledger_vendor")) for row in rows), " ".join(clean(row.get("ledger_desc")) for row in rows), ] ) ) return _is_wehago_excepted_voucher_group(group) 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"] = normalize_wehago_display_date(row.get("ledger_date"), int(row.get("fiscal_year") or 0) or None) if not current.get("proof_date"): current["proof_date"] = normalize_wehago_display_date(row.get("proof_date"), int(row.get("fiscal_year") or 0) or None) 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["ledger_date"] = normalize_wehago_display_date(row.get("ledger_date"), int(row.get("fiscal_year") or 0) or None) row_payload["proof_date"] = normalize_wehago_display_date(row.get("proof_date"), int(row.get("fiscal_year") or 0) or None) 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) def hanmac_line_identity(row: dict[str, Any]) -> str: row_key = clean(row.get("voucher_row_key")) if row_key: return f"row:{row_key}" fiscal_year = int(row.get("fiscal_year") or 0) proof_date = clean(row.get("proof_date")) draft_no = clean(row.get("draft_no")) voucher_no = clean(row.get("voucher_no")) account = clean(row.get("voucher_account_code")) or clean(row.get("voucher_account_name")) vendor = clean(row.get("voucher_vendor")) debit = f"{parse_amount(row.get('voucher_debit')):.2f}" credit = f"{parse_amount(row.get('voucher_credit')):.2f}" desc = clean(row.get("voucher_desc")) return "|".join([str(fiscal_year), proof_date, draft_no, voucher_no, account, vendor, debit, credit, desc]) def row_has_hanmac_voucher_side(row: dict[str, Any]) -> bool: return any( clean(row.get(field)) for field in ( "voucher_row_key", "draft_no", "proof_date", "voucher_account_code", "voucher_account_name", "voucher_vendor", "voucher_desc", ) ) or parse_amount(row.get("voucher_debit")) > 0 or parse_amount(row.get("voucher_credit")) > 0 def build_hanmac_unconnected_sections(current_sections: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]: connected_identities: set[str] = set() for status_key in ("voucher_matched", "voucher_recheck"): for group in current_sections.get(status_key, []) or []: for row in group.get("rows", []) or []: if row_has_hanmac_voucher_side(row): connected_identities.add(hanmac_line_identity(row)) candidate_by_identity: dict[str, dict[str, Any]] = {} for status_key in ("matched", "amount_mismatch", "voucher_only"): for row in rows_by_status.get(status_key, []) or []: if not row_has_hanmac_voucher_side(row): continue identity = hanmac_line_identity(row) if not identity or identity in connected_identities or identity in candidate_by_identity: continue candidate_by_identity[identity] = dict(row) hanmac_grouped: dict[tuple[int, str], dict[str, Any]] = {} for row in candidate_by_identity.values(): erp_key = erp_group_key(row) fiscal_year = int(row.get("fiscal_year") or 0) current = hanmac_grouped.get(erp_key) if current is None: current = { "fiscal_year": fiscal_year, "status_label": "Hanmac unconnected", "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": [], } hanmac_grouped[erp_key] = current 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")) current["voucher_row_count"] += 1 current["voucher_debit"] += parse_amount(row.get("voucher_debit")) current["voucher_credit"] += parse_amount(row.get("voucher_credit")) append_unique(current["voucher_accounts"], row.get("voucher_account_name")) 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["rows"].append( { **dict(row), "status_label": "Hanmac unconnected", "ledger_date": "", "ledger_account_code": "", "ledger_account_name": "", "ledger_vendor": "", "ledger_debit": 0.0, "ledger_credit": 0.0, "ledger_desc": "", } ) groups: list[dict[str, Any]] = [] for row in hanmac_grouped.values(): row["rows"].sort( key=lambda item: ( clean(item.get("proof_date")), clean(item.get("draft_no")), clean(item.get("voucher_no")), clean(item.get("voucher_account_name")), -parse_amount(item.get("voucher_debit")), -parse_amount(item.get("voucher_credit")), clean(item.get("voucher_desc")), ) ) normalized_summary = dict(row) normalized_summary["ledger_accounts"] = "" normalized_summary["voucher_accounts"] = ", ".join(row["voucher_accounts"]) normalized_summary["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"]) groups.append({"summary": normalized_summary, "rows": list(row["rows"])}) return sorted( groups, key=lambda item: ( int(item.get("summary", {}).get("fiscal_year") or 0), clean(item.get("summary", {}).get("proof_date")), clean(item.get("summary", {}).get("voucher_no")), clean(item.get("summary", {}).get("draft_no")), ), ) voucher_sections = { "voucher_matched": [], "erp_voucher_matched": [], "voucher_unmatched": [], "voucher_excepted": [], "hanmac_unconnected": [], "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 or has_multi_match_wehago_exception(row) ) 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 or has_multi_match_wehago_exception(row) or has_multi_match_erp_exception(row) ) ): 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 and erp_key not in multi_match_erp_keys) or (erp_key in recheck_erp_group_keys and erp_key not in multi_match_erp_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")), ) ) voucher_sections = _promote_obvious_recheck_groups(voucher_sections) voucher_sections = _retarget_obvious_recheck_groups(voucher_sections) voucher_sections = _apply_vat_date_anchor_review(voucher_sections) voucher_sections = _apply_grouped_cost_counterpart_review(voucher_sections) voucher_sections = _dedupe_wehago_sections_to_single_status(voucher_sections) voucher_sections = _dedupe_erp_sections_to_single_status(voucher_sections) voucher_sections = _move_wehago_confirmed_reversal_pairs_to_excepted(voucher_sections) voucher_sections = _apply_wehago_cancel_reissue_recheck(voucher_sections) voucher_sections = _move_wehago_offset_tax_invoice_groups_to_excepted(voucher_sections) retained_unmatched: list[dict[str, Any]] = [] excepted_groups: list[dict[str, Any]] = [] for group in voucher_sections.get("voucher_unmatched", []) or []: if _is_boundary_vat_tax_invoice_group(group) and _voucher_group_has_erp_counterpart(group): group_copy = { "summary": dict(group.get("summary") or {}), "rows": [dict(row) for row in group.get("rows", []) or []], } group_copy["summary"]["status_label"] = "Matched" review_reason = clean(group_copy["summary"].get("review_reason")) group_copy["summary"]["review_reason"] = " / ".join( item for item in [review_reason, "VAT_TAX_INVOICE_BOUNDARY_MATCH"] if item ) for row in group_copy["rows"]: row["status_label"] = "Matched" row["review_reason"] = clean(row.get("review_reason")) or "VAT_TAX_INVOICE_BOUNDARY_MATCH" voucher_sections["voucher_matched"].append(group_copy) continue is_excepted, reason = is_wehago_excepted_group(group) if not is_excepted: retained_unmatched.append(group) continue group_copy = { "summary": dict(group.get("summary") or {}), "rows": [dict(row) for row in group.get("rows", []) or []], } group_copy["summary"]["status_label"] = "Excepted" review_reason = clean(group_copy["summary"].get("review_reason")) group_copy["summary"]["review_reason"] = " / ".join( item for item in [review_reason, reason] if item ) for row in group_copy["rows"]: row["status_label"] = "Excepted" row["review_reason"] = clean(row.get("review_reason")) or reason excepted_groups.append(group_copy) voucher_sections["voucher_unmatched"] = retained_unmatched voucher_sections["voucher_excepted"] = sorted( list(voucher_sections.get("voucher_excepted", []) or []) + excepted_groups, key=lambda item: ( int(item.get("summary", {}).get("fiscal_year") or 0), clean(item.get("summary", {}).get("ledger_date")), clean(item.get("summary", {}).get("voucher_no")), ), ) voucher_sections["hanmac_unconnected"] = build_hanmac_unconnected_sections(voucher_sections) return voucher_sections def _wehago_section_identity(group: dict[str, Any], fallback: str = "") -> str: summary = group.get("summary") or {} fiscal_year = int(summary.get("fiscal_year") or 0) return normalize_wehago_voucher_identity( fiscal_year, summary.get("voucher_no"), summary.get("ledger_date"), fallback, ) def _erp_section_identity(group: dict[str, Any], fallback: str = "") -> str: summary = group.get("summary") or {} fiscal_year = int(summary.get("fiscal_year") or 0) draft_no = clean(summary.get("draft_no")) proof_date = clean(summary.get("proof_date")) voucher_no = clean(summary.get("voucher_no")) base = draft_no or voucher_no or fallback return "|".join(str(part) for part in (fiscal_year, proof_date, base) if clean(part)) def _section_direct_rows(group: dict[str, Any]) -> list[dict[str, Any]]: return [ row for row in list(group.get("rows") or []) if clean(row.get("ledger_account_name")) and clean(row.get("voucher_account_name")) ] def _section_vat_exception_capacity(group: dict[str, Any], *, side: str) -> int: rows = list(group.get("rows") or []) prefix = "ledger" if side == "wehago" else "voucher" buckets: dict[str, set[str]] = {} for row in rows: bucket = _multi_voucher_exception_bucket( row.get(f"{prefix}_account_code"), row.get(f"{prefix}_account_name"), ) if not bucket: continue row_key = clean(row.get(f"{prefix}_row_key")) or ( build_ledger_row_key(row) if prefix == "ledger" else build_voucher_row_key(row) ) buckets.setdefault(bucket, set()).add(row_key) return max(1, *(len(row_keys) for row_keys in buckets.values())) if buckets else 1 def _section_amount_sides_match(group: dict[str, Any]) -> bool: summary = group.get("summary") or {} ledger_debit = parse_amount(summary.get("ledger_debit")) ledger_credit = parse_amount(summary.get("ledger_credit")) voucher_debit = parse_amount(summary.get("voucher_debit")) voucher_credit = parse_amount(summary.get("voucher_credit")) total_amount = max(ledger_debit, ledger_credit, voucher_debit, voucher_credit) if total_amount <= 0: return False return abs(ledger_debit - voucher_debit) < 0.5 and abs(ledger_credit - voucher_credit) < 0.5 def _row_has_manual_review_like_evidence(row: dict[str, Any]) -> bool: if not _row_passes_user_defined_match_basis(row): return False if _has_named_reference_conflict(row, row): return False if _is_clear_vat_match_row(row): return True if _account_base_desc_approved(row) or _is_approved_guarantee_account_pair(row): return True vendor_match = _same_or_similar_vendor(row) desc_match = _same_or_similar_desc(row) or _contained_core_desc_match(row.get("ledger_desc"), row.get("voucher_desc")) month_gap = _compare_row_month_gap(row) if month_gap is None: return vendor_match and desc_match if month_gap == 0: return vendor_match or desc_match if month_gap <= 1: return vendor_match and desc_match return False def _is_obvious_recheck_group(group: dict[str, Any]) -> bool: direct_rows = _section_direct_rows(group) if not direct_rows: return False if _has_strong_partial_recheck_evidence(group, direct_rows): return True if not _section_amount_sides_match(group): return False business_rows = [ row for row in direct_rows if _is_obvious_recheck_business_row(row) and _row_has_manual_review_like_evidence(row) ] if not business_rows: return False month_gaps = [gap for gap in (_compare_row_month_gap(row) for row in business_rows) if gap is not None] if month_gaps and min(month_gaps) > 1: return False if any((_compare_row_month_gap(row) or 0) == 0 and (_same_or_similar_vendor(row) or _same_or_similar_desc(row)) for row in business_rows): return True if any((_compare_row_month_gap(row) or 99) <= 1 and _same_or_similar_vendor(row) and _same_or_similar_desc(row) for row in business_rows): return True return False def _has_strong_partial_recheck_evidence( group: dict[str, Any], direct_rows: list[dict[str, Any]] | None = None, ) -> bool: summary = dict(group.get("summary") or {}) if not clean(summary.get("draft_no")): return False rows = list(direct_rows if direct_rows is not None else _section_direct_rows(group)) if not rows: return False strong_rows = 0 vat_rows = 0 for row in rows: if _has_named_reference_conflict(row, row): continue 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: continue if _is_clear_vat_match_row(row): strong_rows += 1 vat_rows += 1 continue if not _matched_row_passes_account_pair_rule(row): continue account_match = ( _account_names_compatible(row, row) or _account_base_desc_approved(row) or _is_approved_guarantee_account_pair(row) ) if not account_match: continue if _same_or_similar_vendor(row) or _same_or_similar_desc(row) or _contained_core_desc_match(row.get("ledger_desc"), row.get("voucher_desc")): strong_rows += 1 return strong_rows >= 2 or vat_rows >= 1 def _recheck_group_rank_key(group: dict[str, Any]) -> tuple[int, int, int, float, str, str]: summary = group.get("summary") or {} direct_rows = _section_direct_rows(group) business_rows = [row for row in direct_rows if _is_obvious_recheck_business_row(row)] same_day_count = sum(1 for row in business_rows if (_compare_row_month_gap(row) or 99) == 0) same_month_count = sum(1 for row in business_rows if (_compare_row_month_gap(row) or 99) <= 1) total_amount = sum(_get_row_match_amount(row, "ledger") for row in business_rows) voucher_no = clean(summary.get("voucher_no")) ledger_date = clean(summary.get("ledger_date")) ledger_date_rank = 99999999 - int(re.sub(r"\D", "", ledger_date) or "0") voucher_no_rank = 99999999 - int(re.sub(r"\D", "", voucher_no) or "0") return ( same_day_count, same_month_count, len(business_rows), total_amount, f"{ledger_date_rank:08d}", f"{voucher_no_rank:08d}", ) def _promote_obvious_recheck_groups( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: recheck_groups = list(voucher_sections.get("voucher_recheck") or []) if not recheck_groups: return voucher_sections candidates: list[dict[str, Any]] = [] for group in recheck_groups: if not _is_obvious_recheck_group(group): continue wehago_identity = _wehago_section_identity(group) erp_identity = _erp_section_identity(group) if not wehago_identity or not erp_identity: continue candidates.append( { "group": group, "wehago_identity": wehago_identity, "erp_identity": erp_identity, "rank": _recheck_group_rank_key(group), "wehago_capacity": _section_vat_exception_capacity(group, side="wehago"), "erp_capacity": 1, } ) if not candidates: return voucher_sections candidates.sort(key=lambda item: item["rank"], reverse=True) used_wehago: dict[str, int] = {} used_erp: dict[str, int] = {} promoted_groups: list[dict[str, Any]] = [] promoted_original_ids: set[int] = set() remaining_recheck: list[dict[str, Any]] = [] for candidate in candidates: wehago_identity = candidate["wehago_identity"] erp_identity = candidate["erp_identity"] wehago_capacity = int(candidate["wehago_capacity"] or 1) erp_capacity = int(candidate["erp_capacity"] or 1) if used_wehago.get(wehago_identity, 0) >= wehago_capacity or used_erp.get(erp_identity, 0) >= erp_capacity: continue promoted_group = copy.deepcopy(candidate["group"]) if isinstance(promoted_group.get("summary"), dict): promoted_group["summary"]["status_label"] = "Matched" promoted_group["summary"]["review_reason"] = clean(promoted_group["summary"].get("review_reason")) or "RECHECK_OBVIOUS_GROUP_MATCH" for row in list(promoted_group.get("rows") or []): row["status_label"] = "Matched" row["review_reason"] = clean(row.get("review_reason")) or "RECHECK_OBVIOUS_GROUP_MATCH" promoted_groups.append(promoted_group) promoted_original_ids.add(id(candidate["group"])) used_wehago[wehago_identity] = used_wehago.get(wehago_identity, 0) + 1 used_erp[erp_identity] = used_erp.get(erp_identity, 0) + 1 for group in recheck_groups: if id(group) in promoted_original_ids: continue remaining_recheck.append(group) if not promoted_groups: return voucher_sections voucher_sections["voucher_matched"] = list(voucher_sections.get("voucher_matched") or []) + promoted_groups voucher_sections["voucher_recheck"] = remaining_recheck return voucher_sections def _erp_draft_year(value: Any) -> int: matched = re.search(r"(?:^|[-_/])(20\d{2})\d{4}(?:[-_/]|$)", clean(value)) return int(matched.group(1)) if matched else 0 def _erp_draft_base(value: Any) -> str: draft_no = clean(value) return re.sub(r"-\d+$", "", draft_no) if draft_no else "" def _restrict_erp_rows_to_fiscal_year( sections: dict[str, dict[str, Any]], fiscal_year: int, ) -> dict[str, dict[str, Any]]: if fiscal_year <= 0: return sections restored_ledger_rows = list(sections.get("ledger_only", {}).get("rows", [])) ledger_keys = { clean(row.get("ledger_row_key")) or build_ledger_row_key(row) for row in restored_ledger_rows } def keep_row(row: dict[str, Any]) -> bool: draft_year = _erp_draft_year(row.get("draft_no")) return not draft_year or draft_year == fiscal_year for status_key in ("matched", "amount_mismatch"): kept_rows: list[dict[str, Any]] = [] for row in list(sections.get(status_key, {}).get("rows", [])): if keep_row(row): kept_rows.append(row) continue if not ( clean(row.get("ledger_account_name")) or clean(row.get("ledger_desc")) or abs(parse_amount(row.get("ledger_debit"))) >= 0.5 or abs(parse_amount(row.get("ledger_credit"))) >= 0.5 ): continue restored = _blank_voucher_side(row) restored["status_label"] = "Unmatched" restored["review_reason"] = "ERP_DRAFT_YEAR_OUT_OF_SCOPE" ledger_key = clean(restored.get("ledger_row_key")) or build_ledger_row_key(restored) if ledger_key and ledger_key not in ledger_keys: ledger_keys.add(ledger_key) restored_ledger_rows.append(restored) sections[status_key]["rows"] = kept_rows sections[status_key]["count"] = len(kept_rows) voucher_rows = [ row for row in list(sections.get("voucher_only", {}).get("rows", [])) if keep_row(row) ] sections["voucher_only"]["rows"] = voucher_rows sections["voucher_only"]["count"] = len(voucher_rows) sections["ledger_only"]["rows"] = restored_ledger_rows sections["ledger_only"]["count"] = len(restored_ledger_rows) return sections def _expand_erp_unmatched_groups_from_raw_rows( conn: Any, start_year: int | None, end_year: int | None, voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: unmatched_groups = list(voucher_sections.get("erp_voucher_unmatched") or []) if start_year is None or end_year is None or not unmatched_groups: return voucher_sections raw_rows = conn.execute( text( """ SELECT fiscal_year, proof_date, confirmed_no, draft_no, account_code, account_name, debit_supply, credit_supply, vendor_name, desc1, desc2 FROM wehago_voucher_rows WHERE fiscal_year BETWEEN :start_year AND :end_year AND COALESCE(draft_no, '') <> '' ORDER BY fiscal_year ASC, draft_no ASC """ ), {"start_year": int(start_year), "end_year": int(end_year)}, ).mappings().all() rows_by_base: dict[tuple[int, str], list[dict[str, Any]]] = {} for source_row in raw_rows: raw = dict(source_row) fiscal_year = int(raw.get("fiscal_year") or 0) draft_year = _erp_draft_year(raw.get("draft_no")) draft_base = _erp_draft_base(raw.get("draft_no")) if fiscal_year <= 0 or (draft_year and draft_year != fiscal_year) or not draft_base: continue rows_by_base.setdefault((fiscal_year, draft_base), []).append(raw) expanded_groups: list[dict[str, Any]] = [] for group in unmatched_groups: summary = dict(group.get("summary") or {}) fiscal_year = int(summary.get("fiscal_year") or 0) bases: list[str] = [] for row in list(group.get("rows") or []): draft_base = _erp_draft_base(row.get("draft_no")) if draft_base and draft_base not in bases: bases.append(draft_base) if not bases: draft_base = _erp_draft_base(summary.get("draft_no")) if draft_base: bases.append(draft_base) replacement_rows: list[dict[str, Any]] = [] for draft_base in bases: for raw in rows_by_base.get((fiscal_year, draft_base), []): replacement = { "fiscal_year": fiscal_year, "status_label": "ERP Unmatched", "ledger_date": "", "proof_date": clean(raw.get("proof_date")), "voucher_no": "", "draft_no": clean(raw.get("draft_no")), "ledger_account_code": "", "ledger_account_name": "", "ledger_vendor": "", "ledger_debit": 0, "ledger_credit": 0, "ledger_desc": "", "ledger_row_key": "", "voucher_account_code": clean(raw.get("account_code")), "voucher_account_name": clean(raw.get("account_name")), "voucher_vendor": clean(raw.get("vendor_name")), "voucher_debit": parse_amount(raw.get("debit_supply")), "voucher_credit": parse_amount(raw.get("credit_supply")), "voucher_desc": " ".join( part for part in (clean(raw.get("desc1")), clean(raw.get("desc2"))) if part ), "voucher_row_key": "", } replacement["voucher_row_key"] = build_voucher_row_key(replacement) replacement_rows.append(replacement) if not replacement_rows: expanded_groups.append(group) continue group_summary = _build_group_summary_from_rows( fiscal_year=fiscal_year, voucher_no="", draft_no=", ".join(bases), rows=replacement_rows, status_label="ERP Unmatched", review_reason=clean(summary.get("review_reason")), ) expanded_groups.append({"summary": group_summary, "rows": replacement_rows}) voucher_sections["erp_voucher_unmatched"] = expanded_groups return voucher_sections def _blank_ledger_side(row: dict[str, Any]) -> dict[str, Any]: payload = dict(row) for field in ( "ledger_date", "ledger_account_code", "ledger_account_name", "ledger_vendor", "ledger_desc", "ledger_row_key", ): payload[field] = "" for field in ("ledger_debit", "ledger_credit"): payload[field] = 0 return payload def _blank_voucher_side(row: dict[str, Any]) -> dict[str, Any]: payload = dict(row) for field in ( "proof_date", "draft_no", "voucher_account_code", "voucher_account_name", "voucher_vendor", "voucher_desc", "voucher_row_key", ): payload[field] = "" for field in ("voucher_debit", "voucher_credit"): payload[field] = 0 return payload def _build_group_summary_from_rows( *, fiscal_year: int, voucher_no: str, draft_no: str, rows: list[dict[str, Any]], status_label: str, review_reason: str, ) -> dict[str, Any]: ledger_accounts: list[str] = [] voucher_accounts: list[str] = [] ledger_vendors: list[str] = [] voucher_vendors: list[str] = [] 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) summary = { "fiscal_year": fiscal_year, "status_label": status_label, "ledger_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": review_reason, } for row in rows: if clean(row.get("ledger_account_name")) or clean(row.get("ledger_desc")): summary["ledger_row_count"] += 1 summary["ledger_debit"] += parse_amount(row.get("ledger_debit")) summary["ledger_credit"] += parse_amount(row.get("ledger_credit")) if clean(row.get("voucher_account_name")) or clean(row.get("voucher_desc")): summary["voucher_row_count"] += 1 summary["voucher_debit"] += parse_amount(row.get("voucher_debit")) summary["voucher_credit"] += parse_amount(row.get("voucher_credit")) if not clean(summary["ledger_date"]): summary["ledger_date"] = clean(row.get("ledger_date")) if not clean(summary["proof_date"]): summary["proof_date"] = clean(row.get("proof_date")) if not clean(summary["draft_no"]) and clean(row.get("draft_no")): summary["draft_no"] = clean(row.get("draft_no")) append_unique(ledger_accounts, row.get("ledger_account_name")) append_unique(voucher_accounts, row.get("voucher_account_name")) append_unique(ledger_vendors, row.get("ledger_vendor")) append_unique(voucher_vendors, row.get("voucher_vendor")) summary["ledger_accounts"] = ", ".join(ledger_accounts) summary["voucher_accounts"] = ", ".join(voucher_accounts) summary["ledger_vendors"] = ", ".join(ledger_vendors) summary["voucher_vendors"] = ", ".join(voucher_vendors) return summary def _build_retargeted_group_match( source_group: dict[str, Any], candidate_group: dict[str, Any], ) -> dict[str, Any] | None: ledger_rows = [ dict(row) for row in list(source_group.get("rows") or []) if clean(row.get("ledger_account_name")) ] voucher_rows = [ dict(row) for row in list(candidate_group.get("rows") or []) if clean(row.get("voucher_account_name")) ] if not ledger_rows or not voucher_rows: return None amount_index, account_index = _build_amount_account_index(voucher_rows, prefix="voucher") pair_candidates: list[tuple[float, 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) 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", ""), } ) score_result = _score_pair_match(merged, merged) if not ( score_result.get("auto_eligible") or _is_recheck_row_clear_match(merged) or _is_recheck_row_obvious_same_voucher_match(merged) ): continue pair_candidates.append((float(score_result.get("score", 0)), ledger_row, voucher_row)) if not pair_candidates: return None pair_candidates.sort(key=lambda item: item[0], reverse=True) used_ledger: set[str] = set() used_voucher: set[str] = set() rows: list[dict[str, Any]] = [] for _score, ledger_row, voucher_row in pair_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 used_ledger or voucher_key in used_voucher: 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", ""), "status_label": "Matched", "review_reason": "RECHECK_RETARGET_MATCH", } ) if not _matched_row_passes_account_pair_rule(merged): continue rows.append(merged) used_ledger.add(ledger_key) used_voucher.add(voucher_key) if not rows: return None for ledger_row in ledger_rows: ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row) if ledger_key not in used_ledger: payload = _blank_voucher_side(ledger_row) payload["status_label"] = "Unmatched" rows.append(payload) for voucher_row in voucher_rows: voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) if voucher_key not in used_voucher: payload = _blank_ledger_side(voucher_row) payload["status_label"] = "ERP Unmatched" rows.append(payload) rows.sort( key=lambda item: ( clean(item.get("ledger_date")) or clean(item.get("proof_date")), clean(item.get("voucher_no")), 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")), ) ) source_summary = dict(source_group.get("summary") or {}) candidate_summary = dict(candidate_group.get("summary") or {}) summary = _build_group_summary_from_rows( fiscal_year=int(source_summary.get("fiscal_year") or candidate_summary.get("fiscal_year") or 0), voucher_no=clean(source_summary.get("voucher_no")), draft_no=clean(candidate_summary.get("draft_no")), rows=rows, status_label="Matched", review_reason="RECHECK_RETARGET_MATCH", ) return {"summary": summary, "rows": rows} def _retarget_obvious_recheck_groups( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: recheck_groups = list(voucher_sections.get("voucher_recheck") or []) erp_unmatched_groups = list(voucher_sections.get("erp_voucher_unmatched") or []) if not recheck_groups or not erp_unmatched_groups: return voucher_sections remaining_recheck: list[dict[str, Any]] = [] remaining_erp_unmatched: list[dict[str, Any]] = [] promoted_groups: list[dict[str, Any]] = [] used_erp_group_ids: set[int] = set() for recheck_group in recheck_groups: best_candidate: dict[str, Any] | None = None best_score = -1.0 for candidate_group in erp_unmatched_groups: if id(candidate_group) in used_erp_group_ids: continue candidate_match = _build_retargeted_group_match(recheck_group, candidate_group) if candidate_match is None: continue score = 0.0 for row in candidate_match.get("rows") or []: if clean(row.get("ledger_account_name")) and clean(row.get("voucher_account_name")): score += float(_score_pair_match(row, row).get("score", 0)) if score > best_score: best_candidate = candidate_group best_score = score if best_candidate is None: remaining_recheck.append(recheck_group) continue promoted_group = _build_retargeted_group_match(recheck_group, best_candidate) if promoted_group is None: remaining_recheck.append(recheck_group) continue promoted_groups.append(promoted_group) used_erp_group_ids.add(id(best_candidate)) for candidate_group in erp_unmatched_groups: if id(candidate_group) not in used_erp_group_ids: remaining_erp_unmatched.append(candidate_group) if not promoted_groups: return voucher_sections voucher_sections["voucher_matched"] = list(voucher_sections.get("voucher_matched") or []) + promoted_groups voucher_sections["erp_voucher_matched"] = list(voucher_sections.get("erp_voucher_matched") or []) + [ copy.deepcopy(group) for group in promoted_groups ] voucher_sections["voucher_recheck"] = remaining_recheck voucher_sections["erp_voucher_unmatched"] = remaining_erp_unmatched return voucher_sections def _row_account_family(row: dict[str, Any], prefix: str) -> str: return _classify_account_family(row.get(f"{prefix}_account_code"), row.get(f"{prefix}_account_name")) def _row_account_category(row: dict[str, Any], prefix: str) -> str: return _classify_account_category(row.get(f"{prefix}_account_code"), row.get(f"{prefix}_account_name")) def _section_side_rows(group: dict[str, Any], prefix: str) -> list[dict[str, Any]]: account_field = f"{prefix}_account_name" return [dict(row) for row in list(group.get("rows") or []) if clean(row.get(account_field))] def _section_vat_anchors( group: dict[str, Any], prefix: str, ) -> list[dict[str, Any]]: date_field = "ledger_date" if prefix == "ledger" else "proof_date" anchors: list[dict[str, Any]] = [] seen: set[tuple[str, date | None, float, str]] = set() voucher_group_proof_dates: list[date] = [] if prefix == "voucher": for row in _section_side_rows(group, prefix): proof_date = _parse_iso_date(row.get("proof_date")) if proof_date and proof_date not in voucher_group_proof_dates: voucher_group_proof_dates.append(proof_date) for row in _section_side_rows(group, prefix): family = _row_account_family(row, prefix) if not _is_vat_family(family): continue amount = _get_row_match_amount(row, prefix) row_key = clean(row.get(f"{prefix}_row_key")) or ( build_ledger_row_key(row) if prefix == "ledger" else build_voucher_row_key(row) ) date_values = [_parse_iso_date(row.get(date_field))] if prefix == "voucher" and not date_values[0] and voucher_group_proof_dates: date_values = voucher_group_proof_dates for date_value in date_values: identity = (family, date_value, round(amount, 2), row_key) if identity in seen: continue seen.add(identity) anchors.append( { "family": family, "date": date_value, "amount": amount, "row": row, } ) return anchors def _best_vat_date_anchor( wehago_group: dict[str, Any], erp_group: dict[str, Any], ) -> dict[str, Any] | None: ledger_anchors = _section_vat_anchors(wehago_group, "ledger") voucher_anchors = _section_vat_anchors(erp_group, "voucher") best_anchor: dict[str, Any] | None = None best_score = 0 for ledger_anchor in ledger_anchors: ledger_date = ledger_anchor.get("date") if not ledger_date: continue for voucher_anchor in voucher_anchors: if ledger_anchor.get("family") != voucher_anchor.get("family"): continue voucher_date = voucher_anchor.get("date") if not voucher_date: continue score = 0 if ledger_date == voucher_date: score = 2 elif ledger_date.year == voucher_date.year and ledger_date.month == voucher_date.month: score = 1 if score > best_score: best_score = score best_anchor = { "score": score, "family": ledger_anchor.get("family"), "ledger_date": ledger_date, "voucher_date": voucher_date, "ledger_amount": float(ledger_anchor.get("amount") or 0), "voucher_amount": float(voucher_anchor.get("amount") or 0), } return best_anchor def _section_vat_amount_candidates(*groups: dict[str, Any]) -> list[float]: amounts: list[float] = [] seen: set[float] = set() for group in groups: for prefix in ("ledger", "voucher"): for anchor in _section_vat_anchors(group, prefix): amount = round(float(anchor.get("amount") or 0), 2) if amount <= 0 or amount in seen: continue seen.add(amount) amounts.append(amount) return amounts def _section_explanatory_amount_candidates(*groups: dict[str, Any]) -> list[float]: amounts: list[float] = [] seen: set[float] = set() for group in groups: for prefix in ("ledger", "voucher"): for row in _section_side_rows(group, prefix): amount = round(abs(_get_row_match_amount(row, prefix)), 2) if amount <= 0 or amount in seen: continue seen.add(amount) amounts.append(amount) return amounts def _vat_embedded_amount_reason( ledger_row: dict[str, Any], voucher_row: dict[str, Any], vat_amounts: list[float], explanatory_amounts: list[float] | None = None, ) -> str: ledger_amount = _get_row_match_amount(ledger_row, "ledger") voucher_amount = _get_row_match_amount(voucher_row, "voucher") if ledger_amount <= 0 or voucher_amount <= 0: return "" if abs(ledger_amount - voucher_amount) < 0.5: return "금액 일치" amount_gap = abs(ledger_amount - voucher_amount) for vat_amount in vat_amounts: if abs(amount_gap - vat_amount) < 0.5: return "VAT_AMOUNT_EMBEDDED_IN_BUSINESS_LINE" for explain_amount in list(explanatory_amounts or []): if abs(amount_gap - explain_amount) < 0.5: return "GROUP_OFFSET_AMOUNT_EXPLAINS_BUSINESS_LINE" return "" def _business_rows_can_match_with_vat_anchor( ledger_row: dict[str, Any], voucher_row: dict[str, Any], vat_amounts: list[float], explanatory_amounts: list[float] | None = None, ) -> tuple[float, str]: if _is_vat_family(_row_account_family(ledger_row, "ledger")): return 0.0, "" if _is_vat_family(_row_account_family(voucher_row, "voucher")): return 0.0, "" 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", ""), } ) if not _matched_row_passes_account_pair_rule(merged): return 0.0, "" amount_reason = _vat_embedded_amount_reason(ledger_row, voucher_row, vat_amounts, explanatory_amounts) if not amount_reason: return 0.0, "" account_match = _account_base_names_compatible( ledger_row.get("ledger_account_name"), voucher_row.get("voucher_account_name"), ) vendor_match = _core_token_overlap(ledger_row.get("ledger_vendor"), voucher_row.get("voucher_vendor")) desc_match = ( _contained_core_desc_match(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")) or _core_token_strong_overlap(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")) ) if _has_named_reference_conflict(ledger_row, voucher_row) and not (vendor_match and desc_match): return 0.0, "" if not (account_match or vendor_match or desc_match): return 0.0, "" score = 70.0 if amount_reason == "금액 일치": score += 20 else: score += 10 if account_match: score += 12 if vendor_match: score += 8 if desc_match: score += 8 if _row_account_category(ledger_row, "ledger") == _row_account_category(voucher_row, "voucher"): score += 5 return score, amount_reason def _build_vat_anchor_group_match( source_group: dict[str, Any], candidate_group: dict[str, Any], *, review_reason: str, ) -> dict[str, Any] | None: anchor = _best_vat_date_anchor(source_group, candidate_group) if not anchor: return None ledger_rows = _section_side_rows(source_group, "ledger") voucher_rows = _section_side_rows(candidate_group, "voucher") if not ledger_rows or not voucher_rows: return None vat_amounts = _section_vat_amount_candidates(source_group, candidate_group) explanatory_amounts = _section_explanatory_amount_candidates(source_group, candidate_group) pair_candidates: list[tuple[float, str, dict[str, Any], dict[str, Any]]] = [] for ledger_row in ledger_rows: for voucher_row in voucher_rows: score, amount_reason = _business_rows_can_match_with_vat_anchor( ledger_row, voucher_row, vat_amounts, explanatory_amounts, ) if score <= 0: continue if int(anchor.get("score") or 0) >= 2: score += 10 else: score += 3 pair_candidates.append((score, amount_reason, ledger_row, voucher_row)) if not pair_candidates: return None pair_candidates.sort(key=lambda item: item[0], reverse=True) used_ledger: set[str] = set() used_voucher: set[str] = set() rows: list[dict[str, Any]] = [] matched_business_count = 0 for _score, amount_reason, ledger_row, voucher_row in pair_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 used_ledger or voucher_key in used_voucher: 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", ""), "status_label": "Matched", "review_reason": amount_reason if amount_reason != "금액 일치" else review_reason, } ) rows.append(merged) used_ledger.add(ledger_key) used_voucher.add(voucher_key) matched_business_count += 1 if matched_business_count <= 0: return None for ledger_row in ledger_rows: ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row) if ledger_key in used_ledger: continue payload = _blank_voucher_side(ledger_row) payload["status_label"] = "Unmatched" payload["review_reason"] = review_reason rows.append(payload) for voucher_row in voucher_rows: voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) if voucher_key in used_voucher: continue payload = _blank_ledger_side(voucher_row) payload["status_label"] = "ERP Unmatched" payload["review_reason"] = review_reason rows.append(payload) rows.sort( key=lambda item: ( clean(item.get("ledger_date")) or clean(item.get("proof_date")), clean(item.get("voucher_no")), 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")), ) ) source_summary = dict(source_group.get("summary") or {}) candidate_summary = dict(candidate_group.get("summary") or {}) summary = _build_group_summary_from_rows( fiscal_year=int(source_summary.get("fiscal_year") or candidate_summary.get("fiscal_year") or 0), voucher_no=clean(source_summary.get("voucher_no")), draft_no=clean(candidate_summary.get("draft_no")), rows=rows, status_label="Matched", review_reason=review_reason, ) return {"summary": summary, "rows": rows} def _matched_group_has_vat_date_anchor(group: dict[str, Any]) -> bool: return bool(_best_vat_date_anchor(group, group)) def _apply_vat_date_anchor_review( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: erp_unmatched_groups = list(voucher_sections.get("erp_voucher_unmatched") or []) erp_reusable_matched_groups = list(voucher_sections.get("erp_voucher_matched") or []) erp_candidate_groups: list[tuple[dict[str, Any], bool]] = [ (group, False) for group in erp_unmatched_groups ] + [ (group, True) for group in erp_reusable_matched_groups ] if not erp_candidate_groups and not voucher_sections.get("voucher_recheck"): return voucher_sections candidate_anchor_index: dict[tuple[str, str, str], list[tuple[dict[str, Any], bool]]] = {} for candidate_group, reusable in erp_candidate_groups: for anchor in _section_vat_anchors(candidate_group, "voucher"): anchor_date = anchor.get("date") family = clean(anchor.get("family")) if not anchor_date or not family: continue candidate_anchor_index.setdefault((family, "date", anchor_date.isoformat()), []).append((candidate_group, reusable)) candidate_anchor_index.setdefault((family, "month", f"{anchor_date.year:04d}-{anchor_date.month:02d}"), []).append((candidate_group, reusable)) promoted_groups: list[dict[str, Any]] = [] used_erp_group_ids: set[int] = set() moved_wehago_group_ids: set[int] = set() def candidate_pool_for_source(source_group: dict[str, Any]) -> list[tuple[dict[str, Any], bool]]: selected: list[tuple[dict[str, Any], bool]] = [] seen: set[int] = set() for anchor in _section_vat_anchors(source_group, "ledger"): anchor_date = anchor.get("date") family = clean(anchor.get("family")) if not anchor_date or not family: continue keys = [ (family, "date", anchor_date.isoformat()), (family, "month", f"{anchor_date.year:04d}-{anchor_date.month:02d}"), ] for key in keys: for candidate_group, reusable in candidate_anchor_index.get(key, []): if id(candidate_group) in seen: continue seen.add(id(candidate_group)) selected.append((candidate_group, reusable)) return selected def find_best_candidate(source_group: dict[str, Any], reason: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None, float, bool]: best_group: dict[str, Any] | None = None best_match: dict[str, Any] | None = None best_reusable = False best_score = -1.0 for candidate_group, reusable in candidate_pool_for_source(source_group): if not reusable and id(candidate_group) in used_erp_group_ids: continue matched_group = _build_vat_anchor_group_match(source_group, candidate_group, review_reason=reason) if matched_group is None: continue direct_rows = _section_direct_rows(matched_group) anchor = _best_vat_date_anchor(source_group, candidate_group) or {} score = (float(anchor.get("score") or 0) * 100.0) + len(direct_rows) * 10.0 score += sum(_get_row_match_amount(row, "ledger") for row in direct_rows) / 1_000_000_000 if score > best_score: best_group = candidate_group best_match = matched_group best_reusable = reusable best_score = score return best_group, best_match, best_score, best_reusable for status_key, reason in ( ("voucher_unmatched", "VAT_DATE_UNMATCHED_RETARGET_MATCH"), ("voucher_recheck", "VAT_DATE_RECHECK_RETARGET_MATCH"), ): for group in list(voucher_sections.get(status_key) or []): best_group, matched_group, _score, reusable = find_best_candidate(group, reason) if best_group is None or matched_group is None: continue promoted_groups.append(matched_group) moved_wehago_group_ids.add(id(group)) if not reusable: used_erp_group_ids.add(id(best_group)) for group in list(voucher_sections.get("voucher_recheck") or []): if id(group) in moved_wehago_group_ids: continue promoted_group = _build_vat_anchor_group_match( group, group, review_reason="VAT_DATE_RECHECK_INTERNAL_MATCH", ) if promoted_group is None: continue promoted_groups.append(promoted_group) moved_wehago_group_ids.add(id(group)) for group in list(voucher_sections.get("voucher_matched") or []): if _matched_group_has_vat_date_anchor(group): continue best_group, matched_group, score, reusable = find_best_candidate(group, "VAT_DATE_EXISTING_MATCH_RETARGET") if best_group is None or matched_group is None or score < 210: continue promoted_groups.append(matched_group) moved_wehago_group_ids.add(id(group)) if not reusable: used_erp_group_ids.add(id(best_group)) if not promoted_groups: return voucher_sections for status_key in ("voucher_unmatched", "voucher_recheck", "voucher_matched"): voucher_sections[status_key] = [ group for group in list(voucher_sections.get(status_key) or []) if id(group) not in moved_wehago_group_ids ] voucher_sections["erp_voucher_unmatched"] = [ group for group in erp_unmatched_groups if id(group) not in used_erp_group_ids ] voucher_sections["voucher_matched"] = list(voucher_sections.get("voucher_matched") or []) + promoted_groups voucher_sections["erp_voucher_matched"] = list(voucher_sections.get("erp_voucher_matched") or []) + [ copy.deepcopy(group) for group in promoted_groups ] return voucher_sections def _is_grouped_cost_source_row(row: dict[str, Any]) -> bool: family = _row_account_family(row, "ledger") category = _row_account_category(row, "ledger") if _is_vat_family(family) or family in {"bank", "payable", "receivable", "advance"}: return False return category in {"expense", "revenue"} def _is_grouped_cost_business_row(row: dict[str, Any]) -> bool: family = _row_account_family(row, "voucher") category = _row_account_category(row, "voucher") if _is_vat_family(family) or family in {"bank", "payable", "receivable", "advance"}: return False return category in {"expense", "revenue"} def _is_grouped_cost_counterpart_row(row: dict[str, Any]) -> bool: family = _row_account_family(row, "voucher") return family in {"payable", "bank", "receivable", "advance"} def _group_has_business_support_for_counterpart( ledger_row: dict[str, Any], candidate_group: dict[str, Any], ) -> bool: ledger_category = _row_account_category(ledger_row, "ledger") for voucher_row in _section_side_rows(candidate_group, "voucher"): if not _is_grouped_cost_business_row(voucher_row): continue if ledger_category and _row_account_category(voucher_row, "voucher") != ledger_category: continue merged = dict(ledger_row) merged.update( { "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", ""), "voucher_debit": voucher_row.get("voucher_debit", 0), "voucher_credit": voucher_row.get("voucher_credit", 0), } ) if ( _account_base_names_compatible(ledger_row.get("ledger_account_name"), voucher_row.get("voucher_account_name")) or _contained_core_desc_match(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")) or _meaningful_cross_source_overlap(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")) or _meaningful_cross_source_overlap(ledger_row.get("ledger_vendor"), voucher_row.get("voucher_vendor")) ): return True return False def _grouped_counterpart_match_score( ledger_row: dict[str, Any], voucher_row: dict[str, Any], candidate_group: dict[str, Any], ) -> float: if not _is_grouped_cost_source_row(ledger_row): return 0.0 if not _is_grouped_cost_counterpart_row(voucher_row): return 0.0 ledger_amount = _get_row_match_amount(ledger_row, "ledger") voucher_amount = _get_row_match_amount(voucher_row, "voucher") if ledger_amount <= 0 or abs(ledger_amount - voucher_amount) >= 0.5: return 0.0 vendor_match = _meaningful_cross_source_overlap(ledger_row.get("ledger_vendor"), voucher_row.get("voucher_vendor")) desc_match = ( _contained_core_desc_match(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")) or _meaningful_cross_source_overlap(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")) ) if _has_named_reference_conflict(ledger_row, voucher_row) and not (vendor_match and desc_match): return 0.0 if not (vendor_match or desc_match): return 0.0 if not _group_has_business_support_for_counterpart(ledger_row, candidate_group): return 0.0 score = 90.0 if vendor_match: score += 15 if desc_match: score += 15 if _contained_core_desc_match(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")): score += 5 return score def _build_grouped_cost_counterpart_match( source_group: dict[str, Any], candidate_group: dict[str, Any], *, review_reason: str, ) -> dict[str, Any] | None: ledger_rows = _section_side_rows(source_group, "ledger") voucher_rows = _section_side_rows(candidate_group, "voucher") if not ledger_rows or not voucher_rows: return None pair_candidates: list[tuple[float, dict[str, Any], dict[str, Any]]] = [] for ledger_row in ledger_rows: for voucher_row in voucher_rows: score = _grouped_counterpart_match_score(ledger_row, voucher_row, candidate_group) if score <= 0: continue pair_candidates.append((score, ledger_row, voucher_row)) if not pair_candidates: return None pair_candidates.sort(key=lambda item: item[0], reverse=True) used_ledger: set[str] = set() used_voucher: set[str] = set() rows: list[dict[str, Any]] = [] for _score, ledger_row, voucher_row in pair_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 used_ledger or voucher_key in used_voucher: 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", ""), "status_label": "Matched", "review_reason": review_reason, } ) rows.append(merged) used_ledger.add(ledger_key) used_voucher.add(voucher_key) if not rows: return None for ledger_row in ledger_rows: ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row) if ledger_key in used_ledger: continue payload = _blank_voucher_side(ledger_row) payload["status_label"] = "Unmatched" payload["review_reason"] = review_reason rows.append(payload) for voucher_row in voucher_rows: voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) if voucher_key in used_voucher: continue payload = _blank_ledger_side(voucher_row) payload["status_label"] = "ERP Unmatched" payload["review_reason"] = review_reason rows.append(payload) rows.sort( key=lambda item: ( clean(item.get("ledger_date")) or clean(item.get("proof_date")), clean(item.get("voucher_no")), 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")), ) ) source_summary = dict(source_group.get("summary") or {}) candidate_summary = dict(candidate_group.get("summary") or {}) summary = _build_group_summary_from_rows( fiscal_year=int(source_summary.get("fiscal_year") or candidate_summary.get("fiscal_year") or 0), voucher_no=clean(source_summary.get("voucher_no")), draft_no=clean(candidate_summary.get("draft_no")), rows=rows, status_label="Matched", review_reason=review_reason, ) return {"summary": summary, "rows": rows} def _apply_grouped_cost_counterpart_review( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: erp_candidates = list(voucher_sections.get("erp_voucher_unmatched") or []) + list( voucher_sections.get("erp_voucher_matched") or [] ) if not erp_candidates: return voucher_sections amount_candidate_index: dict[float, list[dict[str, Any]]] = {} for candidate_group in erp_candidates: has_business_row = any(_is_grouped_cost_business_row(row) for row in _section_side_rows(candidate_group, "voucher")) if not has_business_row: continue for voucher_row in _section_side_rows(candidate_group, "voucher"): if not _is_grouped_cost_counterpart_row(voucher_row): continue amount = round(_get_row_match_amount(voucher_row, "voucher"), 2) if amount <= 0: continue amount_candidate_index.setdefault(amount, []).append(candidate_group) promoted_groups: list[dict[str, Any]] = [] moved_wehago_group_ids: set[int] = set() for status_key in ("voucher_unmatched", "voucher_recheck"): for group in list(voucher_sections.get(status_key) or []): candidate_pool: list[dict[str, Any]] = [] seen_candidates: set[int] = set() for ledger_row in _section_side_rows(group, "ledger"): if not _is_grouped_cost_source_row(ledger_row): continue amount = round(_get_row_match_amount(ledger_row, "ledger"), 2) for candidate_group in amount_candidate_index.get(amount, []): if id(candidate_group) in seen_candidates: continue seen_candidates.add(id(candidate_group)) candidate_pool.append(candidate_group) if not candidate_pool: continue best_group: dict[str, Any] | None = None best_match: dict[str, Any] | None = None best_score = -1.0 for candidate_group in candidate_pool: candidate_match = _build_grouped_cost_counterpart_match( group, candidate_group, review_reason="ERP_GROUPED_COST_COUNTERPART_MATCH", ) if candidate_match is None: continue score = sum(_get_row_match_amount(row, "ledger") for row in _section_direct_rows(candidate_match)) score += len(_section_direct_rows(candidate_match)) * 1000000000 if score > best_score: best_group = candidate_group best_match = candidate_match best_score = score if best_group is None or best_match is None: continue promoted_groups.append(best_match) moved_wehago_group_ids.add(id(group)) if not promoted_groups: return voucher_sections for status_key in ("voucher_unmatched", "voucher_recheck"): voucher_sections[status_key] = [ group for group in list(voucher_sections.get(status_key) or []) if id(group) not in moved_wehago_group_ids ] voucher_sections["voucher_matched"] = list(voucher_sections.get("voucher_matched") or []) + promoted_groups voucher_sections["erp_voucher_matched"] = list(voucher_sections.get("erp_voucher_matched") or []) + [ copy.deepcopy(group) for group in promoted_groups ] return voucher_sections def _raw_erp_amount_entries_by_year( conn: Any, start_year: int | None, end_year: int | None, ) -> dict[int, dict[float, list[dict[str, Any]]]]: if start_year is None or end_year is None: return {} result = conn.execute( text( """ SELECT fiscal_year, proof_date, confirmed_no, draft_no, account_code, account_name, debit_supply, debit_tax, credit_supply, credit_tax, support_dept_name, cost_dept_name, desc1, desc2, vendor_name, tax_code, management_item FROM wehago_voucher_rows WHERE fiscal_year BETWEEN :start_year AND :end_year """ ), {"start_year": int(start_year), "end_year": int(end_year)}, ) rows = result.mappings() if hasattr(result, "mappings") else result indexed: dict[int, dict[float, list[dict[str, Any]]]] = {} for row in rows: raw = dict(row) year = int(raw.get("fiscal_year") or 0) if not year: continue draft_year = _erp_draft_year(raw.get("draft_no")) if draft_year and draft_year != year: continue fields = ( ("debit_supply", "debit", False), ("credit_supply", "credit", False), ("debit_tax", "debit", True), ("credit_tax", "credit", True), ) for field, side, tax_evidence in fields: amount = parse_amount(raw.get(field)) if abs(amount) < 0.5: continue entry = dict(raw) entry["_raw_amount_field"] = field entry["_raw_amount_side"] = side entry["_raw_amount_value"] = amount entry["_raw_tax_evidence"] = tax_evidence entry["_raw_entry_dates"] = tuple(_raw_erp_entry_date_values(entry)) indexed.setdefault(year, {}).setdefault(round(abs(amount), 2), []).append(entry) if "불공제" in clean(raw.get("tax_code")): for supply_field, tax_field, side in ( ("debit_supply", "debit_tax", "debit"), ("credit_supply", "credit_tax", "credit"), ): supply_amount = parse_amount(raw.get(supply_field)) tax_amount = parse_amount(raw.get(tax_field)) gross_amount = supply_amount + tax_amount if abs(supply_amount) < 0.5 or abs(tax_amount) < 0.5 or abs(gross_amount) < 0.5: continue entry = dict(raw) entry["_raw_amount_field"] = f"{supply_field}+{tax_field}" entry["_raw_amount_side"] = side entry["_raw_amount_value"] = gross_amount entry["_raw_display_amount_value"] = supply_amount entry["_raw_nondeductible_tax_gross"] = True entry["_raw_entry_dates"] = tuple(_raw_erp_entry_date_values(entry)) indexed.setdefault(year, {}).setdefault(round(abs(gross_amount), 2), []).append(entry) return indexed def _raw_erp_entry_to_voucher_row(entry: dict[str, Any], ledger_row: dict[str, Any]) -> dict[str, Any]: amount = parse_amount(entry.get("_raw_display_amount_value", entry.get("_raw_amount_value"))) side = clean(entry.get("_raw_amount_side")) or "debit" is_tax_evidence = bool(entry.get("_raw_tax_evidence")) account_name = clean(entry.get("account_name")) if is_tax_evidence: account_name = "매입세액" if side == "debit" else "매출세액" desc = " ".join(part for part in (clean(entry.get("desc1")), clean(entry.get("desc2"))) if part) row = { "fiscal_year": int(entry.get("fiscal_year") or ledger_row.get("fiscal_year") or 0), "status_label": "Matched", "ledger_date": clean(ledger_row.get("ledger_date")), "proof_date": clean(entry.get("proof_date")), "voucher_no": clean(ledger_row.get("voucher_no")), "draft_no": clean(entry.get("draft_no")), "voucher_confirmed_no": clean(entry.get("confirmed_no")), "ledger_account_code": ledger_row.get("ledger_account_code", ""), "ledger_account_name": ledger_row.get("ledger_account_name", ""), "ledger_vendor": ledger_row.get("ledger_vendor", ""), "ledger_debit": ledger_row.get("ledger_debit", 0), "ledger_credit": ledger_row.get("ledger_credit", 0), "ledger_desc": ledger_row.get("ledger_desc", ""), "ledger_row_key": clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row), "voucher_account_code": entry.get("account_code", ""), "voucher_account_name": account_name, "voucher_vendor": entry.get("vendor_name", ""), "voucher_debit": amount if side == "debit" else 0, "voucher_credit": amount if side == "credit" else 0, "voucher_desc": desc, "voucher_row_key": "", "_raw_nondeductible_tax_gross": bool(entry.get("_raw_nondeductible_tax_gross")), "review_reason": "RAW_ERP_TRACE_MATCH", "matched_case": "RAW_ERP_SOURCE_TRACE_MATCH", } row["voucher_row_key"] = build_voucher_row_key(row) row["match_identity_key"] = "|".join( clean(part) for part in ( row.get("fiscal_year"), row.get("voucher_no"), row.get("ledger_row_key"), row.get("draft_no"), row.get("voucher_row_key"), ) if clean(part) ) return row def _raw_erp_reference_variants(value: Any) -> set[str]: raw = clean(value) if not raw: return set() variants: set[str] = {raw} parts = [part for part in raw.split("-") if part != ""] if len(parts) > 1: normalized_parts = [ str(int(part)) if re.fullmatch(r"\d+", part or "") else part for part in parts ] compact = "-".join(normalized_parts) variants.add(compact) if len(normalized_parts) > 1: variants.add("-".join(normalized_parts[:-1])) if len(normalized_parts) > 2: variants.add("-".join(normalized_parts[:-2])) return { normalized for normalized in (normalize_text(variant) for variant in variants) if len(normalized) >= 8 } def _raw_erp_direct_voucher_reference_matches(ledger_row: dict[str, Any], entry: dict[str, Any]) -> tuple[bool, bool]: ledger_refs = [ ledger_row.get("voucher_no"), ledger_row.get("draft_no"), ledger_row.get("group_voucher_no"), ledger_row.get("group_draft_no"), ] erp_refs = [ entry.get("confirmed_no"), entry.get("draft_no"), ] ledger_tokens: set[str] = set() erp_tokens: set[str] = set() for ref in ledger_refs: ledger_tokens.update(_raw_erp_reference_variants(ref)) for ref in erp_refs: erp_tokens.update(_raw_erp_reference_variants(ref)) if not ledger_tokens or not erp_tokens: return False, False if ledger_tokens & erp_tokens: return True, True for ledger_token in ledger_tokens: for erp_token in erp_tokens: shorter, longer = sorted((ledger_token, erp_token), key=len) if len(shorter) >= 10 and shorter in longer: return True, False return False, False def _raw_erp_management_matches_ledger(ledger_row: dict[str, Any], entry: dict[str, Any]) -> tuple[bool, bool]: management_text = clean(entry.get("management_item")) if not management_text: return False, False management_norm = normalize_text(management_text) ledger_vendor = clean(ledger_row.get("ledger_vendor")) ledger_desc = clean(ledger_row.get("ledger_desc")) ledger_voucher_no = clean(ledger_row.get("voucher_no")) ledger_draft_no = clean(ledger_row.get("draft_no")) ledger_project = ledger_desc.split("/", 1)[0] strong_refs = [ ledger_voucher_no, ledger_draft_no, clean(ledger_row.get("ledger_row_key")), ] for ref in strong_refs: normalized_ref = normalize_text(ref) if normalized_ref and normalized_ref in management_norm: return True, True if ledger_vendor and _core_token_overlap(ledger_vendor, management_text): return True, False if ledger_project and _core_token_overlap(ledger_project, management_text): return True, False if ( _contained_core_desc_match(ledger_desc, management_text) or _core_token_strong_overlap(ledger_desc, management_text) or _core_token_overlap(ledger_desc, management_text) ): return True, False return False, False def _raw_erp_vehicle_reference_tokens(value: Any) -> set[str]: normalized = re.sub(r"[^0-9가-힣]", "", clean(value)) return set(re.findall(r"\d{2,3}[가-힣]\d{4}", normalized)) def _raw_erp_vehicle_reference_state(ledger_row: dict[str, Any], entry: dict[str, Any]) -> tuple[bool, bool]: ledger_tokens = _raw_erp_vehicle_reference_tokens( " ".join( part for part in (clean(ledger_row.get("ledger_desc")), clean(ledger_row.get("ledger_vendor"))) if part ) ) erp_tokens = _raw_erp_vehicle_reference_tokens( " ".join( part for part in ( clean(entry.get("desc1")), clean(entry.get("desc2")), clean(entry.get("vendor_name")), clean(entry.get("management_item")), ) if part ) ) if not ledger_tokens or not erp_tokens: return False, False return bool(ledger_tokens & erp_tokens), ledger_tokens.isdisjoint(erp_tokens) def _meaningful_cross_source_tokens(value: Any) -> set[str]: normalized = normalize_text(value) if not normalized: return set() stop_tokens = { "사용", "수수료", "세부내용", "설정번호", "은행명", "계좌번호", "예금주", "교통비", "출장비", "식대", "기타", } tokens: set[str] = set() for token in re.findall(r"[0-9a-z가-힣]+", normalized): if len(token) < 3: continue if token.isdigit(): continue if re.fullmatch(r"\d{1,2}(년|월|일|ea|건)", token): continue if token in stop_tokens: continue tokens.add(token) return tokens def _meaningful_cross_source_overlap(left: Any, right: Any) -> bool: left_tokens = _meaningful_cross_source_tokens(left) right_tokens = _meaningful_cross_source_tokens(right) if not left_tokens or not right_tokens: return False return bool(left_tokens & right_tokens) def _raw_erp_has_cross_source_evidence( ledger_row: dict[str, Any], entry: dict[str, Any], candidate_row: dict[str, Any], *, direct_ref_matched: bool, management_matched: bool, ) -> bool: if direct_ref_matched or management_matched: return True if _core_token_overlap(ledger_row.get("ledger_vendor"), entry.get("vendor_name")): return True if ( _contained_core_desc_match(ledger_row.get("ledger_desc"), candidate_row.get("voucher_desc")) or _meaningful_cross_source_overlap(ledger_row.get("ledger_desc"), candidate_row.get("voucher_desc")) ): return True ledger_project = clean(ledger_row.get("ledger_desc")).split("/", 1)[0] project_text = " ".join( part for part in (clean(entry.get("support_dept_name")), clean(entry.get("cost_dept_name"))) if part ) if ledger_project and _meaningful_cross_source_overlap(ledger_project, project_text): return True return False def _raw_erp_trace_score(ledger_row: dict[str, Any], entry: dict[str, Any]) -> tuple[float, dict[str, Any]]: candidate_row = _raw_erp_entry_to_voucher_row(entry, ledger_row) ledger_amount = max( abs(parse_amount(ledger_row.get("ledger_debit"))), abs(parse_amount(ledger_row.get("ledger_credit"))), ) voucher_amount = abs(parse_amount(entry.get("_raw_amount_value"))) if ledger_amount <= 0 or abs(ledger_amount - voucher_amount) >= 0.5: return 0.0, candidate_row score = 0.0 ledger_family = _row_account_family(candidate_row, "ledger") voucher_family = _row_account_family(candidate_row, "voucher") ledger_category = _row_account_category(candidate_row, "ledger") voucher_category = _row_account_category(candidate_row, "voucher") is_tax_evidence = bool(entry.get("_raw_tax_evidence")) if _account_names_compatible(candidate_row, candidate_row): score += 30 elif ledger_family and voucher_family and ledger_family == voucher_family: score += 24 elif ledger_category and voucher_category and ledger_category == voucher_category: score += 18 elif ledger_category in {"expense", "revenue"} and voucher_family in {"payable", "bank", "receivable", "advance"}: score += 15 elif ledger_family in {"payable", "bank", "receivable", "advance"} and voucher_family in {"payable", "bank", "receivable", "advance"}: score += 12 if _is_vat_family(ledger_family) and (_is_vat_family(voucher_family) or is_tax_evidence): score += 35 direct_ref_matched, direct_ref_strong = _raw_erp_direct_voucher_reference_matches(ledger_row, entry) if direct_ref_strong: score += 45 elif direct_ref_matched: score += 28 vehicle_ref_matched, vehicle_ref_conflict = _raw_erp_vehicle_reference_state(ledger_row, entry) if vehicle_ref_conflict: return 0.0, candidate_row if vehicle_ref_matched: score += 60 ledger_date = _parse_row_date_with_year(ledger_row, "ledger_date") ledger_proof_date = _parse_row_date_with_year(ledger_row, "proof_date") entry_dates = _raw_erp_entry_date_values(entry) if entry_dates: date_matches = [date_value for date_value in (ledger_date, ledger_proof_date) if date_value] if any(date_value == entry_date for date_value in date_matches for entry_date in entry_dates): score += 35 if is_tax_evidence or _is_vat_family(ledger_family) else 30 elif any( date_value.year == entry_date.year and date_value.month == entry_date.month for date_value in date_matches for entry_date in entry_dates ): score += 15 if is_tax_evidence or _is_vat_family(ledger_family) else 12 if _core_token_overlap(ledger_row.get("ledger_vendor"), entry.get("vendor_name")): score += 20 if ( _contained_core_desc_match(ledger_row.get("ledger_desc"), candidate_row.get("voucher_desc")) or _core_token_strong_overlap(ledger_row.get("ledger_desc"), candidate_row.get("voucher_desc")) ): score += 25 ledger_project = clean(ledger_row.get("ledger_desc")).split("/", 1)[0] erp_project_text = " ".join( part for part in (clean(entry.get("support_dept_name")), clean(entry.get("cost_dept_name"))) if part ) if ledger_project and _core_token_overlap(ledger_project, erp_project_text): score += 20 management_matched, management_strong = _raw_erp_management_matches_ledger(ledger_row, entry) if management_strong: score += 35 elif management_matched: score += 20 has_cross_source_evidence = _raw_erp_has_cross_source_evidence( ledger_row, entry, candidate_row, direct_ref_matched=direct_ref_matched, management_matched=management_matched, ) has_vat_date_evidence = bool( _is_vat_family(ledger_family) and (_is_vat_family(voucher_family) or is_tax_evidence) and entry_dates and any( date_value == entry_date for date_value in (ledger_date, ledger_proof_date) if date_value for entry_date in entry_dates ) ) if not has_cross_source_evidence and not has_vat_date_evidence: return 0.0, candidate_row if ( ledger_category in {"expense", "revenue"} and voucher_family in {"payable", "bank", "receivable", "advance"} and not has_cross_source_evidence ): return 0.0, candidate_row if _has_named_reference_conflict(ledger_row, candidate_row) and score < 100: return 0.0, candidate_row return score, candidate_row def _raw_erp_trace_prefilter(ledger_row: dict[str, Any], entry: dict[str, Any]) -> bool: direct_ref_matched, _direct_ref_strong = _raw_erp_direct_voucher_reference_matches(ledger_row, entry) if direct_ref_matched: return True vehicle_ref_matched, vehicle_ref_conflict = _raw_erp_vehicle_reference_state(ledger_row, entry) if vehicle_ref_conflict: return False if vehicle_ref_matched: return True ledger_date = _parse_row_date_with_year(ledger_row, "ledger_date") ledger_proof_date = _parse_row_date_with_year(ledger_row, "proof_date") entry_dates = _raw_erp_entry_date_values(entry) if entry_dates: for date_value in (ledger_date, ledger_proof_date): if date_value: for entry_date in entry_dates: if abs((date_value - entry_date).days) <= 62: return True desc_text = " ".join(part for part in (clean(entry.get("desc1")), clean(entry.get("desc2"))) if part) project_text = " ".join( part for part in (clean(entry.get("support_dept_name")), clean(entry.get("cost_dept_name"))) if part ) management_text = clean(entry.get("management_item")) management_matched, _management_strong = _raw_erp_management_matches_ledger(ledger_row, entry) if management_matched: return True if _core_token_overlap(ledger_row.get("ledger_vendor"), entry.get("vendor_name")): return True if ( _contained_core_desc_match(ledger_row.get("ledger_desc"), desc_text) or _core_token_strong_overlap(ledger_row.get("ledger_desc"), desc_text) or _core_token_overlap(ledger_row.get("ledger_desc"), project_text) or _core_token_overlap(ledger_row.get("ledger_desc"), management_text) ): return True return False def _raw_erp_trace_prefilter_score(ledger_row: dict[str, Any], entry: dict[str, Any]) -> int: score = 0 direct_ref_matched, direct_ref_strong = _raw_erp_direct_voucher_reference_matches(ledger_row, entry) if direct_ref_strong: score += 100 elif direct_ref_matched: score += 70 vehicle_ref_matched, vehicle_ref_conflict = _raw_erp_vehicle_reference_state(ledger_row, entry) if vehicle_ref_conflict: return 0 if vehicle_ref_matched: score += 80 management_matched, management_strong = _raw_erp_management_matches_ledger(ledger_row, entry) if management_strong: score += 80 elif management_matched: score += 45 ledger_date = _parse_row_date_with_year(ledger_row, "ledger_date") ledger_proof_date = _parse_row_date_with_year(ledger_row, "proof_date") entry_dates = _raw_erp_entry_date_values(entry) if entry_dates: date_values = [date_value for date_value in (ledger_date, ledger_proof_date) if date_value] if any(date_value == entry_date for date_value in date_values for entry_date in entry_dates): score += 50 elif any( date_value.year == entry_date.year and date_value.month == entry_date.month for date_value in date_values for entry_date in entry_dates ): score += 25 elif any(abs((date_value - entry_date).days) <= 62 for date_value in date_values for entry_date in entry_dates): score += 10 if _core_token_overlap(ledger_row.get("ledger_vendor"), entry.get("vendor_name")): score += 35 desc_text = " ".join(part for part in (clean(entry.get("desc1")), clean(entry.get("desc2"))) if part) if _contained_core_desc_match(ledger_row.get("ledger_desc"), desc_text) or _core_token_strong_overlap(ledger_row.get("ledger_desc"), desc_text): score += 35 ledger_project = clean(ledger_row.get("ledger_desc")).split("/", 1)[0] project_text = " ".join( part for part in (clean(entry.get("support_dept_name")), clean(entry.get("cost_dept_name"))) if part ) if ledger_project and _core_token_overlap(ledger_project, project_text): score += 30 return score def _build_raw_erp_trace_match( source_group: dict[str, Any], amount_index: dict[float, list[dict[str, Any]]], ) -> dict[str, Any] | None: ledger_rows = _section_side_rows(source_group, "ledger") if not ledger_rows: return None pair_candidates: list[tuple[float, dict[str, Any], dict[str, Any]]] = [] for ledger_row in ledger_rows: amount = round( max( abs(parse_amount(ledger_row.get("ledger_debit"))), abs(parse_amount(ledger_row.get("ledger_credit"))), ), 2, ) if amount <= 0: continue entries = list(amount_index.get(amount, []) or []) entries = [entry for entry in entries if _raw_erp_trace_prefilter(ledger_row, entry)] if len(entries) > 120: ranked_entries = [ (_raw_erp_trace_prefilter_score(ledger_row, entry), entry) for entry in entries ] ranked_entries = [item for item in ranked_entries if item[0] > 0] ranked_entries.sort(key=lambda item: item[0], reverse=True) entries = [entry for _score, entry in ranked_entries[:120]] for entry in entries: score, candidate_row = _raw_erp_trace_score(ledger_row, entry) if score < 70: continue pair_candidates.append((score, ledger_row, candidate_row)) if not pair_candidates: return None pair_candidates.sort(key=lambda item: item[0], reverse=True) used_ledger: set[str] = set() used_voucher: set[str] = set() rows: list[dict[str, Any]] = [] matched_amount = 0.0 for score, ledger_row, candidate_row in pair_candidates: ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row) voucher_key = clean(candidate_row.get("voucher_row_key")) or build_voucher_row_key(candidate_row) if ledger_key in used_ledger or voucher_key in used_voucher: continue row = dict(candidate_row) if score >= 105 and _is_vat_family(_row_account_family(row, "ledger")): row["matched_case"] = "VAT_PROOF_DATE_TRACE_MATCH" elif any( parse_amount(row.get(field)) < 0 for field in ("ledger_debit", "ledger_credit", "voucher_debit", "voucher_credit") ): row["matched_case"] = "NEGATIVE_REVERSAL_TRACE_MATCH" elif row.get("_raw_nondeductible_tax_gross"): row["matched_case"] = "NONDEDUCTIBLE_VAT_GROSS_TRACE_MATCH" else: row["matched_case"] = "RAW_ERP_SOURCE_TRACE_MATCH" row["review_reason"] = row["matched_case"] rows.append(row) used_ledger.add(ledger_key) used_voucher.add(voucher_key) matched_amount += max( abs(parse_amount(row.get("ledger_debit"))), abs(parse_amount(row.get("ledger_credit"))), ) if not rows: return None ledger_total = sum( max(abs(parse_amount(row.get("ledger_debit"))), abs(parse_amount(row.get("ledger_credit")))) for row in ledger_rows ) has_vat_or_proof = any( clean(row.get("matched_case")) == "VAT_PROOF_DATE_TRACE_MATCH" or bool(clean(row.get("proof_date"))) for row in rows ) if matched_amount < max(1.0, ledger_total * 0.45) and not has_vat_or_proof: return None for ledger_row in ledger_rows: ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row) if ledger_key in used_ledger: continue payload = _blank_voucher_side(ledger_row) payload["status_label"] = "Unmatched" payload["review_reason"] = "RAW_ERP_TRACE_CONTEXT_ONLY" rows.append(payload) rows.sort( key=lambda item: ( clean(item.get("ledger_date")) or clean(item.get("proof_date")), clean(item.get("voucher_no")), 0 if (clean(item.get("ledger_account_name")) and clean(item.get("voucher_account_name"))) else 1, clean(item.get("ledger_account_name")) or clean(item.get("voucher_account_name")), clean(item.get("draft_no")), -abs(parse_amount(item.get("ledger_debit")) or parse_amount(item.get("voucher_debit"))), -abs(parse_amount(item.get("ledger_credit")) or parse_amount(item.get("voucher_credit"))), clean(item.get("ledger_desc")) or clean(item.get("voucher_desc")), ) ) summary = dict(source_group.get("summary") or {}) direct_draft_nos: list[str] = [] for row in rows: draft_no = clean(row.get("draft_no")) if draft_no and draft_no not in direct_draft_nos: direct_draft_nos.append(draft_no) confirmed_no = clean(row.get("voucher_confirmed_no")) if confirmed_no and confirmed_no not in direct_draft_nos: direct_draft_nos.append(confirmed_no) group_summary = _build_group_summary_from_rows( fiscal_year=int(summary.get("fiscal_year") or 0), voucher_no=clean(summary.get("voucher_no")), draft_no=", ".join(direct_draft_nos), rows=rows, status_label="Matched", review_reason="RAW_ERP_SOURCE_TRACE_MATCH", ) return {"summary": group_summary, "rows": rows} def _apply_raw_erp_trace_promotions( conn: Any, start_year: int | None, end_year: int | None, voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: amount_entries = _raw_erp_amount_entries_by_year(conn, start_year, end_year) if not amount_entries: return voucher_sections promoted_groups: list[dict[str, Any]] = [] moved_group_ids: set[int] = set() for status_key in ("voucher_unmatched", "voucher_recheck", "voucher_excepted"): for group in list(voucher_sections.get(status_key) or []): summary = group.get("summary") or {} year = int(summary.get("fiscal_year") or 0) amount_index = amount_entries.get(year) or {} if not amount_index: continue matched_group = _build_raw_erp_trace_match(group, amount_index) if matched_group is None: continue promoted_groups.append(matched_group) moved_group_ids.add(id(group)) if not promoted_groups: return voucher_sections for status_key in ("voucher_unmatched", "voucher_recheck", "voucher_excepted"): voucher_sections[status_key] = [ group for group in list(voucher_sections.get(status_key) or []) if id(group) not in moved_group_ids ] voucher_sections["voucher_matched"] = list(voucher_sections.get("voucher_matched") or []) + promoted_groups voucher_sections["erp_voucher_matched"] = list(voucher_sections.get("erp_voucher_matched") or []) + [ copy.deepcopy(group) for group in promoted_groups ] return voucher_sections def _dedupe_wehago_sections_to_single_status( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: priority = { "voucher_matched": 0, "voucher_recheck": 1, "voucher_unmatched": 2, } def _group_quality(group: dict[str, Any]) -> tuple[int, int, float, float]: rows = list(group.get("rows", []) or []) direct_rows = sum( 1 for row in rows if clean(row.get("ledger_account_name")) and clean(row.get("voucher_account_name")) ) recheck_rows = sum(1 for row in rows if clean(row.get("status_label")) == "Recheck") summary = dict(group.get("summary") or {}) matched_amount = max( abs(parse_amount(summary.get("ledger_debit")) - parse_amount(summary.get("voucher_debit"))), abs(parse_amount(summary.get("ledger_credit")) - parse_amount(summary.get("voucher_credit"))), ) total_amount = max( parse_amount(summary.get("ledger_debit")), parse_amount(summary.get("ledger_credit")), parse_amount(summary.get("voucher_debit")), parse_amount(summary.get("voucher_credit")), ) return (direct_rows, -recheck_rows, -matched_amount, total_amount) grouped_by_identity: dict[str, list[tuple[int, str, dict[str, Any]]]] = {} for status_key in ("voucher_unmatched", "voucher_matched", "voucher_recheck"): for index, group in enumerate(voucher_sections.get(status_key, []) or []): identity = _wehago_section_identity(group, f"{status_key}:{index}") grouped_by_identity.setdefault(identity, []).append((priority[status_key], status_key, group)) allowed_ids_by_status: dict[str, set[int]] = { "voucher_matched": set(), "voucher_unmatched": set(), "voucher_recheck": set(), } for _identity, candidates in grouped_by_identity.items(): sorted_candidates = sorted( candidates, key=lambda item: (item[0], _group_quality(item[2])), reverse=False, ) if len(sorted_candidates) > 1: sorted_candidates = sorted( sorted_candidates, key=lambda item: (item[0], _group_quality(item[2])), ) capacity = max( _section_vat_exception_capacity(group, side="wehago") for _rank, _status_key, group in sorted_candidates ) for _rank, status_key, group in sorted_candidates[:capacity]: allowed_ids_by_status[status_key].add(id(group)) for status_key in ("voucher_matched", "voucher_unmatched", "voucher_recheck"): voucher_sections[status_key] = [ group for group in voucher_sections.get(status_key, []) or [] if id(group) in allowed_ids_by_status[status_key] ] return voucher_sections def _dedupe_erp_sections_to_single_status( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: priority = { "erp_voucher_matched": 0, "erp_voucher_unmatched": 1, } def _group_quality(group: dict[str, Any]) -> tuple[int, float]: rows = list(group.get("rows", []) or []) direct_rows = sum( 1 for row in rows if clean(row.get("ledger_account_name")) and clean(row.get("voucher_account_name")) ) summary = dict(group.get("summary") or {}) total_amount = max( parse_amount(summary.get("ledger_debit")), parse_amount(summary.get("ledger_credit")), parse_amount(summary.get("voucher_debit")), parse_amount(summary.get("voucher_credit")), ) return (direct_rows, total_amount) grouped_by_identity: dict[str, list[tuple[int, str, dict[str, Any]]]] = {} for status_key in ("erp_voucher_unmatched", "erp_voucher_matched"): for index, group in enumerate(voucher_sections.get(status_key, []) or []): identity = _erp_section_identity(group, f"{status_key}:{index}") grouped_by_identity.setdefault(identity, []).append((priority[status_key], status_key, group)) allowed_ids_by_status: dict[str, set[int]] = { "erp_voucher_matched": set(), "erp_voucher_unmatched": set(), } for _identity, candidates in grouped_by_identity.items(): sorted_candidates = sorted( candidates, key=lambda item: (item[0], _group_quality(item[2])), ) capacity = 1 for _rank, status_key, group in sorted_candidates[:capacity]: allowed_ids_by_status[status_key].add(id(group)) for status_key in ("erp_voucher_matched", "erp_voucher_unmatched"): voucher_sections[status_key] = [ group for group in voucher_sections.get(status_key, []) or [] if id(group) in allowed_ids_by_status[status_key] ] return voucher_sections def _ledger_group_month_day(value: Any, fiscal_year: int | None = None) -> str: normalized = normalize_wehago_display_date(value, fiscal_year) matched = re.search(r"(\d{1,2})[-./](\d{1,2})$", clean(normalized)) if not matched: matched = re.search(r"\d{4}[-./](\d{1,2})[-./](\d{1,2})", clean(value)) if not matched: return "" return f"{int(matched.group(1)):02d}-{int(matched.group(2)):02d}" def _voucher_group_text(group: dict[str, Any]) -> str: summary = group.get("summary") or group rows = list(group.get("rows") or []) return normalize_text( " ".join( [ clean(summary.get("voucher_no") or summary.get("group_voucher_no")), clean(summary.get("draft_no") or summary.get("group_draft_no")), clean(summary.get("ledger_accounts") or summary.get("group_ledger_accounts")), clean(summary.get("voucher_accounts") or summary.get("group_voucher_accounts")), clean(summary.get("ledger_vendors") or summary.get("group_ledger_vendors")), clean(summary.get("voucher_vendors") or summary.get("group_voucher_vendors")), clean(summary.get("review_reason")), clean(summary.get("ledger_account_name")), clean(summary.get("voucher_account_name")), clean(summary.get("ledger_vendor")), clean(summary.get("voucher_vendor")), clean(summary.get("ledger_desc")), clean(summary.get("voucher_desc")), " ".join(clean(row.get("ledger_account_name")) for row in rows), " ".join(clean(row.get("voucher_account_name")) for row in rows), " ".join(clean(row.get("ledger_vendor")) for row in rows), " ".join(clean(row.get("voucher_vendor")) for row in rows), " ".join(clean(row.get("ledger_desc")) for row in rows), " ".join(clean(row.get("voucher_desc")) for row in rows), ] ) ) def _voucher_group_ledger_description_text(group: dict[str, Any]) -> str: summary = group.get("summary") or group rows = list(group.get("rows") or []) return normalize_text( " ".join( [ clean(summary.get("ledger_desc")), " ".join(clean(row.get("ledger_desc")) for row in rows), ] ) ) def _voucher_group_ledger_account_text(group: dict[str, Any]) -> str: summary = group.get("summary") or group rows = list(group.get("rows") or []) return normalize_text( " ".join( [ clean(summary.get("ledger_accounts") or summary.get("group_ledger_accounts")), clean(summary.get("ledger_account_name")), " ".join(clean(row.get("ledger_account_name")) for row in rows), ] ) ) def _voucher_group_month_days(group: dict[str, Any]) -> set[str]: summary = group.get("summary") or group rows = list(group.get("rows") or []) fiscal_year = int(summary.get("fiscal_year") or 0) or None date_candidates = [summary.get("ledger_date")] + [row.get("ledger_date") for row in rows] month_days = {_ledger_group_month_day(value, fiscal_year) for value in date_candidates if clean(value)} month_days.discard("") return month_days def _voucher_group_dates(group: dict[str, Any]) -> set[date]: summary = group.get("summary") or group fiscal_year = int(summary.get("fiscal_year") or 0) or None result: set[date] = set() for month_day in _voucher_group_month_days(group): if not fiscal_year: continue try: month_text, day_text = month_day.split("-", 1) result.add(date(int(fiscal_year), int(month_text), int(day_text))) except Exception: continue return result def _voucher_groups_within_days(left: dict[str, Any], right: dict[str, Any], max_days: int) -> bool: left_dates = _voucher_group_dates(left) right_dates = _voucher_group_dates(right) return any(abs((left_date - right_date).days) <= max_days for left_date in left_dates for right_date in right_dates) def _voucher_group_has_erp_counterpart(group: dict[str, Any]) -> bool: summary = group.get("summary") or group rows = list(group.get("rows") or []) if clean(summary.get("draft_no") or summary.get("group_draft_no")): return True if clean(summary.get("voucher_accounts") or summary.get("group_voucher_accounts")): return True if abs(parse_amount(summary.get("voucher_debit"))) >= 0.5 or abs(parse_amount(summary.get("voucher_credit"))) >= 0.5: return True for row in rows: if clean(row.get("draft_no")) or clean(row.get("voucher_account_name")): return True if abs(parse_amount(row.get("voucher_debit"))) >= 0.5 or abs(parse_amount(row.get("voucher_credit"))) >= 0.5: return True return False def _is_boundary_vat_tax_invoice_group(group: dict[str, Any]) -> bool: month_days = _voucher_group_month_days(group) if not (month_days & {"01-01", "03-31", "06-30", "09-30", "12-31"}): return False haystack = _voucher_group_text(group) compact_haystack = haystack.replace(" ", "") has_vat_account = any( marker in compact_haystack for marker in ( "부가세대급금", "부가세예수금", "부가가치세대급금", "부가가치세예수금", "매입세액", "매출세액", ) ) if not has_vat_account: return False has_tax_invoice_signal = any( marker in compact_haystack for marker in ( "세금계산서", "전자세금계산서", "계산서발행", "매입세액", "매출세액", ) ) return has_tax_invoice_signal or _voucher_group_has_erp_counterpart(group) def _is_wehago_excepted_voucher_group(group: dict[str, Any]) -> tuple[bool, str]: summary = group.get("summary") or group month_days = _voucher_group_month_days(group) haystack = _voucher_group_text(group) compact_haystack = haystack.replace(" ", "") compact_ledger_desc = _voucher_group_ledger_description_text(group).replace(" ", "") compact_ledger_accounts = _voucher_group_ledger_account_text(group).replace(" ", "") if "감가상각" in compact_ledger_accounts: return True, "WEHAGO_EXCEPTED_DEPRECIATION_ACCOUNT" if _is_boundary_vat_tax_invoice_group(group): return False, "" offset_desc = re.sub(r"상계\s*\d+(?:-\d+)?\s*소구(?:역)?", "", _voucher_group_ledger_description_text(group)) compact_offset_desc = offset_desc.replace(" ", "") has_accounting_offset = any( token in compact_offset_desc for token in ( "상계전표", "상계처리", "상계분개", "채권채무상계", "채권상계", "채무상계", "미수금상계", "미지급금상계", "외상매입금상계", "외상매출금상계", ) ) if has_accounting_offset: return True, "WEHAGO_EXCEPTED_OFFSET_ENTRY" has_accounting_substitution = any( token in compact_ledger_desc for token in ( "계정대체", "거래처대체", "대체분개", "전기대체", "전년대체", "전년도대체", "결산대체", "감사대체", "환입대체", "계상분대체", ) ) if has_accounting_substitution: return True, "WEHAGO_EXCEPTED_SUBSTITUTION_ENTRY" if clean(summary.get("ledger_vendors") or summary.get("group_ledger_vendors")) == "결산 환원분개": return True, "WEHAGO_EXCEPTED_CLOSING_REVERSAL" if any( token in compact_haystack for token in ( "결산환원분개", "환원분개", "계상분대체", "환입대체", ) ): return True, "WEHAGO_EXCEPTED_CLOSING_REVERSAL" if any(token in compact_haystack for token in ("전기이월", "기초이월", "전기잔액", "기초잔액")): return True, "WEHAGO_EXCEPTED_OPENING_BALANCE" if "01-01" in month_days and any( token in compact_haystack for token in ( "전기이월", "기초이월", "전기잔액", "기초잔액", "전기대체", "전년대체", "전년도대체", "결산환원분개", "환원분개", ) ): return True, "WEHAGO_EXCEPTED_YEAR_OPENING_SUBSTITUTION" audit_period_days = {"03-31", "06-30", "09-30", "12-31"} audit_anchor = any( token in haystack for token in ( "회계감사", "외부감사", "감사조정", "감사대체", "감사수정", "회계법인", ) ) or ("감사" in haystack and "감가상각" not in haystack) adjustment_anchor = any( token in haystack for token in ( "대체", "조정", "수정", "재분류", "계정대체", "상계", "환입", ) ) if month_days & audit_period_days and audit_anchor and adjustment_anchor: return True, "WEHAGO_EXCEPTED_AUDIT_ADJUSTMENT" return False, "" def _group_has_tax_invoice_cancel_signal(group: dict[str, Any]) -> bool: haystack = _voucher_group_text(group) compact_haystack = haystack.replace(" ", "") has_tax_signal = any( token in compact_haystack for token in ("세금계산서", "전자세금계산서", "계산서발행", "계산서취소", "부가세대급금", "부가세예수금", "매입세액", "매출세액") ) has_cancel_signal = any( token in compact_haystack for token in ("취소", "취소전표", "상계", "반제", "환입", "마이너스") ) has_negative_amount = any( parse_amount(row.get(field)) < -0.5 for row in list(group.get("rows") or []) for field in ("ledger_debit", "ledger_credit") ) return bool(has_tax_signal and (has_cancel_signal or has_negative_amount)) def _group_has_offset_tax_invoice_structure(group: dict[str, Any]) -> bool: haystack = _voucher_group_text(group) compact_haystack = haystack.replace(" ", "") has_vat_account = any( token in compact_haystack for token in ("부가세대급금", "부가세예수금", "매입세액", "매출세액") ) has_counterpart = any( token in compact_haystack for token in ("외상매입금", "외상매출금", "미지급금", "미수금") ) has_tax_hint = any( token in compact_haystack for token in ("세금계산서", "전자세금계산서", "계산서", "부가세") ) nonzero_rows = [ row for row in list(group.get("rows") or []) if abs(parse_amount(row.get("ledger_debit")) - parse_amount(row.get("ledger_credit"))) >= 0.5 ] return bool(has_vat_account and has_counterpart and (has_tax_hint or len(nonzero_rows) >= 3)) def _offset_group_row_signature(row: dict[str, Any]) -> tuple[str, str, str]: account = normalize_text(row.get("ledger_account_name")) vendor = normalize_text(row.get("ledger_vendor")) desc = "" return account, vendor, desc def _offset_group_vector(group: dict[str, Any]) -> dict[tuple[str, str, str], float]: vector: dict[tuple[str, str, str], float] = {} for row in list(group.get("rows") or []): if not clean(row.get("ledger_account_name")): continue amount = parse_amount(row.get("ledger_debit")) - parse_amount(row.get("ledger_credit")) if abs(amount) < 0.5: continue key = _offset_group_row_signature(row) if not any(key): continue vector[key] = vector.get(key, 0.0) + amount return {key: value for key, value in vector.items() if abs(value) >= 0.5} def _offset_vectors_cancel_each_other(left: dict[tuple[str, str, str], float], right: dict[tuple[str, str, str], float]) -> bool: if not left or not right or set(left) != set(right): return False return all(abs(left[key] + right[key]) < 0.5 for key in left) def _offset_vectors_same_direction(left: dict[tuple[str, str, str], float], right: dict[tuple[str, str, str], float]) -> bool: if not left or not right or set(left) != set(right): return False return all(abs(left[key] - right[key]) < 0.5 for key in left) def _voucher_group_has_review_reason(group: dict[str, Any], *needles: str) -> bool: haystack = _voucher_group_text(group) return any(normalize_text(needle) in haystack for needle in needles if clean(needle)) def _copy_wehago_group_with_status( group: dict[str, Any], *, status_label: str, reason: str, ) -> dict[str, Any]: group_copy = { "summary": dict(group.get("summary") or {}), "rows": [dict(row) for row in group.get("rows", []) or []], } group_copy["summary"]["status_label"] = status_label review_reason = clean(group_copy["summary"].get("review_reason")) group_copy["summary"]["review_reason"] = " / ".join( item for item in [review_reason, reason] if item ) for row in group_copy["rows"]: row["status_label"] = status_label row["review_reason"] = clean(row.get("review_reason")) or reason return group_copy def _move_wehago_confirmed_reversal_pairs_to_excepted( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: # A matched voucher may be the original posting targeted by a cancellation. # Keep it out of automatic excepting so cancellation/reissue review can see it. candidate_statuses = ("voucher_unmatched", "voucher_recheck") groups = [ group for status_key in candidate_statuses for group in list(voucher_sections.get(status_key) or []) ] vectors: dict[int, dict[tuple[str, str, str], float]] = {} eligible: list[dict[str, Any]] = [] for group in groups: if not (_group_has_tax_invoice_cancel_signal(group) or _group_has_offset_tax_invoice_structure(group)): continue vector = _offset_group_vector(group) if not vector: continue vectors[id(group)] = vector eligible.append(group) if len(eligible) < 2: return voucher_sections indexed: dict[tuple[tuple[tuple[str, str, str], float], ...], list[dict[str, Any]]] = {} for group in eligible: key = tuple(sorted((row_key, round(amount, 4)) for row_key, amount in vectors[id(group)].items())) indexed.setdefault(key, []).append(group) partners: dict[int, list[dict[str, Any]]] = {id(group): [] for group in eligible} visited: set[tuple[int, int]] = set() for group in eligible: opposite_key = tuple(sorted((row_key, round(-amount, 4)) for row_key, amount in vectors[id(group)].items())) for other in indexed.get(opposite_key, []): if other is group: continue pair_key = tuple(sorted((id(group), id(other)))) if pair_key in visited: continue visited.add(pair_key) if not _voucher_groups_within_days(group, other, 7): continue partners[id(group)].append(other) partners[id(other)].append(group) moved_ids: set[int] = set() for group in eligible: possible = partners[id(group)] if len(possible) != 1: continue other = possible[0] if len(partners[id(other)]) != 1 or partners[id(other)][0] is not group: continue moved_ids.update((id(group), id(other))) if not moved_ids: return voucher_sections moved_excepted: list[dict[str, Any]] = [] for status_key in candidate_statuses: retained: list[dict[str, Any]] = [] for group in list(voucher_sections.get(status_key) or []): if id(group) not in moved_ids: retained.append(group) continue moved_excepted.append( _copy_wehago_group_with_status( group, status_label="Excepted", reason="WEHAGO_EXCEPTED_CONFIRMED_REVERSAL_PAIR", ) ) voucher_sections[status_key] = retained voucher_sections["voucher_excepted"] = list(voucher_sections.get("voucher_excepted") or []) + moved_excepted return voucher_sections def _apply_wehago_cancel_reissue_recheck( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: matched_groups = list(voucher_sections.get("voucher_matched") or []) candidate_statuses = ("voucher_unmatched", "voucher_recheck") candidate_groups: list[tuple[str, dict[str, Any]]] = [ (status_key, group) for status_key in candidate_statuses for group in list(voucher_sections.get(status_key) or []) ] if not matched_groups or not candidate_groups: return voucher_sections matched_vectors: dict[int, dict[tuple[str, str, str], float]] = {} candidate_vectors: dict[int, dict[tuple[str, str, str], float]] = {} for group in matched_groups: if _group_has_tax_invoice_cancel_signal(group) or _group_has_offset_tax_invoice_structure(group): vector = _offset_group_vector(group) if vector: matched_vectors[id(group)] = vector for _status_key, group in candidate_groups: if _group_has_tax_invoice_cancel_signal(group) or _group_has_offset_tax_invoice_structure(group): vector = _offset_group_vector(group) if vector: candidate_vectors[id(group)] = vector if not matched_vectors or not candidate_vectors: return voucher_sections matched_to_recheck: set[int] = set() candidate_to_recheck: dict[int, str] = {} search_days = 93 for matched in matched_groups: matched_vector = matched_vectors.get(id(matched)) if not matched_vector: continue cancel_candidates = [ group for _status_key, group in candidate_groups if id(group) in candidate_vectors and _offset_vectors_cancel_each_other(matched_vector, candidate_vectors[id(group)]) and _voucher_groups_within_days(matched, group, search_days) ] if not cancel_candidates: continue reissue_candidates = [ group for _status_key, group in candidate_groups if id(group) in candidate_vectors and id(group) not in {id(item) for item in cancel_candidates} and _offset_vectors_same_direction(matched_vector, candidate_vectors[id(group)]) and _voucher_groups_within_days(matched, group, search_days) ] matched_to_recheck.add(id(matched)) for group in cancel_candidates: candidate_to_recheck[id(group)] = "CANCEL_TARGET_ALREADY_MATCHED_RECHECK" for group in reissue_candidates: candidate_to_recheck[id(group)] = "CANCEL_REISSUE_RETARGET_RECHECK" if not matched_to_recheck and not candidate_to_recheck: return voucher_sections retained_matched: list[dict[str, Any]] = [] appended_recheck: list[dict[str, Any]] = [] for group in matched_groups: if id(group) not in matched_to_recheck: retained_matched.append(group) continue appended_recheck.append( _copy_wehago_group_with_status( group, status_label="Recheck", reason="MATCHED_CANCEL_TARGET_RECHECK", ) ) retained_by_status: dict[str, list[dict[str, Any]]] = {status_key: [] for status_key in candidate_statuses} for status_key, group in candidate_groups: reason = candidate_to_recheck.get(id(group)) if not reason: retained_by_status[status_key].append(group) continue appended_recheck.append( _copy_wehago_group_with_status( group, status_label="Recheck", reason=reason, ) ) voucher_sections["voucher_matched"] = retained_matched for status_key in candidate_statuses: voucher_sections[status_key] = retained_by_status[status_key] voucher_sections["voucher_recheck"] = list(voucher_sections.get("voucher_recheck") or []) + appended_recheck return voucher_sections def _move_wehago_offset_tax_invoice_groups_to_excepted( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: candidate_statuses = ("voucher_unmatched", "voucher_recheck") candidate_groups: list[tuple[str, dict[str, Any]]] = [ (status_key, group) for status_key in candidate_statuses for group in list(voucher_sections.get(status_key) or []) ] if len(candidate_groups) < 2: return voucher_sections candidate_vectors: dict[int, dict[tuple[str, str, str], float]] = {} tax_offset_candidate_ids: set[int] = set() for _status_key, group in candidate_groups: if _voucher_group_has_review_reason( group, "MATCHED_CANCEL_TARGET_RECHECK", "CANCEL_TARGET_ALREADY_MATCHED_RECHECK", "CANCEL_REISSUE_RETARGET_RECHECK", ): continue if _group_has_tax_invoice_cancel_signal(group) or _group_has_offset_tax_invoice_structure(group): tax_offset_candidate_ids.add(id(group)) vector = _offset_group_vector(group) if vector: candidate_vectors[id(group)] = vector if not candidate_vectors: return voucher_sections moved_ids: set[int] = set() candidates = [(status_key, group) for status_key, group in candidate_groups if id(group) in candidate_vectors] for index, (_status_key, group) in enumerate(candidates): if id(group) in moved_ids: continue left_vector = candidate_vectors[id(group)] left_dates = _voucher_group_month_days(group) for _other_status_key, other in candidates[index + 1:]: if id(other) in moved_ids: continue same_date = bool(left_dates & _voucher_group_month_days(other)) is_tax_offset_pair = id(group) in tax_offset_candidate_ids and id(other) in tax_offset_candidate_ids right_vector = candidate_vectors[id(other)] if not _offset_vectors_cancel_each_other(left_vector, right_vector): continue is_near_reversal_pair = _voucher_groups_within_days(group, other, 62) if not (same_date or is_tax_offset_pair or is_near_reversal_pair): continue moved_ids.add(id(group)) moved_ids.add(id(other)) break if not moved_ids: return voucher_sections retained_by_status: dict[str, list[dict[str, Any]]] = {status_key: [] for status_key in candidate_statuses} moved_excepted: list[dict[str, Any]] = [] for status_key, group in candidate_groups: if id(group) not in moved_ids: retained_by_status[status_key].append(group) continue group_copy = { "summary": dict(group.get("summary") or {}), "rows": [dict(row) for row in group.get("rows", []) or []], } group_copy["summary"]["status_label"] = "Excepted" review_reason = clean(group_copy["summary"].get("review_reason")) reason = "WEHAGO_EXCEPTED_OFFSET_REVERSAL_PAIR" group_copy["summary"]["review_reason"] = " / ".join( item for item in [review_reason, reason] if item ) for row in group_copy["rows"]: row["status_label"] = "Excepted" row["review_reason"] = clean(row.get("review_reason")) or reason moved_excepted.append(group_copy) for status_key in candidate_statuses: voucher_sections[status_key] = retained_by_status[status_key] voucher_sections["voucher_excepted"] = list(voucher_sections.get("voucher_excepted") or []) + moved_excepted 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 _build_voucher_status_detail_response_from_sections( voucher_sections: 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]: source_rows = list(voucher_sections.get(status, []) or []) 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] shown_rows: list[dict[str, Any]] = [] for group in shown_groups: sanitized_rows = _sanitize_voucher_group_rows(list(group.get("rows", []) or [])) group["rows"] = sanitized_rows for row in sanitized_rows: shown_rows.append(dict(row)) return { "columns": DETAIL_COLUMN_MAP[status], "rows": shown_rows, "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 _build_voucher_sections_from_db( conn: Any, start_year: int, end_year: int, selected_years: Iterable[int] | None = None, ) -> dict[str, list[dict[str, Any]]]: if selected_years is not None: rows_by_status, _snapshot_state = _collect_cached_status_rows_by_range( conn, start_year, end_year, selected_years=selected_years, ) selected_year_list = sorted({int(year) for year in selected_years if int(year or 0) > 0}) if len(selected_year_list) > 1: 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 } else: rows_by_status = {} for status_key in ("matched", "ledger_only", "amount_mismatch", "voucher_only"): payload = _fetch_status_detail_rows_from_db( conn, start_year, end_year, status_key, "", "", "", "", "", "", "", "", "", False, 0, 1_000_000, ) rows_by_status[status_key] = list(payload.get("rows", [])) 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 _build_filtered_voucher_sections_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, ) -> dict[str, list[dict[str, Any]]]: rows_by_status: dict[str, list[dict[str, Any]]] = {} for base_status in ("matched", "ledger_only", "amount_mismatch", "voucher_only"): payload = _fetch_status_detail_rows_from_db( conn, start_year, end_year, base_status, voucher_filter, draft_filter, wehago_account_filter, erp_account_filter, wehago_amount_filter, erp_amount_filter, wehago_vendor_filter, erp_vendor_filter, desc_filter, False, 0, 100000, ) rows_by_status[base_status] = list(payload.get("rows", []) or []) 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 _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 _has_status_detail_filters( 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, ) -> bool: return any( [ voucher_filter, draft_filter, wehago_account_filter, erp_account_filter, wehago_amount_filter, erp_amount_filter, wehago_vendor_filter, erp_vendor_filter, desc_filter, boundary_excluded_filter, ] ) 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, cursor: str = "", ) -> dict[str, Any]: if engine is not None and not _maybe_mark_wehago_compare_db_ready(engine): 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, "next_cursor": "", } 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", "voucherexcepted": "voucher_excepted", "wehagoexcepted": "voucher_excepted", "hanmacunconnected": "hanmac_unconnected", "hanmac_unconnected": "hanmac_unconnected", } valid_statuses = {"matched", "bridge_expense_review", "ledger_only", "amount_mismatch", "voucher_only", "voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched", "voucher_recheck", "voucher_excepted", "hanmac_unconnected"} if normalized_status not in valid_statuses: normalized_status = allowed.get(normalized_status, normalized_status) if normalized_status not in valid_statuses: raise ValueError("상태 값이 올바르지 않습니다.") voucher_filter = clean(voucher_no) draft_filter = clean(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) has_voucher_group_filters = bool( voucher_filter or draft_filter or wehago_account_filter or erp_account_filter or clean(wehago_amount) or clean(erp_amount) or wehago_vendor_filter or erp_vendor_filter or desc_filter ) response_cache_key = _status_detail_response_cache_key( start_year, end_year, status=normalized_status, voucher_no=voucher_filter, draft_no=draft_filter, wehago_account=wehago_account_filter, erp_account=erp_account_filter, wehago_amount=clean(wehago_amount), erp_amount=clean(erp_amount), wehago_vendor=wehago_vendor_filter, erp_vendor=erp_vendor_filter, desc_keyword=desc_filter, review_reason=clean(review_reason), boundary_excluded=boundary_excluded_filter, offset=safe_offset, limit=safe_limit, cursor=cursor, ) cached_response = _get_ttl_cached_payload( _STATUS_DETAIL_RESPONSE_CACHE, response_cache_key, _STATUS_DETAIL_RESPONSE_CACHE_TTL_SEC, ) if cached_response is not None: return cached_response if normalized_status in QUERY_VOUCHER_STATUS_KEYS and has_voucher_group_filters: with engine.begin() as conn: query_page = _load_query_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, cursor=cursor, ) if query_page is not None: shown_groups, total_count, next_cursor = query_page next_offset = safe_offset + len(shown_groups) shown_rows: list[dict[str, Any]] = [] for group in shown_groups: sanitized_rows = _sanitize_voucher_group_rows(list(group.get("rows", []) or [])) group["rows"] = sanitized_rows for row in sanitized_rows: shown_rows.append(dict(row)) payload = { "columns": DETAIL_COLUMN_MAP[normalized_status], "rows": shown_rows, "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, "next_cursor": next_cursor, "notice": "", "pending": False, "ready_years": [], "pending_years": [], "bank_payable_case_count": 0, "boundary_excluded_count": 0, } return _store_ttl_cached_payload( _STATUS_DETAIL_RESPONSE_CACHE, response_cache_key, payload, _STATUS_DETAIL_RESPONSE_CACHE_TTL_SEC, ) def finalize_status_detail_payload(payload: dict[str, Any]) -> dict[str, Any]: if ( engine is not None and normalized_status in QUERY_PAGE_CACHEABLE_STATUS_KEYS and isinstance(payload, dict) and not payload.get("pending") ): try: _run_compare_write_transaction( engine, lambda conn: _store_query_page_projection_cache( conn, start_year, end_year, normalized_status, response_cache_key, payload, ), attempts=3, base_delay=0.05, ) except Exception: pass return _store_ttl_cached_payload( _STATUS_DETAIL_RESPONSE_CACHE, response_cache_key, payload, 5.0 if payload.get("pending") else _STATUS_DETAIL_RESPONSE_CACHE_TTL_SEC, ) if normalized_status in QUERY_VOUCHER_STATUS_KEYS and not has_voucher_group_filters: direct_query_page = _fast_sqlite_query_group_page( engine, start_year, end_year, normalized_status, safe_offset, safe_limit, cursor=cursor, known_total_count=None, ) if direct_query_page is not None: shown_groups, total_count, next_cursor = direct_query_page next_offset = safe_offset + len(shown_groups) shown_rows: list[dict[str, Any]] = [] for group in shown_groups: sanitized_rows = _sanitize_voucher_group_rows(list(group.get("rows", []) or [])) group["rows"] = sanitized_rows for row in sanitized_rows: shown_rows.append(dict(row)) return finalize_status_detail_payload({ "columns": DETAIL_COLUMN_MAP[normalized_status], "rows": shown_rows, "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, "next_cursor": next_cursor, "notice": "", "pending": False, "ready_years": [], "pending_years": [], "bank_payable_case_count": 0, "boundary_excluded_count": 0, }) if normalized_status in QUERY_VOUCHER_STATUS_KEYS: persisted_page_payload: dict[str, Any] | None = None if not has_voucher_group_filters: with engine.begin() as conn: persisted_page_payload = _load_query_page_projection_cache( conn, start_year, end_year, normalized_status, response_cache_key, ) if persisted_page_payload is None: persisted_page_payload = _load_latest_query_page_projection_cache_any_signature( conn, start_year, end_year, normalized_status, response_cache_key, ) if persisted_page_payload is not None: persisted_page_payload.setdefault("notice", "") persisted_page_payload.setdefault("pending", False) persisted_page_payload.setdefault("ready_years", []) persisted_page_payload.setdefault("pending_years", []) persisted_page_payload.setdefault("next_cursor", "") return finalize_status_detail_payload(persisted_page_payload) if not has_voucher_group_filters: quick_sections = _get_cached_voucher_sections_by_range(engine, start_year, end_year) quick_payload = _build_voucher_status_detail_response_from_sections( quick_sections, 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, ) if int(quick_payload.get("total_count", 0) or 0) > 0: quick_payload["notice"] = clean(quick_payload.get("notice")) or "준비된 전표 스냅샷 결과를 먼저 표시합니다." quick_payload["pending"] = False quick_payload["ready_years"] = [] quick_payload["pending_years"] = [] quick_payload.setdefault("next_cursor", "") return finalize_status_detail_payload(quick_payload) if normalized_status == "bridge_expense_review": with engine.begin() as conn: payload = _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, ) payload.setdefault("next_cursor", "") return finalize_status_detail_payload(payload) with engine.begin() as conn: snapshot_state = _get_compare_snapshot_state(conn, start_year, end_year) pending_years = snapshot_state["missing"] + snapshot_state["stale"] ready_years: list[int] = [] 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: ready_years.append(year) if pending_years: try: enqueue_query_projection_rebuild(engine, start_year, end_year) except Exception: pass if not ready_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})" cached_metric_counts: dict[str, int] = {} with engine.begin() as conn: metric_projection = _load_query_metric_projection(conn, start_year, end_year) if metric_projection and isinstance(metric_projection.get("counts"), dict): cached_metric_counts = { str(key): int(value or 0) for key, value in dict(metric_projection.get("counts") or {}).items() } else: metric_cache = _load_metric_counts_cache(conn, start_year, end_year) if isinstance(metric_cache, dict): cached_metric_counts = { str(key): int(value or 0) for key, value in metric_cache.items() } if normalized_status in QUERY_VOUCHER_STATUS_KEYS and has_voucher_group_filters: with engine.begin() as conn: query_page = _load_query_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, cursor=cursor, ) if query_page is not None: shown_groups, total_count, next_cursor = query_page next_offset = safe_offset + len(shown_groups) shown_rows: list[dict[str, Any]] = [] for group in shown_groups: sanitized_rows = _sanitize_voucher_group_rows(list(group.get("rows", []) or [])) group["rows"] = sanitized_rows for row in sanitized_rows: shown_rows.append(dict(row)) return finalize_status_detail_payload({ "columns": DETAIL_COLUMN_MAP[normalized_status], "rows": shown_rows, "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, "next_cursor": next_cursor, "notice": notice, "pending": bool(pending_years), "ready_years": ready_years, "pending_years": pending_years, "bank_payable_case_count": 0, "boundary_excluded_count": 0, }) if normalized_status == "voucher_recheck": query_page = None if not has_voucher_group_filters: query_page = _fast_sqlite_query_group_page( engine, start_year, end_year, normalized_status, safe_offset, safe_limit, cursor=cursor, known_total_count=cached_metric_counts.get(normalized_status), ) if query_page is None: with engine.begin() as conn: query_page = _load_query_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, cursor=cursor, known_total_count=( cached_metric_counts.get(normalized_status) if not has_voucher_group_filters else None ), ) if query_page is not None: shown_groups, total_count, next_cursor = query_page else: try: enqueue_query_projection_rebuild(engine, start_year, end_year) except Exception: pass shown_groups, total_count, next_cursor = _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, cursor=cursor, known_total_count=( cached_metric_counts.get(normalized_status) if not has_voucher_group_filters else None ), ) else: shown_groups, total_count, next_cursor = query_page needs_fallback = total_count == 0 or (total_count > 0 and not shown_groups) if needs_fallback and not pending_years: voucher_sections = _get_cached_voucher_sections_by_range(engine, start_year, end_year) fallback_payload = _build_voucher_status_detail_response_from_sections( voucher_sections, 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, ) if int(fallback_payload.get("total_count", 0) or 0) <= 0: with engine.begin() as conn: fallback_sections = _build_voucher_sections_from_db( conn, start_year, end_year, ) fallback_payload = _build_voucher_status_detail_response_from_sections( fallback_sections, 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, ) if int(fallback_payload.get("total_count", 0) or 0) > 0: fallback_payload["notice"] = clean(fallback_payload.get("notice")) or "표시용 캐시를 다시 구성했습니다." fallback_payload["pending"] = False fallback_payload["ready_years"] = ready_years fallback_payload["pending_years"] = pending_years return finalize_status_detail_payload(fallback_payload) if needs_fallback and ready_years and normalized_status != "hanmac_unconnected": with engine.begin() as conn: fallback_sections = _build_voucher_sections_from_db( conn, start_year, end_year, selected_years=ready_years, ) fallback_payload = _build_voucher_status_detail_response_from_sections( fallback_sections, 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, ) if int(fallback_payload.get("total_count", 0) or 0) > 0: ready_text = ", ".join(str(year) for year in ready_years[:5]) pending_text = ", ".join(str(year) for year in pending_years[:5]) pending_suffix = "..." if len(pending_years) > 5 else "" fallback_payload["notice"] = f"준비된 연도({ready_text})를 먼저 표시합니다. 나머지 연도({pending_text}{pending_suffix})는 갱신 중입니다." fallback_payload["pending"] = True fallback_payload["ready_years"] = ready_years fallback_payload["pending_years"] = pending_years return finalize_status_detail_payload(fallback_payload) next_offset = safe_offset + len(shown_groups) shown_rows: list[dict[str, Any]] = [] for group in shown_groups: sanitized_rows = _sanitize_voucher_group_rows(list(group.get("rows", []) or [])) group["rows"] = sanitized_rows for row in sanitized_rows: shown_rows.append(dict(row)) return finalize_status_detail_payload({ "columns": DETAIL_COLUMN_MAP[normalized_status], "rows": shown_rows, "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, "next_cursor": next_cursor, "notice": notice, "pending": bool(pending_years), "ready_years": ready_years, "pending_years": pending_years, "bank_payable_case_count": 0, "boundary_excluded_count": 0, }) if normalized_status in {"voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched", "hanmac_unconnected"}: query_page = None if normalized_status == "hanmac_unconnected": with engine.begin() as conn: query_page = _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, cursor=cursor, ) elif not has_voucher_group_filters: query_page = _fast_sqlite_query_group_page( engine, start_year, end_year, normalized_status, safe_offset, safe_limit, cursor=cursor, known_total_count=cached_metric_counts.get(normalized_status), ) if query_page is None: with engine.begin() as conn: query_page = _load_query_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, cursor=cursor, known_total_count=( cached_metric_counts.get(normalized_status) if not has_voucher_group_filters else None ), ) if query_page is not None: shown_groups, total_count, next_cursor = query_page else: try: enqueue_query_projection_rebuild(engine, start_year, end_year) except Exception: pass shown_groups, total_count, next_cursor = _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, cursor=cursor, known_total_count=( cached_metric_counts.get(normalized_status) if not has_voucher_group_filters else None ), ) else: shown_groups, total_count, next_cursor = query_page needs_fallback = total_count == 0 or (total_count > 0 and not shown_groups) if needs_fallback and not pending_years and normalized_status != "hanmac_unconnected": voucher_sections = _get_cached_voucher_sections_by_range(engine, start_year, end_year) fallback_payload = _build_voucher_status_detail_response_from_sections( voucher_sections, 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, ) if int(fallback_payload.get("total_count", 0) or 0) <= 0: with engine.begin() as conn: fallback_sections = _build_voucher_sections_from_db( conn, start_year, end_year, ) fallback_payload = _build_voucher_status_detail_response_from_sections( fallback_sections, 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, ) if int(fallback_payload.get("total_count", 0) or 0) > 0: fallback_payload["notice"] = clean(fallback_payload.get("notice")) or "표시용 캐시를 다시 구성했습니다." fallback_payload["pending"] = False fallback_payload["ready_years"] = ready_years fallback_payload["pending_years"] = pending_years return finalize_status_detail_payload(fallback_payload) if needs_fallback and ready_years: with engine.begin() as conn: fallback_sections = _build_voucher_sections_from_db( conn, start_year, end_year, selected_years=ready_years, ) fallback_payload = _build_voucher_status_detail_response_from_sections( fallback_sections, 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, ) if int(fallback_payload.get("total_count", 0) or 0) > 0: ready_text = ", ".join(str(year) for year in ready_years[:5]) pending_text = ", ".join(str(year) for year in pending_years[:5]) pending_suffix = "..." if len(pending_years) > 5 else "" fallback_payload["notice"] = f"준비된 연도({ready_text})를 먼저 표시합니다. 나머지 연도({pending_text}{pending_suffix})는 갱신 중입니다." fallback_payload["pending"] = True fallback_payload["ready_years"] = ready_years fallback_payload["pending_years"] = pending_years return finalize_status_detail_payload(fallback_payload) next_offset = safe_offset + len(shown_groups) shown_rows: list[dict[str, Any]] = [] for group in shown_groups: sanitized_rows = _sanitize_voucher_group_rows(list(group.get("rows", []) or [])) group["rows"] = sanitized_rows for row in sanitized_rows: shown_rows.append(dict(row)) return finalize_status_detail_payload({ "columns": DETAIL_COLUMN_MAP[normalized_status], "rows": shown_rows, "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, "next_cursor": next_cursor, "notice": notice, "pending": bool(pending_years), "ready_years": ready_years, "pending_years": pending_years, "bank_payable_case_count": 0, "boundary_excluded_count": 0, }) if normalized_status in QUERY_STANDARD_ROW_STATUS_KEYS: with engine.begin() as conn: query_row_page = _load_query_row_page( conn, start_year, end_year, normalized_status, safe_offset, safe_limit, ) if query_row_page is not None: query_row_page["notice"] = notice or clean(query_row_page.get("notice")) query_row_page["pending"] = bool(pending_years) query_row_page["ready_years"] = ready_years query_row_page["pending_years"] = pending_years query_row_page.setdefault("next_cursor", "") return finalize_status_detail_payload(query_row_page) try: enqueue_query_projection_rebuild(engine, start_year, end_year) except Exception: pass with engine.begin() as conn: payload = _fetch_status_detail_rows_from_db( conn, start_year, end_year, normalized_status, voucher_filter, draft_filter, wehago_account_filter, erp_account_filter, clean(wehago_amount), clean(erp_amount), wehago_vendor_filter, erp_vendor_filter, desc_filter, boundary_excluded_filter, safe_offset, safe_limit, known_total_count=( cached_metric_counts.get(normalized_status) if not _has_status_detail_filters( 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, ) else None ), ) payload["notice"] = notice or clean(payload.get("notice")) payload["pending"] = bool(pending_years) payload["ready_years"] = ready_years payload["pending_years"] = pending_years payload.setdefault("next_cursor", "") return finalize_status_detail_payload(payload) 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")) payload["pending"] = bool(pending_years) payload["ready_years"] = ready_years payload["pending_years"] = pending_years payload.setdefault("next_cursor", "") return finalize_status_detail_payload(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", "voucherrecheck": "voucher_recheck", "voucherexcepted": "voucher_excepted", "wehagoexcepted": "voucher_excepted", "hanmacunconnected": "hanmac_unconnected", "voucher_matched": "voucher_matched", "erp_voucher_matched": "erp_voucher_matched", "voucher_unmatched": "voucher_unmatched", "erp_voucher_unmatched": "erp_voucher_unmatched", "voucher_recheck": "voucher_recheck", "voucher_excepted": "voucher_excepted", "hanmac_unconnected": "hanmac_unconnected", } normalized_status = status_map.get(normalized_status, normalized_status) if normalized_status not in VOUCHER_EXPORT_STATUS_KEYS: raise ValueError("엑셀 다운로드는 WEHAGO/HANMAC 전표 비교 표에서만 지원됩니다.") 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) voucher_filter = clean(voucher_no) draft_filter = clean(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) query_groups: list[dict[str, Any]] | None = None if start_year is not None and end_year is not None: with engine.begin() as conn: query_result = _load_query_group_page( conn, start_year, end_year, normalized_status, voucher_filter, draft_filter, wehago_account_filter, erp_account_filter, wehago_amount_filter, erp_amount_filter, wehago_vendor_filter, erp_vendor_filter, desc_filter, 0, 200000, ) if query_result is not None: query_groups = query_result[0] if query_groups is not None: return normalized_status, query_groups rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year) voucher_sections = _get_cached_voucher_sections_by_range(engine, start_year, end_year, rows_by_status) source_rows = voucher_sections.get(normalized_status, []) filtered_rows = [ group for group in source_rows if _filter_voucher_status_row( group, voucher_filter, draft_filter, wehago_account_filter, erp_account_filter, 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 _status_detail_response_cache_key( 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: bool, offset: int, limit: int, cursor: str, ) -> str: normalized = { "logic_version": QUERY_PROJECTION_VERSION, "start_year": int(start_year or 0), "end_year": int(end_year or 0), "status": clean(status).lower(), "voucher_no": clean(voucher_no), "draft_no": clean(draft_no), "wehago_account": clean(wehago_account), "erp_account": clean(erp_account), "wehago_amount": clean(wehago_amount), "erp_amount": clean(erp_amount), "wehago_vendor": clean(wehago_vendor), "erp_vendor": clean(erp_vendor), "desc_keyword": clean(desc_keyword), "review_reason": clean(review_reason), "boundary_excluded": bool(boundary_excluded), "offset": int(offset or 0), "limit": int(limit or 0), "cursor": clean(cursor), } digest = hashlib.sha1(json.dumps(normalized, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest() return f"status-detail:{digest}" 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 cleanup_compare_runtime_artifacts( engine: Any, *, export_retention_sec: int = 24 * 60 * 60, cache_retention_sec: int = 7 * 24 * 60 * 60, ) -> None: init_wehago_compare_db(engine) export_threshold = datetime.now() - timedelta(seconds=max(int(export_retention_sec), 60)) cache_threshold = datetime.now() - timedelta(seconds=max(int(cache_retention_sec), 60)) keep_paths: set[str] = set() with engine.begin() as conn: ready_paths = conn.execute( text( """ SELECT file_path FROM wehago_compare_export_jobs WHERE state = 'ready' AND COALESCE(file_path, '') <> '' AND updated_at >= :export_threshold """ ), {"export_threshold": export_threshold.strftime("%Y-%m-%d %H:%M:%S")}, ).scalars().all() keep_paths = {clean(path) for path in ready_paths if clean(path)} conn.execute( text( """ DELETE FROM wehago_compare_export_jobs WHERE state IN ('ready', 'failed') AND updated_at < :export_threshold """ ), {"export_threshold": export_threshold.strftime("%Y-%m-%d %H:%M:%S")}, ) conn.execute( text( """ DELETE FROM wehago_background_jobs WHERE state IN ('done', 'failed') AND updated_at < :cache_threshold """ ), {"cache_threshold": cache_threshold.strftime("%Y-%m-%d %H:%M:%S")}, ) for table_name in ( "wehago_metric_count_cache", "wehago_summary_range_cache", "wehago_compare_query_metrics", "wehago_compare_query_groups", "wehago_compare_query_rows", "wehago_compare_query_page_cache", ): conn.execute( text( f""" DELETE FROM {table_name} WHERE updated_at < :cache_threshold """ ), {"cache_threshold": cache_threshold.strftime("%Y-%m-%d %H:%M:%S")}, ) _cleanup_old_compare_export_files(keep_paths=keep_paths, older_than_sec=export_retention_sec) 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 def _operation(conn: Any) -> dict[str, Any]: _try_normalize_compare_background_jobs(conn, force=False) 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), }, ) return {'job_key': job_key, 'state': 'queued', 'download_url': '', 'row_count': 0, 'file_name': ''} try: result = _run_compare_write_transaction(engine, _operation, attempts=10, base_delay=0.2) except OperationalError: with engine.begin() as conn: existing_payload = _read_existing_job(conn) if existing_payload: return existing_payload raise _ensure_compare_export_worker(engine) _COMPARE_EXPORT_JOB_EVENT.set() return result 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=False) 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: 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: try: conn.execute( text( """ DELETE FROM wehago_compare_export_row_cache WHERE fiscal_year = :fiscal_year AND snapshot_signature = :snapshot_signature """ ), {"fiscal_year": year, "snapshot_signature": signature}, ) conn.execute(insert_sql, payload_rows) conn.execute( text( """ DELETE FROM wehago_compare_export_row_cache WHERE fiscal_year = :fiscal_year AND snapshot_signature <> :snapshot_signature AND snapshot_signature NOT IN ( SELECT snapshot_signature FROM wehago_compare_export_row_cache WHERE fiscal_year = :fiscal_year GROUP BY snapshot_signature ORDER BY COUNT(*) DESC LIMIT 2 ) """ ), {"fiscal_year": year, "snapshot_signature": signature}, ) except OperationalError as exc: logger.warning("Skipped export row cache write for year %s due to lock: %s", year, exc) def _ensure_year_export_row_cache(conn: Any, year: int) -> str: signature = _build_db_state_signature(conn, year, year) status_row = _load_snapshot_status_map(conn, [year]).get(int(year)) or {} stored_signature = clean(status_row.get("snapshot_signature")) if ( clean(status_row.get("state")) == "ready" and stored_signature and _snapshot_signature_equivalent(stored_signature, signature) ): 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": stored_signature}, ).first() if exists: return stored_signature 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 sections = _load_year_resolved_sections_cache(conn, year, signature) if sections is None: sections = _load_latest_year_resolved_sections_cache_any_signature( conn, year, current_signature=signature, allow_legacy_same_state=True, ) 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 _project_year_export_row_cache_from_latest_resolved(conn: Any, year: int, signature: str) -> bool: sections = _load_latest_year_resolved_sections_cache_any_signature( conn, year, current_signature=signature, allow_legacy_same_state=True, ) if sections is None: return False 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 True def _get_fast_year_export_row_cache_signature(conn: Any, year: int) -> str: signature = _build_db_state_signature(conn, year, year) status_row = _load_snapshot_status_map(conn, [year]).get(int(year)) stored_signature = clean((status_row or {}).get("snapshot_signature")) current_ready = bool( status_row and clean(status_row.get("state")) == "ready" and stored_signature and ( stored_signature == signature or _snapshot_signature_equivalent(stored_signature, signature) ) ) if current_ready and stored_signature and stored_signature != signature: 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": stored_signature}, ).first() if exists: return stored_signature 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 and current_ready: return signature fallback = conn.execute( text( """ SELECT snapshot_signature FROM wehago_compare_export_row_cache WHERE fiscal_year = :fiscal_year AND COALESCE(snapshot_signature, '') <> '' AND (:allow_current = 1 OR snapshot_signature <> :current_signature) GROUP BY snapshot_signature ORDER BY COUNT(*) DESC LIMIT 1 """ ), { "fiscal_year": year, "allow_current": 1 if current_ready else 0, "current_signature": signature, }, ).scalar() if fallback and _signature_uses_current_logic(fallback): return clean(fallback) # The yearly snapshot rebuild can take a long time on a local PC. For # read-only query/export paths, keep the UI usable with the newest stored # export row cache while the current-logic snapshot is rebuilt separately. return clean(fallback) 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 = clean(voucher_no) draft_filter = clean(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) if normalized_status == "hanmac_unconnected": groups = _load_hanmac_unconnected_source_groups(conn, int(start_year), int(end_year)) export_rows: list[dict[str, Any]] = [] for group_sort, group in enumerate(groups): summary = group.get("summary") or {} rows = list(group.get("rows") or []) if voucher_filter and voucher_filter not in normalize_text(summary.get("voucher_no")): continue if draft_filter and draft_filter not in normalize_text(summary.get("draft_no")): continue if erp_account_filter and erp_account_filter not in normalize_text(summary.get("voucher_accounts")): continue if erp_vendor_filter and erp_vendor_filter not in normalize_text(summary.get("voucher_vendors")): continue if erp_amount_filter: amount_text = " ".join( [ format_amount(summary.get("voucher_debit")), format_amount(summary.get("voucher_credit")), ] ) if erp_amount_filter not in amount_text: continue if desc_filter: haystack = " ".join( [ clean(summary.get("voucher_accounts")), clean(summary.get("voucher_vendors")), " ".join(clean(row.get("voucher_desc")) for row in rows), ] ) if desc_filter not in normalize_text(haystack): continue for row_sort, row in enumerate(rows): export_rows.append( { **dict(row), "group_voucher_no": clean(summary.get("voucher_no")), "group_draft_no": clean(summary.get("draft_no")), "group_ledger_accounts": "", "group_voucher_accounts": clean(summary.get("voucher_accounts")), "group_ledger_vendors": "", "group_voucher_vendors": clean(summary.get("voucher_vendors")), "group_ledger_debit": 0.0, "group_ledger_credit": 0.0, "group_voucher_debit": parse_amount(summary.get("voucher_debit")), "group_voucher_credit": parse_amount(summary.get("voucher_credit")), "group_sort": group_sort, "row_sort": row_sort, } ) return export_rows, len(export_rows) selected_years = list(range(int(start_year), int(end_year) + 1)) signatures = {year: _get_fast_year_export_row_cache_signature(conn, year) for year in selected_years} all_rows: list[dict[str, Any]] = [] attempted_export_cache = False for year in selected_years: signature = signatures.get(year) or '' if not signature: continue attempted_export_cache = True 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, group_sort, row_sort 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)) if all_rows or attempted_export_cache: return all_rows, len(all_rows) projection_scope = _find_best_query_projection_scope( conn, "wehago_compare_query_rows", start_year, end_year, normalized_status, ) if not projection_scope: return [], 0 projection_start_year, projection_end_year, signature = projection_scope query_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_index, row_index FROM wehago_compare_query_rows WHERE start_year = :projection_start_year AND end_year = :projection_end_year AND status_key = :status_key AND signature = :signature AND fiscal_year BETWEEN :selected_start_year AND :selected_end_year ORDER BY fiscal_year ASC, group_index ASC, row_index ASC """ ), { "projection_start_year": projection_start_year, "projection_end_year": projection_end_year, "selected_start_year": int(start_year), "selected_end_year": int(end_year), "status_key": normalized_status, "signature": signature, }, ).mappings().all() for raw_row in query_rows: row = dict(raw_row) row["group_voucher_no"] = clean(row.get("voucher_no")) row["group_draft_no"] = clean(row.get("draft_no")) row["group_ledger_accounts"] = clean(row.get("ledger_account_name")) row["group_voucher_accounts"] = clean(row.get("voucher_account_name")) row["group_ledger_vendors"] = clean(row.get("ledger_vendor")) row["group_voucher_vendors"] = clean(row.get("voucher_vendor")) row["group_ledger_debit"] = row.get("ledger_debit") row["group_ledger_credit"] = row.get("ledger_credit") row["group_voucher_debit"] = row.get("voucher_debit") row["group_voucher_credit"] = row.get("voucher_credit") row["group_sort"] = int(row.get("group_index") or 0) row["row_sort"] = int(row.get("row_index") or 0) 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_texts = [ format_amount(row.get("group_ledger_debit")), format_amount(row.get("group_ledger_credit")), format_amount(row.get("ledger_debit")), format_amount(row.get("ledger_credit")), ] if not any(wehago_amount_filter in amount_text for amount_text in amount_texts): continue if erp_amount_filter: amount_texts = [ format_amount(row.get("group_voucher_debit")), format_amount(row.get("group_voucher_credit")), format_amount(row.get("voucher_debit")), format_amount(row.get("voucher_credit")), ] if not any(erp_amount_filter in amount_text for amount_text in amount_texts): continue all_rows.append(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", "voucher_excepted": "Excepted", "hanmac_unconnected": "Hanmac unconnected", } 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, clean(row.get("group_sort")), group_voucher_no, group_draft_no) 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 _hanmac_voucher_base(draft_no: Any) -> str: return re.sub(r"-\d+$", "", clean(draft_no)) def _is_hanmac_cost_account(account_code: Any, account_name: Any) -> bool: family = _classify_account_family(account_code, account_name) if _is_vat_family(family) or family in {"bank", "payable", "receivable", "advance"}: return False return _classify_account_category(account_code, account_name) == "expense" def _hanmac_unconnected_cache_key( conn: Any, start_year: int, end_year: int, ) -> str: signatures = [ f"{year}:{_get_fast_year_export_row_cache_signature(conn, year)}" for year in range(int(start_year), int(end_year) + 1) ] return "|".join([QUERY_PROJECTION_VERSION, str(start_year), str(end_year), *signatures]) def _matched_hanmac_cost_voucher_bases( conn: Any, start_year: int, end_year: int, ) -> set[tuple[int, str]]: matched_bases: set[tuple[int, str]] = set() for year in range(int(start_year), int(end_year) + 1): signature = _get_fast_year_export_row_cache_signature(conn, year) if not signature: continue rows = conn.execute( text( """ SELECT fiscal_year, draft_no, voucher_account_name FROM wehago_compare_export_row_cache WHERE fiscal_year = :fiscal_year AND snapshot_signature = :signature AND status_key IN ('voucher_matched', 'erp_voucher_matched', 'voucher_recheck') AND COALESCE(draft_no, '') <> '' AND COALESCE(voucher_account_name, '') <> '' AND ( COALESCE(ledger_account_name, '') <> '' OR COALESCE(ledger_desc, '') <> '' ) """ ), {"fiscal_year": year, "signature": signature}, ).mappings().all() for row in rows: if not _is_hanmac_cost_account("", row.get("voucher_account_name")): continue voucher_base = _hanmac_voucher_base(row.get("draft_no")) if voucher_base: matched_bases.add((int(row.get("fiscal_year") or year), voucher_base)) return matched_bases def _load_hanmac_unconnected_source_groups( conn: Any, start_year: int, end_year: int, ) -> list[dict[str, Any]]: def append_distinct(target: list[str], value: Any) -> None: normalized = clean(value) if normalized and normalized not in target: target.append(normalized) cache_key = _hanmac_unconnected_cache_key(conn, start_year, end_year) now = time.time() cached = _HANMAC_UNCONNECTED_CACHE.get(cache_key) if cached and (now - float(cached.get("ts", 0))) <= _HANMAC_UNCONNECTED_CACHE_TTL_SEC: return cached["groups"] matched_cost_bases = _matched_hanmac_cost_voucher_bases(conn, start_year, end_year) raw_rows = conn.execute( text( """ SELECT fiscal_year, proof_date, confirmed_no, draft_no, account_code, account_name, debit_supply, credit_supply, vendor_name, desc1, desc2 FROM wehago_voucher_rows WHERE fiscal_year BETWEEN :start_year AND :end_year AND COALESCE(draft_no, '') <> '' ORDER BY fiscal_year ASC, draft_no ASC """ ), {"start_year": int(start_year), "end_year": int(end_year)}, ).mappings().all() grouped: dict[tuple[int, str], dict[str, Any]] = {} for source_row in raw_rows: row = dict(source_row) fiscal_year = int(row.get("fiscal_year") or 0) voucher_base = _hanmac_voucher_base(row.get("draft_no")) draft_year = _erp_draft_year(row.get("draft_no")) if fiscal_year <= 0 or (draft_year and draft_year != fiscal_year) or not voucher_base: continue key = (fiscal_year, voucher_base) current = grouped.setdefault( key, { "fiscal_year": fiscal_year, "status_label": "Hanmac unconnected", "ledger_date": "", "proof_date": "", "voucher_no": "", "draft_no": voucher_base, "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": "비용 계정 WEHAGO 미연결", "has_cost_account": False, "voucher_nos": [], "rows": [], }, ) proof_date = clean(row.get("proof_date")) confirmed_no = clean(row.get("confirmed_no")) account_code = clean(row.get("account_code")) account_name = clean(row.get("account_name")) voucher_vendor = clean(row.get("vendor_name")) voucher_desc = " ".join(item for item in [clean(row.get("desc1")), clean(row.get("desc2"))] if item) voucher_debit = parse_amount(row.get("debit_supply")) voucher_credit = parse_amount(row.get("credit_supply")) if proof_date and not current["proof_date"]: current["proof_date"] = proof_date if confirmed_no: append_distinct(current["voucher_nos"], confirmed_no) append_distinct(current["voucher_accounts"], account_name) append_distinct(current["voucher_vendors"], voucher_vendor) current["voucher_row_count"] += 1 current["voucher_debit"] += voucher_debit current["voucher_credit"] += voucher_credit if _is_hanmac_cost_account(account_code, account_name): current["has_cost_account"] = True current["rows"].append( { "fiscal_year": fiscal_year, "status_label": "Hanmac unconnected", "ledger_date": "", "proof_date": proof_date, "voucher_no": confirmed_no, "draft_no": clean(row.get("draft_no")), "ledger_account_name": "", "voucher_account_name": account_name, "ledger_vendor": "", "voucher_vendor": voucher_vendor, "ledger_debit": 0.0, "ledger_credit": 0.0, "voucher_debit": voucher_debit, "voucher_credit": voucher_credit, "ledger_desc": "", "voucher_desc": voucher_desc, } ) groups: list[dict[str, Any]] = [] for key, current in grouped.items(): if not current.pop("has_cost_account") or key in matched_cost_bases: continue current["voucher_no"] = ", ".join(current.pop("voucher_nos")) current["voucher_accounts"] = ", ".join(current["voucher_accounts"]) current["voucher_vendors"] = ", ".join(current["voucher_vendors"]) groups.append({"summary": current, "rows": list(current.pop("rows"))}) groups.sort( key=lambda group: ( int(group.get("summary", {}).get("fiscal_year") or 0), clean(group.get("summary", {}).get("proof_date")), clean(group.get("summary", {}).get("draft_no")), ) ) _HANMAC_UNCONNECTED_CACHE.clear() _HANMAC_UNCONNECTED_CACHE[cache_key] = {"ts": now, "groups": groups} return groups def _count_hanmac_unconnected_export_groups(conn: Any, fiscal_year: int, signature: str) -> int: return len(_load_hanmac_unconnected_source_groups(conn, fiscal_year, fiscal_year)) def _fetch_hanmac_unconnected_export_group_page( conn: Any, start_year: int | None, end_year: int | None, voucher_no: str, draft_no: str, erp_account: str, erp_amount: str, erp_vendor: str, desc_keyword: str, offset: int, limit: int, cursor: str = "", known_total_count: int | None = None, ) -> tuple[list[dict[str, Any]], int, str]: if start_year is None or end_year is None: return [], 0, "" voucher_filter = normalize_text(voucher_no) draft_filter = normalize_text(draft_no) erp_account_filter = normalize_text(erp_account) erp_vendor_filter = normalize_text(erp_vendor) erp_amount_filter = clean(erp_amount) desc_filter = normalize_text(desc_keyword) groups = _load_hanmac_unconnected_source_groups(conn, int(start_year), int(end_year)) filtered_groups: list[dict[str, Any]] = [] for group in groups: summary = group.get("summary") or {} rows = list(group.get("rows") or []) if voucher_filter and voucher_filter not in normalize_text(summary.get("voucher_no")): continue if draft_filter and draft_filter not in normalize_text(summary.get("draft_no")): continue if erp_account_filter and erp_account_filter not in normalize_text(summary.get("voucher_accounts")): continue if erp_vendor_filter and erp_vendor_filter not in normalize_text(summary.get("voucher_vendors")): continue if erp_amount_filter: amount_values = [ format_amount(summary.get("voucher_debit")), format_amount(summary.get("voucher_credit")), ] if not any(erp_amount_filter in amount_value for amount_value in amount_values): continue if desc_filter: haystack = " ".join( [ clean(summary.get("voucher_accounts")), clean(summary.get("voucher_vendors")), " ".join(clean(row.get("voucher_desc")) for row in rows), ] ) if desc_filter not in normalize_text(haystack): continue filtered_groups.append(group) total_count = len(filtered_groups) if total_count == 0: return [], 0, "" cursor_text = clean(cursor) cursor_offset = int(offset or 0) if cursor_text: try: cursor_offset = max(int(cursor_text), 0) except ValueError: cursor_offset = 0 shown_groups = filtered_groups[cursor_offset : cursor_offset + int(limit)] next_cursor = "" if cursor_offset + len(shown_groups) < total_count: next_cursor = str(cursor_offset + len(shown_groups)) return shown_groups, total_count, next_cursor 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, cursor: str = "", known_total_count: int | None = None, ) -> tuple[list[dict[str, Any]], int, str]: if start_year is None or end_year is None: return [], 0, "" if normalized_status == "hanmac_unconnected": return _fetch_hanmac_unconnected_export_group_page( conn, start_year, end_year, voucher_no, draft_no, erp_account, erp_amount, erp_vendor, desc_keyword, offset, limit, cursor=cursor, known_total_count=known_total_count, ) voucher_filter = clean(voucher_no) draft_filter = clean(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: _get_fast_year_export_row_cache_signature(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_where_sql = where_sql detail_where_sql = where_sql if not desc_filter: header_where_sql = """ status_key = :status_key AND fiscal_year = :fiscal_year AND snapshot_signature = :signature AND row_sort = 0 AND (:voucher_keyword = '' OR COALESCE(group_voucher_no, 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 ( :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 || '%' ) """ detail_where_sql = """ status_key = :status_key AND fiscal_year = :fiscal_year AND snapshot_signature = :signature """ 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, } if desc_filter: header_sql = f""" SELECT fiscal_year, group_sort, MIN(COALESCE(ledger_date, '')) AS ledger_date, MIN(COALESCE(group_voucher_no, '')) AS group_voucher_no, MIN(COALESCE(group_draft_no, '')) AS group_draft_no FROM wehago_compare_export_row_cache WHERE {header_where_sql} GROUP BY fiscal_year, group_sort ORDER BY fiscal_year ASC, group_sort ASC """ else: header_sql = f""" SELECT 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 {header_where_sql} ORDER BY fiscal_year ASC, group_sort ASC """ year_headers = conn.execute(text(header_sql), params).mappings().all() header_rows.extend(dict(row) for row in year_headers) total_count = max(int(known_total_count or 0), 0) if known_total_count is not None else len(header_rows) if total_count == 0: return [], 0, "" selected_headers = header_rows cursor_text = clean(cursor) if cursor_text: try: raw_year, raw_group = cursor_text.split(":", 1) cursor_year = int(raw_year or 0) cursor_group = int(raw_group or -1) except Exception: cursor_year = 0 cursor_group = -1 cursor_text = "" if cursor_text: selected_headers = [ header for header in selected_headers if ( int(header.get("fiscal_year") or 0) > cursor_year or ( int(header.get("fiscal_year") or 0) == cursor_year and int(header.get("group_sort") or 0) > cursor_group ) ) ] if cursor_text: selected_headers = selected_headers[:limit] else: selected_headers = selected_headers[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 {detail_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) next_cursor = "" if len(selected_headers) >= int(limit or 0): last_header = selected_headers[-1] next_cursor = f"{int(last_header.get('fiscal_year') or 0)}:{int(last_header.get('group_sort') or 0)}" return groups, total_count, next_cursor 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 _column_letter_from_index(index: int) -> str: dividend = max(int(index), 1) letters = "" while dividend: dividend, modulo = divmod(dividend - 1, 26) letters = chr(65 + modulo) + letters return letters def _build_styled_voucher_export_cell( worksheet: Any, value: Any, *, header: bool = False, amount: bool = False, group_start: bool = False, ) -> WriteOnlyCell: cell = WriteOnlyCell(worksheet, value=value) if header: cell.fill = VOUCHER_EXPORT_HEADER_FILL cell.font = VOUCHER_EXPORT_HEADER_FONT cell.alignment = VOUCHER_EXPORT_HEADER_ALIGNMENT cell.border = VOUCHER_EXPORT_HEADER_BORDER else: cell.fill = VOUCHER_EXPORT_BODY_FILL cell.font = VOUCHER_EXPORT_BODY_FONT cell.alignment = VOUCHER_EXPORT_AMOUNT_ALIGNMENT if amount else VOUCHER_EXPORT_TEXT_ALIGNMENT cell.border = VOUCHER_EXPORT_GROUP_START_BORDER if group_start else VOUCHER_EXPORT_BODY_BORDER if amount: cell.number_format = '#,##0.##' return cell def _append_styled_voucher_export_header(worksheet: Any) -> None: worksheet.append( [ _build_styled_voucher_export_cell(worksheet, label, header=True) for _, label in VOUCHER_EXPORT_LINE_COLUMNS ] ) def _append_styled_voucher_export_row( worksheet: Any, row: dict[str, Any], previous_group_key: tuple[Any, ...] | None, ) -> tuple[Any, ...]: group_key = ( row.get("fiscal_year"), row.get("group_sort"), clean(row.get("group_voucher_no")) or clean(row.get("voucher_no")), clean(row.get("group_draft_no")) or clean(row.get("draft_no")), ) group_start = previous_group_key != group_key amount_fields = {"ledger_debit", "ledger_credit", "voucher_debit", "voucher_credit"} worksheet.append( [ _build_styled_voucher_export_cell( worksheet, _coerce_voucher_export_cell(field, row.get(field)), amount=field in amount_fields, group_start=group_start, ) for field, _ in VOUCHER_EXPORT_LINE_COLUMNS ] ) return group_key def _apply_voucher_export_sheet_layout(worksheet: Any, row_count: int) -> None: worksheet.freeze_panes = "A2" worksheet.sheet_view.showGridLines = False worksheet.auto_filter.ref = f"A1:N{max(int(row_count or 0) + 1, 1)}" for index, (field, _label) in enumerate(VOUCHER_EXPORT_LINE_COLUMNS, start=1): worksheet.column_dimensions[_column_letter_from_index(index)].width = VOUCHER_EXPORT_COLUMN_WIDTHS.get(field, 14) 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]) _apply_voucher_export_sheet_layout(worksheet, row_count) _append_styled_voucher_export_header(worksheet) previous_group_key: tuple[Any, ...] | None = None for row in export_rows: previous_group_key = _append_styled_voucher_export_row(worksheet, row, previous_group_key) 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", "hanmacunconnected": "hanmac_unconnected", "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", "voucher_excepted": "voucher_excepted", "hanmac_unconnected": "hanmac_unconnected", } 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", "voucher_excepted", "hanmac_unconnected"}: 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", "voucher_excepted", "hanmac_unconnected"}: 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_query_projection_rebuild(engine, start_year, end_year) 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" ] default_start_year = min(years) if years else get_default_year(years) default_end_year = max(years) if years else get_default_year(years) if start_year is None and end_year is None: start_year = default_start_year end_year = default_end_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_start_year if end_year not in valid_years: end_year = default_end_year if start_year == default_start_year else (start_year or default_end_year) if start_year and end_year and start_year > end_year: start_year, end_year = end_year, start_year 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_counts = {status_key: 0 for status_key, _, _ in STATUS_META} if include_metric_counts: if warm_caches: with engine.begin() as conn: metric_counts = get_dashboard_metric_counts(conn, start_year, end_year) else: metric_counts, _pending = get_dashboard_metric_counts_nonblocking(engine, start_year, end_year) 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]: db_path = _sqlite_db_path_from_engine(engine) if db_path: try: sqlite_conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=1.0) sqlite_conn.row_factory = sqlite3.Row try: available_years = sorted( { int(row[0]) for row in sqlite_conn.execute( """ SELECT fiscal_year FROM wehago_snapshot_status UNION SELECT DISTINCT fiscal_year FROM wehago_compare_query_groups """ ).fetchall() if int(row[0] or 0) > 0 }, reverse=True, ) default_start_year = min(available_years) if available_years else date.today().year default_end_year = max(available_years) if available_years else date.today().year if start_year is None and end_year is None: start_year = default_start_year end_year = default_end_year elif start_year is None: start_year = end_year elif end_year is None: end_year = start_year valid_years = set(available_years) if valid_years: if start_year not in valid_years: start_year = default_start_year if end_year not in valid_years: end_year = default_end_year if start_year == default_start_year else (start_year or default_end_year) if start_year and end_year and start_year > end_year: start_year, end_year = end_year, start_year counts = _load_broader_query_projection_counts_from_groups(engine, start_year, end_year) or _empty_metric_counts() last_action_row = sqlite_conn.execute( """ SELECT id, action_type, payload_json, created_at FROM wehago_action_history ORDER BY id DESC LIMIT 1 """ ).fetchone() last_action = None if last_action_row: try: action_payload = json.loads(last_action_row["payload_json"] or "{}") except Exception: action_payload = {} last_action = { "id": int(last_action_row["id"] or 0), "action_type": clean(last_action_row["action_type"]), "count": int(action_payload.get("count") or 0), "created_at": clean(last_action_row["created_at"]), } snapshot_state = {"ready": [], "stale": [], "missing": [], "queued": [], "running": [], "failed": []} status_rows = { int(row["fiscal_year"]): clean(row["state"]) for row in sqlite_conn.execute( """ SELECT fiscal_year, state FROM wehago_snapshot_status WHERE fiscal_year BETWEEN ? AND ? """, (int(start_year or 0), int(end_year or 0)), ).fetchall() if int(row["fiscal_year"] or 0) > 0 } for year in range(int(start_year or 0), int(end_year or 0) + 1): state = status_rows.get(year, "missing") snapshot_state.setdefault(state, []) snapshot_state[state].append(year) pending = bool(snapshot_state.get("missing") or snapshot_state.get("stale") or snapshot_state.get("queued") or snapshot_state.get("running")) metric_sections = [ { "key": status_key, "label": label, "description": description, "count": int(counts.get(status_key, 0) or 0), "columns": DETAIL_COLUMN_MAP[status_key], "rows": [], } for status_key, label, description in STATUS_META ] snapshot_aggregate = { "ready_count": len(snapshot_state.get("ready", [])), "stale_count": len(snapshot_state.get("stale", [])), "missing_count": len(snapshot_state.get("missing", [])), "queued_count": len(snapshot_state.get("queued", [])), "running_count": len(snapshot_state.get("running", [])), "failed_count": len(snapshot_state.get("failed", [])), } return { "selected_start_year": start_year, "selected_end_year": end_year, "metric_sections": metric_sections, "last_action": last_action, "pending": pending, "snapshot_state": snapshot_state, "snapshot_policy": {"available_years": available_years}, "snapshot_aggregate": snapshot_aggregate, "snapshot_status_payload": None, } finally: sqlite_conn.close() except Exception: pass init_wehago_compare_db(engine) snapshot_state: dict[str, Any] = {"ready": [], "stale": [], "missing": []} snapshot_policy: dict[str, Any] = {} snapshot_aggregate: dict[str, Any] = {} 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" ] default_start_year = min(years) if years else get_default_year(years) default_end_year = max(years) if years else get_default_year(years) if start_year is None and end_year is None: start_year = default_start_year end_year = default_end_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_start_year if end_year not in valid_years: end_year = default_end_year if start_year == default_start_year else (start_year or default_end_year) if start_year and end_year and start_year > end_year: start_year, end_year = end_year, start_year last_action = get_last_action_summary(conn=conn) snapshot_state = _get_compare_snapshot_state(conn, start_year, end_year) snapshot_policy = _build_compare_snapshot_policy( start_year, end_year, _discover_available_fiscal_years(conn), ) snapshot_aggregate = _aggregate_snapshot_state_counts(snapshot_state) 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") ) metric_counts: dict[str, int] | None = None try: with engine.begin() as conn: fast_projection_counts = _resolve_fast_projection_counts_from_cached_rows( engine, conn, start_year, end_year, ) if fast_projection_counts: metric_counts = _merge_export_cache_voucher_counts(conn, fast_projection_counts, start_year, end_year) except Exception: metric_counts = None if metric_counts is None: metric_counts, pending = get_dashboard_metric_counts_nonblocking( engine, start_year, end_year, ) 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 ] payload = { "selected_start_year": start_year, "selected_end_year": end_year, "metric_sections": metric_sections, "last_action": last_action, "pending": pending, "snapshot_state": snapshot_state, "snapshot_policy": snapshot_policy, "snapshot_aggregate": snapshot_aggregate, "snapshot_status_payload": None, } return payload 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" ] default_start_year = min(years) if years else get_default_year(years) default_end_year = max(years) if 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_start_year resolved_end = default_end_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, ) def _maybe_mark_wehago_compare_db_ready(engine: Any) -> bool: global _WEHAGO_COMPARE_DB_READY if _WEHAGO_COMPARE_DB_READY: return True db_path = _sqlite_db_path_from_engine(engine) if not db_path: return False try: conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=1.0) try: row = conn.execute( """ SELECT 1 FROM sqlite_master WHERE type = 'table' AND name IN ( 'wehago_source_files', 'wehago_voucher_rows', 'wehago_ledger_rows', 'wehago_snapshot_status', 'wehago_compare_query_groups', 'wehago_compare_query_rows' ) GROUP BY 1 HAVING COUNT(*) >= 6 """ ).fetchone() if row: _WEHAGO_COMPARE_DB_READY = True return True finally: conn.close() except Exception: return False return False