from __future__ import annotations import copy import hashlib from io import BytesIO import json import logging import os 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, Mapping 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 logger = logging.getLogger(__name__) 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-v54-voucher-key-final-status-basis" STATUS_PROJECTION_VERSION = "status-projection-v2" HANMAC_UNCONNECTED_STATUS_PROJECTION_VERSION = f"{STATUS_PROJECTION_VERSION}-hanmac-unconnected-source" EXPORT_COMPACT_STATUS_PROJECTION_VERSION = f"{STATUS_PROJECTION_VERSION}-export-cache-erp-detail-guard" COMPACT_EXPORT_STATUS_KEYS = { "voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched", "voucher_recheck", "voucher_excepted", } def _allow_stale_export_detail_fallback() -> bool: return clean(os.getenv("WEHAGO_ALLOW_STALE_EXPORT_DETAIL_FALLBACK", "")).lower() in {"1", "true", "yes", "y", "on"} def _allow_sync_detail_rebuild_fallback() -> bool: return clean(os.getenv("WEHAGO_ALLOW_SYNC_DETAIL_REBUILD_FALLBACK", "")).lower() in {"1", "true", "yes", "y", "on"} def _trace_compare_projection_enabled() -> bool: return clean(os.getenv("WEHAGO_COMPARE_TRACE_PROJECTION", "")).lower() in {"1", "true", "yes", "y", "on"} def _trace_compare_projection(step: str, **payload: Any) -> None: if not _trace_compare_projection_enabled(): return try: print({"step": step, **payload}, flush=True) except Exception: return def _enable_raw_erp_trace_promotions() -> bool: return clean(os.getenv("WEHAGO_ENABLE_RAW_ERP_TRACE_PROMOTIONS", "")).lower() in {"1", "true", "yes", "y", "on"} 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", "가전표번호"), ("status_label", "행 상태"), ("matched_case", "매칭 유형"), ("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 적요"), ("review_reason", "검토 사유"), ] HANMAC_UNCONNECTED_LINE_COLUMNS = [ ("fiscal_year", "연도"), ("proof_date", "Hanmac 일자"), ("voucher_no", "확정전표번호"), ("draft_no", "가전표번호"), ("voucher_account_name", "Hanmac 계정"), ("voucher_vendor", "Hanmac 거래처"), ("voucher_debit", "Hanmac 차변"), ("voucher_credit", "Hanmac 대변"), ("voucher_desc", "Hanmac 적요"), ] 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": HANMAC_UNCONNECTED_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_REFRESH_ENQUEUEING: set[str] = set() _COMPARE_REFRESH_ENQUEUEING_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 ENABLE_YEAR_SNAPSHOT_EXPORT_ROW_CACHE = False RECHECK_RESOLUTION_POLICY_VERSION = "recheck-v44-canonical-voucher-split-context" METRIC_COUNT_CACHE_VERSION = "voucher-summary-v28-project-token-without-slash-guard" 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 get_hanmac_erp_candidate_year_range( start_year: int | None, end_year: int | None, ) -> tuple[int | None, int | None]: if start_year is None or end_year is None: return None, None low = min(int(start_year), int(end_year)) high = max(int(start_year), int(end_year)) return low - 1, high + 1 def _row_effective_erp_date(row: dict[str, Any]) -> date | None: parsed = _parse_iso_date(row.get("proof_date")) if parsed: return parsed number_date = _hanmac_voucher_date_from_numbers(row.get("draft_no"), row.get("voucher_no"), row.get("confirmed_no")) parsed = _parse_iso_date(number_date) if parsed: return parsed parsed = _parse_iso_date(row.get("voucher_date")) if parsed: return parsed parsed = _parse_iso_date(row.get("ledger_date")) if parsed: return parsed return None def _row_effective_wehago_date(row: dict[str, Any]) -> date | None: parsed = _parse_row_date_with_year(row, "ledger_date") if parsed: return parsed return _parse_row_date_with_year(row, "proof_date") def _is_adjacent_month_erp_candidate_for_range( row: dict[str, Any], start_year: int, end_year: int, ) -> bool: candidate_year = _row_erp_candidate_year(row) if not candidate_year: return True if int(start_year) <= candidate_year <= int(end_year): return True erp_date = _row_effective_erp_date(row) if candidate_year == int(start_year) - 1: return bool(erp_date and erp_date.year == candidate_year and erp_date.month == 12) if candidate_year == int(end_year) + 1: return bool(erp_date and erp_date.year == candidate_year and erp_date.month == 1) return False def _is_adjacent_month_erp_candidate_for_wehago_row( ledger_row: dict[str, Any], voucher_row: dict[str, Any], fiscal_year: int, ) -> bool: voucher_year = _row_erp_candidate_year(voucher_row) if not voucher_year or voucher_year == int(fiscal_year): return True if voucher_year not in {int(fiscal_year) - 1, int(fiscal_year) + 1}: return False if not _is_adjacent_month_erp_candidate_for_range(voucher_row, fiscal_year, fiscal_year): return False ledger_date = _row_effective_wehago_date(ledger_row) voucher_date = _row_effective_erp_date(voucher_row) if not ledger_date or not voucher_date: return True return abs((ledger_date - voucher_date).days) <= 45 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 if clean(os.getenv("WEHAGO_ENABLE_CACHED_ADJACENT_YEAR_COUNT_ADJUSTMENT", "")).lower() not in {"1", "true", "yes", "y", "on"}: 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() erp_existence = _active_erp_voucher_existence_signature(conn, start_year, end_year) 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]}", f"ev:{erp_existence}", ] ) 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 _is_derived_wehago_query_projection_signature(signature: Any) -> bool: normalized = clean(signature) if not normalized: return False lowered = normalized.lower() return ( "|snapshot-recheck-promote|" in lowered or "|db-reconciled-" in lowered or "snapshot-recheck-promote" in lowered or "db-reconciled-" in lowered ) def _active_query_projection_signature( conn: Any, start_year: int | None, end_year: int | None, ) -> str: if start_year is None or end_year is None: return "" try: row = conn.execute( text( """ SELECT setting_json FROM wehago_compare_settings WHERE setting_key = :setting_key LIMIT 1 """ ), {"setting_key": f"wehago_active_query_projection:{int(start_year)}:{int(end_year)}"}, ).first() except Exception: return "" if not row or not row[0]: return "" try: payload = json.loads(str(row[0] or "{}")) except Exception: return "" if not isinstance(payload, dict): return "" signature = clean(payload.get("signature")) if not signature: return "" if not signature.startswith(f"{QUERY_PROJECTION_VERSION}|"): return "" if _is_derived_wehago_query_projection_signature(signature): return "" try: exists = conn.execute( text( """ SELECT 1 FROM wehago_compare_query_groups WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature LIMIT 1 """ ), {"start_year": int(start_year), "end_year": int(end_year), "signature": signature}, ).first() except Exception: return "" return signature if exists else "" def _effective_query_projection_signature(conn: Any, start_year: int | None, end_year: int | None) -> str: return _active_query_projection_signature(conn, start_year, end_year) or _query_projection_signature(conn, start_year, end_year) def _status_projection_setting_key(status_key: str, start_year: int | None, end_year: int | None) -> str: return f"wehago_active_status_projection:{clean(status_key)}:{int(start_year or 0)}:{int(end_year or 0)}" ERP_VOUCHER_EXISTENCE_REASON = "ERP_VOUCHER_NOT_ACTIVE_IN_LATEST_SNAPSHOT" def _erp_voucher_existence_setting_key(fiscal_year: int) -> str: return f"wehago_active_erp_voucher_existence:{int(fiscal_year or 0)}" def _active_erp_voucher_existence_signature(conn: Any, start_year: int | None, end_year: int | None) -> str: if start_year is None or end_year is None: return "" erp_start_year, erp_end_year = get_hanmac_erp_candidate_year_range(start_year, end_year) rows = conn.execute( text( """ SELECT setting_key, setting_json, updated_at FROM wehago_compare_settings WHERE setting_key LIKE 'wehago_active_erp_voucher_existence:%' ORDER BY setting_key """ ) ).mappings().all() parts: list[str] = [] for row in rows: try: year = int(clean(row.get("setting_key")).rsplit(":", 1)[-1] or 0) except Exception: continue if year < int(erp_start_year or start_year) or year > int(erp_end_year or end_year): continue try: payload = json.loads(clean(row.get("setting_json")) or "{}") except Exception: payload = {} if not isinstance(payload, dict): payload = {} snapshot_id = int(payload.get("snapshot_id") or 0) if snapshot_id <= 0: continue count_row = conn.execute( text( """ SELECT COUNT(*) AS base_count FROM wehago_erp_voucher_existence WHERE snapshot_id = :snapshot_id AND fiscal_year = :fiscal_year AND is_active = 1 """ ), {"snapshot_id": snapshot_id, "fiscal_year": year}, ).mappings().first() parts.append( f"{year}:{snapshot_id}:{int((count_row or {}).get('base_count') or 0)}:{clean(row.get('updated_at'))}" ) return ",".join(parts) def _load_active_erp_voucher_base_sets( conn: Any, start_year: int | None, end_year: int | None, ) -> dict[int, set[str]]: if start_year is None or end_year is None: return {} erp_start_year, erp_end_year = get_hanmac_erp_candidate_year_range(start_year, end_year) result: dict[int, set[str]] = {} for year in range(int(erp_start_year or start_year), int(erp_end_year or end_year) + 1): row = conn.execute( text( """ SELECT setting_json FROM wehago_compare_settings WHERE setting_key = :setting_key LIMIT 1 """ ), {"setting_key": _erp_voucher_existence_setting_key(year)}, ).first() if not row or not row[0]: continue try: payload = json.loads(str(row[0] or "{}")) except Exception: payload = {} if not isinstance(payload, dict): continue snapshot_id = int(payload.get("snapshot_id") or 0) if snapshot_id <= 0: continue bases = { clean(base_row.get("draft_base")) for base_row in conn.execute( text( """ SELECT draft_base FROM wehago_erp_voucher_existence WHERE snapshot_id = :snapshot_id AND fiscal_year = :fiscal_year AND is_active = 1 AND COALESCE(draft_base, '') <> '' """ ), {"snapshot_id": snapshot_id, "fiscal_year": year}, ).mappings() if clean(base_row.get("draft_base")) } result[year] = bases return result def _erp_draft_bases_from_group(group: dict[str, Any]) -> set[str]: bases: set[str] = set() summary = group.get("summary") or {} for value in [summary.get("draft_no")]: for part in re.split(r"\s*,\s*", clean(value)): base = _erp_draft_base(part) if base: bases.add(base) for row in list(group.get("rows") or []): for part in re.split(r"\s*,\s*", clean(row.get("draft_no"))): base = _erp_draft_base(part) if base: bases.add(base) return bases def _inactive_erp_bases_for_group( group: dict[str, Any], active_base_sets: dict[int, set[str]], ) -> set[str]: inactive: set[str] = set() for base in _erp_draft_bases_from_group(group): year = _erp_draft_year(base) if year <= 0 or year not in active_base_sets: continue if base not in active_base_sets.get(year, set()): inactive.add(base) return inactive def _copy_group_as_wehago_unmatched_for_inactive_erp( group: dict[str, Any], inactive_bases: set[str], ) -> dict[str, Any] | None: rows: list[dict[str, Any]] = [] reason = f"{ERP_VOUCHER_EXISTENCE_REASON}:{','.join(sorted(inactive_bases))}" for row in list(group.get("rows") or []): 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 payload = _blank_voucher_side(dict(row)) payload["status_label"] = "Unmatched" existing_reason = clean(payload.get("review_reason")) payload["review_reason"] = " / ".join(part for part in [existing_reason, reason] if part) payload["matched_case"] = "" payload["match_identity_key"] = clean(payload.get("ledger_row_key")) or build_ledger_row_key(payload) rows.append(payload) if not rows: return None summary = dict(group.get("summary") or {}) fiscal_year = int(summary.get("fiscal_year") or rows[0].get("fiscal_year") or 0) voucher_no = clean(summary.get("voucher_no") or rows[0].get("voucher_no")) rebuilt = _build_group_summary_from_rows( fiscal_year=fiscal_year, voucher_no=voucher_no, draft_no="", rows=rows, status_label="Unmatched", review_reason=reason, ) return {"summary": rebuilt, "rows": rows} def _apply_erp_voucher_existence_gate( conn: Any, start_year: int | None, end_year: int | None, voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: active_base_sets = _load_active_erp_voucher_base_sets(conn, start_year, end_year) if not active_base_sets: return voucher_sections demoted_unmatched: list[dict[str, Any]] = [] for status_key in ("voucher_matched", "voucher_recheck"): retained: list[dict[str, Any]] = [] for group in list(voucher_sections.get(status_key) or []): inactive_bases = _inactive_erp_bases_for_group(group, active_base_sets) if not inactive_bases: retained.append(group) continue demoted = _copy_group_as_wehago_unmatched_for_inactive_erp(group, inactive_bases) if demoted is not None: demoted_unmatched.append(demoted) voucher_sections[status_key] = retained retained_unmatched: list[dict[str, Any]] = [] for group in list(voucher_sections.get("voucher_unmatched") or []): inactive_bases = _inactive_erp_bases_for_group(group, active_base_sets) if not inactive_bases: retained_unmatched.append(group) continue demoted = _copy_group_as_wehago_unmatched_for_inactive_erp(group, inactive_bases) retained_unmatched.append(demoted or group) voucher_sections["voucher_unmatched"] = retained_unmatched + demoted_unmatched for status_key in ("erp_voucher_matched", "erp_voucher_unmatched", "hanmac_unconnected"): voucher_sections[status_key] = [ group for group in list(voucher_sections.get(status_key) or []) if not _inactive_erp_bases_for_group(group, active_base_sets) ] return voucher_sections def activate_erp_voucher_existence_snapshot( conn: Any, fiscal_year: int, *, source_file_id: int | None = None, source_label: str = "", snapshot_mode: str = "full", ) -> dict[str, Any]: year = int(fiscal_year or 0) if year <= 0: raise ValueError("fiscal_year is required for ERP voucher existence snapshot.") where = ["v.fiscal_year = :fiscal_year", "COALESCE(v.draft_no, '') <> ''"] params: dict[str, Any] = {"fiscal_year": year} if source_file_id is not None: where.append("v.source_file_id = :source_file_id") params["source_file_id"] = int(source_file_id) source_row = None if source_file_id is not None: source_row = conn.execute( text( """ SELECT id, file_name, file_hash, source_origin FROM wehago_source_files WHERE id = :source_file_id LIMIT 1 """ ), {"source_file_id": int(source_file_id)}, ).mappings().first() rows = conn.execute( text( f""" SELECT v.source_file_id, v.draft_no, v.row_number FROM wehago_voucher_rows v WHERE {' AND '.join(where)} ORDER BY v.draft_no ASC, v.row_number ASC, v.id ASC """ ), params, ).mappings().all() base_payloads: dict[str, dict[str, Any]] = {} for row in rows: base = _erp_draft_base(row.get("draft_no")) if not base: continue current = base_payloads.setdefault( base, { "fiscal_year": year, "draft_base": base, "is_active": 1, "row_count": 0, "first_draft_no": clean(row.get("draft_no")), "last_draft_no": clean(row.get("draft_no")), "first_seen_row_number": int(row.get("row_number") or 0), "last_seen_row_number": int(row.get("row_number") or 0), "source_file_id": int(row.get("source_file_id") or source_file_id or 0) or None, }, ) current["row_count"] = int(current.get("row_count") or 0) + 1 current["last_draft_no"] = clean(row.get("draft_no")) current["last_seen_row_number"] = int(row.get("row_number") or 0) snapshot_result = conn.execute( text( """ INSERT INTO wehago_erp_voucher_existence_snapshots ( fiscal_year, source_file_id, source_origin, source_label, source_hash, snapshot_mode, row_count, draft_base_count, activated_at, created_at ) VALUES ( :fiscal_year, :source_file_id, :source_origin, :source_label, :source_hash, :snapshot_mode, :row_count, :draft_base_count, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) """ ), { "fiscal_year": year, "source_file_id": int(source_file_id or 0) or None, "source_origin": clean((source_row or {}).get("source_origin")), "source_label": clean(source_label) or clean((source_row or {}).get("file_name")) or f"erp-voucher-{year}", "source_hash": clean((source_row or {}).get("file_hash")), "snapshot_mode": clean(snapshot_mode) or "full", "row_count": len(rows), "draft_base_count": len(base_payloads), }, ) snapshot_id = int(snapshot_result.lastrowid) if base_payloads: conn.execute( text( """ INSERT INTO wehago_erp_voucher_existence ( snapshot_id, fiscal_year, draft_base, is_active, row_count, first_draft_no, last_draft_no, first_seen_row_number, last_seen_row_number, source_file_id, created_at ) VALUES ( :snapshot_id, :fiscal_year, :draft_base, :is_active, :row_count, :first_draft_no, :last_draft_no, :first_seen_row_number, :last_seen_row_number, :source_file_id, CURRENT_TIMESTAMP ) """ ), [{**payload, "snapshot_id": snapshot_id} for payload in base_payloads.values()], ) setting_payload = { "snapshot_id": snapshot_id, "fiscal_year": year, "source_file_id": int(source_file_id or 0) or None, "source_label": clean(source_label) or clean((source_row or {}).get("file_name")), "snapshot_mode": clean(snapshot_mode) or "full", "row_count": len(rows), "draft_base_count": len(base_payloads), } conn.execute( text( """ INSERT INTO wehago_compare_settings (setting_key, setting_json, updated_at) VALUES (:setting_key, :setting_json, CURRENT_TIMESTAMP) ON CONFLICT(setting_key) DO UPDATE SET setting_json = excluded.setting_json, updated_at = CURRENT_TIMESTAMP """ ), { "setting_key": _erp_voucher_existence_setting_key(year), "setting_json": json.dumps(setting_payload, ensure_ascii=False), }, ) return setting_payload def _hanmac_unconnected_status_projection_signature(conn: Any, start_year: int | None, end_year: int | None) -> str: if start_year is None or end_year is None: return "" return "|".join( [ HANMAC_UNCONNECTED_STATUS_PROJECTION_VERSION, "hanmac_unconnected", str(int(start_year)), str(int(end_year)), _hanmac_unconnected_cache_key(conn, int(start_year), int(end_year)), ] ) def _export_cache_status_projection_signature( conn: Any, status_key: str, start_year: int | None, end_year: int | None, ) -> str: if start_year is None or end_year is None: return "" normalized_status = clean(status_key) if normalized_status not in COMPACT_EXPORT_STATUS_KEYS: return "" 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( [ EXPORT_COMPACT_STATUS_PROJECTION_VERSION, QUERY_PROJECTION_VERSION, normalized_status, str(int(start_year)), str(int(end_year)), *signatures, ] ) def _status_projection_signature( conn: Any, status_key: str, start_year: int | None, end_year: int | None, ) -> str: normalized_status = clean(status_key) if normalized_status == "hanmac_unconnected": return _hanmac_unconnected_status_projection_signature(conn, start_year, end_year) if normalized_status in COMPACT_EXPORT_STATUS_KEYS: return _export_cache_status_projection_signature(conn, normalized_status, start_year, end_year) return "" def _expected_status_projection_signatures_for_range( conn: Any, start_year: int | None, end_year: int | None, ) -> dict[str, str]: if start_year is None or end_year is None: return {} try: start = int(start_year) end = int(end_year) except (TypeError, ValueError): return {} if start > end: start, end = end, start year_signatures = [ f"{year}:{_get_fast_year_export_row_cache_signature(conn, year)}" for year in range(start, end + 1) ] hanmac_cache_key = "|".join( [ QUERY_PROJECTION_VERSION, str(start), str(end), *year_signatures, ] ) expected = { "hanmac_unconnected": "|".join( [ HANMAC_UNCONNECTED_STATUS_PROJECTION_VERSION, "hanmac_unconnected", str(start), str(end), hanmac_cache_key, ] ) } for status_key in COMPACT_EXPORT_STATUS_KEYS: expected[status_key] = "|".join( [ EXPORT_COMPACT_STATUS_PROJECTION_VERSION, QUERY_PROJECTION_VERSION, status_key, str(start), str(end), *year_signatures, ] ) return expected def _active_status_projection_signatures_for_range( conn: Any, start_year: int | None, end_year: int | None, status_keys: Iterable[str] | None = None, ) -> dict[str, str]: if start_year is None or end_year is None: return {} try: start = int(start_year) end = int(end_year) except (TypeError, ValueError): return {} expected = _expected_status_projection_signatures_for_range(conn, start, end) requested = [clean(status_key) for status_key in (status_keys or expected.keys())] requested = [status_key for status_key in requested if status_key in expected] if not requested: return {} setting_keys = { _status_projection_setting_key(status_key, start, end): status_key for status_key in requested } placeholders = ", ".join(f":key_{index}" for index, _key in enumerate(setting_keys)) params = {f"key_{index}": key for index, key in enumerate(setting_keys)} try: rows = conn.execute( text( f""" SELECT setting_key, setting_json FROM wehago_compare_settings WHERE setting_key IN ({placeholders}) """ ), params, ).mappings().all() except Exception: return {} candidates: dict[str, str] = {} for row in rows: status_key = setting_keys.get(clean(row.get("setting_key"))) if not status_key: continue try: payload = json.loads(clean(row.get("setting_json")) or "{}") except Exception: continue if not isinstance(payload, dict): continue signature = clean(payload.get("signature")) if signature and signature == expected.get(status_key): candidates[status_key] = signature if not candidates: return {} status_placeholders = ", ".join(f":status_{index}" for index, _status in enumerate(candidates)) exists_params = { "start_year": start, "end_year": end, **{f"status_{index}": status for index, status in enumerate(candidates)}, } for index, (status_key, signature) in enumerate(candidates.items()): exists_params[f"signature_{index}"] = signature signature_conditions = " OR ".join( f"(status_key = :status_{index} AND signature = :signature_{index})" for index, _item in enumerate(candidates.items()) ) try: existing_rows = conn.execute( text( f""" SELECT status_key, signature FROM wehago_status_projection_groups WHERE start_year = :start_year AND end_year = :end_year AND status_key IN ({status_placeholders}) AND ({signature_conditions}) GROUP BY status_key, signature """ ), exists_params, ).mappings().all() except Exception: return {} existing = {(clean(row.get("status_key")), clean(row.get("signature"))) for row in existing_rows} return { status_key: signature for status_key, signature in candidates.items() if (status_key, signature) in existing } def _active_status_projection_signature( conn: Any, status_key: str, start_year: int | None, end_year: int | None, ) -> str: if start_year is None or end_year is None: return "" normalized_status = clean(status_key) expected_signature = _status_projection_signature(conn, normalized_status, start_year, end_year) if not expected_signature: return "" try: row = conn.execute( text( """ SELECT setting_json FROM wehago_compare_settings WHERE setting_key = :setting_key LIMIT 1 """ ), {"setting_key": _status_projection_setting_key(normalized_status, start_year, end_year)}, ).first() except Exception: return "" if not row or not row[0]: return "" try: payload = json.loads(str(row[0] or "{}")) except Exception: return "" if not isinstance(payload, dict): return "" signature = clean(payload.get("signature")) if signature != expected_signature: return "" table_name = ( "wehago_status_projection_groups" if normalized_status == "hanmac_unconnected" or normalized_status in COMPACT_EXPORT_STATUS_KEYS else "wehago_compare_query_groups" ) exists = conn.execute( text( f""" SELECT 1 FROM {table_name} WHERE start_year = :start_year AND end_year = :end_year AND status_key = :status_key AND signature = :signature LIMIT 1 """ ), { "start_year": int(start_year), "end_year": int(end_year), "status_key": normalized_status, "signature": signature, }, ).first() return signature if exists else "" def _active_status_projection_export_token( conn: Any, status_key: str, start_year: int | None, end_year: int | None, ) -> str: signature = _active_status_projection_signature(conn, status_key, start_year, end_year) if not signature or start_year is None or end_year is None: return "" setting_updated_at = "" setting_total_count = 0 try: row = conn.execute( text( """ SELECT setting_json, updated_at FROM wehago_compare_settings WHERE setting_key = :setting_key LIMIT 1 """ ), {"setting_key": _status_projection_setting_key(status_key, start_year, end_year)}, ).mappings().first() setting_updated_at = clean((row or {}).get("updated_at")) try: payload = json.loads(clean((row or {}).get("setting_json")) or "{}") except Exception: payload = {} if isinstance(payload, dict): setting_total_count = int(payload.get("total_count") or 0) except Exception: setting_updated_at = "" setting_total_count = 0 try: group_row = conn.execute( text( """ SELECT COUNT(*) AS group_count, COALESCE(MAX(updated_at), '') AS max_updated_at FROM wehago_status_projection_groups WHERE start_year = :start_year AND end_year = :end_year AND status_key = :status_key AND signature = :signature """ ), { "start_year": int(start_year), "end_year": int(end_year), "status_key": clean(status_key), "signature": signature, }, ).mappings().first() group_count = int((group_row or {}).get("group_count") or 0) group_updated_at = clean((group_row or {}).get("max_updated_at")) except Exception: group_count = 0 group_updated_at = "" return "|".join( [ signature, f"setting_count:{setting_total_count}", f"group_count:{group_count}", f"setting_updated:{setting_updated_at}", f"group_updated:{group_updated_at}", ] ) def _store_active_status_projection_signature( conn: Any, status_key: str, start_year: int | None, end_year: int | None, signature: str, total_count: int, ) -> None: if start_year is None or end_year is None or not clean(signature): return normalized_status = clean(status_key) payload = { "signature": clean(signature), "status_key": normalized_status, "start_year": int(start_year), "end_year": int(end_year), "total_count": int(total_count or 0), "projection_version": STATUS_PROJECTION_VERSION, } conn.execute( text( """ INSERT INTO wehago_compare_settings (setting_key, setting_json, updated_at) VALUES (:setting_key, :setting_json, CURRENT_TIMESTAMP) ON CONFLICT(setting_key) DO UPDATE SET setting_json = excluded.setting_json, updated_at = CURRENT_TIMESTAMP """ ), { "setting_key": _status_projection_setting_key(normalized_status, start_year, end_year), "setting_json": json.dumps(payload, ensure_ascii=False), }, ) def _status_projection_run_setting_key(start_year: int | None, end_year: int | None) -> str: return f"wehago_active_status_projection_run:{int(start_year or 0)}:{int(end_year or 0)}" def _load_cached_active_status_projection_run_summary( 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 try: row = conn.execute( text( """ SELECT setting_json FROM wehago_compare_settings WHERE setting_key = :setting_key LIMIT 1 """ ), {"setting_key": _status_projection_run_setting_key(start_year, end_year)}, ).first() except Exception: return None if not row or not row[0]: return None try: payload = json.loads(str(row[0] or "{}")) except Exception: return None if not isinstance(payload, dict): return None if clean(payload.get("source")) != "active_status_projection_run": return None if int(payload.get("start_year") or 0) != int(start_year or 0): return None if int(payload.get("end_year") or 0) != int(end_year or 0): return None if clean(payload.get("logic_version")) != QUERY_PROJECTION_VERSION: return None counts = payload.get("counts") if not isinstance(counts, dict): return None if int(payload.get("classified_total") or 0) != int(payload.get("raw_total") or 0): return None if not _active_status_projection_run_matches_current_sources(conn, payload): return None return payload def _active_status_projection_run_matches_current_sources( conn: Any, payload: dict[str, Any], ) -> bool: start_year = int(payload.get("start_year") or 0) end_year = int(payload.get("end_year") or 0) active_signatures = payload.get("active_signatures") if start_year <= 0 or end_year < start_year or not isinstance(active_signatures, dict): return False if set(active_signatures) != set(WEHAGO_FINAL_STATUS_ORDER): return False expected_signatures = _expected_status_projection_signatures_for_range(conn, start_year, end_year) if any( clean(active_signatures.get(status_key)) != clean(expected_signatures.get(status_key)) for status_key in WEHAGO_FINAL_STATUS_ORDER ): return False for year in range(start_year, end_year + 1): current_signature = _build_db_state_signature(conn, year, year) status_row = conn.execute( text( """ SELECT snapshot_signature, state FROM wehago_snapshot_status WHERE fiscal_year = :fiscal_year LIMIT 1 """ ), {"fiscal_year": year}, ).mappings().first() stored_signature = clean((status_row or {}).get("snapshot_signature")) if clean((status_row or {}).get("state")).lower() != "ready": return False if not stored_signature or not ( stored_signature == current_signature or _snapshot_signature_equivalent(stored_signature, current_signature) ): return False return True def _projection_voucher_compare_key_from_parts( fiscal_year: int, ledger_date: Any, proof_date: Any, voucher_no: Any, ) -> str: first_voucher_no = clean(clean(voucher_no).split(",", 1)[0]) normalized_date = _normalize_wehago_projection_date(ledger_date or proof_date, fiscal_year or None) if not normalized_date or not first_voucher_no: return "" return f"{normalized_date.replace('-', '')}-{first_voucher_no}" _WEHAGO_STRONG_ACCOUNTING_EXCEPTION_TOKENS = ( "결산분개", "결산대체전표", "결산대체분개", "대체전표", "대체분개", "상계전표", "상계분개", "계상전표", "계상분개", "계상분대체", "취소전표", ) _WEHAGO_STRONG_ACCOUNTING_EXCEPTION_VENDOR_TOKENS = ( "결산분개", "결산환원분개", "결산대체", "대체전표", "상계전표", "계상전표", ) def _is_strong_wehago_accounting_exception_text(vendor_text: Any, description_text: Any) -> bool: compact_vendor_text = clean(vendor_text).replace(" ", "") compact_description_text = re.sub( r"상계\s*\d+(?:-\d+)?\s*소구(?:역)?", "", clean(description_text), ).replace(" ", "") return any( token in compact_description_text for token in _WEHAGO_STRONG_ACCOUNTING_EXCEPTION_TOKENS ) or any( token in compact_vendor_text for token in _WEHAGO_STRONG_ACCOUNTING_EXCEPTION_VENDOR_TOKENS ) def _load_wehago_strong_accounting_exception_compare_keys( conn: Any, start_year: int | None, end_year: int | None, ) -> set[str]: if start_year is None or end_year is None: return set() rows = conn.execute( text( """ SELECT compare_voucher_no, vendor_name, description FROM wehago_ledger_rows WHERE fiscal_year BETWEEN :start_year AND :end_year AND COALESCE(compare_voucher_no, '') <> '' AND ( COALESCE(vendor_name, '') LIKE '%결산%' OR COALESCE(vendor_name, '') LIKE '%대체전표%' OR COALESCE(vendor_name, '') LIKE '%상계전표%' OR COALESCE(vendor_name, '') LIKE '%계상전표%' OR COALESCE(description, '') LIKE '%결산%' OR COALESCE(description, '') LIKE '%대체%' OR COALESCE(description, '') LIKE '%상계%' OR COALESCE(description, '') LIKE '%계상%' OR COALESCE(description, '') LIKE '%취소전표%' ) """ ), {"start_year": int(start_year), "end_year": int(end_year)}, ).mappings() keys: set[str] = set() for row in rows: compare_no = clean(row.get("compare_voucher_no")) if compare_no and _is_strong_wehago_accounting_exception_text(row.get("vendor_name"), row.get("description")): keys.add(compare_no) return keys def _load_active_status_projection_run_summary( conn: Any, start_year: int | None, end_year: int | None, *, allow_rebuild: bool = True, ) -> dict[str, Any] | None: if start_year is None or end_year is None: return None active_signatures = _active_status_projection_signatures_for_range( conn, start_year, end_year, WEHAGO_FINAL_STATUS_ORDER, ) if set(active_signatures) != set(WEHAGO_FINAL_STATUS_ORDER): return None stats: dict[str, dict[str, Any]] = {} run_parts = [ "active-status-projection-run-v1", QUERY_PROJECTION_VERSION, str(int(start_year)), str(int(end_year)), ] for status_key in WEHAGO_FINAL_STATUS_ORDER: signature = active_signatures[status_key] row = conn.execute( text( """ SELECT COUNT(*) AS group_count, COALESCE(MAX(updated_at), '') AS max_updated_at FROM wehago_status_projection_groups WHERE start_year = :start_year AND end_year = :end_year AND status_key = :status_key AND signature = :signature """ ), { "start_year": int(start_year), "end_year": int(end_year), "status_key": status_key, "signature": signature, }, ).mappings().first() group_count = int((row or {}).get("group_count") or 0) max_updated_at = clean((row or {}).get("max_updated_at")) stats[status_key] = { "signature": signature, "group_count": group_count, "max_updated_at": max_updated_at, } run_parts.extend([status_key, signature, str(group_count), max_updated_at]) run_id = hashlib.sha256("|".join(run_parts).encode("utf-8")).hexdigest() setting_key = _status_projection_run_setting_key(start_year, end_year) try: cached_row = conn.execute( text( """ SELECT setting_json FROM wehago_compare_settings WHERE setting_key = :setting_key LIMIT 1 """ ), {"setting_key": setting_key}, ).first() if cached_row and cached_row[0]: cached_payload = json.loads(str(cached_row[0] or "{}")) if isinstance(cached_payload, dict) and clean(cached_payload.get("run_id")) == run_id: return cached_payload except Exception: pass if not allow_rebuild: return None raw_keys = { clean(row.get("compare_voucher_no")) for row in conn.execute( text( """ SELECT compare_voucher_no FROM wehago_ledger_rows WHERE fiscal_year BETWEEN :start_year AND :end_year AND COALESCE(compare_voucher_no, '') <> '' GROUP BY compare_voucher_no """ ), {"start_year": int(start_year), "end_year": int(end_year)}, ).mappings().all() if clean(row.get("compare_voucher_no")) } raw_total = len(raw_keys) if raw_total <= 0: return None final_by_key: dict[str, str] = {} group_total = 0 blank_key_count = 0 outside_raw_count = 0 key_seen_count: dict[str, int] = {} key_statuses: dict[str, set[str]] = {} source_group_counts = {status_key: 0 for status_key in WEHAGO_FINAL_STATUS_ORDER} for status_key in WEHAGO_FINAL_STATUS_ORDER: signature = active_signatures[status_key] rows = conn.execute( text( """ SELECT fiscal_year, ledger_date, proof_date, voucher_no, ledger_debit, ledger_credit, voucher_debit, voucher_credit, ledger_accounts, voucher_accounts, rows_json FROM wehago_status_projection_groups WHERE start_year = :start_year AND end_year = :end_year AND status_key = :status_key AND signature = :signature """ ), { "start_year": int(start_year), "end_year": int(end_year), "status_key": status_key, "signature": signature, }, ).mappings() for row in rows: group_total += 1 effective_status_key = status_key if status_key == "voucher_matched": try: detail_rows = json.loads(clean(row.get("rows_json")) or "[]") except Exception: detail_rows = [] if not isinstance(detail_rows, list): detail_rows = [] if _matched_group_should_be_recheck_by_direction(row, detail_rows): effective_status_key = "voucher_recheck" source_group_counts[effective_status_key] += 1 compare_key = _projection_voucher_compare_key_from_parts( int(row.get("fiscal_year") or 0), row.get("ledger_date"), row.get("proof_date"), row.get("voucher_no"), ) if not compare_key: blank_key_count += 1 continue if compare_key not in raw_keys: outside_raw_count += 1 continue key_seen_count[compare_key] = int(key_seen_count.get(compare_key, 0) or 0) + 1 key_statuses.setdefault(compare_key, set()).add(effective_status_key) current_status = final_by_key.get(compare_key) if current_status is None or WEHAGO_FINAL_STATUS_PRIORITY[effective_status_key] < WEHAGO_FINAL_STATUS_PRIORITY[current_status]: final_by_key[compare_key] = effective_status_key for compare_key in _load_wehago_strong_accounting_exception_compare_keys(conn, start_year, end_year): if compare_key in raw_keys: final_by_key[compare_key] = "voucher_excepted" missing_raw_count = len(raw_keys - set(final_by_key)) final_counts = {status_key: 0 for status_key in WEHAGO_FINAL_STATUS_ORDER} for status_key in final_by_key.values(): final_counts[status_key] += 1 final_counts["voucher_unmatched"] += missing_raw_count classified_total = sum(final_counts.values()) duplicate_group_count = sum(max(0, count - 1) for count in key_seen_count.values()) multi_appearance_voucher_count = sum(1 for count in key_seen_count.values() if count > 1) multi_status_voucher_count = sum(1 for statuses in key_statuses.values() if len(statuses) > 1) same_status_duplicate_voucher_count = sum( 1 for key, count in key_seen_count.items() if count > 1 and len(key_statuses.get(key) or set()) == 1 ) payload = { "run_id": run_id, "start_year": int(start_year), "end_year": int(end_year), "logic_version": QUERY_PROJECTION_VERSION, "raw_total": raw_total, "classified_total": classified_total, "difference": classified_total - raw_total, "group_total": group_total, "group_difference": group_total - raw_total, "source_group_total": group_total, "source_group_difference": group_total - raw_total, "projection_outside_raw_total": outside_raw_count, "blank_key_count": blank_key_count, "missing_raw_count": missing_raw_count, "duplicate_group_count": duplicate_group_count, "multi_appearance_voucher_count": multi_appearance_voucher_count, "multi_status_voucher_count": multi_status_voucher_count, "same_status_duplicate_voucher_count": same_status_duplicate_voucher_count, "source_group_counts": source_group_counts, "counts": final_counts, "items": [ { "key": key, "label": { "voucher_matched": "Voucher", "voucher_recheck": "Recheck", "voucher_unmatched": "Unmatched", "voucher_excepted": "Excepted", }[key], "count": int(final_counts.get(key, 0) or 0), "group_count": int(source_group_counts.get(key, 0) or 0), } for key in WEHAGO_FINAL_STATUS_ORDER ], "active_signatures": active_signatures, "run_stats": stats, "source": "active_status_projection_run", "ready": classified_total == raw_total, } try: conn.execute( text( """ INSERT INTO wehago_compare_settings (setting_key, setting_json, updated_at) VALUES (:setting_key, :setting_json, CURRENT_TIMESTAMP) ON CONFLICT(setting_key) DO UPDATE SET setting_json = excluded.setting_json, updated_at = CURRENT_TIMESTAMP """ ), { "setting_key": setting_key, "setting_json": json.dumps(payload, ensure_ascii=False, separators=(",", ":")), }, ) except Exception: pass return payload def _ensure_active_status_run_final_projection( conn: Any, start_year: int | None, end_year: int | None, payload: dict[str, Any] | None, ) -> bool: if start_year is None or end_year is None or not isinstance(payload, dict): return False run_id = clean(payload.get("run_id")) active_signatures = payload.get("active_signatures") raw_total = int(payload.get("raw_total") or 0) if not run_id or raw_total <= 0 or not isinstance(active_signatures, dict): return False if set(active_signatures) != set(WEHAGO_FINAL_STATUS_ORDER): return False try: existing_row = conn.execute( text( """ SELECT COUNT(*) AS row_count, SUM(CASE WHEN COALESCE(display_signature, '') <> '' THEN 1 ELSE 0 END) AS display_count FROM wehago_compare_final_status_projection WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature """ ), {"start_year": int(start_year), "end_year": int(end_year), "signature": run_id}, ).mappings().first() existing_count = int((existing_row or {}).get("row_count") or 0) existing_display_count = int((existing_row or {}).get("display_count") or 0) if existing_count == raw_total and existing_display_count > 0: return True raw_rows = conn.execute( text( """ SELECT fiscal_year, compare_voucher_no, MIN(ledger_date) AS ledger_date, MIN(voucher_no) AS voucher_no FROM wehago_ledger_rows WHERE fiscal_year BETWEEN :start_year AND :end_year AND COALESCE(compare_voucher_no, '') <> '' GROUP BY fiscal_year, compare_voucher_no """ ), {"start_year": int(start_year), "end_year": int(end_year)}, ).mappings().all() raw_by_key = { clean(row.get("compare_voucher_no")): row for row in raw_rows if clean(row.get("compare_voucher_no")) } if not raw_by_key: return False final_by_key: dict[str, str] = {} source_statuses_by_key: dict[str, set[str]] = {} source_group_count_by_key: dict[str, int] = {} display_by_key: dict[str, dict[str, Any]] = {} for status_key in WEHAGO_FINAL_STATUS_ORDER: status_rank = int(WEHAGO_FINAL_STATUS_PRIORITY[status_key]) signature = clean(active_signatures.get(status_key)) rows = conn.execute( text( """ SELECT fiscal_year, ledger_date, proof_date, voucher_no, group_index, ledger_row_count, voucher_row_count, ledger_debit, ledger_credit, voucher_debit, voucher_credit, ledger_accounts, voucher_accounts, rows_json FROM wehago_status_projection_groups WHERE start_year = :start_year AND end_year = :end_year AND status_key = :status_key AND signature = :signature """ ), { "start_year": int(start_year), "end_year": int(end_year), "status_key": status_key, "signature": signature, }, ).mappings() for row in rows: effective_status_key = status_key effective_status_rank = status_rank if status_key == "voucher_matched": try: detail_rows = json.loads(clean(row.get("rows_json")) or "[]") except Exception: detail_rows = [] if not isinstance(detail_rows, list): detail_rows = [] if _matched_group_should_be_recheck_by_direction(row, detail_rows): effective_status_key = "voucher_recheck" effective_status_rank = int(WEHAGO_FINAL_STATUS_PRIORITY[effective_status_key]) compare_key = _projection_voucher_compare_key_from_parts( int(row.get("fiscal_year") or 0), row.get("ledger_date"), row.get("proof_date"), row.get("voucher_no"), ) if not compare_key or compare_key not in raw_by_key: continue source_statuses_by_key.setdefault(compare_key, set()).add(effective_status_key) source_group_count_by_key[compare_key] = int(source_group_count_by_key.get(compare_key, 0) or 0) + 1 current_status = final_by_key.get(compare_key) if current_status is None or effective_status_rank < WEHAGO_FINAL_STATUS_PRIORITY[current_status]: final_by_key[compare_key] = effective_status_key current_display = display_by_key.get(compare_key) current_rank = int((current_display or {}).get("rank") or 99) current_group_index = int((current_display or {}).get("group_index") or 10**9) group_index = int(row.get("group_index") or 0) if current_display is None or effective_status_rank < current_rank or ( effective_status_rank == current_rank and group_index < current_group_index ): display_by_key[compare_key] = { "status_key": status_key, "signature": signature, "group_index": group_index, "rank": effective_status_rank, } for compare_key in _load_wehago_strong_accounting_exception_compare_keys(conn, start_year, end_year): if compare_key in raw_by_key: final_by_key[compare_key] = "voucher_excepted" source_statuses_by_key.setdefault(compare_key, set()).add("voucher_excepted") conn.execute( text( """ DELETE FROM wehago_compare_final_status_projection WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature """ ), {"start_year": int(start_year), "end_year": int(end_year), "signature": run_id}, ) insert_sql = text( """ INSERT INTO wehago_compare_final_status_projection ( start_year, end_year, signature, identity_key, compare_voucher_no, fiscal_year, ledger_date, voucher_no, final_status, final_rank, source_statuses, source_group_count, display_status_key, display_signature, display_group_index, created_at, updated_at ) VALUES ( :start_year, :end_year, :signature, :identity_key, :compare_voucher_no, :fiscal_year, :ledger_date, :voucher_no, :final_status, :final_rank, :source_statuses, :source_group_count, :display_status_key, :display_signature, :display_group_index, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) """ ) for compare_key, row in raw_by_key.items(): final_status = clean(final_by_key.get(compare_key)) or "voucher_unmatched" source_statuses = sorted(source_statuses_by_key.get(compare_key) or set()) display = display_by_key.get(compare_key) or {} raw_display_group_index = display.get("group_index") conn.execute( insert_sql, { "start_year": int(start_year), "end_year": int(end_year), "signature": run_id, "identity_key": compare_key, "compare_voucher_no": compare_key, "fiscal_year": int(row.get("fiscal_year") or 0), "ledger_date": clean(row.get("ledger_date")), "voucher_no": clean(row.get("voucher_no")), "final_status": final_status, "final_rank": int(WEHAGO_FINAL_STATUS_PRIORITY.get(final_status, 99)), "source_statuses": ",".join(source_statuses), "source_group_count": int(source_group_count_by_key.get(compare_key, 0) or 0), "display_status_key": clean(display.get("status_key")), "display_signature": clean(display.get("signature")), "display_group_index": int(raw_display_group_index) if raw_display_group_index is not None else -1, }, ) return True except Exception as exc: logger.warning("Failed to materialize active final status projection for %s-%s: %s", start_year, end_year, exc) return False 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 = _effective_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: if ( table_name == "wehago_compare_query_groups" and status_key in QUERY_VOUCHER_STATUS_KEYS and not _allow_stale_export_detail_fallback() ): return None 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: active_signature = "" active_row = conn.execute( """ SELECT setting_json FROM wehago_compare_settings WHERE setting_key = ? LIMIT 1 """, (f"wehago_active_query_projection:{int(start_year)}:{int(end_year)}",), ).fetchone() if active_row and active_row[0]: try: active_payload = json.loads(str(active_row[0] or "{}")) if isinstance(active_payload, dict): active_signature = clean(active_payload.get("signature")) except Exception: active_signature = "" if not active_signature.startswith(f"{QUERY_PROJECTION_VERSION}|"): return None 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: active_signature = "" active_row = conn.execute( """ SELECT setting_json FROM wehago_compare_settings WHERE setting_key = ? LIMIT 1 """, (f"wehago_active_query_projection:{int(start_year)}:{int(end_year)}",), ).fetchone() if active_row and active_row[0]: try: active_payload = json.loads(str(active_row[0] or "{}")) if isinstance(active_payload, dict): candidate_signature = clean(active_payload.get("signature")) if candidate_signature.startswith(f"{QUERY_PROJECTION_VERSION}|"): active_signature = candidate_signature except Exception: active_signature = "" 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 = ? GROUP BY start_year, end_year, signature LIMIT 1 """, ( int(start_year), int(end_year), normalized_status, active_signature, ), ).fetchone() if active_signature else None 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 = ? AND signature LIKE ? GROUP BY start_year, end_year, signature ORDER BY max_updated_at DESC LIMIT 1 """, ( int(start_year), int(end_year), normalized_status, f"{QUERY_PROJECTION_VERSION}|%", ), ).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), }, ) conn.execute( text( """ INSERT INTO wehago_compare_settings (setting_key, setting_json, updated_at) VALUES (:setting_key, :setting_json, CURRENT_TIMESTAMP) ON CONFLICT(setting_key) DO UPDATE SET setting_json = excluded.setting_json, updated_at = CURRENT_TIMESTAMP """ ), { "setting_key": f"wehago_active_query_projection:{int(start_year)}:{int(end_year)}", "setting_json": json.dumps( { "signature": signature, "logic_version": QUERY_PROJECTION_VERSION, "start_year": int(start_year), "end_year": int(end_year), "source_state": payload_source, }, ensure_ascii=False, separators=(",", ":"), ), }, ) 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 _coerce_metric_counts_payload(payload: Any) -> dict[str, int] | None: if not isinstance(payload, dict): return None return {status_key: int(payload.get(status_key, 0) or 0) for status_key, _, _ in STATUS_META} def _load_latest_query_metric_projection_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 None signature = _query_projection_signature(conn, start_year, end_year) row = conn.execute( text( """ SELECT counts_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 or not row[0]: return None try: return _coerce_metric_counts_payload(json.loads(str(row[0]))) except Exception: return None def _load_latest_metric_counts_cache_any_signature_loose( 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 current_signature = _metric_counts_signature(conn, start_year, end_year) row = 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 row or not row[1]: return None if clean(row[0]) != current_signature: return None try: return _coerce_metric_counts_payload(json.loads(str(row[1]))) except Exception: return None def _load_metric_counts_from_cached_subranges( 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 selected_years = set(range(int(start_year), int(end_year) + 1)) if not selected_years: return None candidates: list[dict[str, Any]] = [] for table_name, signature_like in ( ("wehago_compare_query_metrics", f"{QUERY_PROJECTION_VERSION}|%"), ("wehago_metric_count_cache", None), ): rows = conn.execute( text( f""" SELECT start_year, end_year, signature, counts_json, updated_at, created_at FROM {table_name} WHERE start_year >= :start_year AND end_year <= :end_year ORDER BY updated_at DESC, created_at DESC """ ), {"start_year": start_year, "end_year": end_year}, ).mappings().all() for row in rows: row_start = int(row.get("start_year") or 0) row_end = int(row.get("end_year") or 0) if row_start <= 0 or row_end < row_start: continue years = set(range(row_start, row_end + 1)) if not years or not years.issubset(selected_years): continue try: counts = _coerce_metric_counts_payload(json.loads(clean(row.get("counts_json")) or "{}")) except Exception: counts = None if counts is None: continue signature = clean(row.get("signature")) candidates.append( { "start_year": row_start, "end_year": row_end, "years": years, "counts": counts, "current_rank": 0 if signature_like and signature.startswith(QUERY_PROJECTION_VERSION) else 1, "width": len(years), "updated_at": clean(row.get("updated_at")) or clean(row.get("created_at")), } ) if not candidates: return None candidates.sort(key=lambda item: (-int(item["width"]), int(item["current_rank"]), clean(item["updated_at"])), reverse=False) used_years: set[int] = set() merged = _empty_metric_counts() for candidate in candidates: years = set(candidate["years"]) if years & used_years: continue used_years.update(years) counts = dict(candidate["counts"]) for status_key in merged: merged[status_key] += int(counts.get(status_key, 0) or 0) if used_years == selected_years: break return merged if used_years else None WEHAGO_FINAL_STATUS_ORDER = ( "voucher_matched", "voucher_unmatched", "voucher_recheck", "voucher_excepted", ) WEHAGO_FINAL_STATUS_PRIORITY = { "voucher_excepted": 1, "voucher_matched": 2, "voucher_recheck": 3, "voucher_unmatched": 4, } MATCH_DIRECTION_GUARD_REASON = "MATCH_DIRECTION_RECHECK_MAJOR_ACCOUNT_SIGN_CONFLICT" def _enable_active_status_projection_run() -> bool: return os.getenv("WEHAGO_ENABLE_ACTIVE_STATUS_RUN", "").strip().lower() in {"1", "true", "yes", "on"} def _is_direction_guard_major_account_text(*values: Any) -> bool: text_value = normalize_text(" ".join(clean(value) for value in values)) if not text_value: return False major_tokens = ( "수입", "매출", "용역", "원가", "비용", "수수료", "관리비", "외주", "복리", "접대", "세금", "공과", "보험", "차량", "소모품", "부가세", "매입세액", "매출세액", "지급", "급여", "퇴직", ) return any(token in text_value for token in major_tokens) def _signed_row_amount(row: Mapping[str, Any], prefix: str) -> float: if prefix == "ledger": return parse_amount(row.get("ledger_debit")) - parse_amount(row.get("ledger_credit")) return parse_amount(row.get("voucher_debit")) - parse_amount(row.get("voucher_credit")) def _matched_group_should_be_recheck_by_direction(group: Mapping[str, Any], rows: list[dict[str, Any]] | None = None) -> bool: if not _is_direction_guard_major_account_text(group.get("ledger_accounts"), group.get("voucher_accounts")): return False ledger_debit = parse_amount(group.get("ledger_debit")) ledger_credit = parse_amount(group.get("ledger_credit")) voucher_debit = parse_amount(group.get("voucher_debit")) voucher_credit = parse_amount(group.get("voucher_credit")) ledger_values = (ledger_debit, ledger_credit) voucher_values = (voucher_debit, voucher_credit) if any(value < -1 for value in ledger_values) and any(value > 1 for value in voucher_values): return True if any(value < -1 for value in voucher_values) and any(value > 1 for value in ledger_values): return True paired_rows = [ row for row in list(rows or []) if (parse_amount(row.get("ledger_debit")) or parse_amount(row.get("ledger_credit"))) and (parse_amount(row.get("voucher_debit")) or parse_amount(row.get("voucher_credit"))) ] for row in paired_rows: if not _is_direction_guard_major_account_text(row.get("ledger_account_name"), row.get("voucher_account_name")): continue ledger_signed = _signed_row_amount(row, "ledger") voucher_signed = _signed_row_amount(row, "voucher") if abs(abs(ledger_signed) - abs(voucher_signed)) <= 1 and ledger_signed * voucher_signed < 0: return True reason_text = normalize_text( " ".join( [ clean(group.get("review_reason")), *(clean(row.get("review_reason")) for row in list(rows or [])), ] ) ) if "directcostbusinessaccountmatch" not in reason_text and "groupedcost" not in reason_text: return False direct_rows = [ row for row in list(rows or []) if clean(row.get("ledger_account_name")) and clean(row.get("voucher_account_name")) and (parse_amount(row.get("ledger_debit")) or parse_amount(row.get("ledger_credit"))) and (parse_amount(row.get("voucher_debit")) or parse_amount(row.get("voucher_credit"))) ] erp_only_rows = [ row for row in list(rows or []) if clean(row.get("voucher_account_name")) and not clean(row.get("ledger_account_name")) and (parse_amount(row.get("voucher_debit")) or parse_amount(row.get("voucher_credit"))) ] if not direct_rows or not erp_only_rows: return False strong_tokens = ("부가세", "매입세액", "매출세액", "통신비", "전력비", "수도광열", "임차료", "관리비", "소모품", "수수료", "외상매입", "외상매출", "용역") weak_tokens = ("잡이익", "잡손실", "전도금", "미지급금", "보통예금", "현금") has_strong_direct = False for row in direct_rows: account_text = normalize_text(" ".join([clean(row.get("ledger_account_name")), clean(row.get("voucher_account_name"))])) if any(token in account_text for token in strong_tokens) and not any(token in account_text for token in weak_tokens): has_strong_direct = True break if has_strong_direct: return False month_gaps = [] for row in list(rows or []): ledger_date = _parse_row_date_with_year(dict(row), "ledger_date") voucher_date = _parse_iso_date(row.get("proof_date")) or _infer_compare_row_proof_date(dict(row)) if ledger_date and voucher_date: month_gaps.append(_month_gap(ledger_date, voucher_date)) return bool(month_gaps and max(month_gaps) >= 1) return False ERP_REPEATED_CONTEXT_MATCH_TOKENS = ( "ERP_GROUP_REPEATED_CONTEXT_MATCH", "SPLIT_ERP_DRAFT_CONTEXT_MATCH", "SPLIT_DRAFT", "PROJECTION_RECONCILE_SPLIT_DRAFT", "GROUPED_COST", ) def _is_erp_repeated_context_match(row: Mapping[str, Any] | None) -> bool: # ERP source account rows must remain one-to-one in every projection path. return False def _final_status_summary_diagnostics( summary: Mapping[str, Any] | None, *, exception_counts: Mapping[str, int] | None = None, ) -> dict[str, Any]: payload = dict(summary or {}) counts = payload.get("counts") if not isinstance(counts, dict): counts = {} raw_total = int(payload.get("raw_total") or 0) classified_total = int(payload.get("classified_total") or sum(int(counts.get(key, 0) or 0) for key in WEHAGO_FINAL_STATUS_ORDER)) diagnostics = { "wehago_voucher_total": raw_total, "wehago_status_total": classified_total, "wehago_status_difference": classified_total - raw_total, "wehago_status_invariant_ok": classified_total == raw_total, "projection_outside_raw_total": int(payload.get("projection_outside_raw_total") or 0), "blank_key_count": int(payload.get("blank_key_count") or 0), "missing_raw_count": int(payload.get("missing_raw_count") or 0), "duplicate_group_count": int(payload.get("duplicate_group_count") or 0), "multi_status_voucher_count": int(payload.get("multi_status_voucher_count") or 0), "same_status_duplicate_voucher_count": int(payload.get("same_status_duplicate_voucher_count") or 0), "nondeductible_vat_gross_match_count": int((exception_counts or {}).get("nondeductible_vat_gross", 0) or 0), "erp_repeated_context_match_count": int((exception_counts or {}).get("erp_repeated_context", 0) or 0), "payment_only_recheck_count": int((exception_counts or {}).get("payment_only_recheck", 0) or 0), } payload["diagnostics"] = diagnostics payload["ready"] = bool(payload.get("ready")) or diagnostics["wehago_status_invariant_ok"] return payload def _normalize_wehago_projection_date(value: Any, fiscal_year: int | None = None) -> str: normalized = clean(value) if not normalized: return "" if re.fullmatch(r"\d{4}-\d{2}-\d{2}", normalized): return normalized if re.fullmatch(r"\d{1,2}[-./]\d{1,2}", normalized) and fiscal_year: month_text, day_text = re.split(r"[-./]", normalized) return f"{int(fiscal_year):04d}-{int(month_text):02d}-{int(day_text):02d}" matched = re.search(r"(20\d{2})[-./]?(\d{2})[-./]?(\d{2})", normalized) if matched: return f"{int(matched.group(1)):04d}-{int(matched.group(2)):02d}-{int(matched.group(3)):02d}" return normalized def _build_wehago_final_identity_from_summary(summary: dict[str, Any], fallback: str = "") -> dict[str, Any]: fiscal_year = int(summary.get("fiscal_year") or 0) voucher_no = clean(summary.get("voucher_no")) ledger_date = _normalize_wehago_projection_date( summary.get("ledger_date") or summary.get("proof_date"), fiscal_year or None, ) first_voucher_no = clean(voucher_no.split(",", 1)[0]) if voucher_no else "" compare_voucher_no = "" if ledger_date and first_voucher_no: compare_voucher_no = f"{ledger_date.replace('-', '')}-{first_voucher_no}" identity_key = "|".join( [ str(fiscal_year), ledger_date, first_voucher_no or clean(fallback), ] ) return { "identity_key": identity_key, "compare_voucher_no": compare_voucher_no, "fiscal_year": fiscal_year, "ledger_date": ledger_date, "voucher_no": first_voucher_no, } def _store_wehago_final_status_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 = _effective_query_projection_signature(conn, start_year, end_year) resolved: dict[str, dict[str, Any]] = {} for status_key in WEHAGO_FINAL_STATUS_PRIORITY: status_rank = int(WEHAGO_FINAL_STATUS_PRIORITY[status_key]) for group_index, group in enumerate(list(voucher_sections.get(status_key) or [])): summary = dict(group.get("summary") or {}) identity = _build_wehago_final_identity_from_summary(summary, fallback=f"{status_key}:{group_index}") identity_key = clean(identity.get("identity_key")) if not identity_key: continue current = resolved.get(identity_key) source_statuses = set((current or {}).get("source_statuses_set") or set()) source_statuses.add(status_key) source_group_count = int((current or {}).get("source_group_count") or 0) + 1 if current is None or status_rank < int(current.get("final_rank") or 99): resolved[identity_key] = { **identity, "final_status": status_key, "final_rank": status_rank, "source_statuses_set": source_statuses, "source_group_count": source_group_count, "display_status_key": status_key, "display_signature": signature, "display_group_index": group_index, } else: current["source_statuses_set"] = source_statuses current["source_group_count"] = source_group_count try: conn.execute( text( """ DELETE FROM wehago_compare_final_status_projection WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature """ ), {"start_year": int(start_year), "end_year": int(end_year), "signature": signature}, ) for row in resolved.values(): source_statuses = sorted( clean(value) for value in (row.get("source_statuses_set") or set()) if clean(value) ) conn.execute( text( """ INSERT INTO wehago_compare_final_status_projection ( start_year, end_year, signature, identity_key, compare_voucher_no, fiscal_year, ledger_date, voucher_no, final_status, final_rank, source_statuses, source_group_count, display_status_key, display_signature, display_group_index, created_at, updated_at ) VALUES ( :start_year, :end_year, :signature, :identity_key, :compare_voucher_no, :fiscal_year, :ledger_date, :voucher_no, :final_status, :final_rank, :source_statuses, :source_group_count, :display_status_key, :display_signature, :display_group_index, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) """ ), { "start_year": int(start_year), "end_year": int(end_year), "signature": signature, "identity_key": clean(row.get("identity_key")), "compare_voucher_no": clean(row.get("compare_voucher_no")), "fiscal_year": int(row.get("fiscal_year") or 0), "ledger_date": clean(row.get("ledger_date")), "voucher_no": clean(row.get("voucher_no")), "final_status": clean(row.get("final_status")), "final_rank": int(row.get("final_rank") or 99), "source_statuses": ",".join(source_statuses), "source_group_count": int(row.get("source_group_count") or 0), "display_status_key": clean(row.get("display_status_key")), "display_signature": clean(row.get("display_signature")), "display_group_index": int(row.get("display_group_index") if row.get("display_group_index") is not None else -1), }, ) except OperationalError as exc: logger.warning("Skipped final status projection write for %s-%s due to lock: %s", start_year, end_year, exc) def backfill_wehago_final_status_projection_from_query_groups( conn: Any, start_year: int | None, end_year: int | None, ) -> int: if start_year is None or end_year is None: return 0 signature = _load_latest_query_projection_signature_for_range(conn, start_year, end_year) if not signature: return 0 priority_case = " ".join( f"WHEN '{status_key}' THEN {priority}" for status_key, priority in WEHAGO_FINAL_STATUS_PRIORITY.items() ) status_case = " ".join( f"WHEN {priority} THEN '{status_key}'" for status_key, priority in WEHAGO_FINAL_STATUS_PRIORITY.items() ) try: conn.execute( text( """ DELETE FROM wehago_compare_final_status_projection WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature """ ), {"start_year": int(start_year), "end_year": int(end_year), "signature": signature}, ) conn.execute( text( f""" INSERT INTO wehago_compare_final_status_projection ( start_year, end_year, signature, identity_key, compare_voucher_no, fiscal_year, ledger_date, voucher_no, final_status, final_rank, source_statuses, source_group_count, display_status_key, display_signature, display_group_index, created_at, updated_at ) WITH source_groups AS ( SELECT fiscal_year, status_key, CASE status_key {priority_case} ELSE 99 END AS status_rank, COALESCE(NULLIF(TRIM(ledger_date), ''), NULLIF(TRIM(proof_date), ''), '') AS raw_date, COALESCE(NULLIF(TRIM(voucher_no), ''), CAST(group_index AS TEXT)) AS raw_voucher_no, group_index FROM wehago_compare_query_groups WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature AND status_key IN ('voucher_matched', 'voucher_recheck', 'voucher_unmatched', 'voucher_excepted') ), normalized AS ( SELECT fiscal_year, status_key, status_rank, group_index, CASE WHEN raw_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' THEN raw_date WHEN raw_date GLOB '[0-9][0-9]-[0-9][0-9]' THEN printf('%04d-%s', fiscal_year, raw_date) WHEN raw_date GLOB '[0-9]-[0-9][0-9]' THEN printf('%04d-0%s', fiscal_year, raw_date) ELSE raw_date END AS ledger_date, TRIM( CASE WHEN INSTR(raw_voucher_no, ',') > 0 THEN SUBSTR(raw_voucher_no, 1, INSTR(raw_voucher_no, ',') - 1) ELSE raw_voucher_no END ) AS voucher_no FROM source_groups ), classified AS ( SELECT status_key, status_rank, group_index, fiscal_year, ledger_date, voucher_no, CAST(fiscal_year AS TEXT) || '|' || ledger_date || '|' || voucher_no AS identity_key, CASE WHEN ledger_date <> '' AND voucher_no <> '' THEN REPLACE(ledger_date, '-', '') || '-' || voucher_no ELSE '' END AS compare_voucher_no FROM normalized WHERE COALESCE(voucher_no, '') <> '' ), ranked_display AS ( SELECT identity_key, status_key, group_index, ROW_NUMBER() OVER ( PARTITION BY identity_key ORDER BY status_rank ASC, group_index ASC ) AS display_rank FROM classified WHERE COALESCE(identity_key, '') <> '' ), resolved AS ( SELECT identity_key, MIN(status_rank) AS final_rank, MIN(fiscal_year) AS fiscal_year, MIN(ledger_date) AS ledger_date, MIN(voucher_no) AS voucher_no, MIN(compare_voucher_no) AS compare_voucher_no, GROUP_CONCAT(DISTINCT status_key) AS source_statuses, COUNT(*) AS source_group_count FROM classified WHERE COALESCE(identity_key, '') <> '' GROUP BY identity_key ) SELECT :start_year, :end_year, :signature, identity_key, compare_voucher_no, fiscal_year, ledger_date, voucher_no, CASE final_rank {status_case} ELSE '' END AS final_status, final_rank, COALESCE(source_statuses, ''), source_group_count, COALESCE(d.status_key, ''), :signature, COALESCE(d.group_index, -1), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP FROM resolved LEFT JOIN ranked_display d ON d.identity_key = resolved.identity_key AND d.display_rank = 1 WHERE final_rank < 99 """ ), {"start_year": int(start_year), "end_year": int(end_year), "signature": signature}, ) return int( conn.execute( text( """ SELECT COUNT(*) FROM wehago_compare_final_status_projection WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature """ ), {"start_year": int(start_year), "end_year": int(end_year), "signature": signature}, ).scalar_one() or 0 ) except OperationalError as exc: logger.warning("Skipped final status projection backfill for %s-%s due to lock: %s", start_year, end_year, exc) except Exception as exc: logger.warning("Skipped final status projection backfill for %s-%s: %s", start_year, end_year, exc) return 0 def _load_latest_query_projection_signature_for_range( conn: Any, start_year: int | None, end_year: int | None, ) -> str: canonical_signature = _load_canonical_query_projection_signature_for_range(conn, start_year, end_year) if canonical_signature: return canonical_signature if start_year is None or end_year is None: return "" row = conn.execute( text( """ SELECT signature FROM wehago_compare_final_status_projection WHERE start_year = :start_year AND end_year = :end_year AND signature LIKE :signature_like AND signature NOT LIKE '%|snapshot-recheck-promote|%' AND signature NOT LIKE '%|db-reconciled-%' GROUP BY signature ORDER BY COUNT(*) DESC, MAX(updated_at) DESC LIMIT 1 """ ), { "start_year": start_year, "end_year": end_year, "signature_like": f"{QUERY_PROJECTION_VERSION}|%", }, ).first() if row and row[0]: return clean(row[0]) return "" def _load_canonical_query_projection_signature_for_range( conn: Any, start_year: int | None, end_year: int | None, ) -> str: if start_year is None or end_year is None: return "" active_signature = _active_query_projection_signature(conn, start_year, end_year) if active_signature: return active_signature signature = _query_projection_signature(conn, start_year, end_year) row = conn.execute( text( """ SELECT signature FROM wehago_compare_query_groups 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 row and row[0]: return clean(row[0]) row = conn.execute( text( """ SELECT signature FROM wehago_compare_query_groups WHERE start_year = :start_year AND end_year = :end_year AND signature LIKE :signature_like AND signature NOT LIKE '%|snapshot-recheck-promote|%' AND signature NOT LIKE '%|db-reconciled-%' GROUP BY signature ORDER BY COUNT(DISTINCT status_key) DESC, MAX(updated_at) DESC LIMIT 1 """ ), { "start_year": start_year, "end_year": end_year, "signature_like": f"{QUERY_PROJECTION_VERSION}|%", }, ).first() if row and row[0]: return clean(row[0]) return "" def _load_latest_complete_final_status_projection_signature( conn: Any, start_year: int | None, end_year: int | None, ) -> str: if start_year is None or end_year is None: return "" source_signature = _load_canonical_query_projection_signature_for_range(conn, start_year, end_year) if not source_signature: row = conn.execute( text( """ SELECT signature FROM wehago_compare_final_status_projection WHERE start_year = :start_year AND end_year = :end_year AND signature LIKE :signature_like AND signature NOT LIKE '%|snapshot-recheck-promote|%' AND signature NOT LIKE '%|db-reconciled-%' GROUP BY signature ORDER BY MAX(updated_at) DESC, COUNT(*) DESC LIMIT 1 """ ), { "start_year": int(start_year), "end_year": int(end_year), "signature_like": f"{QUERY_PROJECTION_VERSION}|%", }, ).first() source_signature = clean(row[0]) if row and row[0] else "" if not source_signature: return "" try: row = conn.execute( text( """ SELECT p.signature FROM wehago_compare_final_status_projection p WHERE p.start_year = :start_year AND p.end_year = :end_year AND p.signature = :signature GROUP BY p.signature HAVING COUNT(*) > 0 LIMIT 1 """ ), {"start_year": int(start_year), "end_year": int(end_year), "signature": source_signature}, ).first() return clean(row[0]) if row and row[0] else "" except Exception: return "" def ensure_wehago_canonical_projection_state( conn: Any, start_year: int | None, end_year: int | None, *, repair: bool = True, ) -> dict[str, Any]: result: dict[str, Any] = { "start_year": int(start_year or 0), "end_year": int(end_year or 0), "logic_version": QUERY_PROJECTION_VERSION, "canonical_query_signature": "", "active_query_signature": "", "final_projection_signature": "", "final_projection_rows": 0, "stale_active_status_run": False, "stale_active_status_run_logic_version": "", "repaired": [], "needs_query_projection_rebuild": False, "ready": False, } if start_year is None or end_year is None: return result start = int(start_year) end = int(end_year) active_signature = _active_query_projection_signature(conn, start, end) canonical_signature = _load_canonical_query_projection_signature_for_range(conn, start, end) result["active_query_signature"] = active_signature result["canonical_query_signature"] = canonical_signature if not canonical_signature: result["needs_query_projection_rebuild"] = True return result if repair and active_signature != canonical_signature: conn.execute( text( """ INSERT INTO wehago_compare_settings (setting_key, setting_json, updated_at) VALUES (:setting_key, :setting_json, CURRENT_TIMESTAMP) ON CONFLICT(setting_key) DO UPDATE SET setting_json = excluded.setting_json, updated_at = CURRENT_TIMESTAMP """ ), { "setting_key": f"wehago_active_query_projection:{start}:{end}", "setting_json": json.dumps( { "signature": canonical_signature, "logic_version": QUERY_PROJECTION_VERSION, "start_year": start, "end_year": end, "source": "canonical_projection_auto_repair", }, ensure_ascii=False, separators=(",", ":"), ), }, ) result["active_query_signature"] = canonical_signature result["repaired"].append("active_query_projection") final_signature = _load_latest_complete_final_status_projection_signature(conn, start, end) if not final_signature and repair: inserted = backfill_wehago_final_status_projection_from_query_groups(conn, start, end) if inserted > 0: result["repaired"].append("final_status_projection") final_signature = _load_latest_complete_final_status_projection_signature(conn, start, end) result["final_projection_signature"] = final_signature if final_signature: try: result["final_projection_rows"] = int( conn.execute( text( """ SELECT COUNT(*) FROM wehago_compare_final_status_projection WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature """ ), {"start_year": start, "end_year": end, "signature": final_signature}, ).scalar_one() or 0 ) except Exception: result["final_projection_rows"] = 0 stale_run_row = conn.execute( text( """ SELECT setting_json FROM wehago_compare_settings WHERE setting_key = :setting_key LIMIT 1 """ ), {"setting_key": _status_projection_run_setting_key(start, end)}, ).first() if stale_run_row and stale_run_row[0]: try: stale_payload = json.loads(str(stale_run_row[0] or "{}")) except Exception: stale_payload = {} stale_logic_version = clean((stale_payload or {}).get("logic_version")) if isinstance(stale_payload, dict) else "" if stale_logic_version and stale_logic_version != QUERY_PROJECTION_VERSION: result["stale_active_status_run"] = True result["stale_active_status_run_logic_version"] = stale_logic_version if repair: conn.execute( text( """ DELETE FROM wehago_compare_settings WHERE setting_key = :setting_key """ ), {"setting_key": _status_projection_run_setting_key(start, end)}, ) result["repaired"].append("stale_active_status_projection_run_removed") result["ready"] = bool(canonical_signature and final_signature and int(result["final_projection_rows"] or 0) > 0) return result def apply_cross_signature_status_overrides_to_final_projection( conn: Any, start_year: int | None = None, end_year: int | None = None, ) -> int: if ( start_year is not None and end_year is not None and _load_canonical_query_projection_signature_for_range(conn, start_year, end_year) ): return 0 try: before = int( conn.execute( text( """ WITH raw_totals AS ( SELECT fiscal_year AS start_year, fiscal_year AS end_year, COUNT(*) AS raw_count FROM ( SELECT fiscal_year, compare_voucher_no FROM wehago_ledger_rows WHERE (:start_year IS NULL OR fiscal_year = :start_year) AND (:end_year IS NULL OR fiscal_year = :end_year) AND COALESCE(compare_voucher_no, '') <> '' GROUP BY fiscal_year, compare_voucher_no ) GROUP BY fiscal_year ), latest_complete AS ( SELECT p.start_year, p.end_year, p.signature FROM wehago_compare_final_status_projection p JOIN raw_totals r ON r.start_year = p.start_year AND r.end_year = p.end_year WHERE p.signature NOT LIKE '%|snapshot-recheck-promote|%' GROUP BY p.start_year, p.end_year, p.signature HAVING COUNT(*) = MAX(r.raw_count) AND MAX(p.updated_at) = ( SELECT MAX(p2.updated_at) FROM wehago_compare_final_status_projection p2 JOIN raw_totals r2 ON r2.start_year = p2.start_year AND r2.end_year = p2.end_year WHERE p2.start_year = p.start_year AND p2.end_year = p.end_year AND p2.signature NOT LIKE '%|snapshot-recheck-promote|%' GROUP BY p2.start_year, p2.end_year, p2.signature HAVING COUNT(*) = MAX(r2.raw_count) ORDER BY MAX(p2.updated_at) DESC LIMIT 1 ) ), trusted_evidence AS ( SELECT p.start_year, p.end_year, p.fiscal_year, p.compare_voucher_no, MIN(p.final_rank) AS best_rank FROM wehago_compare_final_status_projection p WHERE p.final_status IN ('voucher_excepted', 'voucher_matched', 'voucher_recheck') AND p.signature NOT LIKE '%|db-reconciled-%' AND p.signature NOT LIKE '%|snapshot-recheck-promote|%' GROUP BY p.start_year, p.end_year, p.fiscal_year, p.compare_voucher_no ) SELECT COUNT(*) FROM wehago_compare_final_status_projection target JOIN latest_complete lc ON lc.start_year = target.start_year AND lc.end_year = target.end_year AND lc.signature = target.signature JOIN trusted_evidence e ON e.start_year = target.start_year AND e.end_year = target.end_year AND e.fiscal_year = target.fiscal_year AND e.compare_voucher_no = target.compare_voucher_no WHERE e.best_rank < target.final_rank """ ), {"start_year": int(start_year) if start_year is not None else None, "end_year": int(end_year) if end_year is not None else None}, ).scalar_one() or 0 ) conn.execute( text( """ WITH raw_totals AS ( SELECT fiscal_year AS start_year, fiscal_year AS end_year, COUNT(*) AS raw_count FROM ( SELECT fiscal_year, compare_voucher_no FROM wehago_ledger_rows WHERE (:start_year IS NULL OR fiscal_year = :start_year) AND (:end_year IS NULL OR fiscal_year = :end_year) AND COALESCE(compare_voucher_no, '') <> '' GROUP BY fiscal_year, compare_voucher_no ) GROUP BY fiscal_year ), latest_complete AS ( SELECT p.start_year, p.end_year, p.signature FROM wehago_compare_final_status_projection p JOIN raw_totals r ON r.start_year = p.start_year AND r.end_year = p.end_year WHERE p.signature NOT LIKE '%|snapshot-recheck-promote|%' GROUP BY p.start_year, p.end_year, p.signature HAVING COUNT(*) = MAX(r.raw_count) AND MAX(p.updated_at) = ( SELECT MAX(p2.updated_at) FROM wehago_compare_final_status_projection p2 JOIN raw_totals r2 ON r2.start_year = p2.start_year AND r2.end_year = p2.end_year WHERE p2.start_year = p.start_year AND p2.end_year = p.end_year AND p2.signature NOT LIKE '%|snapshot-recheck-promote|%' GROUP BY p2.start_year, p2.end_year, p2.signature HAVING COUNT(*) = MAX(r2.raw_count) ORDER BY MAX(p2.updated_at) DESC LIMIT 1 ) ), trusted_evidence AS ( SELECT p.start_year, p.end_year, p.fiscal_year, p.compare_voucher_no, MIN(p.final_rank) AS best_rank FROM wehago_compare_final_status_projection p WHERE p.final_status IN ('voucher_excepted', 'voucher_matched', 'voucher_recheck') AND p.signature NOT LIKE '%|db-reconciled-%' AND p.signature NOT LIKE '%|snapshot-recheck-promote|%' GROUP BY p.start_year, p.end_year, p.fiscal_year, p.compare_voucher_no ) UPDATE wehago_compare_final_status_projection SET final_rank = ( SELECT e.best_rank FROM trusted_evidence e WHERE e.start_year = wehago_compare_final_status_projection.start_year AND e.end_year = wehago_compare_final_status_projection.end_year AND e.fiscal_year = wehago_compare_final_status_projection.fiscal_year AND e.compare_voucher_no = wehago_compare_final_status_projection.compare_voucher_no ), final_status = CASE ( SELECT e.best_rank FROM trusted_evidence e WHERE e.start_year = wehago_compare_final_status_projection.start_year AND e.end_year = wehago_compare_final_status_projection.end_year AND e.fiscal_year = wehago_compare_final_status_projection.fiscal_year AND e.compare_voucher_no = wehago_compare_final_status_projection.compare_voucher_no ) WHEN 1 THEN 'voucher_excepted' WHEN 2 THEN 'voucher_matched' WHEN 3 THEN 'voucher_recheck' ELSE final_status END, source_statuses = CASE WHEN INSTR(COALESCE(source_statuses, ''), 'cross_signature_evidence') > 0 THEN source_statuses WHEN COALESCE(source_statuses, '') = '' THEN 'cross_signature_evidence' ELSE source_statuses || ',cross_signature_evidence' END, updated_at = CURRENT_TIMESTAMP WHERE EXISTS ( SELECT 1 FROM latest_complete lc WHERE lc.start_year = wehago_compare_final_status_projection.start_year AND lc.end_year = wehago_compare_final_status_projection.end_year AND lc.signature = wehago_compare_final_status_projection.signature ) AND EXISTS ( SELECT 1 FROM trusted_evidence e WHERE e.start_year = wehago_compare_final_status_projection.start_year AND e.end_year = wehago_compare_final_status_projection.end_year AND e.fiscal_year = wehago_compare_final_status_projection.fiscal_year AND e.compare_voucher_no = wehago_compare_final_status_projection.compare_voucher_no AND e.best_rank < wehago_compare_final_status_projection.final_rank ) """ ), {"start_year": int(start_year) if start_year is not None else None, "end_year": int(end_year) if end_year is not None else None}, ) return before except Exception: return 0 def _load_wehago_match_exception_counts( conn: Any, start_year: int | None, end_year: int | None, signature: str, ) -> dict[str, int]: if start_year is None or end_year is None or not clean(signature): return {} try: row = conn.execute( text( """ SELECT SUM(CASE WHEN UPPER(COALESCE(matched_case, '') || ' ' || COALESCE(review_reason, '')) LIKE '%NONDEDUCTIBLE_VAT_GROSS%' THEN 1 ELSE 0 END) AS nondeductible_vat_gross, SUM(CASE WHEN UPPER(COALESCE(matched_case, '') || ' ' || COALESCE(review_reason, '')) LIKE '%ERP_GROUP_REPEATED_CONTEXT_MATCH%' OR UPPER(COALESCE(matched_case, '') || ' ' || COALESCE(review_reason, '')) LIKE '%SPLIT_ERP_DRAFT_CONTEXT_MATCH%' OR UPPER(COALESCE(matched_case, '') || ' ' || COALESCE(review_reason, '')) LIKE '%SPLIT_DRAFT%' OR UPPER(COALESCE(matched_case, '') || ' ' || COALESCE(review_reason, '')) LIKE '%PROJECTION_RECONCILE_SPLIT_DRAFT%' OR UPPER(COALESCE(matched_case, '') || ' ' || COALESCE(review_reason, '')) LIKE '%GROUPED_COST%' THEN 1 ELSE 0 END) AS erp_repeated_context, SUM(CASE WHEN UPPER(COALESCE(matched_case, '') || ' ' || COALESCE(review_reason, '')) LIKE '%PAYMENT_ONLY_RECHECK%' OR UPPER(COALESCE(matched_case, '') || ' ' || COALESCE(review_reason, '')) LIKE '%결제 계정%' THEN 1 ELSE 0 END) AS payment_only_recheck FROM wehago_compare_query_rows WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature AND fiscal_year BETWEEN :start_year AND :end_year """ ), { "start_year": int(start_year), "end_year": int(end_year), "signature": clean(signature), }, ).mappings().first() except Exception: return {} return { "nondeductible_vat_gross": int((row or {}).get("nondeductible_vat_gross") or 0), "erp_repeated_context": int((row or {}).get("erp_repeated_context") or 0), "payment_only_recheck": int((row or {}).get("payment_only_recheck") or 0), } def get_wehago_final_status_summary_from_conn( conn: Any, start_year: int | None, end_year: int | None, metric_counts: dict[str, int] | None = None, ) -> dict[str, Any]: status_labels = { "voucher_matched": "Voucher", "voucher_recheck": "Recheck", "voucher_unmatched": "Unmatched", "voucher_excepted": "Excepted", } empty_counts = {status_key: 0 for status_key in WEHAGO_FINAL_STATUS_ORDER} if start_year is None or end_year is None: return { "raw_total": 0, "classified_total": 0, "difference": 0, "group_total": 0, "group_difference": 0, "counts": empty_counts, "items": [ {"key": key, "label": status_labels[key], "count": 0} for key in WEHAGO_FINAL_STATUS_ORDER ], "source": "none", } active_signatures = _active_status_projection_signatures_for_range( conn, start_year, end_year, WEHAGO_FINAL_STATUS_ORDER, ) if set(active_signatures) == set(WEHAGO_FINAL_STATUS_ORDER): active_counts = _load_active_status_projection_counts_for_range( conn, start_year, end_year, WEHAGO_FINAL_STATUS_ORDER, ) if set(active_counts) == set(WEHAGO_FINAL_STATUS_ORDER): final_counts = { key: int(active_counts.get(key, 0) or 0) for key in WEHAGO_FINAL_STATUS_ORDER } classified_total = sum(final_counts.values()) return _final_status_summary_diagnostics({ "raw_total": classified_total, "classified_total": classified_total, "difference": 0, "group_total": classified_total, "group_difference": 0, "source_group_total": classified_total, "source_group_difference": 0, "projection_outside_raw_total": 0, "counts": final_counts, "source_group_counts": dict(final_counts), "items": [ {"key": key, "label": status_labels[key], "count": int(final_counts.get(key, 0) or 0)} for key in WEHAGO_FINAL_STATUS_ORDER ], "active_signatures": active_signatures, "projection_signature": "|".join( clean(active_signatures.get(key)) for key in WEHAGO_FINAL_STATUS_ORDER ), "source": "active_status_projection_groups", "ready": True, }) raw_total = int( conn.execute( text( """ SELECT COUNT(DISTINCT compare_voucher_no) FROM wehago_ledger_rows WHERE fiscal_year BETWEEN :start_year AND :end_year AND COALESCE(compare_voucher_no, '') <> '' """ ), {"start_year": int(start_year), "end_year": int(end_year)}, ).scalar_one() or 0 ) group_counts = { key: int((metric_counts or {}).get(key, 0) or 0) for key in WEHAGO_FINAL_STATUS_ORDER } final_counts = dict(empty_counts) apply_cross_signature_status_overrides_to_final_projection(conn, start_year, end_year) signature = _load_latest_complete_final_status_projection_signature(conn, start_year, end_year) if not signature and _load_canonical_query_projection_signature_for_range(conn, start_year, end_year): backfill_wehago_final_status_projection_from_query_groups(conn, start_year, end_year) signature = _load_latest_complete_final_status_projection_signature(conn, start_year, end_year) if signature: exception_counts = _load_wehago_match_exception_counts(conn, start_year, end_year, signature) try: projection_rows = conn.execute( text( """ SELECT final_status, COUNT(*) AS voucher_count FROM wehago_compare_final_status_projection WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature GROUP BY final_status """ ), { "start_year": int(start_year), "end_year": int(end_year), "signature": signature, }, ).mappings().all() except OperationalError: projection_rows = [] if projection_rows: for row in projection_rows: status_key = clean(row.get("final_status")) if status_key in final_counts: final_counts[status_key] = int(row.get("voucher_count") or 0) group_rows = conn.execute( text( """ SELECT final_status, SUM(source_group_count) AS group_count FROM wehago_compare_final_status_projection WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature GROUP BY final_status """ ), { "start_year": int(start_year), "end_year": int(end_year), "signature": signature, }, ).mappings().all() for row in group_rows: status_key = clean(row.get("final_status")) if status_key in group_counts: group_counts[status_key] = int(row.get("group_count") or 0) group_total = sum(group_counts.values()) classified_total = sum(final_counts.values()) return _final_status_summary_diagnostics({ "raw_total": classified_total, "classified_total": classified_total, "difference": 0, "group_total": classified_total, "group_difference": 0, "source_group_total": group_total, "source_group_difference": group_total - classified_total, "projection_outside_raw_total": 0, "counts": final_counts, "items": [ {"key": key, "label": status_labels[key], "count": int(final_counts.get(key, 0) or 0)} for key in WEHAGO_FINAL_STATUS_ORDER ], "active_signatures": {key: signature for key in WEHAGO_FINAL_STATUS_ORDER}, "projection_signature": signature, "source": "final_status_projection", "ready": True, }, exception_counts=exception_counts) if not _load_canonical_query_projection_signature_for_range(conn, start_year, end_year) and _enable_active_status_projection_run(): active_status_summary = _load_cached_active_status_projection_run_summary(conn, start_year, end_year) if active_status_summary and clean(active_status_summary.get("source")) == "active_status_projection_run": return _final_status_summary_diagnostics(active_status_summary) active_status_summary = _load_active_status_projection_run_summary( conn, start_year, end_year, allow_rebuild=False, ) if active_status_summary and clean(active_status_summary.get("source")) == "active_status_projection_run": return _final_status_summary_diagnostics(active_status_summary) signature = _load_latest_query_projection_signature_for_range(conn, start_year, end_year) exception_counts = _load_wehago_match_exception_counts(conn, start_year, end_year, signature) if signature: try: projection_rows = conn.execute( text( """ SELECT final_status, COUNT(*) AS voucher_count FROM wehago_compare_final_status_projection WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature GROUP BY final_status """ ), { "start_year": int(start_year), "end_year": int(end_year), "signature": signature, }, ).mappings().all() except OperationalError: projection_rows = [] if projection_rows: universe_rows = conn.execute( text( """ WITH raw_vouchers AS ( SELECT fiscal_year, compare_voucher_no FROM wehago_ledger_rows WHERE fiscal_year BETWEEN :start_year AND :end_year AND COALESCE(compare_voucher_no, '') <> '' GROUP BY fiscal_year, compare_voucher_no ) SELECT COALESCE(p.final_status, 'voucher_unmatched') AS final_status, COUNT(*) AS voucher_count FROM raw_vouchers r LEFT JOIN wehago_compare_final_status_projection p ON p.start_year = :start_year AND p.end_year = :end_year AND p.signature = :signature AND p.fiscal_year = r.fiscal_year AND p.compare_voucher_no = r.compare_voucher_no GROUP BY COALESCE(p.final_status, 'voucher_unmatched') """ ), { "start_year": int(start_year), "end_year": int(end_year), "signature": signature, }, ).mappings().all() for row in universe_rows: status_key = clean(row.get("final_status")) if status_key in final_counts: final_counts[status_key] = int(row.get("voucher_count") or 0) group_rows = conn.execute( text( """ SELECT final_status, SUM(source_group_count) AS group_count FROM wehago_compare_final_status_projection WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature GROUP BY final_status """ ), { "start_year": int(start_year), "end_year": int(end_year), "signature": signature, }, ).mappings().all() for row in group_rows: status_key = clean(row.get("final_status")) if status_key in group_counts: group_counts[status_key] = int(row.get("group_count") or 0) projection_outside_raw_total = int( conn.execute( text( """ WITH raw_vouchers AS ( SELECT fiscal_year, compare_voucher_no FROM wehago_ledger_rows WHERE fiscal_year BETWEEN :start_year AND :end_year AND COALESCE(compare_voucher_no, '') <> '' GROUP BY fiscal_year, compare_voucher_no ) SELECT COUNT(*) FROM wehago_compare_final_status_projection p LEFT JOIN raw_vouchers r ON r.fiscal_year = p.fiscal_year AND r.compare_voucher_no = p.compare_voucher_no WHERE p.start_year = :start_year AND p.end_year = :end_year AND p.signature = :signature AND ( COALESCE(p.compare_voucher_no, '') = '' OR r.compare_voucher_no IS NULL ) """ ), { "start_year": int(start_year), "end_year": int(end_year), "signature": signature, }, ).scalar_one() or 0 ) group_total = sum(group_counts.values()) classified_total = sum(final_counts.values()) return _final_status_summary_diagnostics({ "raw_total": raw_total, "classified_total": classified_total, "difference": classified_total - raw_total, "group_total": classified_total, "group_difference": classified_total - raw_total, "source_group_total": group_total, "source_group_difference": group_total - raw_total, "projection_outside_raw_total": projection_outside_raw_total, "counts": final_counts, "items": [ {"key": key, "label": status_labels[key], "count": int(final_counts.get(key, 0) or 0)} for key in WEHAGO_FINAL_STATUS_ORDER ], "active_signatures": {key: signature for key in WEHAGO_FINAL_STATUS_ORDER}, "projection_signature": signature, "source": "final_status_projection", "ready": classified_total == raw_total, }, exception_counts=exception_counts) group_rows = conn.execute( text( """ SELECT status_key, COUNT(*) AS group_count FROM wehago_compare_query_groups WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature AND status_key IN ('voucher_matched', 'voucher_unmatched', 'voucher_recheck', 'voucher_excepted') GROUP BY status_key """ ), { "start_year": int(start_year), "end_year": int(end_year), "signature": signature, }, ).mappings().all() for row in group_rows: status_key = clean(row.get("status_key")) if status_key in group_counts: group_counts[status_key] = int(row.get("group_count") or 0) priority_case = " ".join( f"WHEN '{status_key}' THEN {priority}" for status_key, priority in WEHAGO_FINAL_STATUS_PRIORITY.items() ) rows = conn.execute( text( f""" WITH raw_vouchers AS ( SELECT fiscal_year, compare_voucher_no FROM wehago_ledger_rows WHERE fiscal_year BETWEEN :start_year AND :end_year AND COALESCE(compare_voucher_no, '') <> '' GROUP BY fiscal_year, compare_voucher_no ), classified AS ( SELECT CASE status_key {priority_case} ELSE 99 END AS status_rank, fiscal_year, CASE WHEN COALESCE(NULLIF(ledger_date, ''), NULLIF(proof_date, ''), '') <> '' AND COALESCE(NULLIF(voucher_no, ''), '') <> '' THEN ( CASE WHEN COALESCE(NULLIF(ledger_date, ''), NULLIF(proof_date, ''), '') GLOB '[0-9][0-9]-[0-9][0-9]' THEN printf('%04d%s', fiscal_year, REPLACE(COALESCE(NULLIF(ledger_date, ''), NULLIF(proof_date, ''), ''), '-', '')) ELSE REPLACE(COALESCE(NULLIF(ledger_date, ''), NULLIF(proof_date, ''), ''), '-', '') END ) || '-' || TRIM( CASE WHEN INSTR(voucher_no, ',') > 0 THEN SUBSTR(voucher_no, 1, INSTR(voucher_no, ',') - 1) ELSE voucher_no END ) ELSE '' END AS compare_voucher_no FROM wehago_compare_query_groups WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature AND status_key IN ('voucher_matched', 'voucher_recheck', 'voucher_unmatched', 'voucher_excepted') ), resolved AS ( SELECT fiscal_year, compare_voucher_no, MIN(status_rank) AS final_rank FROM classified WHERE COALESCE(compare_voucher_no, '') <> '' GROUP BY fiscal_year, compare_voucher_no ) SELECT COALESCE(resolved.final_rank, :unmatched_rank) AS final_rank, COUNT(*) AS voucher_count FROM raw_vouchers LEFT JOIN resolved ON resolved.fiscal_year = raw_vouchers.fiscal_year AND resolved.compare_voucher_no = raw_vouchers.compare_voucher_no GROUP BY COALESCE(resolved.final_rank, :unmatched_rank) """ ), { "start_year": int(start_year), "end_year": int(end_year), "signature": signature, "unmatched_rank": int(WEHAGO_FINAL_STATUS_PRIORITY["voucher_unmatched"]), }, ).mappings().all() rank_to_status = {priority: key for key, priority in WEHAGO_FINAL_STATUS_PRIORITY.items()} for row in rows: status_key = rank_to_status.get(int(row.get("final_rank") or 0)) if status_key: final_counts[status_key] = int(row.get("voucher_count") or 0) elif raw_total > 0: final_counts["voucher_unmatched"] = raw_total group_total = sum(group_counts.values()) classified_total = sum(final_counts.values()) return _final_status_summary_diagnostics({ "raw_total": raw_total, "classified_total": classified_total, "difference": classified_total - raw_total, "group_total": classified_total, "group_difference": classified_total - raw_total, "source_group_total": group_total, "source_group_difference": group_total - raw_total, "counts": final_counts, "items": [ {"key": key, "label": status_labels[key], "count": int(final_counts.get(key, 0) or 0)} for key in WEHAGO_FINAL_STATUS_ORDER ], "active_signatures": {key: signature for key in WEHAGO_FINAL_STATUS_ORDER} if signature else {}, "projection_signature": signature, "source": "query_projection" if signature else "raw_only", "ready": classified_total == raw_total, }, exception_counts=exception_counts) def apply_wehago_final_status_counts_to_metric_sections( metric_sections: list[dict[str, Any]], final_status_summary: dict[str, Any] | None, ) -> list[dict[str, Any]]: if clean((final_status_summary or {}).get("source")) not in { "query_projection", "final_status_projection", "active_status_projection_run", "active_status_projection_groups", "active_status_projection_missing", }: return metric_sections counts = (final_status_summary or {}).get("counts") if not isinstance(counts, dict): return metric_sections group_counts = (final_status_summary or {}).get("source_group_counts") if not isinstance(group_counts, dict): group_counts = {} active_signatures = (final_status_summary or {}).get("active_signatures") if not isinstance(active_signatures, dict): active_signatures = {} for section in metric_sections: status_key = clean(section.get("key")) if status_key in WEHAGO_FINAL_STATUS_ORDER: section["count"] = int(counts.get(status_key, 0) or 0) section["group_count"] = int(group_counts.get(status_key, 0) or section["count"] or 0) section["count_unit"] = "전표" if clean(active_signatures.get(status_key)): section["projection_signature"] = clean(active_signatures.get(status_key)) return metric_sections def annotate_metric_sections_with_active_projection_metadata( conn: Any, metric_sections: list[dict[str, Any]], start_year: int | None, end_year: int | None, ) -> list[dict[str, Any]]: if start_year is None or end_year is None: return metric_sections voucher_statuses = { "voucher_matched", "voucher_unmatched", "voucher_recheck", "voucher_excepted", "hanmac_unconnected", "erp_voucher_matched", "erp_voucher_unmatched", } fallback_statuses = {"hanmac_unconnected", "erp_voucher_matched", "erp_voucher_unmatched"} projection_signatures: dict[str, str] = {} try: projection_counts = _load_active_status_projection_counts_for_range( conn, start_year, end_year, fallback_statuses, ) except Exception: projection_counts = {} for status_key in fallback_statuses: try: active_signature = _active_status_projection_signature( conn, status_key, start_year, end_year, ) except Exception: active_signature = "" if active_signature: projection_signatures[status_key] = active_signature missing_fallback_statuses = { status_key for status_key in fallback_statuses if int(projection_counts.get(status_key, 0) or 0) <= 0 } if missing_fallback_statuses: try: scope_row = conn.execute( text( """ 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 :target_year BETWEEN start_year AND end_year AND signature LIKE :signature_prefix AND status_key IN ('voucher_matched', 'voucher_unmatched', 'voucher_recheck', 'voucher_excepted', 'hanmac_unconnected', 'erp_voucher_matched', 'erp_voucher_unmatched') 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, CASE WHEN signature LIKE '%db-reconciled-v1%' THEN 0 ELSE 1 END ASC, status_count DESC, (end_year - start_year) ASC, max_updated_at DESC LIMIT 1 """ ), { "target_year": int(start_year), "start_year": int(start_year), "end_year": int(end_year), "signature_prefix": f"{QUERY_PROJECTION_VERSION}|%", }, ).mappings().first() if scope_row: placeholders = ", ".join(f":status_{index}" for index, _status in enumerate(missing_fallback_statuses)) params: dict[str, Any] = { "query_start_year": int(scope_row.get("start_year") or start_year), "query_end_year": int(scope_row.get("end_year") or end_year), "start_year": int(start_year), "end_year": int(end_year), "signature": clean(scope_row.get("signature")), **{f"status_{index}": status for index, status in enumerate(sorted(missing_fallback_statuses))}, } rows = conn.execute( text( f""" SELECT status_key, COUNT(*) AS count, MAX(signature) AS signature FROM wehago_compare_query_groups WHERE start_year = :query_start_year AND end_year = :query_end_year AND fiscal_year BETWEEN :start_year AND :end_year AND signature = :signature AND status_key IN ({placeholders}) GROUP BY status_key """ ), params, ).mappings().all() else: rows = [] for row in rows: status_key = clean(row.get("status_key")) projection_counts[status_key] = int(row.get("count") or 0) projection_signatures.setdefault(status_key, clean(row.get("signature"))) except Exception: pass if int(projection_counts.get("hanmac_unconnected", 0) or 0) <= 0: try: projection_counts["hanmac_unconnected"] = len( _load_hanmac_unconnected_source_groups(conn, int(start_year), int(end_year)) ) except Exception: projection_counts["hanmac_unconnected"] = 0 voucher_final_signature = _load_latest_complete_final_status_projection_signature(conn, start_year, end_year) if not voucher_final_signature and _load_canonical_query_projection_signature_for_range(conn, start_year, end_year): voucher_final_signature = _load_canonical_query_projection_signature_for_range(conn, start_year, end_year) for section in metric_sections: status_key = clean(section.get("key")) if status_key not in voucher_statuses: continue section["count_unit"] = "전표" if status_key in WEHAGO_FINAL_STATUS_ORDER else "전표그룹" if status_key == "hanmac_unconnected": section["count_unit"] = "ERP 전표" if status_key in projection_counts: section["count"] = int(projection_counts.get(status_key, section.get("count", 0)) or 0) section["group_count"] = int(section["count"] or 0) if status_key in fallback_statuses and clean(projection_signatures.get(status_key)): section["projection_signature"] = clean(projection_signatures.get(status_key)) elif not clean(section.get("projection_signature")): if status_key in WEHAGO_FINAL_STATUS_ORDER and voucher_final_signature: section["projection_signature"] = voucher_final_signature else: section["projection_signature"] = projection_signatures.get(status_key) or _active_status_projection_signature( conn, status_key, start_year, end_year, ) return metric_sections def _enqueue_compare_refresh_async( engine: Any, start_year: int | None, end_year: int | None, years_to_enqueue: Iterable[int], ) -> None: if start_year is None or end_year is None: return years = sorted({int(year) for year in years_to_enqueue if int(year or 0) > 0}) refresh_key = f"{start_year}:{end_year}:{','.join(str(year) for year in years)}" with _COMPARE_REFRESH_ENQUEUEING_LOCK: if refresh_key in _COMPARE_REFRESH_ENQUEUEING: return _COMPARE_REFRESH_ENQUEUEING.add(refresh_key) def _worker() -> None: try: try: with engine.begin() as conn: backfill_wehago_final_status_projection_from_query_groups(conn, start_year, end_year) except Exception as exc: logger.warning("Skipped async final status projection backfill for %s-%s: %s", start_year, end_year, exc) try: enqueue_query_projection_rebuild(engine, start_year, end_year) except Exception as exc: logger.warning("Skipped async query projection enqueue for %s-%s: %s", start_year, end_year, exc) try: enqueue_metric_count_rebuild(engine, start_year, end_year) except Exception as exc: logger.warning("Skipped async metric count enqueue for %s-%s: %s", start_year, end_year, exc) if years: try: enqueue_year_snapshot_rebuild(engine, years) except Exception as exc: logger.warning("Skipped async year snapshot enqueue for %s-%s: %s", start_year, end_year, exc) finally: with _COMPARE_REFRESH_ENQUEUEING_LOCK: _COMPARE_REFRESH_ENQUEUEING.discard(refresh_key) threading.Thread( target=_worker, daemon=True, name=f"wehago-compare-refresh-enqueue-{start_year}-{end_year}", ).start() 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, ) group_payloads: list[dict[str, Any]] = [] row_payloads: list[dict[str, Any]] = [] 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), ], ) ) group_payloads.append( { "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): row_payloads.append( { "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")), }, ) group_sql = 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 ) """ ) row_sql = 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 ) """ ) for chunk in _chunked(group_payloads): conn.execute(group_sql, chunk) for chunk in _chunked(row_payloads): conn.execute(row_sql, chunk) 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_final_compact_status_projections( 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 for status_key in COMPACT_EXPORT_STATUS_KEYS: signature = _status_projection_signature(conn, status_key, start_year, end_year) if not signature: continue _store_export_compact_status_projection( conn, start_year, end_year, status_key, signature, list(voucher_sections.get(status_key) or []), ) def _group_has_hanmac_candidate_side(group: Mapping[str, Any]) -> bool: summary = dict(group.get("summary") or {}) if clean(summary.get("draft_no")) or clean(summary.get("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 return any(_row_has_hanmac_candidate_side(row) for row in list(group.get("rows") or [])) def _final_semantic_account_family(account_code: Any, account_name: Any) -> str: text = normalize_text(" ".join(part for part in (clean(account_code), clean(account_name)) if part)) if any(token in text for token in ("부가세대급금", "매입세액", "부가세매입", "부가가치세대급금")): return "vat_input" if any(token in text for token in ("부가세예수금", "부가세계수금", "매출세액", "부가세매출", "부가가치세예수금")): return "vat_output" if any( token in text for token in ( "예수금", "근로소득세", "근로소득주민세", "지방소득세", "기타소득세", "기타소득주민세", "사업소득세", "사업소득주민세", "인적용역소득세", "인적용역소득주민세", ) ): return "withholding_payable" if any( token in text for token in ( "기타예금", "단기금융상품", "정기예금", "금융상품", "유가증권", "투자자산", "채권", "cma", "rp", ) ): return "financial_asset" if any(token in text for token in ("차량유지비", "차량렌탈", "차량임차료", "지급임차료차량렌탈")): return "vehicle_rental_cost" if any( token in text for token in ( "보증수수료", "보증보험", "계약보험료", "손해배상보증보험", "선급보증보험", "선급급보증보험", "하자보험료", ) ): return "guarantee_insurance" family = _classify_account_family(account_code, account_name) if family in {"vat_input", "vat_output", "bank", "payable", "receivable", "advance"}: return family return family or "other" def _final_side_family_amounts(group: Mapping[str, Any], prefix: str) -> dict[str, dict[str, float]]: totals: dict[str, dict[str, float]] = {} for row in list(group.get("rows") or []): if prefix == "ledger" and not _row_has_wehago_ledger_side(row): continue if prefix == "voucher" and not _row_has_hanmac_candidate_side(row): continue family = _final_semantic_account_family(row.get(f"{prefix}_account_code"), row.get(f"{prefix}_account_name")) bucket = totals.setdefault(family, {"debit": 0.0, "credit": 0.0}) bucket["debit"] += parse_amount(row.get(f"{prefix}_debit")) bucket["credit"] += parse_amount(row.get(f"{prefix}_credit")) return totals def _amount_pair_close(left: float, right: float, tolerance: float = 0.5) -> bool: return abs(float(left or 0) - float(right or 0)) < tolerance def _family_side_amount_close( ledger_totals: dict[str, dict[str, float]], voucher_totals: dict[str, dict[str, float]], family: str, ) -> bool: ledger = ledger_totals.get(family, {}) voucher = voucher_totals.get(family, {}) return ( ( abs(ledger.get("debit", 0.0)) >= 0.5 and _amount_pair_close(ledger.get("debit", 0.0), voucher.get("debit", 0.0)) ) or ( abs(ledger.get("credit", 0.0)) >= 0.5 and _amount_pair_close(ledger.get("credit", 0.0), voucher.get("credit", 0.0)) ) ) def _group_has_family(group: Mapping[str, Any], family: str, prefix: str | None = None) -> bool: prefixes = [prefix] if prefix else ["ledger", "voucher"] for row in list(group.get("rows") or []): for item_prefix in prefixes: if item_prefix == "ledger" and not _row_has_wehago_ledger_side(row): continue if item_prefix == "voucher" and not _row_has_hanmac_candidate_side(row): continue if _final_semantic_account_family(row.get(f"{item_prefix}_account_code"), row.get(f"{item_prefix}_account_name")) == family: return True return False def _final_group_ledger_date(group: Mapping[str, Any]) -> date | None: summary = dict(group.get("summary") or {}) parsed = _parse_iso_date(summary.get("ledger_date")) if parsed: return parsed fiscal_year = int(summary.get("fiscal_year") or 0) or None parsed = _parse_row_date_with_year({"fiscal_year": fiscal_year or 0, "ledger_date": summary.get("ledger_date")}, "ledger_date") if parsed: return parsed normalized = normalize_wehago_display_date(summary.get("ledger_date"), fiscal_year) parsed = _parse_iso_date(normalized) if parsed: return parsed for row in list(group.get("rows") or []): parsed = _parse_iso_date(row.get("ledger_date")) if parsed: return parsed parsed = _parse_row_date_with_year(row, "ledger_date") if parsed: return parsed normalized = normalize_wehago_display_date(row.get("ledger_date"), int(row.get("fiscal_year") or 0) or fiscal_year) parsed = _parse_iso_date(normalized) if parsed: return parsed return None def _raw_erp_rows_by_draft_base(conn: Any, bases: set[str]) -> dict[str, list[dict[str, Any]]]: raw_rows = _load_raw_erp_rows_for_draft_bases(conn, bases) rows_by_base: dict[str, list[dict[str, Any]]] = {} for raw in raw_rows: base = _erp_draft_base(raw.get("draft_no") or raw.get("confirmed_no")) if base: rows_by_base.setdefault(base, []).append(raw) return rows_by_base def _group_exact_proof_date_bases(conn: Any, group: Mapping[str, Any]) -> set[str]: ledger_date = _final_group_ledger_date(group) if ledger_date is None: return set() bases = _compact_group_draft_bases(group) if not bases: return set() exact_bases: set[str] = set() for base, raw_rows in _raw_erp_rows_by_draft_base(conn, bases).items(): for raw in raw_rows: proof_date = _parse_iso_date(raw.get("proof_date")) account_family = _final_semantic_account_family(raw.get("account_code"), raw.get("account_name")) if proof_date == ledger_date and account_family not in {"vat_input", "vat_output"}: exact_bases.add(base) break return exact_bases def _filter_group_to_draft_bases(group: Mapping[str, Any], bases: set[str]) -> dict[str, Any]: if not bases: return {"summary": dict(group.get("summary") or {}), "rows": [dict(row) for row in list(group.get("rows") or [])]} summary = dict(group.get("summary") or {}) rows: list[dict[str, Any]] = [] kept_base_seen = False 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: if _row_has_wehago_ledger_side(row): rows.append(_blank_voucher_side(dict(row))) continue if draft_base in bases: kept_base_seen = True rows.append(dict(row)) if not kept_base_seen: summary["draft_no"] = ", ".join(sorted(bases)) filtered = _refresh_group_summary_from_rows({"summary": summary, "rows": rows}) if not clean(filtered.get("summary", {}).get("draft_no")): filtered["summary"]["draft_no"] = ", ".join(sorted(bases)) return _append_reason_to_group(filtered, "EXACT_PROOF_DATE_DRAFT_SELECTED") def _withholding_tax_voucher_group_match(group: Mapping[str, Any]) -> bool: if not (_group_has_family(group, "withholding_payable", "ledger") and _group_has_family(group, "withholding_payable", "voucher")): return False ledger_totals = _final_side_family_amounts(group, "ledger") voucher_totals = _final_side_family_amounts(group, "voucher") withholding_ok = _family_side_amount_close(ledger_totals, voucher_totals, "withholding_payable") bank_ok = _family_side_amount_close(ledger_totals, voucher_totals, "bank") ledger_withholding = ledger_totals.get("withholding_payable", {}) voucher_withholding = voucher_totals.get("withholding_payable", {}) has_material_tax = max( abs(ledger_withholding.get("debit", 0.0)), abs(ledger_withholding.get("credit", 0.0)), abs(voucher_withholding.get("debit", 0.0)), abs(voucher_withholding.get("credit", 0.0)), ) >= 0.5 return bool(has_material_tax and withholding_ok and (bank_ok or _section_amount_sides_match(_refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])})))) def _final_group_search_text(group: Mapping[str, Any]) -> str: summary = dict(group.get("summary") or {}) parts: list[str] = [ 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")), ] for row in list(group.get("rows") or []): parts.extend( [ clean(row.get("ledger_account_name")), clean(row.get("voucher_account_name")), clean(row.get("ledger_vendor")), clean(row.get("voucher_vendor")), clean(row.get("ledger_desc")), clean(row.get("voucher_desc")), ] ) return normalize_text(" ".join(part for part in parts if part)) def _financial_transfer_voucher_group_match(group: Mapping[str, Any]) -> bool: if not _group_has_family(group, "bank", "ledger"): return False if not (_group_has_family(group, "bank", "voucher") and _group_has_family(group, "financial_asset", "voucher")): return False refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) if not _section_amount_sides_match(refreshed): return False if _group_has_service_month_conflict_for_guard(refreshed): return False ledger_totals = _final_side_family_amounts(group, "ledger") voucher_totals = _final_side_family_amounts(group, "voucher") financial_ok = ( _family_side_amount_close(ledger_totals, voucher_totals, "financial_asset") or _family_side_amount_close(ledger_totals, voucher_totals, "bank") ) bank_ok = _family_side_amount_close(ledger_totals, voucher_totals, "bank") text = _final_group_search_text(group) transfer_signal = any(token in text for token in ("법인통장간이체", "계좌이체", "기타제예금", "기자제예금", "cma", "rp", "단기사채", "정기예금")) return bool(financial_ok and bank_ok and transfer_signal) def _vehicle_rental_voucher_group_match(conn: Any, group: Mapping[str, Any]) -> bool: if not (_group_has_family(group, "vehicle_rental_cost", "ledger") and _group_has_family(group, "vehicle_rental_cost", "voucher")): return False ledger_date = _final_group_ledger_date(group) if ledger_date is None: return False exact_bases = _group_exact_proof_date_bases(conn, group) if not exact_bases: return False ledger_totals = _final_side_family_amounts(group, "ledger") voucher_totals = _final_side_family_amounts(group, "voucher") cost_ok = _family_side_amount_close(ledger_totals, voucher_totals, "vehicle_rental_cost") payable_ok = _family_side_amount_close(ledger_totals, voucher_totals, "payable") return bool(cost_ok and (payable_ok or _section_amount_sides_match(_refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])})))) def _final_group_promote_reason(conn: Any, group: Mapping[str, Any]) -> str: exact_bases = _group_exact_proof_date_bases(conn, group) if exact_bases and _mixed_recheck_residual_is_covered_by_raw_erp(conn, _filter_group_to_draft_bases(group, exact_bases)): return "EXACT_PROOF_DATE_RESIDUAL_COVERED_MATCH" if _withholding_tax_voucher_group_match(group): return "WITHHOLDING_TAX_ACCOUNT_GROUP_MATCH" if _financial_transfer_voucher_group_match(group): return "FINANCIAL_TRANSFER_ACCOUNT_GROUP_MATCH" if _vehicle_rental_voucher_group_match(conn, group): return "VEHICLE_RENTAL_VAT_EXCEPTION_GROUP_MATCH" if _mixed_recheck_residual_is_covered_by_raw_erp(conn, group): return "MIXED_RESIDUAL_COVERED_BY_ERP_DRAFT_MATCH" return "" def _raw_wehago_ledger_row_to_display_row(raw: Mapping[str, Any], status_label: str) -> dict[str, Any]: fiscal_year = int(raw.get("fiscal_year") or 0) payload = { "fiscal_year": fiscal_year, "status_label": status_label, "ledger_date": normalize_wehago_display_date(raw.get("ledger_date"), fiscal_year or None), "proof_date": "", "voucher_no": clean(raw.get("voucher_no")), "draft_no": "", "ledger_account_code": clean(raw.get("account_code")), "ledger_account_name": clean(raw.get("account_name")), "ledger_vendor": clean(raw.get("vendor_name")), "ledger_debit": parse_amount(raw.get("debit")), "ledger_credit": parse_amount(raw.get("credit")), "ledger_desc": clean(raw.get("description")), "voucher_account_code": "", "voucher_account_name": "", "voucher_vendor": "", "voucher_debit": 0.0, "voucher_credit": 0.0, "voucher_desc": "", "review_reason": "FULL_WEHAGO_VOUCHER_CONTEXT", "matched_case": "FULL_WEHAGO_CONTEXT_ROW", "ledger_source_row_id": clean(raw.get("id")), "ledger_source_file_id": clean(raw.get("source_file_id")), "ledger_source_sheet_name": clean(raw.get("sheet_name")), "ledger_source_row_number": clean(raw.get("row_number")), "ledger_row_key": "", "voucher_row_key": "", "match_identity_key": "", } payload["ledger_row_key"] = build_ledger_row_key(payload) payload["match_identity_key"] = payload["ledger_row_key"] return payload def _display_ledger_content_key(row: Mapping[str, Any]) -> tuple[Any, ...]: fiscal_year = int(row.get("fiscal_year") or 0) return ( fiscal_year, normalize_wehago_display_date(row.get("ledger_date"), fiscal_year or None), clean(row.get("voucher_no")), clean(row.get("ledger_account_name")), clean(row.get("ledger_vendor")), round(parse_amount(row.get("ledger_debit")), 2), round(parse_amount(row.get("ledger_credit")), 2), normalize_text(row.get("ledger_desc")), ) def _display_voucher_content_key(row: Mapping[str, Any]) -> tuple[Any, ...]: return ( clean(row.get("proof_date")), clean(row.get("draft_no")), clean(row.get("voucher_account_name")), clean(row.get("voucher_vendor")), round(parse_amount(row.get("voucher_debit")), 2), round(parse_amount(row.get("voucher_credit")), 2), normalize_text(row.get("voucher_desc")), ) def _blank_wehago_identity_for_erp_only_display(row: dict[str, Any]) -> dict[str, Any]: payload = dict(row) if not _row_has_wehago_ledger_side(payload): payload["ledger_date"] = "" payload["voucher_no"] = "" return payload def _display_row_amount_family_merge_score(ledger_row: Mapping[str, Any], voucher_row: Mapping[str, Any]) -> int: if not _row_has_wehago_ledger_side(ledger_row) or _row_has_hanmac_candidate_side(ledger_row): return -1 if not _row_has_hanmac_candidate_side(voucher_row): return -1 ledger_amount = _get_row_match_amount(ledger_row, "ledger") voucher_amount = _get_row_match_amount(voucher_row, "voucher") if ledger_amount < 0.5 or abs(ledger_amount - voucher_amount) >= 0.5: return -1 ledger_family = _final_semantic_account_family(ledger_row.get("ledger_account_code"), ledger_row.get("ledger_account_name")) voucher_family = _final_semantic_account_family(voucher_row.get("voucher_account_code"), voucher_row.get("voucher_account_name")) score = 1 if ledger_family == voucher_family and ledger_family != "other": score += 5 if _is_vat_family(ledger_family) and _is_vat_family(voucher_family): score += 5 if _text_related(ledger_row.get("ledger_vendor"), voucher_row.get("voucher_vendor")): score += 2 if _text_related(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")): score += 1 if normalize_text(ledger_row.get("ledger_account_name")) and normalize_text(voucher_row.get("voucher_account_name")): if _text_related(ledger_row.get("ledger_account_name"), voucher_row.get("voucher_account_name")): score += 1 return score def _merge_erp_display_row_into_ledger_row(ledger_row: dict[str, Any], voucher_row: Mapping[str, Any]) -> dict[str, Any]: merged = dict(ledger_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", ): merged[field] = voucher_row.get(field, "") merged["review_reason"] = " / ".join( part for part in [clean(ledger_row.get("review_reason")), clean(voucher_row.get("review_reason"))] if part ) merged["matched_case"] = clean(ledger_row.get("matched_case")) or clean(voucher_row.get("matched_case")) or "FULL_ERP_CONTEXT_MERGED" merged["match_identity_key"] = "|".join( part for part in [clean(merged.get("ledger_row_key")), clean(merged.get("voucher_row_key"))] if part ) return merged def _find_mergeable_ledger_only_row_index( rows: list[dict[str, Any]], voucher_row: Mapping[str, Any], used_indices: set[int], ) -> int | None: best_index: int | None = None best_score = -1 for index, row in enumerate(rows): if index in used_indices: continue score = _display_row_amount_family_merge_score(row, voucher_row) if score > best_score: best_score = score best_index = index if best_score < 3: return None return best_index def _raw_erp_display_proof_date(raw: Mapping[str, Any]) -> str: proof_date = clean(raw.get("proof_date")) if proof_date: return proof_date account_family = _final_semantic_account_family(raw.get("account_code"), raw.get("account_name")) if account_family in {"vat_input", "vat_output", "payable", "receivable"}: return "" return _hanmac_voucher_date_from_numbers(raw.get("draft_no"), raw.get("confirmed_no")) def _load_raw_wehago_rows_for_group(conn: Any, group: Mapping[str, Any]) -> list[dict[str, Any]]: summary = dict(group.get("summary") or {}) fiscal_year = int(summary.get("fiscal_year") or 0) voucher_no = clean(summary.get("voucher_no")) ledger_date = normalize_wehago_display_date(summary.get("ledger_date"), fiscal_year or None) if fiscal_year <= 0 or not voucher_no: return [] rows = conn.execute( text( """ SELECT id, source_file_id, sheet_name, row_number, fiscal_year, ledger_date, voucher_no, compare_voucher_no, account_code, account_name, vendor_name, debit, credit, description FROM wehago_ledger_rows WHERE fiscal_year = :fiscal_year AND COALESCE(voucher_no, '') = :voucher_no AND (:ledger_date = '' OR COALESCE(ledger_date, '') = :ledger_date) ORDER BY ledger_date ASC, voucher_no ASC, row_number ASC, id ASC """ ), {"fiscal_year": fiscal_year, "voucher_no": voucher_no, "ledger_date": ledger_date}, ).mappings().all() if rows: return [dict(row) for row in rows] return [ dict(row) for row in conn.execute( text( """ SELECT id, source_file_id, sheet_name, row_number, fiscal_year, ledger_date, voucher_no, compare_voucher_no, account_code, account_name, vendor_name, debit, credit, description FROM wehago_ledger_rows WHERE fiscal_year = :fiscal_year AND COALESCE(voucher_no, '') = :voucher_no ORDER BY ledger_date ASC, voucher_no ASC, row_number ASC, id ASC """ ), {"fiscal_year": fiscal_year, "voucher_no": voucher_no}, ).mappings().all() ] def _load_final_projection_display_context( conn: Any, start_year: int | None, end_year: int | None, voucher_sections: Mapping[str, list[dict[str, Any]]], ) -> dict[str, Any]: anchor_keys: set[tuple[int, str, str]] = set() draft_bases: set[str] = set() for status_key in ("voucher_matched", "voucher_recheck", "voucher_unmatched", "voucher_excepted"): for group in list(voucher_sections.get(status_key) or []): summary = dict(group.get("summary") or {}) fiscal_year = int(summary.get("fiscal_year") or 0) voucher_no = clean(summary.get("voucher_no")) ledger_date = normalize_wehago_display_date(summary.get("ledger_date"), fiscal_year or None) if fiscal_year > 0 and voucher_no: anchor_keys.add((fiscal_year, ledger_date, voucher_no)) draft_bases.update(_compact_group_draft_bases(group)) ledger_by_anchor: dict[tuple[int, str, str], list[dict[str, Any]]] = {} if anchor_keys and start_year is not None and end_year is not None: rows = conn.execute( text( """ SELECT id, source_file_id, sheet_name, row_number, fiscal_year, ledger_date, voucher_no, compare_voucher_no, account_code, account_name, vendor_name, debit, credit, description FROM wehago_ledger_rows WHERE fiscal_year BETWEEN :start_year AND :end_year ORDER BY fiscal_year ASC, ledger_date ASC, voucher_no ASC, row_number ASC, id ASC """ ), {"start_year": int(start_year), "end_year": int(end_year)}, ).mappings().all() for row in rows: raw = dict(row) key = ( int(raw.get("fiscal_year") or 0), normalize_wehago_display_date(raw.get("ledger_date"), int(raw.get("fiscal_year") or 0) or None), clean(raw.get("voucher_no")), ) if key in anchor_keys: ledger_by_anchor.setdefault(key, []).append(raw) raw_erp_by_base: dict[str, list[dict[str, Any]]] = {} for chunk in _chunked([{"base": base} for base in sorted(draft_bases)], 250): bases = {clean(item.get("base")) for item in chunk if clean(item.get("base"))} for raw in _load_raw_erp_rows_for_draft_bases(conn, bases): base = _erp_draft_base(raw.get("draft_no") or raw.get("confirmed_no")) if base: raw_erp_by_base.setdefault(base, []).append(raw) raw_erp_by_proof_date: dict[str, list[dict[str, Any]]] = {} if start_year is not None and end_year is not None: erp_start_year, erp_end_year = get_hanmac_erp_candidate_year_range(start_year, end_year) proof_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, row_number, id FROM wehago_voucher_rows WHERE fiscal_year BETWEEN :start_year AND :end_year AND COALESCE(proof_date, '') <> '' AND COALESCE(draft_no, '') <> '' ORDER BY proof_date ASC, draft_no ASC, row_number ASC, id ASC """ ), {"start_year": int(erp_start_year or start_year), "end_year": int(erp_end_year or end_year)}, ).mappings().all() for row in proof_rows: raw = dict(row) proof_date = clean(raw.get("proof_date")) if proof_date: raw_erp_by_proof_date.setdefault(proof_date, []).append(raw) base = _erp_draft_base(raw.get("draft_no") or raw.get("confirmed_no")) if base: raw_erp_by_base.setdefault(base, []).append(raw) return {"ledger_by_anchor": ledger_by_anchor, "raw_erp_by_base": raw_erp_by_base, "raw_erp_by_proof_date": raw_erp_by_proof_date} def _context_exact_proof_date_bases(raw_erp_by_base: Mapping[str, list[dict[str, Any]]], group: Mapping[str, Any]) -> set[str]: ledger_date = _final_group_ledger_date(group) if ledger_date is None: return set() exact_bases: set[str] = set() for base in _compact_group_draft_bases(group): for raw in list(raw_erp_by_base.get(base) or []): proof_date = _parse_iso_date(raw.get("proof_date")) account_family = _final_semantic_account_family(raw.get("account_code"), raw.get("account_name")) if proof_date == ledger_date and account_family not in {"vat_input", "vat_output"}: exact_bases.add(base) break return exact_bases def _raw_erp_candidate_amount(raw: Mapping[str, Any]) -> float: return max(abs(parse_amount(raw.get("debit_supply"))), abs(parse_amount(raw.get("credit_supply")))) def _raw_erp_candidate_text(raw: Mapping[str, Any]) -> str: return " ".join([clean(raw.get("account_name")), clean(raw.get("vendor_name")), clean(raw.get("desc1")), clean(raw.get("desc2"))]) def _select_external_exact_proof_date_bases( context: Mapping[str, Any], group: Mapping[str, Any], ) -> set[str]: ledger_date = _final_group_ledger_date(group) if ledger_date is None: return set() proof_key = ledger_date.isoformat() raw_by_date = context.get("raw_erp_by_proof_date") if isinstance(context.get("raw_erp_by_proof_date"), dict) else {} candidates = list(raw_by_date.get(proof_key) or []) if not candidates: return set() ledger_rows = [ dict(row) for row in list(group.get("rows") or []) if _row_has_wehago_ledger_side(row) and _final_semantic_account_family(row.get("ledger_account_code"), row.get("ledger_account_name")) not in {"bank", "payable", "receivable", "advance"} and _get_row_match_amount(row, "ledger") >= 0.5 ] if not ledger_rows: return set() current_bases = _compact_group_draft_bases(group) scored: dict[str, float] = {} for raw in candidates: base = _erp_draft_base(raw.get("draft_no") or raw.get("confirmed_no")) if not base: continue raw_amount = _raw_erp_candidate_amount(raw) if raw_amount < 0.5: continue raw_family = _final_semantic_account_family(raw.get("account_code"), raw.get("account_name")) raw_text = _raw_erp_candidate_text(raw) for ledger_row in ledger_rows: ledger_amount = _get_row_match_amount(ledger_row, "ledger") if abs(ledger_amount - raw_amount) >= 0.5: continue ledger_family = _final_semantic_account_family(ledger_row.get("ledger_account_code"), ledger_row.get("ledger_account_name")) score = 1.0 if ledger_family == raw_family or _is_vat_family(raw_family): score += 2.0 if _text_related(ledger_row.get("ledger_desc"), raw_text) or _text_related(ledger_row.get("ledger_vendor"), raw.get("vendor_name")): score += 2.0 if base in current_bases: score += 0.5 scored[base] = max(scored.get(base, 0.0), score) if not scored: return set() best_score = max(scored.values()) if best_score < 3.0: return set() return {base for base, score in scored.items() if score == best_score} def _ensure_raw_erp_bases_loaded( conn: Any, raw_erp_by_base: dict[str, list[dict[str, Any]]], bases: set[str], ) -> dict[str, list[dict[str, Any]]]: requested_bases = {clean(base) for base in bases if clean(base)} if not requested_bases: return raw_erp_by_base seen_lines = { base: {clean(raw.get("draft_no")) for raw in list(raw_erp_by_base.get(base) or []) if clean(raw.get("draft_no"))} for base in requested_bases } for raw in _load_raw_erp_rows_for_draft_bases(conn, requested_bases): base = _erp_draft_base(raw.get("draft_no") or raw.get("confirmed_no")) draft_line = clean(raw.get("draft_no")) if base and draft_line not in seen_lines.setdefault(base, set()): raw_erp_by_base.setdefault(base, []).append(raw) if draft_line: seen_lines[base].add(draft_line) return raw_erp_by_base def _context_mixed_recheck_residual_is_covered_by_raw_erp( raw_erp_by_base: Mapping[str, list[dict[str, Any]]], group: Mapping[str, Any], ) -> bool: rows = [dict(row) for row in list(group.get("rows") or [])] residual_rows = [ row for row in rows if _row_has_wehago_ledger_side(row) and not _row_has_hanmac_candidate_side(row) ] if not residual_rows: return False represented = {_draft_line_key(row.get("draft_no")) for row in rows if _row_has_hanmac_candidate_side(row)} available_raw: list[dict[str, Any]] = [] for base in _compact_group_draft_bases(group): available_raw.extend( raw for raw in list(raw_erp_by_base.get(base) or []) if _draft_line_key(raw.get("draft_no")) not in represented ) if not available_raw: return False residual_debit = round(sum(parse_amount(row.get("ledger_debit")) for row in residual_rows), 2) residual_credit = round(sum(parse_amount(row.get("ledger_credit")) for row in residual_rows), 2) residual_desc = " ".join(clean(row.get("ledger_desc")) for row in residual_rows) residual_account = " ".join(clean(row.get("ledger_account_name")) for row in residual_rows) for raw in available_raw: raw_debit = round(parse_amount(raw.get("debit_supply")), 2) raw_credit = round(parse_amount(raw.get("credit_supply")), 2) amount_matches = ( (abs(residual_debit) >= 0.5 and abs(raw_debit - residual_debit) < 0.5) or (abs(residual_credit) >= 0.5 and abs(raw_credit - residual_credit) < 0.5) ) if not amount_matches: continue raw_text = " ".join([clean(raw.get("account_name")), clean(raw.get("desc1")), clean(raw.get("desc2"))]) if _text_related(residual_desc, raw_text) or _text_related(residual_account, raw.get("account_name")): return True raw_debit_total = round(sum(parse_amount(raw.get("debit_supply")) for raw in available_raw), 2) raw_credit_total = round(sum(parse_amount(raw.get("credit_supply")) for raw in available_raw), 2) if ( (abs(residual_debit) >= 0.5 and abs(raw_debit_total - residual_debit) < 0.5) or (abs(residual_credit) >= 0.5 and abs(raw_credit_total - residual_credit) < 0.5) ): raw_account_text = " ".join(clean(raw.get("account_name")) for raw in available_raw) raw_desc_text = " ".join(" ".join([clean(raw.get("desc1")), clean(raw.get("desc2"))]) for raw in available_raw) if _text_related(residual_account, raw_account_text) or _text_related(residual_desc, raw_desc_text): return True return False def _context_vehicle_rental_voucher_group_match( raw_erp_by_base: Mapping[str, list[dict[str, Any]]], group: Mapping[str, Any], ) -> bool: if not (_group_has_family(group, "vehicle_rental_cost", "ledger") and _group_has_family(group, "vehicle_rental_cost", "voucher")): return False if not _context_exact_proof_date_bases(raw_erp_by_base, group): return False ledger_totals = _final_side_family_amounts(group, "ledger") voucher_totals = _final_side_family_amounts(group, "voucher") cost_ok = _family_side_amount_close(ledger_totals, voucher_totals, "vehicle_rental_cost") payable_ok = _family_side_amount_close(ledger_totals, voucher_totals, "payable") return bool(cost_ok and (payable_ok or _section_amount_sides_match(_refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])})))) def _row_direct_amount_match(row: Mapping[str, Any]) -> bool: if not (_row_has_wehago_ledger_side(row) and _row_has_hanmac_candidate_side(row)): return False return ( abs(parse_amount(row.get("ledger_debit")) - parse_amount(row.get("voucher_debit"))) < 0.5 and abs(parse_amount(row.get("ledger_credit")) - parse_amount(row.get("voucher_credit"))) < 0.5 and _get_row_match_amount(dict(row), "ledger") >= 0.5 ) def _row_has_negative_amount(row: Mapping[str, Any]) -> bool: def signed_amount(value: Any) -> float: text_value = clean(value).replace(",", "").strip() if not text_value: return 0.0 if text_value.startswith("(") and text_value.endswith(")"): return -parse_amount(text_value[1:-1]) return parse_amount(text_value) return any( signed_amount(row.get(field)) < -0.5 for field in ("ledger_debit", "ledger_credit", "voucher_debit", "voucher_credit") ) def _signed_field_amount(value: Any) -> float: text_value = clean(value).replace(",", "").strip() if not text_value: return 0.0 if text_value.startswith("(") and text_value.endswith(")"): return -parse_amount(text_value[1:-1]) return parse_amount(text_value) def _context_row_signed_amount(row: Mapping[str, Any], prefix: str) -> float: return _signed_field_amount(row.get(f"{prefix}_debit")) - _signed_field_amount(row.get(f"{prefix}_credit")) def _row_direct_signed_amount_match(row: Mapping[str, Any]) -> bool: if not (_row_has_wehago_ledger_side(row) and _row_has_material_voucher_values(row)): return False ledger_signed = _context_row_signed_amount(row, "ledger") voucher_signed = _context_row_signed_amount(row, "voucher") return abs(ledger_signed) >= 0.5 and abs(ledger_signed - voucher_signed) < 0.5 def _row_family(row: Mapping[str, Any], prefix: str) -> str: return _final_semantic_account_family(row.get(f"{prefix}_account_code"), row.get(f"{prefix}_account_name")) def _direct_ledger_amount_by_families(group: Mapping[str, Any], families: set[str]) -> float: return round( sum( _get_row_match_amount(dict(row), "ledger") for row in list(group.get("rows") or []) if _row_direct_amount_match(row) and _row_family(row, "ledger") in families ), 2, ) def _ledger_side_amount_by_families(group: Mapping[str, Any], families: set[str]) -> float: return round( sum( _get_row_match_amount(dict(row), "ledger") for row in list(group.get("rows") or []) if _row_has_wehago_ledger_side(row) and _row_family(row, "ledger") in families ), 2, ) def _has_strong_direct_cost_vat_context(group: Mapping[str, Any]) -> bool: direct_rows = [row for row in list(group.get("rows") or []) if _row_direct_amount_match(row)] if not direct_rows: return False has_vat = any(_is_vat_family(_row_family(row, "ledger")) and _is_vat_family(_row_family(row, "voucher")) for row in direct_rows) has_cost_or_other = any( _row_family(row, "ledger") not in {"bank", "payable", "receivable", "vat_input", "vat_output"} and _row_family(row, "voucher") not in {"bank", "payable", "receivable", "vat_input", "vat_output"} for row in direct_rows ) context = any( _recheck_context_overlap(row, row) for row in direct_rows if clean(row.get("ledger_vendor")) or clean(row.get("ledger_desc")) ) return bool(has_cost_or_other and (has_vat or len(direct_rows) >= 2) and context) def _has_direct_business_cost_context(group: Mapping[str, Any]) -> bool: for row in list(group.get("rows") or []): if not _row_direct_amount_match(row): continue ledger_family = _row_family(row, "ledger") voucher_family = _row_family(row, "voucher") if ledger_family in {"bank", "payable", "receivable", "vat_input", "vat_output"}: continue if voucher_family in {"bank", "payable", "receivable", "vat_input", "vat_output"}: continue if _recheck_context_overlap(row, row): return True return False def _row_has_material_ledger_values(row: Mapping[str, Any]) -> bool: return bool( clean(row.get("ledger_account_name")) or clean(row.get("ledger_vendor")) 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 ) def _row_has_material_voucher_values(row: Mapping[str, Any]) -> bool: return bool( clean(row.get("voucher_account_name")) or clean(row.get("voucher_vendor")) or clean(row.get("voucher_desc")) or abs(parse_amount(row.get("voucher_debit"))) >= 0.5 or abs(parse_amount(row.get("voucher_credit"))) >= 0.5 ) def _ledger_unmatched_amount_by_families(group: Mapping[str, Any], families: set[str]) -> float: return round( sum( _get_row_match_amount(dict(row), "ledger") for row in list(group.get("rows") or []) if _row_has_wehago_ledger_side(row) and not _row_has_material_voucher_values(row) and _row_family(row, "ledger") in families ), 2, ) def _voucher_unmatched_amount_by_families(group: Mapping[str, Any], families: set[str]) -> float: return round( sum( _get_row_match_amount(dict(row), "voucher") for row in list(group.get("rows") or []) if not _row_has_material_ledger_values(row) and _row_has_hanmac_candidate_side(row) and _row_family(row, "voucher") in families and not _row_has_negative_amount(row) ), 2, ) def _context_lg_monthly_special_bundle_match( raw_erp_by_base: Mapping[str, list[dict[str, Any]]], group: Mapping[str, Any], ) -> str: if not _context_exact_proof_date_bases(raw_erp_by_base, group): return "" refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) if _group_has_service_month_conflict_for_guard(refreshed): return "" text = _final_group_search_text(refreshed) is_webhard = "엘지유플러스" in text and "웹하드" in text is_air_purifier = "엘지전자" in text and "공기청정기" in text if not (is_webhard or is_air_purifier): return "" if any(_row_has_negative_amount(row) for row in list(refreshed.get("rows") or [])): return "" if not (_has_strong_direct_cost_vat_context(refreshed) or _has_direct_business_cost_context(refreshed)): return "" ledger_total = max( parse_amount(refreshed.get("summary", {}).get("ledger_debit")), parse_amount(refreshed.get("summary", {}).get("ledger_credit")), ) voucher_total = max( parse_amount(refreshed.get("summary", {}).get("voucher_debit")), parse_amount(refreshed.get("summary", {}).get("voucher_credit")), ) if ledger_total < 0.5 or voucher_total < ledger_total - 0.5: return "" direct_cost_vat = _direct_ledger_amount_by_families(refreshed, {"vat_input", "vat_output", "other", "vehicle_rental_cost"}) ledger_cost_vat = _ledger_side_amount_by_families(refreshed, {"vat_input", "vat_output", "other", "vehicle_rental_cost"}) unmatched_ledger_tax_payable = _ledger_unmatched_amount_by_families(refreshed, {"vat_input", "vat_output", "payable", "receivable"}) unmatched_voucher_tax_payable = _voucher_unmatched_amount_by_families(refreshed, {"vat_input", "vat_output", "payable", "receivable"}) if direct_cost_vat < 0.5: return "" if abs(direct_cost_vat - ledger_cost_vat) >= 0.5 and unmatched_voucher_tax_payable < unmatched_ledger_tax_payable - 0.5: return "" return "LG_WEBHARD_MONTHLY_BUNDLE_MATCH" if is_webhard else "LG_AIR_PURIFIER_RENTAL_BUNDLE_MATCH" def _context_group_has_exact_or_draft_date_base( raw_erp_by_base: Mapping[str, list[dict[str, Any]]], group: Mapping[str, Any], ) -> bool: if _context_exact_proof_date_bases(raw_erp_by_base, group): return True ledger_date = _final_group_ledger_date(group) if ledger_date is None: return False for base in _compact_group_draft_bases(group): match = re.search(r"(20\d{2})(\d{2})(\d{2})", clean(base)) if not match: continue try: if date(int(match.group(1)), int(match.group(2)), int(match.group(3))) == ledger_date: return True except ValueError: continue return False def _context_property_fee_wehago_fulfilled_match( raw_erp_by_base: Mapping[str, list[dict[str, Any]]], group: Mapping[str, Any], ) -> bool: if not _context_exact_proof_date_bases(raw_erp_by_base, group): return False refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) text = _final_group_search_text(refreshed) if "송파웰츠타워관리위원회" not in text or "관리비" not in text: return False if any(_row_has_negative_amount(row) for row in list(refreshed.get("rows") or [])): return False ledger_debit = parse_amount(refreshed.get("summary", {}).get("ledger_debit")) direct_debit = round( sum(parse_amount(row.get("ledger_debit")) for row in list(refreshed.get("rows") or []) if _row_direct_amount_match(row)), 2, ) if ledger_debit < 0.5 or abs(ledger_debit - direct_debit) >= 0.5: return False unmatched_ledger = [ row for row in list(refreshed.get("rows") or []) if _row_has_wehago_ledger_side(row) and not _row_has_material_voucher_values(row) ] if not unmatched_ledger: return False if not all(_row_family(row, "ledger") in {"payable", "receivable", "bank"} for row in unmatched_ledger): return False unmatched_ledger_amount = _ledger_unmatched_amount_by_families(refreshed, {"payable", "receivable", "bank"}) unmatched_voucher_amount = _voucher_unmatched_amount_by_families(refreshed, {"payable", "receivable", "bank", "other"}) return unmatched_voucher_amount >= unmatched_ledger_amount - 0.5 def _context_positive_tax_invoice_with_adjustment_match( raw_erp_by_base: Mapping[str, list[dict[str, Any]]], group: Mapping[str, Any], ) -> bool: if not _context_exact_proof_date_bases(raw_erp_by_base, group): return False refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) rows = [dict(row) for row in list(refreshed.get("rows") or [])] negative_voucher_rows = [ row for row in rows if not _row_has_material_ledger_values(row) and _row_has_hanmac_candidate_side(row) and _row_has_negative_amount(row) ] if not negative_voucher_rows: return False if any(_row_has_negative_amount(row) for row in rows if _row_has_wehago_ledger_side(row)): return False positive_unmatched_voucher = [ row for row in rows if not _row_has_material_ledger_values(row) and _row_has_hanmac_candidate_side(row) and not _row_has_negative_amount(row) and _get_row_match_amount(row, "voucher") >= 0.5 ] if positive_unmatched_voucher: return False direct_rows = [row for row in rows if _row_direct_amount_match(row)] if not direct_rows: return False direct_debit = round(sum(parse_amount(row.get("ledger_debit")) for row in direct_rows), 2) ledger_debit = parse_amount(refreshed.get("summary", {}).get("ledger_debit")) ledger_credit = parse_amount(refreshed.get("summary", {}).get("ledger_credit")) if ledger_debit < 0.5 or abs(direct_debit - ledger_debit) >= 0.5: return False unmatched_ledger = [ row for row in rows if _row_has_wehago_ledger_side(row) and not _row_has_material_voucher_values(row) ] if not unmatched_ledger or abs(sum(parse_amount(row.get("ledger_credit")) for row in unmatched_ledger) - ledger_credit) >= 0.5: return False if not all(_row_family(row, "ledger") in {"payable", "receivable"} for row in unmatched_ledger): return False has_vat = any(_is_vat_family(_row_family(row, "ledger")) and _is_vat_family(_row_family(row, "voucher")) for row in direct_rows) has_cost = any(_row_family(row, "ledger") not in {"vat_input", "vat_output", "payable", "receivable", "bank"} for row in direct_rows) if not (has_vat and has_cost): return False return any( _recheck_context_overlap(row, row) or _text_related(row.get("ledger_vendor"), row.get("voucher_vendor")) or _text_related(row.get("ledger_account_name"), row.get("voucher_account_name")) or _text_related(row.get("ledger_desc"), row.get("voucher_desc")) for row in direct_rows ) def _context_signed_tax_invoice_adjustment_match( raw_erp_by_base: Mapping[str, list[dict[str, Any]]], group: Mapping[str, Any], ) -> bool: if not _context_group_has_exact_or_draft_date_base(raw_erp_by_base, group): return False refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) rows = [dict(row) for row in list(refreshed.get("rows") or [])] direct_rows = [row for row in rows if _row_direct_signed_amount_match(row)] if len(direct_rows) < 2: return False direct_families = {(_row_family(row, "ledger"), _row_family(row, "voucher")) for row in direct_rows} has_sales_adjustment = ("sales", "sales") in direct_families and ("vat_output", "vat_output") in direct_families has_purchase_adjustment = ( ("vat_input", "vat_input") in direct_families and any( left not in {"vat_input", "vat_output", "payable", "receivable", "bank"} and right not in {"vat_input", "vat_output", "payable", "receivable", "bank"} for left, right in direct_families ) ) if not (has_sales_adjustment or has_purchase_adjustment): return False if any(_row_sides_have_opposite_signed_amount(dict(row)) for row in direct_rows): return False if not any(_row_has_negative_amount(row) for row in rows if _row_has_wehago_ledger_side(row)): return False direct_ledger_signed = round(sum(_context_row_signed_amount(row, "ledger") for row in direct_rows), 2) direct_voucher_signed = round(sum(_context_row_signed_amount(row, "voucher") for row in direct_rows), 2) if abs(direct_ledger_signed - direct_voucher_signed) >= 0.5: return False unmatched_ledger = [ row for row in rows if _row_has_wehago_ledger_side(row) and not _row_has_material_voucher_values(row) ] unmatched_voucher = [ row for row in rows if _row_has_material_voucher_values(row) and not _row_has_material_ledger_values(row) ] if not unmatched_ledger or not unmatched_voucher: return False if not all(_row_family(row, "ledger") in {"payable", "receivable"} for row in unmatched_ledger): return False if not all(_row_family(row, "voucher") in {"payable", "receivable"} for row in unmatched_voucher): return False ledger_clearing = round(sum(_context_row_signed_amount(row, "ledger") for row in unmatched_ledger), 2) voucher_clearing = round(sum(_context_row_signed_amount(row, "voucher") for row in unmatched_voucher), 2) if abs(direct_ledger_signed + ledger_clearing) >= 0.5: return False if abs(direct_voucher_signed + voucher_clearing) >= 0.5: return False return any( _recheck_context_overlap(row, row) or _text_related(row.get("ledger_vendor"), row.get("voucher_vendor")) or _text_related(row.get("ledger_desc"), row.get("voucher_desc")) for row in direct_rows ) def _context_guarantee_insurance_group_match( raw_erp_by_base: Mapping[str, list[dict[str, Any]]], group: Mapping[str, Any], ) -> bool: refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) text = _final_group_search_text(refreshed) if not any( token in text for token in ("보증수수료", "보증보험", "공제조합", "계약보험료", "손해배상보증보험", "선급보증보험", "선급급보증보험", "하자보험료") ): return False rows = [dict(row) for row in list(refreshed.get("rows") or [])] direct_rows = [ row for row in rows if _row_direct_amount_match(row) and _row_family(row, "ledger") == "guarantee_insurance" and _row_family(row, "voucher") == "guarantee_insurance" ] if not direct_rows: return False ledger_cost = round(sum(_get_row_match_amount(row, "ledger") for row in direct_rows), 2) voucher_cost = round(sum(_get_row_match_amount(row, "voucher") for row in direct_rows), 2) if ledger_cost < 0.5 or abs(ledger_cost - voucher_cost) >= 0.5: return False ledger_bank = _ledger_unmatched_amount_by_families(refreshed, {"bank"}) voucher_clearing = _voucher_unmatched_amount_by_families(refreshed, {"payable", "bank"}) if ledger_bank < ledger_cost - 0.5: return False if voucher_clearing < ledger_cost - 0.5: return False return any( _text_related(row.get("ledger_vendor"), row.get("voucher_vendor")) or _text_related(row.get("ledger_desc"), row.get("voucher_desc")) for row in direct_rows ) def _context_aggregated_vat_with_adjustment_match( raw_erp_by_base: Mapping[str, list[dict[str, Any]]], group: Mapping[str, Any], ) -> bool: if not _context_group_has_exact_or_draft_date_base(raw_erp_by_base, group): return False refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) rows = [dict(row) for row in list(refreshed.get("rows") or [])] direct_cost_rows = [ row for row in rows if _row_direct_amount_match(row) and _row_family(row, "ledger") not in {"vat_input", "vat_output", "payable", "receivable", "bank"} and _row_family(row, "voucher") not in {"vat_input", "vat_output", "payable", "receivable", "bank"} and ( _text_related(row.get("ledger_vendor"), row.get("voucher_vendor")) or _text_related(row.get("ledger_desc"), row.get("voucher_desc")) ) ] if not direct_cost_rows: return False ledger_vat = _ledger_unmatched_amount_by_families(refreshed, {"vat_input", "vat_output"}) ledger_payable = _ledger_unmatched_amount_by_families(refreshed, {"payable", "receivable"}) voucher_vat = _voucher_unmatched_amount_by_families(refreshed, {"vat_input", "vat_output"}) negative_voucher_amount = round( sum( abs(_context_row_signed_amount(row, "voucher")) for row in rows if not _row_has_material_ledger_values(row) and _row_has_material_voucher_values(row) and _row_has_negative_amount(row) ), 2, ) if ledger_vat < 0.5 or ledger_payable < 0.5: return False if voucher_vat < ledger_vat - 0.5: return False if negative_voucher_amount < ledger_payable - 0.5: return False return True def _context_payable_payment_bundle_line_match( raw_erp_by_base: Mapping[str, list[dict[str, Any]]], group: Mapping[str, Any], ) -> bool: if not _context_group_has_exact_or_draft_date_base(raw_erp_by_base, group): return False refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) if _group_has_service_month_conflict_for_guard(refreshed): return False if any(_row_has_negative_amount(row) for row in list(refreshed.get("rows") or [])): return False if _context_wehago_cost_against_erp_payment_bridge_only(refreshed): return False rows = [dict(row) for row in list(refreshed.get("rows") or [])] direct_payable = [ row for row in rows if _row_direct_amount_match(row) and _row_family(row, "ledger") in {"payable", "receivable"} and _row_family(row, "voucher") in {"payable", "receivable"} and _get_row_match_amount(row, "ledger") >= 0.5 and (_text_related(row.get("ledger_vendor"), row.get("voucher_vendor")) or _recheck_context_overlap(row, row)) ] if not direct_payable: return False direct_amount = round(sum(_get_row_match_amount(row, "ledger") for row in direct_payable), 2) ledger_bank_amount = _ledger_unmatched_amount_by_families(refreshed, {"bank"}) if ledger_bank_amount < 0.5 or abs(ledger_bank_amount - direct_amount) >= 0.5: return False voucher_bank_amount = _voucher_unmatched_amount_by_families(refreshed, {"bank"}) if voucher_bank_amount < ledger_bank_amount - 0.5: return False ledger_totals = _final_side_family_amounts(refreshed, "ledger") ledger_bank = ledger_totals.get("bank", {}) has_bank_credit = ledger_bank.get("credit", 0.0) >= 0.5 has_bank_debit = ledger_bank.get("debit", 0.0) >= 0.5 if not (has_bank_credit or has_bank_debit): return False return True def _context_row_text(row: Mapping[str, Any], prefix: str | None = None) -> str: if prefix: fields = ( f"{prefix}_account_name", f"{prefix}_vendor", f"{prefix}_desc", ) else: fields = ( "ledger_account_name", "ledger_vendor", "ledger_desc", "voucher_account_name", "voucher_vendor", "voucher_desc", ) return normalize_text(" ".join(clean(row.get(field)) for field in fields)) def _is_final_business_family(family: str) -> bool: return bool(family and family not in {"bank", "payable", "receivable", "advance", "vat_input", "vat_output"}) def _context_wehago_cost_against_erp_payment_bridge_only(group: Mapping[str, Any]) -> bool: rows = [dict(row) for row in list(group.get("rows") or [])] ledger_business = round( sum( _get_row_match_amount(row, "ledger") for row in rows if _row_has_wehago_ledger_side(row) and _is_final_business_family(_row_family(row, "ledger")) ), 2, ) if ledger_business < 0.5: return False voucher_payment = round( sum( _get_row_match_amount(row, "voucher") for row in rows if _row_has_material_voucher_values(row) and _row_family(row, "voucher") in {"bank", "payable"} ), 2, ) if voucher_payment < ledger_business - 0.5: return False direct_business = round( sum( _get_row_match_amount(row, "ledger") for row in rows if _row_direct_amount_match(row) and _is_final_business_family(_row_family(row, "ledger")) and _is_final_business_family(_row_family(row, "voucher")) ), 2, ) return direct_business < ledger_business - 0.5 def _context_tax_offset_partial_counterpart_recheck(group: Mapping[str, Any]) -> bool: refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) rows = [dict(row) for row in list(refreshed.get("rows") or [])] ledger_families = { _row_family(row, "ledger") for row in rows if _row_has_wehago_ledger_side(row) and _get_row_match_amount(row, "ledger") >= 0.5 } if not ({"vat_input", "vat_output"} <= ledger_families): return False if not ({"receivable", "payable"} & ledger_families): return False has_direct_counterpart = any( _row_direct_amount_match(row) and _row_family(row, "ledger") in {"receivable", "payable"} and _row_family(row, "voucher") in {"receivable", "payable"} for row in rows ) if not has_direct_counterpart: return False return not _section_amount_sides_match(refreshed) def _amount_by_row_predicate(rows: list[dict[str, Any]], prefix: str, predicate: Callable[[dict[str, Any]], bool]) -> float: return round( sum(_get_row_match_amount(row, prefix) for row in rows if predicate(row)), 2, ) def _context_receivable_bank_fee_net_settlement_match( raw_erp_by_base: Mapping[str, list[dict[str, Any]]], group: Mapping[str, Any], ) -> bool: refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) if _group_has_service_month_conflict_for_guard(refreshed): return False rows = [dict(row) for row in list(refreshed.get("rows") or [])] direct_receivable = [ row for row in rows if _row_direct_amount_match(row) and _row_family(row, "ledger") in {"receivable", "payable"} and _row_family(row, "voucher") in {"receivable", "payable"} and ( _text_related(row.get("ledger_vendor"), row.get("voucher_vendor")) or _text_related(row.get("ledger_desc"), row.get("voucher_desc")) or _text_related(row.get("ledger_account_name"), row.get("voucher_account_name")) ) ] if not direct_receivable: return False ledger_bank = _amount_by_row_predicate( rows, "ledger", lambda row: _row_has_wehago_ledger_side(row) and _row_family(row, "ledger") == "bank", ) voucher_bank = _amount_by_row_predicate( rows, "voucher", lambda row: _row_has_material_voucher_values(row) and _row_family(row, "voucher") == "bank", ) bank_diff = abs(ledger_bank - voucher_bank) if bank_diff < 0.5: return False fee_tokens = ("수입인지", "인지대", "세금과공과", "지급수수료", "수수료") fee_amount = round( sum( max(_get_row_match_amount(row, "ledger"), _get_row_match_amount(row, "voucher")) for row in rows if any(token in _context_row_text(row) for token in fee_tokens) ), 2, ) if fee_amount < 0.5 or abs(bank_diff - fee_amount) >= 0.5: return False return any(_recheck_context_overlap(row, row) or _text_related(row.get("ledger_vendor"), row.get("voucher_vendor")) for row in direct_receivable) def _is_misc_income_loss_row(row: Mapping[str, Any], prefix: str) -> bool: text = _context_row_text(row, prefix) return "잡이익" in text or "잡손실" in text def _context_minor_misc_income_loss_residual_match( raw_erp_by_base: Mapping[str, list[dict[str, Any]]], group: Mapping[str, Any], tolerance: float = 1000.0, ) -> bool: refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) if _group_has_service_month_conflict_for_guard(refreshed): return False rows = [dict(row) for row in list(refreshed.get("rows") or [])] core_direct_rows = [ row for row in rows if _row_direct_amount_match(row) and not _is_misc_income_loss_row(row, "ledger") and not _is_misc_income_loss_row(row, "voucher") and ( _row_family(row, "ledger") == _row_family(row, "voucher") or _is_vat_family(_row_family(row, "ledger")) and _is_vat_family(_row_family(row, "voucher")) or _text_related(row.get("ledger_vendor"), row.get("voucher_vendor")) or _text_related(row.get("ledger_desc"), row.get("voucher_desc")) ) ] if len(core_direct_rows) < 2: return False residual_rows = [ row for row in rows if ( (_row_has_wehago_ledger_side(row) and not _row_has_material_voucher_values(row)) or (_row_has_material_voucher_values(row) and not _row_has_wehago_ledger_side(row)) or (_row_has_wehago_ledger_side(row) and _row_has_material_voucher_values(row) and _row_sides_have_opposite_signed_amount(row)) ) ] misc_rows = [ row for row in residual_rows if _is_misc_income_loss_row(row, "ledger") or _is_misc_income_loss_row(row, "voucher") ] if not misc_rows or len(misc_rows) != len(residual_rows): return False ledger_misc = round(sum(abs(_context_row_signed_amount(row, "ledger")) for row in misc_rows), 2) voucher_misc = round(sum(abs(_context_row_signed_amount(row, "voucher")) for row in misc_rows), 2) if max(ledger_misc, voucher_misc) > tolerance: return False return abs(ledger_misc - voucher_misc) < 0.5 def _context_exact_proof_date_voucher_group_match( raw_erp_by_base: Mapping[str, list[dict[str, Any]]], group: Mapping[str, Any], ) -> bool: if not _context_exact_proof_date_bases(raw_erp_by_base, group): return False refreshed = _refresh_group_summary_from_rows( {"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])} ) if not _section_amount_sides_match(refreshed): return False if _group_has_service_month_conflict_for_guard(refreshed): return False if _group_has_tax_anchor_date_conflict_for_guard(refreshed): return False rows = [dict(row) for row in list(refreshed.get("rows") or [])] if not any(_row_has_wehago_ledger_side(row) for row in rows): return False if not any(_row_has_hanmac_candidate_side(row) for row in rows): return False ledger_families = { _final_semantic_account_family(row.get("ledger_account_code"), row.get("ledger_account_name")) for row in rows if _row_has_wehago_ledger_side(row) and _get_row_match_amount(row, "ledger") >= 0.5 } voucher_families = { _final_semantic_account_family(row.get("voucher_account_code"), row.get("voucher_account_name")) for row in rows if _row_has_hanmac_candidate_side(row) and _get_row_match_amount(row, "voucher") >= 0.5 } meaningful_family_match = bool((ledger_families - {"other"}) & (voucher_families - {"other"})) tax_invoice_family_match = bool( {"vat_input", "payable"} & ledger_families and {"vat_input", "payable"} & voucher_families ) text_match = any( _recheck_context_overlap(row, row) for row in rows if _row_has_wehago_ledger_side(row) and _row_has_hanmac_candidate_side(row) ) if meaningful_family_match or tax_invoice_family_match or text_match: return True ledger_text = " ".join( " ".join([clean(row.get("ledger_vendor")), clean(row.get("ledger_desc")), clean(row.get("ledger_account_name"))]) for row in rows if _row_has_wehago_ledger_side(row) ) voucher_text = " ".join( " ".join([clean(row.get("voucher_vendor")), clean(row.get("voucher_desc")), clean(row.get("voucher_account_name"))]) for row in rows if _row_has_hanmac_candidate_side(row) ) return _text_related(ledger_text, voucher_text) def _context_final_group_promote_reason( raw_erp_by_base: Mapping[str, list[dict[str, Any]]], group: Mapping[str, Any], ) -> str: if _context_tax_offset_partial_counterpart_recheck(group): return "" exact_bases = _context_exact_proof_date_bases(raw_erp_by_base, group) if exact_bases and _context_mixed_recheck_residual_is_covered_by_raw_erp(raw_erp_by_base, _filter_group_to_draft_bases(group, exact_bases)): return "EXACT_PROOF_DATE_RESIDUAL_COVERED_MATCH" lg_reason = _context_lg_monthly_special_bundle_match(raw_erp_by_base, group) if lg_reason: return lg_reason if _context_property_fee_wehago_fulfilled_match(raw_erp_by_base, group): return "WEHAGO_FULFILLED_PROPERTY_FEE_MATCH" if _context_positive_tax_invoice_with_adjustment_match(raw_erp_by_base, group): return "POSITIVE_TAX_INVOICE_WITH_ERP_ADJUSTMENT_MATCH" if _context_signed_tax_invoice_adjustment_match(raw_erp_by_base, group): return "SIGNED_TAX_INVOICE_ADJUSTMENT_MATCH" if _context_guarantee_insurance_group_match(raw_erp_by_base, group): return "GUARANTEE_INSURANCE_GROUP_MATCH" if _context_aggregated_vat_with_adjustment_match(raw_erp_by_base, group): return "AGGREGATED_VAT_WITH_ADJUSTMENT_MATCH" if _context_receivable_bank_fee_net_settlement_match(raw_erp_by_base, group): return "RECEIVABLE_BANK_FEE_NET_SETTLEMENT_MATCH" if _context_minor_misc_income_loss_residual_match(raw_erp_by_base, group): return "MINOR_MISC_INCOME_LOSS_RESIDUAL_MATCH" if _context_payable_payment_bundle_line_match(raw_erp_by_base, group): return "PAYABLE_PAYMENT_BUNDLE_LINE_MATCH" if _withholding_tax_voucher_group_match(group): return "WITHHOLDING_TAX_ACCOUNT_GROUP_MATCH" if _financial_transfer_voucher_group_match(group): return "FINANCIAL_TRANSFER_ACCOUNT_GROUP_MATCH" if _context_vehicle_rental_voucher_group_match(raw_erp_by_base, group): return "VEHICLE_RENTAL_VAT_EXCEPTION_GROUP_MATCH" if _context_exact_proof_date_voucher_group_match(raw_erp_by_base, group): return "EXACT_PROOF_DATE_VOUCHER_GROUP_MATCH" if _context_mixed_recheck_residual_is_covered_by_raw_erp(raw_erp_by_base, group): return "MIXED_RESIDUAL_COVERED_BY_ERP_DRAFT_MATCH" return "" def _context_final_group_hold_recheck_reason(group: Mapping[str, Any]) -> str: if _context_tax_offset_partial_counterpart_recheck(group): return "TAX_OFFSET_PARTIAL_COUNTERPART_RECHECK" return "" def _context_card_payment_bridge_partial_recheck(group: Mapping[str, Any]) -> str: refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) rows = [dict(row) for row in list(refreshed.get("rows") or [])] negative_business_direct = [ row for row in rows if _row_direct_signed_amount_match(row) and _is_final_business_family(_row_family(row, "ledger")) and _is_final_business_family(_row_family(row, "voucher")) and _row_has_negative_amount(row) ] if not negative_business_direct: return "" bank_direct = [ row for row in rows if _row_direct_amount_match(row) and _row_family(row, "ledger") == "bank" and _row_family(row, "voucher") == "bank" ] if not bank_direct: return "" negative_amount = round(sum(abs(_context_row_signed_amount(row, "ledger")) for row in negative_business_direct), 2) bank_amount = round(sum(_get_row_match_amount(row, "ledger") for row in bank_direct), 2) if negative_amount < 0.5 or bank_amount < max(negative_amount * 5, 100000): return "" return "CARD_PAYMENT_BRIDGE_PARTIAL_COST_ADJUSTMENT_RECHECK" def _group_has_material_hanmac_side(group: Mapping[str, Any]) -> bool: summary = dict(group.get("summary") or {}) if clean(summary.get("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 return any(_row_has_material_voucher_values(row) for row in list(group.get("rows") or [])) def _group_direct_compatible_row_count(group: Mapping[str, Any]) -> int: count = 0 for row in list(group.get("rows") or []): if not (_row_has_wehago_ledger_side(row) and _row_has_material_voucher_values(row)): continue if _row_sides_have_opposite_signed_amount(dict(row)): continue ledger_family = _row_family(row, "ledger") voucher_family = _row_family(row, "voucher") if ledger_family == voucher_family and ledger_family != "other": count += 1 continue if _row_direct_amount_match(row) and ( _account_base_names_compatible(row.get("ledger_account_name"), row.get("voucher_account_name")) or _text_related(row.get("ledger_vendor"), row.get("voucher_vendor")) or _text_related(row.get("ledger_desc"), row.get("voucher_desc")) ): count += 1 return count def _group_has_only_invalid_opposite_direction_candidates(group: Mapping[str, Any]) -> bool: paired_rows = [ dict(row) for row in list(group.get("rows") or []) if _row_has_wehago_ledger_side(row) and _row_has_material_voucher_values(row) ] if not paired_rows: return False return all(_row_sides_have_opposite_signed_amount(row) for row in paired_rows) def _group_draft_date_distance(group: Mapping[str, Any]) -> int: ledger_date = _final_group_ledger_date(group) if ledger_date is None: return 999999 distances: list[int] = [] for base in _compact_group_draft_bases(group): match = re.search(r"(20\d{2})(\d{2})(\d{2})", clean(base)) if not match: continue try: draft_date = date(int(match.group(1)), int(match.group(2)), int(match.group(3))) except ValueError: continue distances.append(abs((ledger_date - draft_date).days)) return min(distances) if distances else 999999 def _group_has_vat_ledger_anchor(group: Mapping[str, Any]) -> bool: return any( _is_vat_family(_row_family(row, "ledger")) for row in list(group.get("rows") or []) if _row_has_wehago_ledger_side(row) ) def _recheck_group_should_demote_to_unmatched(group: Mapping[str, Any]) -> str: if not _group_has_material_hanmac_side(group): return "RECHECK_WITHOUT_MATERIAL_ERP_TO_UNMATCHED" if _group_has_only_invalid_opposite_direction_candidates(group): return "RECHECK_ONLY_OPPOSITE_SIGN_CANDIDATES_TO_UNMATCHED" if _group_has_vat_ledger_anchor(group) and _group_draft_date_distance(group) > 45: return "RECHECK_VAT_DRAFT_DATE_DISTANCE_TO_UNMATCHED" if _group_direct_compatible_row_count(group) == 0: summary = dict(group.get("summary") or {}) text = _final_group_search_text(group) has_bridge_hint = any(token in text for token in ("현대카드", "국민카드", "법인카드", "카드", "상계", "부가세", "세무서", "공제조합", "보험", "미수금", "미지급금")) if parse_amount(summary.get("voucher_debit")) >= max(parse_amount(summary.get("ledger_debit")) * 10, 100000): return "RECHECK_WITHOUT_DIRECT_COMPATIBLE_ROW_TO_UNMATCHED" if not has_bridge_hint and parse_amount(summary.get("voucher_debit")) >= parse_amount(summary.get("ledger_debit")) * 3: return "RECHECK_WITHOUT_DIRECT_COMPATIBLE_ROW_TO_UNMATCHED" return "" def _complete_wehago_voucher_display_group_with_context( context: Mapping[str, Any], group: Mapping[str, Any], status_key: str, ) -> dict[str, Any] | None: status_label = _final_compact_group_status_label(status_key) rows = [dict(row) for row in list(group.get("rows") or [])] summary = dict(group.get("summary") or {}) anchor = _wehago_anchor_key_from_parts(summary.get("fiscal_year"), summary.get("ledger_date"), summary.get("voucher_no")) seen_ledger = { clean(row.get("ledger_row_key")) or build_ledger_row_key(row) for row in rows if _row_has_wehago_ledger_side(row) } seen_ledger_content = { _display_ledger_content_key(row) for row in rows if _row_has_wehago_ledger_side(row) } ledger_by_anchor = context.get("ledger_by_anchor") if isinstance(context.get("ledger_by_anchor"), dict) else {} for raw in list(ledger_by_anchor.get(anchor) or []): display_row = _raw_wehago_ledger_row_to_display_row(raw, status_label) row_key = clean(display_row.get("ledger_row_key")) content_key = _display_ledger_content_key(display_row) if row_key and row_key not in seen_ledger and content_key not in seen_ledger_content: rows.append(display_row) seen_ledger.add(row_key) seen_ledger_content.add(content_key) raw_erp_by_base = context.get("raw_erp_by_base") if isinstance(context.get("raw_erp_by_base"), dict) else {} bases = _compact_group_draft_bases({"summary": summary, "rows": rows}) exact_bases = _context_exact_proof_date_bases(raw_erp_by_base, {"summary": summary, "rows": rows}) if exact_bases: bases = exact_bases seen_voucher = { clean(row.get("voucher_row_key")) or build_voucher_row_key(row) for row in rows if _row_has_hanmac_candidate_side(row) } seen_draft_lines = { clean(row.get("draft_no")) for row in rows if _row_has_hanmac_candidate_side(row) and clean(row.get("draft_no")) } seen_voucher_content = { _display_voucher_content_key(row) for row in rows if _row_has_hanmac_candidate_side(row) } merged_ledger_indices: set[int] = set() for base in sorted(bases): for raw in list(raw_erp_by_base.get(base) or []): display_row = _raw_erp_recheck_row(raw, anchor) display_row["status_label"] = status_label display_row["review_reason"] = "FULL_ERP_DRAFT_CONTEXT" display_row["matched_case"] = "FULL_ERP_CONTEXT_ROW" display_row = _blank_wehago_identity_for_erp_only_display(display_row) row_key = clean(display_row.get("voucher_row_key")) or build_voucher_row_key(display_row) draft_line = clean(display_row.get("draft_no")) content_key = _display_voucher_content_key(display_row) merge_index = _find_mergeable_ledger_only_row_index(rows, display_row, merged_ledger_indices) if ( merge_index is not None and row_key and row_key not in seen_voucher and draft_line not in seen_draft_lines and content_key not in seen_voucher_content ): rows[merge_index] = _merge_erp_display_row_into_ledger_row(rows[merge_index], display_row) merged_ledger_indices.add(merge_index) seen_voucher.add(row_key) if draft_line: seen_draft_lines.add(draft_line) seen_voucher_content.add(content_key) continue if row_key and row_key not in seen_voucher and draft_line not in seen_draft_lines and content_key not in seen_voucher_content: rows.append(display_row) seen_voucher.add(row_key) if draft_line: seen_draft_lines.add(draft_line) seen_voucher_content.add(content_key) completed = _refresh_group_summary_from_rows({"summary": summary, "rows": _sanitize_voucher_group_rows(rows)}) completed["summary"]["status_label"] = status_label if clean(summary.get("review_reason")): completed["summary"]["review_reason"] = clean(summary.get("review_reason")) if status_key.startswith("voucher_") and not any(_row_has_wehago_ledger_side(row) for row in completed.get("rows") or []): return None return completed def _complete_wehago_voucher_display_group(conn: Any, group: Mapping[str, Any], status_key: str) -> dict[str, Any] | None: status_label = _final_compact_group_status_label(status_key) rows = [dict(row) for row in list(group.get("rows") or [])] seen_ledger = { clean(row.get("ledger_row_key")) or build_ledger_row_key(row) for row in rows if _row_has_wehago_ledger_side(row) } for raw in _load_raw_wehago_rows_for_group(conn, group): display_row = _raw_wehago_ledger_row_to_display_row(raw, status_label) row_key = clean(display_row.get("ledger_row_key")) if row_key and row_key not in seen_ledger: rows.append(display_row) seen_ledger.add(row_key) bases = _compact_group_draft_bases({"summary": dict(group.get("summary") or {}), "rows": rows}) exact_bases = _group_exact_proof_date_bases(conn, {"summary": dict(group.get("summary") or {}), "rows": rows}) if exact_bases: bases = exact_bases seen_voucher = { clean(row.get("voucher_row_key")) or build_voucher_row_key(row) for row in rows if _row_has_hanmac_candidate_side(row) } anchor = _wehago_anchor_key_from_parts( (group.get("summary") or {}).get("fiscal_year"), (group.get("summary") or {}).get("ledger_date"), (group.get("summary") or {}).get("voucher_no"), ) for raw in _load_raw_erp_rows_for_draft_bases(conn, bases): display_row = _raw_erp_recheck_row(raw, anchor) display_row["status_label"] = status_label display_row["review_reason"] = "FULL_ERP_DRAFT_CONTEXT" display_row["matched_case"] = "FULL_ERP_CONTEXT_ROW" row_key = clean(display_row.get("voucher_row_key")) or build_voucher_row_key(display_row) if row_key and row_key not in seen_voucher: rows.append(display_row) seen_voucher.add(row_key) completed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": _sanitize_voucher_group_rows(rows)}) completed["summary"]["status_label"] = status_label if clean((group.get("summary") or {}).get("review_reason")): completed["summary"]["review_reason"] = clean((group.get("summary") or {}).get("review_reason")) if status_key.startswith("voucher_") and not any(_row_has_wehago_ledger_side(row) for row in completed.get("rows") or []): return None return completed def _final_compact_group_status_label(status_key: str) -> str: if status_key == "voucher_matched": return "Matched" if status_key == "voucher_recheck": return "Recheck" if status_key == "voucher_excepted": return "Excepted" if status_key == "erp_voucher_matched": return "Matched" if status_key == "erp_voucher_unmatched": return "ERP Unmatched" if status_key == "hanmac_unconnected": return "Hanmac unconnected" return "Unmatched" def _sanitize_wehago_display_group_rows(group: Mapping[str, Any], status_key: str) -> dict[str, Any] | None: status_label = _final_compact_group_status_label(status_key) summary = dict(group.get("summary") or {}) rows: list[dict[str, Any]] = [] for raw_row in list(group.get("rows") or []): row = dict(raw_row) if status_key.startswith("voucher_") and not _row_has_wehago_ledger_side(row): continue if not _row_has_material_voucher_values(row): row = _blank_voucher_side(row) if not _row_has_wehago_ledger_side(row): row = _blank_ledger_side(row) row["status_label"] = status_label rows.append(row) if status_key.startswith("voucher_") and not rows: return None refreshed = _refresh_group_summary_from_rows({"summary": summary, "rows": rows}) refreshed["summary"]["status_label"] = status_label if clean(summary.get("review_reason")): refreshed["summary"]["review_reason"] = clean(summary.get("review_reason")) return refreshed def _compact_group_draft_bases(group: Mapping[str, Any]) -> set[str]: values: list[Any] = [] summary = dict(group.get("summary") or {}) values.append(summary.get("draft_no")) for row in list(group.get("rows") or []): values.append(row.get("draft_no")) bases: set[str] = set() for value in values: for candidate in _split_erp_number_candidates(value): base = _erp_draft_base(candidate) if base: bases.add(base) return bases def _load_raw_erp_rows_for_draft_bases(conn: Any, bases: set[str]) -> list[dict[str, Any]]: if not bases: return [] conditions: list[str] = [] params: dict[str, Any] = {} for index, base in enumerate(sorted(bases)): key = f"draft_base_{index}" conditions.append( f"(COALESCE(draft_no, '') = :{key} OR COALESCE(draft_no, '') LIKE :{key}_suffix)" ) params[key] = base params[f"{key}_suffix"] = f"{base}-%" rows = conn.execute( text( f""" SELECT fiscal_year, proof_date, confirmed_no, draft_no, account_code, account_name, debit_supply, credit_supply, vendor_name, desc1, desc2, row_number, id FROM wehago_voucher_rows WHERE {' OR '.join(conditions)} ORDER BY fiscal_year ASC, draft_no ASC, row_number ASC, id ASC """ ), params, ).mappings().all() return [dict(row) for row in rows] def _draft_line_key(value: Any) -> str: return clean(value) def _text_related(left: Any, right: Any) -> bool: left_text = normalize_text(left) right_text = normalize_text(right) if not left_text or not right_text: return True if left_text in right_text or right_text in left_text: return True return SequenceMatcher(None, left_text, right_text).ratio() >= 0.55 def _mixed_recheck_residual_is_covered_by_raw_erp(conn: Any, group: Mapping[str, Any]) -> bool: rows = [dict(row) for row in list(group.get("rows") or [])] residual_rows = [ row for row in rows if _row_has_wehago_ledger_side(row) and not _row_has_hanmac_candidate_side(row) ] if not residual_rows: return False bases = _compact_group_draft_bases(group) if not bases: return False raw_rows = _load_raw_erp_rows_for_draft_bases(conn, bases) if not raw_rows: return False represented = {_draft_line_key(row.get("draft_no")) for row in rows if _row_has_hanmac_candidate_side(row)} available_raw = [ row for row in raw_rows if _draft_line_key(row.get("draft_no")) not in represented ] if not available_raw: return False residual_debit = round(sum(parse_amount(row.get("ledger_debit")) for row in residual_rows), 2) residual_credit = round(sum(parse_amount(row.get("ledger_credit")) for row in residual_rows), 2) residual_desc = " ".join(clean(row.get("ledger_desc")) for row in residual_rows) residual_account = " ".join(clean(row.get("ledger_account_name")) for row in residual_rows) for raw in available_raw: raw_debit = round(parse_amount(raw.get("debit_supply")), 2) raw_credit = round(parse_amount(raw.get("credit_supply")), 2) amount_matches = ( (abs(residual_debit) >= 0.5 and abs(raw_debit - residual_debit) < 0.5) or (abs(residual_credit) >= 0.5 and abs(raw_credit - residual_credit) < 0.5) ) if not amount_matches: continue raw_text = " ".join([clean(raw.get("account_name")), clean(raw.get("desc1")), clean(raw.get("desc2"))]) if _text_related(residual_desc, raw_text) or _text_related(residual_account, raw.get("account_name")): return True raw_debit_total = round(sum(parse_amount(raw.get("debit_supply")) for raw in available_raw), 2) raw_credit_total = round(sum(parse_amount(raw.get("credit_supply")) for raw in available_raw), 2) if ( (abs(residual_debit) >= 0.5 and abs(raw_debit_total - residual_debit) < 0.5) or (abs(residual_credit) >= 0.5 and abs(raw_credit_total - residual_credit) < 0.5) ): raw_account_text = " ".join(clean(raw.get("account_name")) for raw in available_raw) raw_desc_text = " ".join(" ".join([clean(raw.get("desc1")), clean(raw.get("desc2"))]) for raw in available_raw) if _text_related(residual_account, raw_account_text) or _text_related(residual_desc, raw_desc_text): return True return False def _append_reason_to_group(group: dict[str, Any], reason: str) -> dict[str, Any]: summary = group.setdefault("summary", {}) existing = clean(summary.get("review_reason")) summary["review_reason"] = " / ".join(item for item in [existing, reason] if item) for row in list(group.get("rows") or []): existing_row = clean(row.get("review_reason")) row["review_reason"] = " / ".join(item for item in [existing_row, reason] if item) return group def _apply_final_compact_projection_normalization( conn: Any, start_year: int | None, end_year: int | None, voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: normalized = {key: list(value or []) for key, value in voucher_sections.items()} for status_key in COMPACT_EXPORT_STATUS_KEYS: normalized.setdefault(status_key, []) display_context = _load_final_projection_display_context(conn, start_year, end_year, normalized) raw_erp_by_base = display_context.get("raw_erp_by_base") if isinstance(display_context.get("raw_erp_by_base"), dict) else {} retained_recheck: list[dict[str, Any]] = [] demoted_unmatched: list[dict[str, Any]] = [] promoted_matched: list[dict[str, Any]] = [] for group in list(normalized.get("voucher_recheck") or []): exact_bases = _context_exact_proof_date_bases(raw_erp_by_base, group) if not exact_bases: exact_bases = _select_external_exact_proof_date_bases(display_context, group) raw_erp_by_base = _ensure_raw_erp_bases_loaded(conn, raw_erp_by_base, exact_bases) if exact_bases: group = _filter_group_to_draft_bases(group, exact_bases) raw_erp_by_base = display_context.get("raw_erp_by_base") if isinstance(display_context.get("raw_erp_by_base"), dict) else raw_erp_by_base hold_reason = _context_final_group_hold_recheck_reason(group) if hold_reason: clean_group = _sanitize_wehago_display_group_rows(group, "voucher_recheck") if clean_group is not None: retained_recheck.append(_append_reason_to_group(clean_group, hold_reason)) continue demote_reason = _recheck_group_should_demote_to_unmatched(group) if demote_reason: clean_group = _sanitize_wehago_display_group_rows(group, "voucher_unmatched") if clean_group is not None: demoted_unmatched.append(_append_reason_to_group(clean_group, demote_reason)) continue if not _group_has_hanmac_candidate_side(group): clean_group = _sanitize_wehago_display_group_rows(group, "voucher_unmatched") if clean_group is not None: demoted_unmatched.append(_append_reason_to_group(clean_group, "RECHECK_WITHOUT_ERP_CANDIDATE_TO_UNMATCHED")) continue promote_reason = _context_final_group_promote_reason(raw_erp_by_base, group) if promote_reason: clean_group = _sanitize_wehago_display_group_rows(group, "voucher_matched") if clean_group is not None: promoted_matched.append(_append_reason_to_group(clean_group, promote_reason)) continue clean_group = _sanitize_wehago_display_group_rows(group, "voucher_recheck") if clean_group is not None: retained_recheck.append(clean_group) normalized["voucher_recheck"] = retained_recheck normalized["voucher_unmatched"] = list(normalized.get("voucher_unmatched") or []) + demoted_unmatched normalized["voucher_matched"] = list(normalized.get("voucher_matched") or []) + promoted_matched matched_to_recheck: list[dict[str, Any]] = [] for status_key in ("voucher_matched", "voucher_unmatched", "voucher_excepted"): cleaned_groups: list[dict[str, Any]] = [] for group in list(normalized.get(status_key) or []): if status_key == "voucher_matched": exact_bases = _context_exact_proof_date_bases(raw_erp_by_base, group) if not exact_bases: exact_bases = _select_external_exact_proof_date_bases(display_context, group) raw_erp_by_base = _ensure_raw_erp_bases_loaded(conn, raw_erp_by_base, exact_bases) if exact_bases: group = _filter_group_to_draft_bases(group, exact_bases) partial_recheck_reason = _context_card_payment_bridge_partial_recheck(group) if partial_recheck_reason: clean_recheck = _sanitize_wehago_display_group_rows(group, "voucher_recheck") if clean_recheck is not None: matched_to_recheck.append(_append_reason_to_group(clean_recheck, partial_recheck_reason)) continue clean_group = _sanitize_wehago_display_group_rows(group, status_key) if clean_group is not None: cleaned_groups.append(clean_group) normalized[status_key] = cleaned_groups if matched_to_recheck: normalized["voucher_recheck"] = list(normalized.get("voucher_recheck") or []) + matched_to_recheck normalized = _dedupe_wehago_sections_to_single_status(normalized) normalized = _dedupe_erp_sections_to_single_status(normalized) for status_key in ("voucher_matched", "voucher_recheck", "voucher_unmatched", "voucher_excepted"): completed_groups: list[dict[str, Any]] = [] for group in list(normalized.get(status_key) or []): if status_key in {"voucher_matched", "voucher_recheck"}: exact_bases = _context_exact_proof_date_bases(raw_erp_by_base, group) if not exact_bases: exact_bases = _select_external_exact_proof_date_bases(display_context, group) raw_erp_by_base = _ensure_raw_erp_bases_loaded(conn, raw_erp_by_base, exact_bases) if exact_bases: group = _filter_group_to_draft_bases(group, exact_bases) completed = _complete_wehago_voucher_display_group_with_context(display_context, group, status_key) if completed is not None: completed_groups.append(completed) normalized[status_key] = completed_groups completed_matched: list[dict[str, Any]] = [] completed_matched_to_recheck: list[dict[str, Any]] = [] for group in list(normalized.get("voucher_matched") or []): partial_recheck_reason = _context_card_payment_bridge_partial_recheck(group) if partial_recheck_reason: clean_recheck = _sanitize_wehago_display_group_rows(group, "voucher_recheck") if clean_recheck is not None: completed_matched_to_recheck.append(_append_reason_to_group(clean_recheck, partial_recheck_reason)) continue completed_matched.append(group) normalized["voucher_matched"] = completed_matched if completed_matched_to_recheck: normalized["voucher_recheck"] = list(normalized.get("voucher_recheck") or []) + completed_matched_to_recheck final_retained_recheck: list[dict[str, Any]] = [] final_promoted_matched: list[dict[str, Any]] = [] final_demoted_unmatched: list[dict[str, Any]] = [] for group in list(normalized.get("voucher_recheck") or []): hold_reason = _context_final_group_hold_recheck_reason(group) if hold_reason: final_retained_recheck.append(_append_reason_to_group(group, hold_reason)) continue demote_reason = _recheck_group_should_demote_to_unmatched(group) if demote_reason: clean_group = _sanitize_wehago_display_group_rows(group, "voucher_unmatched") if clean_group is not None: final_demoted_unmatched.append(_append_reason_to_group(clean_group, demote_reason)) continue promote_reason = _context_final_group_promote_reason(raw_erp_by_base, group) if promote_reason: clean_group = _sanitize_wehago_display_group_rows(group, "voucher_matched") if clean_group is not None: final_promoted_matched.append(_append_reason_to_group(clean_group, promote_reason)) continue final_retained_recheck.append(group) normalized["voucher_recheck"] = final_retained_recheck if final_promoted_matched: normalized["voucher_matched"] = list(normalized.get("voucher_matched") or []) + final_promoted_matched if final_demoted_unmatched: normalized["voucher_unmatched"] = list(normalized.get("voucher_unmatched") or []) + final_demoted_unmatched normalized = _dedupe_wehago_sections_to_single_status(normalized) return normalized def _json_safe_projection_payload(value: Any) -> Any: if isinstance(value, dict): return {str(key): _json_safe_projection_payload(item) for key, item in value.items()} if isinstance(value, (list, tuple)): return [_json_safe_projection_payload(item) for item in value] if isinstance(value, set): return sorted(_json_safe_projection_payload(item) for item in value) if isinstance(value, (date, datetime)): return value.isoformat() return value def _chunked(items: list[dict[str, Any]], size: int = 1000) -> Iterable[list[dict[str, Any]]]: for index in range(0, len(items), max(int(size or 1000), 1)): yield items[index : index + max(int(size or 1000), 1)] def _store_status_query_group_projection( conn: Any, start_year: int | None, end_year: int | None, status_key: str, signature: str, groups: list[dict[str, Any]], ) -> None: if start_year is None or end_year is None or not clean(status_key) or not clean(signature): return normalized_status = clean(status_key) conn.execute( text( """ DELETE FROM wehago_compare_query_rows WHERE start_year = :start_year AND end_year = :end_year AND status_key = :status_key AND signature = :signature """ ), { "start_year": int(start_year), "end_year": int(end_year), "status_key": normalized_status, "signature": clean(signature), }, ) conn.execute( text( """ DELETE FROM wehago_compare_query_groups WHERE start_year = :start_year AND end_year = :end_year AND status_key = :status_key AND signature = :signature """ ), { "start_year": int(start_year), "end_year": int(end_year), "status_key": normalized_status, "signature": clean(signature), }, ) group_payloads: list[dict[str, Any]] = [] row_payloads: list[dict[str, Any]] = [] 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), ], ) ) group_payloads.append( { "start_year": int(start_year), "end_year": int(end_year), "status_key": normalized_status, "signature": clean(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): row_payloads.append( { "start_year": int(start_year), "end_year": int(end_year), "status_key": normalized_status, "signature": clean(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")), } ) group_sql = 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 ) """ ) row_sql = 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 ) """ ) for chunk in _chunked(group_payloads): conn.execute(group_sql, chunk) for chunk in _chunked(row_payloads): conn.execute(row_sql, chunk) _store_active_status_projection_signature(conn, normalized_status, start_year, end_year, signature, len(groups)) 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 = _effective_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 signature = _effective_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 query_hash = :query_hash AND signature = :signature LIMIT 1 """ ), { "start_year": start_year, "end_year": end_year, "status_key": status_key, "query_hash": clean(query_hash), "signature": signature, }, ).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 status_signature = "" if normalized_status == "hanmac_unconnected": status_signature = _active_status_projection_signature(conn, normalized_status, start_year, end_year) projection_scope = ( (int(start_year), int(end_year), status_signature) if status_signature else _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), } has_detail_filter = any( [ clean(voucher_no), clean(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), ] ) 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 _final_status_label(status_key: str) -> str: if status_key == "voucher_matched": return "Matched" if status_key == "voucher_recheck": return "Recheck" if status_key == "voucher_excepted": return "Excepted" return "Unmatched" def _voucher_summary_display_row(summary: dict[str, Any]) -> dict[str, Any]: return { "fiscal_year": int(summary.get("fiscal_year") or 0), "ledger_date": clean(summary.get("ledger_date")), "voucher_no": clean(summary.get("voucher_no")), "draft_no": clean(summary.get("draft_no")), "ledger_account_name": clean(summary.get("ledger_accounts")), "voucher_account_name": clean(summary.get("voucher_accounts")), "ledger_vendor": clean(summary.get("ledger_vendors")), "voucher_vendor": clean(summary.get("voucher_vendors")), "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_desc": clean(summary.get("review_reason")), "voucher_desc": "", "review_reason": clean(summary.get("review_reason")), } def _raw_ledger_group_to_final_group( raw_group: dict[str, Any], normalized_status: str, *, review_reason: str = "", ) -> dict[str, Any]: rows = [dict(row) for row in list(raw_group.get("rows") or [])] ledger_accounts: list[str] = [] ledger_vendors: list[str] = [] ledger_debit = 0.0 ledger_credit = 0.0 for row in rows: account_name = clean(row.get("ledger_account_name")) vendor_name = clean(row.get("ledger_vendor")) if account_name and account_name not in ledger_accounts: ledger_accounts.append(account_name) if vendor_name and vendor_name not in ledger_vendors: ledger_vendors.append(vendor_name) ledger_debit += parse_amount(row.get("ledger_debit")) ledger_credit += parse_amount(row.get("ledger_credit")) row["status_label"] = _final_status_label(normalized_status) row["review_reason"] = clean(row.get("review_reason")) or review_reason summary = { "fiscal_year": int(raw_group.get("fiscal_year") or 0), "status_label": _final_status_label(normalized_status), "ledger_date": clean(raw_group.get("ledger_date")), "proof_date": "", "voucher_no": clean(raw_group.get("voucher_no")), "draft_no": "", "ledger_row_count": len(rows), "voucher_row_count": 0, "ledger_debit": ledger_debit, "ledger_credit": ledger_credit, "voucher_debit": 0.0, "voucher_credit": 0.0, "ledger_accounts": ", ".join(ledger_accounts), "voucher_accounts": "", "ledger_vendors": ", ".join(ledger_vendors), "voucher_vendors": "", "review_reason": review_reason, } return {"summary": summary, "rows": _sanitize_voucher_group_rows(rows)} def _group_matches_final_filters( group: 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 = dict(group.get("summary") or {}) rows = list(group.get("rows") or []) if voucher_filter and voucher_filter not in clean(summary.get("voucher_no")): return False if draft_filter and draft_filter not in clean(summary.get("draft_no")): return False if wehago_account_filter and wehago_account_filter not in normalize_text( " ".join([clean(summary.get("ledger_accounts")), " ".join(clean(row.get("ledger_account_name")) for row in rows)]) ): return False if erp_account_filter and erp_account_filter not in normalize_text( " ".join([clean(summary.get("voucher_accounts")), " ".join(clean(row.get("voucher_account_name")) for row in rows)]) ): return False if wehago_vendor_filter and wehago_vendor_filter not in normalize_text( " ".join([clean(summary.get("ledger_vendors")), " ".join(clean(row.get("ledger_vendor")) for row in rows)]) ): return False if erp_vendor_filter and erp_vendor_filter not in normalize_text( " ".join([clean(summary.get("voucher_vendors")), " ".join(clean(row.get("voucher_vendor")) for row in rows)]) ): return False if desc_filter and desc_filter not in normalize_text( " ".join( [ 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), ] ) ): return False if wehago_amount_filter: amount_texts = [ format_amount(summary.get("ledger_debit")), format_amount(summary.get("ledger_credit")), *[format_amount(row.get("ledger_debit")) for row in rows], *[format_amount(row.get("ledger_credit")) for row in rows], ] if not any(wehago_amount_filter in text_value for text_value in amount_texts): return False if erp_amount_filter: amount_texts = [ format_amount(summary.get("voucher_debit")), format_amount(summary.get("voucher_credit")), *[format_amount(row.get("voucher_debit")) for row in rows], *[format_amount(row.get("voucher_credit")) for row in rows], ] if not any(erp_amount_filter in text_value for text_value in amount_texts): return False return True def _load_active_wehago_final_status_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 = "", ) -> tuple[list[dict[str, Any]], int, str] | None: if start_year is None or end_year is None or normalized_status not in WEHAGO_FINAL_STATUS_ORDER: return None active_signatures = _active_status_projection_signatures_for_range( conn, start_year, end_year, WEHAGO_FINAL_STATUS_ORDER, ) signature = clean(active_signatures.get(normalized_status)) if not signature: return None union_parts: list[str] = [] params: dict[str, Any] = { "start_year": int(start_year), "end_year": int(end_year), "status_key": normalized_status, "voucher_keyword": clean(voucher_no), "voucher_like": f"%{clean(voucher_no)}%", "draft_keyword": clean(draft_no), "draft_like": f"%{clean(draft_no)}%", "wehago_account_keyword": normalize_text(wehago_account), "wehago_account_like": f"%{normalize_text(wehago_account)}%", "erp_account_keyword": normalize_text(erp_account), "erp_account_like": f"%{normalize_text(erp_account)}%", "wehago_vendor_keyword": normalize_text(wehago_vendor), "wehago_vendor_like": f"%{normalize_text(wehago_vendor)}%", "erp_vendor_keyword": normalize_text(erp_vendor), "erp_vendor_like": f"%{normalize_text(erp_vendor)}%", "desc_keyword": normalize_text(desc_keyword), "desc_like": f"%{normalize_text(desc_keyword)}%", "wehago_amount_keyword": clean(wehago_amount), "erp_amount_keyword": clean(erp_amount), "limit": int(limit), "offset": int(offset), } has_detail_filter = any( [ clean(voucher_no), clean(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), ] ) for index, status_key in enumerate(WEHAGO_FINAL_STATUS_ORDER): params[f"active_status_{index}"] = status_key params[f"active_rank_{index}"] = int(WEHAGO_FINAL_STATUS_PRIORITY[status_key]) params[f"active_signature_{index}"] = clean(active_signatures.get(status_key)) union_parts.append( f""" SELECT :active_status_{index} AS source_status, :active_rank_{index} AS source_rank, fiscal_year, group_index, summary_json, rows_json, COALESCE(NULLIF(ledger_date, ''), NULLIF(proof_date, ''), '') AS raw_date, COALESCE(NULLIF(voucher_no, ''), '') AS raw_voucher_no, draft_no, ledger_accounts, voucher_accounts, ledger_vendors, voucher_vendors, ledger_debit, ledger_credit, voucher_debit, voucher_credit, search_text FROM wehago_status_projection_groups WHERE start_year = :start_year AND end_year = :end_year AND status_key = :active_status_{index} AND signature = :active_signature_{index} AND fiscal_year BETWEEN :start_year AND :end_year """ ) source_sql = f""" WITH raw_vouchers AS ( SELECT fiscal_year, compare_voucher_no, MIN(ledger_date) AS raw_ledger_date, MIN(voucher_no) AS raw_voucher_no FROM wehago_ledger_rows WHERE fiscal_year BETWEEN :start_year AND :end_year AND COALESCE(compare_voucher_no, '') <> '' GROUP BY fiscal_year, compare_voucher_no ), active_groups AS ( {' UNION ALL '.join(union_parts)} ), normalized_groups AS ( SELECT *, CASE WHEN COALESCE(raw_date, '') <> '' AND COALESCE(raw_voucher_no, '') <> '' THEN ( CASE WHEN raw_date GLOB '[0-9][0-9]-[0-9][0-9]' THEN printf('%04d%s', fiscal_year, REPLACE(raw_date, '-', '')) ELSE REPLACE(raw_date, '-', '') END ) || '-' || TRIM(CASE WHEN INSTR(raw_voucher_no, ',') > 0 THEN SUBSTR(raw_voucher_no, 1, INSTR(raw_voucher_no, ',') - 1) ELSE raw_voucher_no END) ELSE '' END AS compare_voucher_no FROM active_groups ), resolved AS ( SELECT compare_voucher_no, MIN(source_rank) AS final_rank FROM normalized_groups WHERE COALESCE(compare_voucher_no, '') <> '' GROUP BY compare_voucher_no ), display_groups AS ( SELECT * FROM ( SELECT ng.*, ROW_NUMBER() OVER ( PARTITION BY ng.compare_voucher_no ORDER BY CASE WHEN ng.source_status = :status_key THEN 0 ELSE 1 END, ng.source_rank ASC, ng.group_index ASC ) AS row_rank FROM normalized_groups ng ) WHERE row_rank = 1 ), final_vouchers AS ( SELECT r.fiscal_year, r.compare_voucher_no, CASE WHEN CASE COALESCE(resolved.final_rank, :unmatched_rank) WHEN 1 THEN 'voucher_excepted' WHEN 2 THEN 'voucher_matched' WHEN 3 THEN 'voucher_recheck' ELSE 'voucher_unmatched' END = 'voucher_matched' AND ( COALESCE(dg.summary_json, '') = '' OR ( TRIM(COALESCE(dg.draft_no, '')) = '' AND TRIM(COALESCE(dg.voucher_accounts, '')) = '' AND TRIM(COALESCE(dg.voucher_vendors, '')) = '' AND ABS(COALESCE(dg.voucher_debit, 0)) < 0.5 AND ABS(COALESCE(dg.voucher_credit, 0)) < 0.5 ) ) THEN 'voucher_unmatched' ELSE CASE COALESCE(resolved.final_rank, :unmatched_rank) WHEN 1 THEN 'voucher_excepted' WHEN 2 THEN 'voucher_matched' WHEN 3 THEN 'voucher_recheck' ELSE 'voucher_unmatched' END END AS final_status, COALESCE(NULLIF(dg.raw_date, ''), r.raw_ledger_date, '') AS final_ledger_date, COALESCE(NULLIF(dg.raw_voucher_no, ''), r.raw_voucher_no, '') AS final_voucher_no, dg.source_status, dg.group_index, dg.summary_json, dg.rows_json, dg.draft_no, dg.ledger_accounts, dg.voucher_accounts, dg.ledger_vendors, dg.voucher_vendors, dg.ledger_debit, dg.ledger_credit, dg.voucher_debit, dg.voucher_credit, dg.search_text FROM raw_vouchers r LEFT JOIN resolved ON resolved.compare_voucher_no = r.compare_voucher_no LEFT JOIN display_groups dg ON dg.compare_voucher_no = r.compare_voucher_no ) """ params["unmatched_rank"] = int(WEHAGO_FINAL_STATUS_PRIORITY["voucher_unmatched"]) filter_sql = """ final_status = :status_key AND ( :voucher_keyword = '' OR COALESCE(final_voucher_no, '') LIKE :voucher_like OR COALESCE(compare_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 has_detail_filter: total_count = int( conn.execute( text(f"{source_sql} SELECT COUNT(*) FROM final_vouchers WHERE {filter_sql}"), params, ).scalar_one() or 0 ) else: active_status_summary = _load_cached_active_status_projection_run_summary(conn, start_year, end_year) if not active_status_summary: active_status_summary = _load_active_status_projection_run_summary( conn, start_year, end_year, allow_rebuild=True, ) total_count = int(((active_status_summary or {}).get("counts") or {}).get(normalized_status, 0) or 0) if total_count <= 0: return [], 0, "" cursor_text = clean(cursor) cursor_year = 0 cursor_compare_no = "" if cursor_text: try: raw_year, raw_compare_no = cursor_text.split(":", 1) cursor_year = int(raw_year or 0) cursor_compare_no = raw_compare_no except Exception: cursor_text = "" query_sql = f"{source_sql} SELECT * FROM final_vouchers WHERE {filter_sql}" if cursor_text: query_sql += """ AND ( fiscal_year > :cursor_year OR (fiscal_year = :cursor_year AND compare_voucher_no > :cursor_compare_no) ) """ params["cursor_year"] = cursor_year params["cursor_compare_no"] = cursor_compare_no query_sql += " ORDER BY fiscal_year ASC, compare_voucher_no ASC LIMIT :limit" if not cursor_text: query_sql += " OFFSET :offset" rows = conn.execute(text(query_sql), params).mappings().all() groups: list[dict[str, Any]] = [] for row in rows: try: summary = json.loads(clean(row.get("summary_json")) or "{}") except Exception: summary = {} try: detail_rows = json.loads(clean(row.get("rows_json")) or "[]") except Exception: detail_rows = [] if not isinstance(summary, dict): summary = {} if not isinstance(detail_rows, list): detail_rows = [] if not summary: summary = { "fiscal_year": int(row.get("fiscal_year") or 0), "status_label": _final_status_label(normalized_status), "ledger_date": clean(row.get("final_ledger_date")), "proof_date": "", "voucher_no": clean(row.get("final_voucher_no")), "draft_no": "", "ledger_accounts": "", "voucher_accounts": "", "ledger_vendors": "", "voucher_vendors": "", "review_reason": "WEHAGO 원천 전표이나 상태 projection group 없음", } summary["status_label"] = _final_status_label(normalized_status) groups.append({"summary": summary, "rows": _sanitize_voucher_group_rows(detail_rows)}) last_row = rows[-1] if rows else None next_cursor = ( f"{int(last_row.get('fiscal_year') or 0)}:{clean(last_row.get('compare_voucher_no'))}" if last_row is not None and len(rows) >= int(limit or 0) else "" ) return groups, total_count, next_cursor params: dict[str, Any] = { "start_year": int(start_year), "end_year": int(end_year), "status_key": normalized_status, "signature": signature, "voucher_keyword": clean(voucher_no), "voucher_like": f"%{clean(voucher_no)}%", "draft_keyword": clean(draft_no), "draft_like": f"%{clean(draft_no)}%", "wehago_account_keyword": normalize_text(wehago_account), "wehago_account_like": f"%{normalize_text(wehago_account)}%", "erp_account_keyword": normalize_text(erp_account), "erp_account_like": f"%{normalize_text(erp_account)}%", "wehago_vendor_keyword": normalize_text(wehago_vendor), "wehago_vendor_like": f"%{normalize_text(wehago_vendor)}%", "erp_vendor_keyword": normalize_text(erp_vendor), "erp_vendor_like": f"%{normalize_text(erp_vendor)}%", "desc_keyword": normalize_text(desc_keyword), "desc_like": f"%{normalize_text(desc_keyword)}%", "wehago_amount_keyword": clean(wehago_amount), "erp_amount_keyword": clean(erp_amount), "limit": int(limit), "offset": int(offset), } where_sql = """ start_year = :start_year AND end_year = :end_year AND status_key = :status_key AND signature = :signature AND fiscal_year BETWEEN :start_year AND :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 || '%' ) """ total_count = int(conn.execute(text(f"SELECT COUNT(*) FROM wehago_status_projection_groups WHERE {where_sql}"), params).scalar_one() or 0) if total_count <= 0: return [], 0, "" cursor_text = clean(cursor) cursor_year = 0 cursor_group_index = -1 if cursor_text: try: raw_year, raw_group_index = cursor_text.split(":", 1) cursor_year = int(raw_year or 0) cursor_group_index = int(raw_group_index or -1) except Exception: cursor_text = "" query_sql = f"SELECT * FROM wehago_status_projection_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 += " OFFSET :offset" projection_rows = conn.execute(text(query_sql), params).mappings().all() groups: list[dict[str, Any]] = [] for row in projection_rows: try: summary = json.loads(clean(row.get("summary_json")) or "{}") except Exception: summary = {} try: detail_rows = json.loads(clean(row.get("rows_json")) or "[]") except Exception: detail_rows = [] if not isinstance(summary, dict): summary = {} if not isinstance(detail_rows, list): detail_rows = [] summary["status_label"] = _final_status_label(normalized_status) groups.append({"summary": summary, "rows": _sanitize_voucher_group_rows(detail_rows)}) last_row = projection_rows[-1] if projection_rows else None next_cursor = ( f"{int(last_row.get('fiscal_year') or 0)}:{int(last_row.get('group_index') or 0)}" if last_row is not None and len(projection_rows) >= int(limit or 0) else "" ) return groups, total_count, next_cursor def _load_final_wehago_status_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 = "", ) -> tuple[list[dict[str, Any]], int, str] | None: if start_year is None or end_year is None or normalized_status not in WEHAGO_FINAL_STATUS_ORDER: return None def query_status_label(status_key: str) -> str: return { "voucher_matched": "Matched", "voucher_recheck": "Recheck", "voucher_unmatched": "Unmatched", "voucher_excepted": "Excepted", "erp_voucher_matched": "Matched", "erp_voucher_unmatched": "ERP Unmatched", "hanmac_unconnected": "Hanmac unconnected", }.get(clean(status_key), clean(status_key)) raw_voucher_filter = clean(voucher_no) voucher_filter = raw_voucher_filter if raw_voucher_filter: default_filter_year = int(start_year) if int(start_year or 0) == int(end_year or 0) else None voucher_filter = normalize_compare_voucher_filter(raw_voucher_filter, default_filter_year) if not voucher_filter: return { "columns": DETAIL_COLUMN_MAP.get(normalized_status, []), "rows": [], "groups": [], "total_count": 0, "shown_count": 0, "offset": 0, "limit": limit, "has_more": False, "next_offset": 0, "next_cursor": "", "notice": "전표번호 조회는 연월일과 전표번호를 함께 입력해야 합니다. 예: 2025-01-10 00200", "pending": False, } 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) has_detail_filter = 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 ) signature = "" apply_cross_signature_status_overrides_to_final_projection(conn, start_year, end_year) signature = _load_latest_complete_final_status_projection_signature(conn, start_year, end_year) if not signature and _load_canonical_query_projection_signature_for_range(conn, start_year, end_year): backfill_wehago_final_status_projection_from_query_groups(conn, start_year, end_year) signature = _load_latest_complete_final_status_projection_signature(conn, start_year, end_year) if not signature: signature = _load_latest_query_projection_signature_for_range(conn, start_year, end_year) if not signature: return None fast_params = { "start_year": int(start_year), "end_year": int(end_year), "signature": signature, "status_key": normalized_status, "limit": int(limit), "offset": int(offset), "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": clean(wehago_amount), "erp_amount_keyword": clean(erp_amount), } direct_final_loaded = False total_count = 0 final_rows: list[Mapping[str, Any]] = [] if not has_detail_filter: try: total_count = int( conn.execute( text( """ SELECT COUNT(*) FROM wehago_compare_final_status_projection WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature AND final_status = :status_key """ ), fast_params, ).scalar_one() or 0 ) if total_count <= 0: return [], 0, "" cursor_year = 0 cursor_compare_no = "" cursor_text = clean(cursor) page_params = dict(fast_params) cursor_sql = "" if cursor_text: try: raw_year, raw_compare_no = cursor_text.split(":", 1) cursor_year = int(raw_year or 0) cursor_compare_no = raw_compare_no cursor_sql = """ AND ( fiscal_year > :cursor_year OR (fiscal_year = :cursor_year AND compare_voucher_no > :cursor_compare_no) ) """ page_params["cursor_year"] = cursor_year page_params["cursor_compare_no"] = cursor_compare_no except Exception: cursor_text = "" cursor_sql = "" offset_sql = "" if cursor_text else " OFFSET :offset" final_rows = conn.execute( text( f""" SELECT fiscal_year, compare_voucher_no, final_status, ledger_date AS final_ledger_date, voucher_no AS final_voucher_no, display_status_key, display_signature, display_group_index FROM wehago_compare_final_status_projection WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature AND final_status = :status_key {cursor_sql} ORDER BY fiscal_year ASC, compare_voucher_no ASC LIMIT :limit {offset_sql} """ ), page_params, ).mappings().all() if not final_rows: return [], total_count, "" direct_final_loaded = True except OperationalError: direct_final_loaded = False fast_source_sql = """ WITH raw_vouchers AS ( SELECT fiscal_year, compare_voucher_no, MIN(ledger_date) AS raw_ledger_date, MIN(voucher_no) AS raw_voucher_no FROM wehago_ledger_rows WHERE fiscal_year BETWEEN :start_year AND :end_year AND COALESCE(compare_voucher_no, '') <> '' GROUP BY fiscal_year, compare_voucher_no ), display_groups AS ( SELECT start_year, end_year, status_key, signature, group_index, COALESCE(summary_json, '') AS summary_json, draft_no, ledger_accounts, voucher_accounts, ledger_vendors, voucher_vendors, ledger_debit, ledger_credit, voucher_debit, voucher_credit, search_text FROM wehago_status_projection_groups WHERE start_year = :start_year AND end_year = :end_year UNION ALL SELECT start_year, end_year, status_key, signature, group_index, '{}' AS summary_json, draft_no, ledger_accounts, voucher_accounts, ledger_vendors, voucher_vendors, ledger_debit, ledger_credit, voucher_debit, voucher_credit, search_text FROM wehago_compare_query_groups WHERE start_year = :start_year AND end_year = :end_year ), final_vouchers AS ( SELECT r.fiscal_year, r.compare_voucher_no, COALESCE(p.final_status, 'voucher_unmatched') AS final_status, COALESCE(NULLIF(p.ledger_date, ''), r.raw_ledger_date, '') AS final_ledger_date, COALESCE(NULLIF(p.voucher_no, ''), r.raw_voucher_no, '') AS final_voucher_no, COALESCE(p.display_status_key, '') AS display_status_key, COALESCE(p.display_signature, '') AS display_signature, COALESCE(p.display_group_index, -1) AS display_group_index, COALESCE(g.draft_no, '') AS display_draft_no, COALESCE(g.ledger_accounts, '') AS display_ledger_accounts, COALESCE(g.voucher_accounts, '') AS display_voucher_accounts, COALESCE(g.ledger_vendors, '') AS display_ledger_vendors, COALESCE(g.voucher_vendors, '') AS display_voucher_vendors, COALESCE(g.ledger_debit, 0) AS display_ledger_debit, COALESCE(g.ledger_credit, 0) AS display_ledger_credit, COALESCE(g.voucher_debit, 0) AS display_voucher_debit, COALESCE(g.voucher_credit, 0) AS display_voucher_credit, COALESCE(g.search_text, '') AS display_search_text FROM raw_vouchers r LEFT JOIN wehago_compare_final_status_projection p ON p.start_year = :start_year AND p.end_year = :end_year AND p.signature = :signature AND p.fiscal_year = r.fiscal_year AND p.compare_voucher_no = r.compare_voucher_no LEFT JOIN display_groups g ON g.start_year = p.start_year AND g.end_year = p.end_year AND g.status_key = p.display_status_key AND g.signature = p.display_signature AND g.group_index = p.display_group_index ) """ fast_filter_sql = """ final_status = :status_key AND ( :voucher_keyword = '' OR COALESCE(final_voucher_no, '') LIKE :voucher_like OR COALESCE(compare_voucher_no, '') LIKE :voucher_like ) AND (:draft_keyword = '' OR COALESCE(display_draft_no, '') LIKE :draft_like) AND (:wehago_account_keyword = '' OR COALESCE(display_ledger_accounts, '') LIKE :wehago_account_like) AND (:erp_account_keyword = '' OR COALESCE(display_voucher_accounts, '') LIKE :erp_account_like) AND (:wehago_vendor_keyword = '' OR COALESCE(display_ledger_vendors, '') LIKE :wehago_vendor_like) AND (:erp_vendor_keyword = '' OR COALESCE(display_voucher_vendors, '') LIKE :erp_vendor_like) AND (:desc_keyword = '' OR COALESCE(display_search_text, '') LIKE :desc_like) AND ( :wehago_amount_keyword = '' OR CAST(ABS(COALESCE(display_ledger_debit, 0)) AS TEXT) LIKE '%' || :wehago_amount_keyword || '%' OR CAST(ABS(COALESCE(display_ledger_credit, 0)) AS TEXT) LIKE '%' || :wehago_amount_keyword || '%' ) AND ( :erp_amount_keyword = '' OR CAST(ABS(COALESCE(display_voucher_debit, 0)) AS TEXT) LIKE '%' || :erp_amount_keyword || '%' OR CAST(ABS(COALESCE(display_voucher_credit, 0)) AS TEXT) LIKE '%' || :erp_amount_keyword || '%' ) """ if not direct_final_loaded: try: total_count = int( conn.execute( text(f"{fast_source_sql} SELECT COUNT(*) FROM final_vouchers WHERE {fast_filter_sql}"), fast_params, ).scalar_one() or 0 ) except OperationalError: return None if total_count <= 0: return [], 0, "" cursor_year = 0 cursor_compare_no = "" cursor_text = clean(cursor) if cursor_text: try: raw_year, raw_compare_no = cursor_text.split(":", 1) cursor_year = int(raw_year or 0) cursor_compare_no = raw_compare_no except Exception: cursor_text = "" fast_query_sql = f""" {fast_source_sql} SELECT * FROM final_vouchers WHERE {fast_filter_sql} """ if cursor_text: fast_query_sql += """ AND ( fiscal_year > :cursor_year OR (fiscal_year = :cursor_year AND compare_voucher_no > :cursor_compare_no) ) """ fast_params["cursor_year"] = cursor_year fast_params["cursor_compare_no"] = cursor_compare_no fast_query_sql += """ ORDER BY fiscal_year ASC, compare_voucher_no ASC LIMIT :limit """ if not cursor_text: fast_query_sql += " OFFSET :offset" final_rows = conn.execute(text(fast_query_sql), fast_params).mappings().all() if not final_rows: return [], total_count, "" display_keys = [ ( clean(row.get("display_status_key")), clean(row.get("display_signature")), int(row.get("display_group_index") or -1), ) for row in final_rows if clean(row.get("display_status_key")) and clean(row.get("display_signature")) and int(row.get("display_group_index") or -1) >= 0 ] group_by_display: dict[tuple[str, str, int], dict[str, Any]] = {} if display_keys: clauses = [] group_params: dict[str, Any] = { "start_year": int(start_year), "end_year": int(end_year), } for index, (status_key, group_signature, group_index) in enumerate(display_keys): clauses.append( f"(status_key = :status_key_{index} AND signature = :signature_{index} AND group_index = :group_index_{index})" ) group_params[f"status_key_{index}"] = status_key group_params[f"signature_{index}"] = group_signature group_params[f"group_index_{index}"] = group_index display_rows = conn.execute( text( f""" SELECT * FROM wehago_status_projection_groups WHERE start_year = :start_year AND end_year = :end_year AND ({' OR '.join(clauses)}) ORDER BY status_key ASC, group_index ASC """ ), group_params, ).mappings().all() for display_row in display_rows: group_by_display[ ( clean(display_row.get("status_key")), clean(display_row.get("signature")), int(display_row.get("group_index") or 0), ) ] = dict(display_row) query_display_rows = conn.execute( text( f""" SELECT * FROM wehago_compare_query_groups WHERE start_year = :start_year AND end_year = :end_year AND ({' OR '.join(clauses)}) ORDER BY status_key ASC, group_index ASC """ ), group_params, ).mappings().all() if query_display_rows: query_group_keys = [ ( clean(row.get("status_key")), clean(row.get("signature")), int(row.get("group_index") or 0), ) for row in query_display_rows ] query_clauses = [] query_params: dict[str, Any] = { "start_year": int(start_year), "end_year": int(end_year), } for index, (status_key, group_signature, group_index) in enumerate(query_group_keys): query_clauses.append( f"(status_key = :status_key_{index} AND signature = :signature_{index} AND group_index = :group_index_{index})" ) query_params[f"status_key_{index}"] = status_key query_params[f"signature_{index}"] = group_signature query_params[f"group_index_{index}"] = group_index query_detail_rows = conn.execute( text( f""" SELECT * FROM wehago_compare_query_rows WHERE start_year = :start_year AND end_year = :end_year AND ({' OR '.join(query_clauses)}) ORDER BY status_key ASC, group_index ASC, row_index ASC """ ), query_params, ).mappings().all() rows_by_query_group: dict[tuple[str, str, int], list[dict[str, Any]]] = {} for detail_row in query_detail_rows: key = ( clean(detail_row.get("status_key")), clean(detail_row.get("signature")), int(detail_row.get("group_index") or 0), ) rows_by_query_group.setdefault(key, []).append(dict(detail_row)) for display_row in query_display_rows: key = ( clean(display_row.get("status_key")), clean(display_row.get("signature")), int(display_row.get("group_index") or 0), ) detail_rows = rows_by_query_group.get(key, []) summary = { "fiscal_year": int(display_row.get("fiscal_year") or 0), "status_label": query_status_label(clean(display_row.get("status_key"))), "ledger_date": clean(display_row.get("ledger_date")), "proof_date": clean(display_row.get("proof_date")), "voucher_no": clean(display_row.get("voucher_no")), "draft_no": clean(display_row.get("draft_no")), "ledger_row_count": int(display_row.get("ledger_row_count") or 0), "voucher_row_count": int(display_row.get("voucher_row_count") or 0), "ledger_debit": parse_amount(display_row.get("ledger_debit")), "ledger_credit": parse_amount(display_row.get("ledger_credit")), "voucher_debit": parse_amount(display_row.get("voucher_debit")), "voucher_credit": parse_amount(display_row.get("voucher_credit")), "ledger_accounts": clean(display_row.get("ledger_accounts")), "voucher_accounts": clean(display_row.get("voucher_accounts")), "ledger_vendors": clean(display_row.get("ledger_vendors")), "voucher_vendors": clean(display_row.get("voucher_vendors")), "review_reason": clean(display_row.get("review_reason")), } group_by_display[key] = { **dict(display_row), "summary_json": json.dumps(summary, ensure_ascii=False, separators=(",", ":")), "rows_json": json.dumps(detail_rows, ensure_ascii=False, separators=(",", ":")), } raw_ledger_by_compare: dict[str, list[dict[str, Any]]] = {} compare_values = [clean(row.get("compare_voucher_no")) for row in final_rows if clean(row.get("compare_voucher_no"))] if compare_values: clauses = [] raw_params: dict[str, Any] = { "start_year": int(start_year), "end_year": int(end_year), } for index, compare_no in enumerate(compare_values): clauses.append(f"compare_voucher_no = :compare_no_{index}") raw_params[f"compare_no_{index}"] = compare_no raw_rows = conn.execute( text( f""" SELECT fiscal_year, compare_voucher_no, ledger_date, voucher_no, account_name, vendor_name, debit, credit, description, row_number FROM wehago_ledger_rows WHERE fiscal_year BETWEEN :start_year AND :end_year AND ({' OR '.join(clauses)}) ORDER BY fiscal_year ASC, compare_voucher_no ASC, row_number ASC, id ASC """ ), raw_params, ).mappings().all() for raw_row in raw_rows: raw_ledger_by_compare.setdefault(clean(raw_row.get("compare_voucher_no")), []).append(dict(raw_row)) groups: list[dict[str, Any]] = [] for final_row in final_rows: compare_no = clean(final_row.get("compare_voucher_no")) display_key = ( clean(final_row.get("display_status_key")), clean(final_row.get("display_signature")), int(final_row.get("display_group_index") or -1), ) display_row = group_by_display.get(display_key, {}) try: summary = json.loads(clean(display_row.get("summary_json")) or "{}") except Exception: summary = {} try: detail_rows = json.loads(clean(display_row.get("rows_json")) or "[]") except Exception: detail_rows = [] if not isinstance(summary, dict): summary = {} if not isinstance(detail_rows, list): detail_rows = [] if summary: summary_compare_key = _wehago_group_compare_key_from_summary(summary) if summary_compare_key and compare_no and summary_compare_key != compare_no: summary = {} detail_rows = [] if (not summary or (normalized_status == "voucher_matched" and not _group_has_erp_detail({"summary": summary, "rows": detail_rows}))): recovered_row = _load_status_projection_group_by_compare_key( conn, start_year, end_year, compare_no=compare_no, fiscal_year=int(final_row.get("fiscal_year") or 0), voucher_no=clean(final_row.get("final_voucher_no")), preferred_statuses=[ clean(final_row.get("display_status_key")), normalized_status, "voucher_matched", "voucher_recheck", "voucher_unmatched", ], ) if recovered_row: try: recovered_summary = json.loads(clean(recovered_row.get("summary_json")) or "{}") except Exception: recovered_summary = {} try: recovered_rows = json.loads(clean(recovered_row.get("rows_json")) or "[]") except Exception: recovered_rows = [] if isinstance(recovered_summary, dict) and isinstance(recovered_rows, list): summary = recovered_summary detail_rows = recovered_rows if not summary: ledger_date = clean(final_row.get("final_ledger_date")) voucher_number = clean(final_row.get("final_voucher_no")) raw_rows = raw_ledger_by_compare.get(compare_no, []) ledger_accounts = sorted({clean(row.get("account_name")) for row in raw_rows if clean(row.get("account_name"))}) ledger_vendors = sorted({clean(row.get("vendor_name")) for row in raw_rows if clean(row.get("vendor_name"))}) ledger_debit = sum(parse_amount(row.get("debit")) for row in raw_rows) ledger_credit = sum(parse_amount(row.get("credit")) for row in raw_rows) summary = { "fiscal_year": int(final_row.get("fiscal_year") or 0), "status_label": _final_status_label(normalized_status), "ledger_date": ledger_date, "proof_date": "", "voucher_no": voucher_number, "draft_no": "", "ledger_row_count": len(raw_rows), "voucher_row_count": 0, "ledger_debit": ledger_debit, "ledger_credit": ledger_credit, "voucher_debit": 0, "voucher_credit": 0, "ledger_accounts": ", ".join(ledger_accounts), "voucher_accounts": "", "ledger_vendors": ", ".join(ledger_vendors), "voucher_vendors": "", "review_reason": "WEHAGO 원장 미분류 전표", } if raw_rows: detail_rows = [ { "fiscal_year": int(raw_row.get("fiscal_year") or final_row.get("fiscal_year") or 0), "status_label": _final_status_label(normalized_status), "ledger_date": clean(raw_row.get("ledger_date")) or ledger_date, "proof_date": "", "voucher_no": clean(raw_row.get("voucher_no")) or voucher_number, "draft_no": "", "ledger_account_name": clean(raw_row.get("account_name")), "voucher_account_name": "", "ledger_vendor": clean(raw_row.get("vendor_name")), "voucher_vendor": "", "ledger_debit": parse_amount(raw_row.get("debit")), "ledger_credit": parse_amount(raw_row.get("credit")), "voucher_debit": 0, "voucher_credit": 0, "ledger_desc": clean(raw_row.get("description")), "voucher_desc": "", "review_reason": "WEHAGO 원장 미분류 전표", } for raw_row in raw_rows ] summary["status_label"] = _final_status_label(normalized_status) groups.append({"summary": summary, "rows": _sanitize_voucher_group_rows(detail_rows)}) last_row = final_rows[-1] next_cursor = ( f"{int(last_row.get('fiscal_year') or 0)}:{clean(last_row.get('compare_voucher_no'))}" if len(final_rows) >= int(limit or 0) else "" ) return groups, total_count, next_cursor params = { "start_year": int(start_year), "end_year": int(end_year), "signature": signature, "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": clean(wehago_amount), "erp_amount_keyword": clean(erp_amount), "limit": int(limit), "offset": int(offset), } source_sql = """ WITH raw_vouchers AS ( SELECT fiscal_year, compare_voucher_no, MIN(ledger_date) AS raw_ledger_date, MIN(voucher_no) AS raw_voucher_no FROM wehago_ledger_rows WHERE fiscal_year BETWEEN :start_year AND :end_year AND COALESCE(compare_voucher_no, '') <> '' GROUP BY fiscal_year, compare_voucher_no ), final_vouchers AS ( SELECT r.fiscal_year, r.compare_voucher_no, COALESCE(p.final_status, 'voucher_unmatched') AS final_status, COALESCE(NULLIF(p.ledger_date, ''), r.raw_ledger_date, '') AS final_ledger_date, COALESCE(NULLIF(p.voucher_no, ''), r.raw_voucher_no, '') AS final_voucher_no FROM raw_vouchers r LEFT JOIN wehago_compare_final_status_projection p ON p.start_year = :start_year AND p.end_year = :end_year AND p.signature = :signature AND p.fiscal_year = r.fiscal_year AND p.compare_voucher_no = r.compare_voucher_no ), ranked_groups AS ( SELECT q.*, ROW_NUMBER() OVER ( PARTITION BY q.fiscal_year, CASE WHEN COALESCE(NULLIF(q.ledger_date, ''), NULLIF(q.proof_date, ''), '') <> '' AND COALESCE(NULLIF(q.voucher_no, ''), '') <> '' THEN REPLACE(COALESCE(NULLIF(q.ledger_date, ''), NULLIF(q.proof_date, ''), ''), '-', '') || '-' || TRIM(CASE WHEN INSTR(q.voucher_no, ',') > 0 THEN SUBSTR(q.voucher_no, 1, INSTR(q.voucher_no, ',') - 1) ELSE q.voucher_no END) ELSE '' END ORDER BY CASE WHEN q.status_key = :status_key THEN 0 ELSE 1 END, CASE q.status_key WHEN 'voucher_excepted' THEN 1 WHEN 'voucher_matched' THEN 2 WHEN 'voucher_recheck' THEN 3 WHEN 'voucher_unmatched' THEN 4 ELSE 99 END, q.group_index ) AS row_rank, CASE WHEN COALESCE(NULLIF(q.ledger_date, ''), NULLIF(q.proof_date, ''), '') <> '' AND COALESCE(NULLIF(q.voucher_no, ''), '') <> '' THEN REPLACE(COALESCE(NULLIF(q.ledger_date, ''), NULLIF(q.proof_date, ''), ''), '-', '') || '-' || TRIM(CASE WHEN INSTR(q.voucher_no, ',') > 0 THEN SUBSTR(q.voucher_no, 1, INSTR(q.voucher_no, ',') - 1) ELSE q.voucher_no END) ELSE '' END AS q_compare_voucher_no FROM wehago_compare_query_groups q WHERE q.start_year = :start_year AND q.end_year = :end_year AND q.signature = :signature AND q.fiscal_year BETWEEN :start_year AND :end_year AND q.status_key IN ('voucher_matched', 'voucher_unmatched', 'voucher_recheck', 'voucher_excepted') ), final_rows AS ( SELECT f.fiscal_year AS final_fiscal_year, f.compare_voucher_no AS final_compare_voucher_no, f.final_status, f.final_ledger_date, f.final_voucher_no, q.* FROM final_vouchers f LEFT JOIN ranked_groups q ON q.fiscal_year = f.fiscal_year AND q.q_compare_voucher_no = f.compare_voucher_no AND q.row_rank = 1 WHERE f.final_status = :status_key ) """ filter_sql = """ ( :voucher_keyword = '' OR COALESCE(voucher_no, '') LIKE :voucher_like OR COALESCE(final_voucher_no, '') LIKE :voucher_like OR COALESCE(final_compare_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 || '%' ) """ try: total_count = int( conn.execute( text(f"{source_sql} SELECT COUNT(*) FROM final_rows WHERE {filter_sql}"), params, ).scalar_one() or 0 ) except OperationalError: return None if total_count <= 0: return [], 0, "" cursor_year = 0 cursor_compare_no = "" cursor_text = clean(cursor) if cursor_text: try: raw_year, raw_compare_no = cursor_text.split(":", 1) cursor_year = int(raw_year or 0) cursor_compare_no = raw_compare_no except Exception: cursor_text = "" query_sql = f""" {source_sql} SELECT * FROM final_rows WHERE {filter_sql} """ if cursor_text: query_sql += """ AND ( final_fiscal_year > :cursor_year OR (final_fiscal_year = :cursor_year AND final_compare_voucher_no > :cursor_compare_no) ) """ params["cursor_year"] = cursor_year params["cursor_compare_no"] = cursor_compare_no query_sql += """ ORDER BY final_fiscal_year ASC, final_compare_voucher_no ASC LIMIT :limit """ if not cursor_text: query_sql += " OFFSET :offset" rows = conn.execute(text(query_sql), params).mappings().all() if not rows: return [], total_count, "" selected_group_keys = [ (int(row.get("group_index") or -1), clean(row.get("status_key"))) for row in rows if row.get("group_index") is not None and clean(row.get("status_key")) ] rows_by_key: dict[tuple[str, int], list[dict[str, Any]]] = {} if selected_group_keys: clauses = [] row_params = { "start_year": int(start_year), "end_year": int(end_year), "signature": signature, } for index, (group_index, status_key) in enumerate(selected_group_keys): clauses.append(f"(status_key = :status_key_{index} AND group_index = :group_index_{index})") row_params[f"status_key_{index}"] = status_key row_params[f"group_index_{index}"] = group_index detail_rows = conn.execute( text( f""" SELECT * FROM wehago_compare_query_rows WHERE start_year = :start_year AND end_year = :end_year AND signature = :signature AND ({' OR '.join(clauses)}) ORDER BY status_key ASC, group_index ASC, row_index ASC """ ), row_params, ).mappings().all() for detail_row in detail_rows: key = (clean(detail_row.get("status_key")), int(detail_row.get("group_index") or 0)) rows_by_key.setdefault(key, []).append(dict(detail_row)) groups: list[dict[str, Any]] = [] for row in rows: source_status = clean(row.get("status_key")) or normalized_status group_index = int(row.get("group_index") or -1) ledger_date = clean(row.get("ledger_date")) or clean(row.get("final_ledger_date")) voucher_number = clean(row.get("voucher_no")) or clean(row.get("final_voucher_no")) summary = { "fiscal_year": int(row.get("final_fiscal_year") or row.get("fiscal_year") or 0), "status_label": _final_status_label(normalized_status), "ledger_date": ledger_date, "proof_date": clean(row.get("proof_date")), "voucher_no": voucher_number, "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")) or ("WEHAGO 원장 미분류 전표" if group_index < 0 else ""), } detail_rows = rows_by_key.get((source_status, group_index), []) if group_index >= 0 else [] groups.append({"summary": summary, "rows": _sanitize_voucher_group_rows(detail_rows)}) last_row = rows[-1] next_cursor = ( f"{int(last_row.get('final_fiscal_year') or 0)}:{clean(last_row.get('final_compare_voucher_no'))}" if len(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 _apply_hanmac_unconnected_source_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 start_year is None or end_year is None: return merged projected_counts = _load_active_status_projection_counts_for_range( conn, start_year, end_year, {"hanmac_unconnected", *COMPACT_EXPORT_STATUS_KEYS}, ) if "hanmac_unconnected" in projected_counts: merged["hanmac_unconnected"] = int(projected_counts.get("hanmac_unconnected") or 0) else: merged["hanmac_unconnected"] = len( _load_hanmac_unconnected_source_groups(conn, int(start_year), int(end_year)) ) for status_key in sorted(COMPACT_EXPORT_STATUS_KEYS): if status_key in projected_counts: merged[status_key] = int(projected_counts.get(status_key) 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) voucher_sections = _prepare_final_display_projection(voucher_sections) 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 _apply_hanmac_unconnected_source_count(conn, cached, start_year, end_year) 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_hanmac_unconnected_source_count(conn, counts, 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) counts = _apply_hanmac_unconnected_source_count(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: for year in range(int(start_year), int(end_year) + 1): _ensure_year_export_row_cache(conn, year) 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) voucher_sections = _prepare_final_display_projection(voucher_sections) rows_by_status = _filter_standard_rows_for_final_projection(rows_by_status, voucher_sections) 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_final_compact_status_projections(conn, start_year, end_year, voucher_sections) _store_query_row_projection(conn, start_year, end_year, rows_by_status) _store_wehago_final_status_projection(conn, start_year, end_year, voucher_sections) _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] = [] compact_status_ready = False 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") ) query_projection = _load_query_metric_projection(conn, start_year, end_year) if query_projection and isinstance(query_projection.get("counts"), dict): query_counts = { status_key: int(query_projection["counts"].get(status_key, 0) or 0) for status_key, _, _ in STATUS_META } query_counts = _apply_hanmac_unconnected_source_count(conn, query_counts, start_year, end_year) compact_status_ready = _all_voucher_status_projections_ready(conn, start_year, end_year) if compact_status_ready: pending = False if _active_query_projection_signature(conn, start_year, end_year): return query_counts, False return query_counts, pending cached_summary = _load_summary_range_cache(conn, start_year, end_year) if cached_summary: counts = cached_summary.get("counts") if isinstance(counts, dict): coerced = _coerce_metric_counts_payload(counts) or _empty_metric_counts() coerced = _apply_hanmac_unconnected_source_count(conn, coerced, start_year, end_year) return coerced, False cached = _load_metric_counts_cache(conn, start_year, end_year) if cached is not None: return _apply_hanmac_unconnected_source_count(conn, cached, start_year, end_year), False fallback_summary = _load_latest_summary_range_cache_any_signature(conn, start_year, end_year) fallback = None if fallback_summary and isinstance(fallback_summary.get("counts"), dict): fallback = _coerce_metric_counts_payload(fallback_summary.get("counts")) if fallback is None: fallback = _load_latest_query_metric_projection_any_signature(conn, start_year, end_year) if fallback is None: fallback = _load_latest_metric_counts_cache_any_signature_loose(conn, start_year, end_year) if fallback is None: fallback = _empty_metric_counts() fallback = _apply_hanmac_unconnected_source_count(conn, fallback, start_year, end_year) compact_status_ready = _all_voucher_status_projections_ready(conn, start_year, end_year) if compact_status_ready: pending = False years_to_enqueue = [ int(year) for year in ((snapshot_state or {}).get("missing") or []) + ((snapshot_state or {}).get("stale") or []) ] if not compact_status_ready and 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 has_fallback_data = any(int((fallback or {}).get(status_key, 0) or 0) for status_key, _, _ in STATUS_META) _enqueue_compare_refresh_async( engine, start_year, end_year, years_to_enqueue if not has_fallback_data else [], ) 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() @lru_cache(maxsize=262144) def _normalize_text_cached(text: str) -> str: text_value = text.lower().strip() text_value = re.sub(r"\s+", "", text_value) return re.sub(r"[\(\)\[\]\{\},._\-/\\:;*×%◇]", "", text_value) def normalize_text(value: Any) -> str: return _normalize_text_cached(clean(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_compare_voucher_filter(value: Any, default_year: int | None = None) -> str: raw_text = clean(value) if not raw_text: return "" normalized = normalize_compare_voucher_no(raw_text) if re.fullmatch(r"\d{8}-\d{5}", normalized): return normalized compact = re.sub(r"\D+", "", raw_text) if len(compact) >= 13 and re.match(r"^(?:19|20)\d{6}", compact): return f"{compact[:8]}-{int(compact[8:]):05d}" full_match = re.search(r"((?:19|20)\d{2})\D*([01]?\d)\D*([0-3]?\d)\D+(\d{1,6})", raw_text) if full_match: year = int(full_match.group(1)) month = int(full_match.group(2)) day = int(full_match.group(3)) voucher_no = int(full_match.group(4)) if 1 <= month <= 12 and 1 <= day <= 31: return f"{year:04d}{month:02d}{day:02d}-{voucher_no:05d}" if default_year: month_day_match = re.search(r"([01]?\d)\D+([0-3]?\d)\D+(\d{1,6})", raw_text) if month_day_match: month = int(month_day_match.group(1)) day = int(month_day_match.group(2)) voucher_no = int(month_day_match.group(3)) if 1 <= month <= 12 and 1 <= day <= 31: return f"{int(default_year):04d}{month:02d}{day:02d}-{voucher_no:05d}" return "" def _row_compare_voucher_key(row: Mapping[str, Any], *, date_field: str = "ledger_date") -> str: compare_key = clean(row.get("compare_voucher_no")) if re.fullmatch(r"\d{8}-\d{5}", compare_key): return compare_key fiscal_year = int(row.get("fiscal_year") or 0) or None row_date = clean(row.get(date_field)) or clean(row.get("ledger_date")) or clean(row.get("proof_date")) return normalize_wehago_voucher_identity( fiscal_year or 0, row.get("voucher_no"), row_date, ) 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}" @lru_cache(maxsize=65536) def _parse_excel_date_text_cached(text_value: str, default_year: int | None = None) -> str | None: 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 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", "") return _parse_excel_date_text_cached(text_value, int(default_year) if default_year else 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_erp_voucher_existence_snapshots ( snapshot_id INTEGER PRIMARY KEY AUTOINCREMENT, fiscal_year INTEGER NOT NULL, source_file_id INTEGER REFERENCES wehago_source_files(id) ON DELETE SET NULL, source_origin TEXT NOT NULL DEFAULT '', source_label TEXT NOT NULL DEFAULT '', source_hash TEXT NOT NULL DEFAULT '', snapshot_mode TEXT NOT NULL DEFAULT 'full', row_count INTEGER NOT NULL DEFAULT 0, draft_base_count INTEGER NOT NULL DEFAULT 0, activated_at TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS wehago_erp_voucher_existence ( snapshot_id INTEGER NOT NULL REFERENCES wehago_erp_voucher_existence_snapshots(snapshot_id) ON DELETE CASCADE, fiscal_year INTEGER NOT NULL, draft_base TEXT NOT NULL, is_active INTEGER NOT NULL DEFAULT 1, row_count INTEGER NOT NULL DEFAULT 0, first_draft_no TEXT NOT NULL DEFAULT '', last_draft_no TEXT NOT NULL DEFAULT '', first_seen_row_number INTEGER NOT NULL DEFAULT 0, last_seen_row_number INTEGER NOT NULL DEFAULT 0, source_file_id INTEGER REFERENCES wehago_source_files(id) ON DELETE SET NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (snapshot_id, draft_base) ) """ ) ) 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_status_projection_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 '', summary_json TEXT NOT NULL DEFAULT '{}', rows_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, group_index) ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS wehago_compare_final_status_projection ( start_year INTEGER NOT NULL, end_year INTEGER NOT NULL, signature TEXT NOT NULL, identity_key TEXT NOT NULL, compare_voucher_no TEXT NOT NULL DEFAULT '', fiscal_year INTEGER NOT NULL DEFAULT 0, ledger_date TEXT NOT NULL DEFAULT '', voucher_no TEXT NOT NULL DEFAULT '', final_status TEXT NOT NULL DEFAULT '', final_rank INTEGER NOT NULL DEFAULT 99, source_statuses TEXT NOT NULL DEFAULT '', source_group_count INTEGER NOT NULL DEFAULT 0, display_status_key TEXT NOT NULL DEFAULT '', display_signature TEXT NOT NULL DEFAULT '', display_group_index INTEGER NOT NULL DEFAULT -1, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (start_year, end_year, signature, identity_key) ) """ ) ) 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_compare_final_status_projection", "display_status_key", "TEXT NOT NULL DEFAULT ''") ensure_column(conn, "wehago_compare_final_status_projection", "display_signature", "TEXT NOT NULL DEFAULT ''") ensure_column(conn, "wehago_compare_final_status_projection", "display_group_index", "INTEGER NOT NULL DEFAULT -1") 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_voucher_year_draft ON wehago_voucher_rows(fiscal_year, draft_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_status_projection_lookup ON wehago_status_projection_groups(status_key, start_year, end_year, signature, fiscal_year, group_index)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_status_projection_filter ON wehago_status_projection_groups(status_key, start_year, end_year, signature, voucher_no, draft_no)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_compare_final_projection_status ON wehago_compare_final_status_projection(start_year, end_year, signature, final_status, fiscal_year)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_compare_final_projection_compare_no ON wehago_compare_final_status_projection(start_year, end_year, signature, compare_voucher_no)")) 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 <> ''")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_erp_existence_year_base ON wehago_erp_voucher_existence(fiscal_year, draft_base, snapshot_id, is_active)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_erp_existence_snapshots_year ON wehago_erp_voucher_existence_snapshots(fiscal_year, activated_at, created_at)")) 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) _prune_obsolete_compare_logic_caches(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: source_row_id = clean(row.get("ledger_source_row_id")) if source_row_id: return f"ledger-db-row:{source_row_id}" source_file_id = clean(row.get("ledger_source_file_id")) source_sheet = clean(row.get("ledger_source_sheet_name")) source_row_number = clean(row.get("ledger_source_row_number")) if source_file_id and source_row_number: return f"ledger-source:{source_file_id}:{source_sheet}:{source_row_number}" 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: source_row_id = clean(row.get("voucher_source_row_id")) if source_row_id: return f"voucher-db-row:{source_row_id}" source_file_id = clean(row.get("voucher_source_file_id")) source_sheet = clean(row.get("voucher_source_sheet_name")) source_row_number = clean(row.get("voucher_source_row_number")) if source_file_id and source_row_number: return f"voucher-source:{source_file_id}:{source_sheet}:{source_row_number}" 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]]: erp_start_year, erp_end_year = get_hanmac_erp_candidate_year_range(start_year, end_year) 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": erp_start_year or start_year, "end_year": erp_end_year or 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) source_pairs = _build_matched_source_row_pairs(conn, year) if source_pairs is not None: sections["matched"]["rows"] = source_pairs["matched"] sections["matched"]["count"] = len(source_pairs["matched"]) sections["ledger_only"]["rows"].extend(source_pairs["ledger_only"]) sections["ledger_only"]["count"] = len(sections["ledger_only"]["rows"]) sections["voucher_only"]["rows"].extend(source_pairs["voucher_only"]) sections["voucher_only"]["count"] = len(sections["voucher_only"]["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) sections = _restrict_erp_rows_to_fiscal_year(sections, year) sections, _changed = _release_reused_or_unidentified_matched_rows(sections) return sections def _build_matched_source_row_pairs(conn: Any, year: int) -> dict[str, list[dict[str, Any]]] | None: """Resolve trusted voucher-level matches into one-to-one source account-row pairs.""" ledger_rows = conn.execute( text( """ SELECT c.voucher_no AS comparison_voucher_no, l.id AS ledger_source_row_id, l.source_file_id AS ledger_source_file_id, l.sheet_name AS ledger_source_sheet_name, l.row_number AS ledger_source_row_number, l.fiscal_year, l.ledger_date, l.voucher_no, l.account_code AS ledger_account_code, l.account_name AS ledger_account_name, l.vendor_name AS ledger_vendor, l.description AS ledger_desc, l.debit AS ledger_debit, l.credit AS ledger_credit FROM wehago_comparison_results c JOIN wehago_ledger_rows l ON l.fiscal_year = c.fiscal_year AND l.compare_voucher_no = c.voucher_no WHERE c.fiscal_year = :fiscal_year AND c.status = 'matched' ORDER BY c.voucher_no, l.row_number, l.id """ ), {"fiscal_year": year}, ).mappings().all() voucher_rows = conn.execute( text( """ SELECT c.voucher_no AS comparison_voucher_no, v.id AS voucher_source_row_id, v.source_file_id AS voucher_source_file_id, v.sheet_name AS voucher_source_sheet_name, v.row_number AS voucher_source_row_number, v.fiscal_year, v.proof_date, v.confirmed_no AS voucher_no, v.draft_no, v.account_code AS voucher_account_code, v.account_name AS voucher_account_name, v.vendor_name AS voucher_vendor, TRIM(COALESCE(v.desc1, '') || ' ' || COALESCE(v.desc2, '')) AS voucher_desc, v.debit_supply AS voucher_debit, v.credit_supply AS voucher_credit FROM wehago_comparison_results c JOIN wehago_voucher_rows v ON v.fiscal_year = c.fiscal_year AND v.compare_voucher_no = c.voucher_no WHERE c.fiscal_year = :fiscal_year AND c.status = 'matched' ORDER BY c.voucher_no, v.row_number, v.id """ ), {"fiscal_year": year}, ).mappings().all() if not ledger_rows and not voucher_rows: return None ledgers_by_group: dict[str, list[dict[str, Any]]] = {} vouchers_by_group: dict[str, list[dict[str, Any]]] = {} for source in ledger_rows: row = dict(source) row["ledger_row_key"] = build_ledger_row_key(row) ledgers_by_group.setdefault(clean(row.get("comparison_voucher_no")), []).append(row) for source in voucher_rows: row = dict(source) row["voucher_row_key"] = build_voucher_row_key(row) vouchers_by_group.setdefault(clean(row.get("comparison_voucher_no")), []).append(row) matched: list[dict[str, Any]] = [] ledger_only: list[dict[str, Any]] = [] voucher_only: list[dict[str, Any]] = [] for group_key in sorted(set(ledgers_by_group) | set(vouchers_by_group)): group_ledgers = ledgers_by_group.get(group_key, []) group_vouchers = vouchers_by_group.get(group_key, []) candidates: list[tuple[float, dict[str, Any], dict[str, Any], dict[str, Any]]] = [] for ledger_row in group_ledgers: for voucher_row in group_vouchers: 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 score = _score_pair_match(ledger_row, voucher_row) if float(score.get("amount_gap", 0) or 0) >= 0.5: continue rank = float(score.get("score", 0) or 0) if clean(ledger_row.get("ledger_account_code")) == clean(voucher_row.get("voucher_account_code")): rank += 30 candidates.append((rank, ledger_row, voucher_row, score)) candidates.sort(key=lambda item: item[0], reverse=True) used_ledger: set[str] = set() used_voucher: set[str] = set() for _rank, ledger_row, voucher_row, score in candidates: ledger_key = clean(ledger_row.get("ledger_row_key")) voucher_key = clean(voucher_row.get("voucher_row_key")) if ledger_key in used_ledger or voucher_key in used_voucher: continue merged = dict(ledger_row) merged.update(voucher_row) merged["voucher_no"] = clean(ledger_row.get("voucher_no")) or clean(voucher_row.get("voucher_no")) merged["review_reason"] = "전표내 계정행 일대일 매칭" merged["matched_case"] = "voucher_group_source_row_match" merged["match_identity_key"] = build_manual_pair_key(ledger_key, voucher_key) matched.append(merged) used_ledger.add(ledger_key) used_voucher.add(voucher_key) for row in group_ledgers: if clean(row.get("ledger_row_key")) not in used_ledger: restored = dict(row) restored["review_reason"] = "매칭 전표 내 ERP 대응 계정행 없음" ledger_only.append(restored) for row in group_vouchers: if clean(row.get("voucher_row_key")) not in used_voucher: restored = dict(row) restored["review_reason"] = "매칭 전표 내 WEHAGO 대응 계정행 없음" voucher_only.append(restored) return {"matched": matched, "ledger_only": ledger_only, "voucher_only": voucher_only} 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 not cached: 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 COALESCE(payload_json, '') <> '' ORDER BY created_at DESC 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, }, ).first() if cached and cached[0]: try: payload = json.loads(str(cached[0])) if isinstance(payload, dict): return 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: dict[str, list[dict[str, Any]]] = {} if ENABLE_YEAR_SNAPSHOT_EXPORT_ROW_CACHE: 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: erp_start_year, erp_end_year = get_hanmac_erp_candidate_year_range(start_year, end_year) 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() erp_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": erp_start_year, "end_year": erp_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) erp_existence = _active_erp_voucher_existence_signature(conn, start_year, end_year) return ( f"{_current_logic_signature()}|" f"s:{source[0]}:{source[1]}|es:{erp_source[0]}:{erp_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'))}|" f"ev:{erp_existence}" ) 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.utcnow() queued_keep: dict[tuple[Any, ...], str] = {} queued_drop: list[str] = [] stale_running: list[dict[str, Any]] = [] 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": job_key, "job_type": job_type, "payload": payload, } ) continue if state != "queued" or scope is None: continue if job_type == "year_snapshot_rebuild": year = int(payload.get("fiscal_year") or 0) signature = clean(payload.get("signature")) status_row = _load_snapshot_status_map(conn, [year]).get(year) if year > 0 else {} stored_signature = clean((status_row or {}).get("snapshot_signature")) if ( year > 0 and signature and clean((status_row or {}).get("state")).lower() == "ready" and stored_signature and ( stored_signature == signature or _snapshot_signature_equivalent(stored_signature, signature) ) ): queued_drop.append(job_key) continue if scope in queued_keep: queued_drop.append(job_key) continue queued_keep[scope] = job_key for job in stale_running: job_key = clean(job.get("job_key")) payload = job.get("payload") if isinstance(job.get("payload"), dict) else {} if clean(job.get("job_type")) == "year_snapshot_rebuild": year = int((payload or {}).get("fiscal_year") or 0) if year > 0: _upsert_snapshot_status( conn, year, signature=clean((payload or {}).get("signature")), state="failed", error_message="abandoned stale running job cleaned during startup", ) 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}, ) running_status_rows = conn.execute( text( """ SELECT fiscal_year, snapshot_signature FROM wehago_snapshot_status WHERE state = 'running' """ ) ).mappings().all() if not running_status_rows: return active_jobs = conn.execute( text( """ SELECT payload_json FROM wehago_background_jobs WHERE job_type = 'year_snapshot_rebuild' AND state IN ('queued', 'running') """ ) ).mappings().all() active_snapshot_keys: set[tuple[int, str]] = set() for row in active_jobs: try: payload = json.loads(clean(row.get("payload_json")) or "{}") except Exception: payload = {} if not isinstance(payload, dict): continue year = int(payload.get("fiscal_year") or 0) signature = clean(payload.get("signature")) if year > 0 and signature: active_snapshot_keys.add((year, signature)) for row in running_status_rows: year = int(row.get("fiscal_year") or 0) signature = clean(row.get("snapshot_signature")) if year <= 0 or (year, signature) in active_snapshot_keys: continue _upsert_snapshot_status( conn, year, signature=signature, state="failed", error_message="orphan running snapshot status reconciled without active job", ) 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 _prune_obsolete_compare_logic_caches(conn: Any) -> None: try: query_like = f"{QUERY_PROJECTION_VERSION}|%" status_like = f"{STATUS_PROJECTION_VERSION}%" metric_like = f"{METRIC_COUNT_CACHE_VERSION}|%" for table_name in ( "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 signature NOT LIKE :query_signature_like AND signature NOT LIKE :status_signature_like """ ), { "query_signature_like": query_like, "status_signature_like": status_like, }, ) for table_name in ("wehago_metric_count_cache", "wehago_summary_range_cache"): conn.execute( text(f"DELETE FROM {table_name} WHERE signature NOT LIKE :signature_like"), {"signature_like": metric_like}, ) conn.execute( text( """ DELETE FROM wehago_status_projection_groups WHERE signature NOT LIKE :status_signature_like """ ), {"status_signature_like": status_like}, ) conn.execute( text( """ DELETE FROM wehago_compare_settings WHERE ( setting_key LIKE 'wehago_active_query_projection:%' OR setting_key LIKE 'wehago_active_status_projection:%' ) AND COALESCE(json_extract(setting_json, '$.signature'), '') NOT LIKE :query_signature_like AND COALESCE(json_extract(setting_json, '$.signature'), '') NOT LIKE :status_signature_like """ ), { "query_signature_like": query_like, "status_signature_like": status_like, }, ) 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]]]: def traced_wrapper_step( step_name: str, current_sections: dict[str, list[dict[str, Any]]] | None, func: Callable[[], dict[str, list[dict[str, Any]]]], ) -> dict[str, list[dict[str, Any]]]: if _trace_compare_projection_enabled(): _trace_compare_projection( f"{step_name}_start", counts={key: len(value or []) for key, value in (current_sections or {}).items()}, ) started_at = time.monotonic() result = func() _trace_compare_projection( f"{step_name}_done", elapsed_sec=round(time.monotonic() - started_at, 3), counts={key: len(value or []) for key, value in result.items()}, ) return result return func() sections = traced_wrapper_step( "build_voucher_sections_from_rows_by_status", None, lambda: _build_voucher_sections_from_rows_by_status(rows_by_status), ) sections = traced_wrapper_step( "expand_erp_unmatched_groups_from_raw_rows", sections, lambda: _expand_erp_unmatched_groups_from_raw_rows(conn, start_year, end_year, sections), ) sections = traced_wrapper_step( "apply_bridge_expense_promotions_to_voucher_sections", sections, lambda: _apply_bridge_expense_promotions_to_voucher_sections(conn, start_year, end_year, sections), ) sections = traced_wrapper_step( "apply_raw_erp_trace_promotions", sections, lambda: _apply_raw_erp_trace_promotions(conn, start_year, end_year, sections), ) sections = traced_wrapper_step( "wrapper_apply_guarded_regression_case_rules_1", sections, lambda: _apply_guarded_regression_case_rules(sections), ) sections = traced_wrapper_step( "wrapper_dedupe_wehago_sections_to_single_status_1", sections, lambda: _dedupe_wehago_sections_to_single_status(sections), ) sections = traced_wrapper_step( "wrapper_move_wehago_purchase_sales_cancel_pairs_to_excepted", sections, lambda: _move_wehago_purchase_sales_cancel_pairs_to_excepted(sections), ) sections = traced_wrapper_step( "wrapper_apply_guarded_regression_case_rules_2", sections, lambda: _apply_guarded_regression_case_rules(sections), ) sections = traced_wrapper_step( "wrapper_dedupe_wehago_sections_to_single_status_2", sections, lambda: _dedupe_wehago_sections_to_single_status(sections), ) if _trace_compare_projection_enabled(): _trace_compare_projection( "build_wehago_anchored_recheck_groups_start", counts={key: len(value or []) for key, value in sections.items()}, ) anchored_started_at = time.monotonic() sections["voucher_recheck"] = _build_wehago_anchored_recheck_groups( conn, start_year, end_year, sections.get("voucher_recheck", []) or [], ) if _trace_compare_projection_enabled(): _trace_compare_projection( "build_wehago_anchored_recheck_groups_done", elapsed_sec=round(time.monotonic() - anchored_started_at, 3), counts={key: len(value or []) for key, value in sections.items()}, ) sections = traced_wrapper_step( "demote_anchored_recheck_groups_without_erp", sections, lambda: _demote_anchored_recheck_groups_without_erp(sections), ) sections = traced_wrapper_step( "promote_obvious_recheck_groups_after_anchor", sections, lambda: _promote_obvious_recheck_groups(sections), ) sections = traced_wrapper_step( "apply_erp_voucher_existence_gate", sections, lambda: _apply_erp_voucher_existence_gate(conn, start_year, end_year, sections), ) sections = traced_wrapper_step( "demote_matched_groups_without_erp_detail", sections, lambda: _demote_matched_groups_without_erp_detail(sections), ) sections = traced_wrapper_step( "wrapper_dedupe_wehago_sections_to_single_status_3", sections, lambda: _dedupe_wehago_sections_to_single_status(sections), ) sections = traced_wrapper_step( "wrapper_dedupe_erp_sections_to_single_status", sections, lambda: _dedupe_erp_sections_to_single_status(sections), ) sections = traced_wrapper_step( "apply_final_display_projection_guard", sections, lambda: _apply_final_display_projection_guard(sections), ) return sections def _push_action_history(conn: Any, action_type: str, payload: dict[str, Any]) -> int: result = conn.execute( text( """ INSERT INTO wehago_action_history (action_type, payload_json, created_at) VALUES (:action_type, :payload_json, CURRENT_TIMESTAMP) """ ), {"action_type": action_type, "payload_json": json.dumps(payload, ensure_ascii=False)}, ) return int(result.lastrowid) def apply_saved_manual_pair_matches( sections: dict[str, dict[str, Any]], pair_matches: list[dict[str, Any]], ) -> dict[str, dict[str, Any]]: if not pair_matches: return sections adjusted: dict[str, dict[str, Any]] = {} for key, section in sections.items(): adjusted[key] = { "count": section["count"], "columns": section["columns"], "rows": list(section["rows"]), } ledger_rows = adjusted["ledger_only"]["rows"] voucher_rows = adjusted["voucher_only"]["rows"] matched_rows = adjusted["matched"]["rows"] ledger_by_key = {clean(row.get("ledger_row_key")): row for row in ledger_rows if clean(row.get("ledger_row_key"))} voucher_by_key = {clean(row.get("voucher_row_key")): row for row in voucher_rows if clean(row.get("voucher_row_key"))} ledger_remove: set[str] = set() voucher_remove: set[str] = set() matched_pair_keys = {clean(row.get("pair_match_key")) for row in matched_rows if clean(row.get("pair_match_key"))} for pair in pair_matches: ledger_key = clean(pair.get("ledger_row_key")) voucher_key = clean(pair.get("voucher_row_key")) pair_key = clean(pair.get("pair_key")) if not ledger_key and not voucher_key: continue 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 WEHAGO_ANCHORED_RECHECK_REASON = "WEHAGO_ANCHORED_RECHECK" def _append_distinct_text(target: list[str], value: Any) -> None: text_value = clean(value) if text_value and text_value not in target: target.append(text_value) def _append_distinct_text_parts(target: list[str], value: Any) -> None: for part in [item.strip() for item in clean(value).split(",") if item.strip()]: _append_distinct_text(target, part) def _wehago_anchor_key_from_parts(fiscal_year: Any, ledger_date: Any, voucher_no: Any) -> tuple[int, str, str]: year = int(fiscal_year or 0) return ( year, normalize_wehago_display_date(ledger_date, year or None), clean(voucher_no), ) def _wehago_anchor_key_from_row(row: Mapping[str, Any]) -> tuple[int, str, str]: return _wehago_anchor_key_from_parts( row.get("fiscal_year"), row.get("ledger_date"), row.get("voucher_no"), ) def _row_has_wehago_ledger_side(row: Mapping[str, Any]) -> bool: return bool( 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 ) def _row_has_hanmac_candidate_side(row: Mapping[str, Any]) -> bool: return bool( clean(row.get("draft_no")) or clean(row.get("proof_date")) or clean(row.get("voucher_account_name")) or clean(row.get("voucher_vendor")) or clean(row.get("voucher_desc")) or abs(parse_amount(row.get("voucher_debit"))) >= 0.5 or abs(parse_amount(row.get("voucher_credit"))) >= 0.5 ) def _as_wehago_only_recheck_row(row: Mapping[str, Any], anchor_key: tuple[int, str, str]) -> dict[str, Any]: fiscal_year, ledger_date, voucher_no = anchor_key payload = { "fiscal_year": fiscal_year, "status_label": "Recheck", "ledger_date": ledger_date, "proof_date": "", "voucher_no": voucher_no, "draft_no": "", "ledger_account_code": clean(row.get("ledger_account_code")), "ledger_account_name": clean(row.get("ledger_account_name")), "ledger_vendor": clean(row.get("ledger_vendor")), "ledger_debit": parse_amount(row.get("ledger_debit")), "ledger_credit": parse_amount(row.get("ledger_credit")), "ledger_desc": clean(row.get("ledger_desc")), "voucher_account_code": "", "voucher_account_name": "", "voucher_vendor": "", "voucher_debit": 0, "voucher_credit": 0, "voucher_desc": "", "review_reason": WEHAGO_ANCHORED_RECHECK_REASON, "matched_case": "", "ledger_row_key": clean(row.get("ledger_row_key")), "voucher_row_key": "", "match_identity_key": "", } payload["ledger_row_key"] = clean(payload.get("ledger_row_key")) or build_ledger_row_key(payload) payload["match_identity_key"] = payload["ledger_row_key"] return payload def _as_hanmac_candidate_recheck_row(row: Mapping[str, Any], anchor_key: tuple[int, str, str]) -> dict[str, Any]: fiscal_year, ledger_date, voucher_no = anchor_key existing_reason = clean(row.get("review_reason")) review_reason = " / ".join( item for item in [existing_reason, WEHAGO_ANCHORED_RECHECK_REASON] if item ) payload = { "fiscal_year": fiscal_year, "status_label": "Recheck", "ledger_date": ledger_date, "proof_date": clean(row.get("proof_date")), "voucher_no": voucher_no, "draft_no": clean(row.get("draft_no")), "ledger_account_code": "", "ledger_account_name": "", "ledger_vendor": "", "ledger_debit": 0, "ledger_credit": 0, "ledger_desc": "", "voucher_account_code": clean(row.get("voucher_account_code")), "voucher_account_name": clean(row.get("voucher_account_name")), "voucher_vendor": clean(row.get("voucher_vendor")), "voucher_debit": parse_amount(row.get("voucher_debit")), "voucher_credit": parse_amount(row.get("voucher_credit")), "voucher_desc": clean(row.get("voucher_desc")), "review_reason": review_reason, "matched_case": clean(row.get("matched_case")), "ledger_row_key": "", "voucher_row_key": clean(row.get("voucher_row_key")), "match_identity_key": "", } payload["voucher_row_key"] = clean(payload.get("voucher_row_key")) or build_voucher_row_key(payload) payload["match_identity_key"] = payload["voucher_row_key"] return payload def _hanmac_candidate_dedupe_key(row: Mapping[str, Any]) -> tuple[Any, ...]: return ( clean(row.get("proof_date")), clean(row.get("draft_no")), clean(row.get("voucher_account_name")), clean(row.get("voucher_vendor")), round(parse_amount(row.get("voucher_debit")), 2), round(parse_amount(row.get("voucher_credit")), 2), clean(row.get("voucher_desc")), ) RECHECK_DIRECT_CANDIDATE = "DIRECT_MATCH_CANDIDATE" RECHECK_SETTLEMENT_BRIDGE = "SETTLEMENT_BRIDGE_CANDIDATE" RECHECK_SHARED_ALLOCATION = "SHARED_ALLOCATION_CANDIDATE" RECHECK_SHARED_BUNDLE = "SHARED_BUNDLE_PAYMENT_CANDIDATE" RECHECK_ERP_CONTEXT = "ERP_CONTEXT_ROW" RECHECK_ERP_UNASSIGNED = "UNASSIGNED_ERP_ROW" RECHECK_FULL_ERP_REASON = "ERP_FULL_DRAFT_VOUCHER_CONTEXT" def _split_erp_number_candidates(value: Any) -> list[str]: return [ clean(part) for part in re.split(r"\s*,\s*", clean(value)) if clean(part) ] def _erp_row_amount(row: Mapping[str, Any], prefix: str) -> float: return max( abs(parse_amount(row.get(f"{prefix}_debit"))), abs(parse_amount(row.get(f"{prefix}_credit"))), ) def _erp_row_side(row: Mapping[str, Any], prefix: str) -> str: debit = abs(parse_amount(row.get(f"{prefix}_debit"))) credit = abs(parse_amount(row.get(f"{prefix}_credit"))) if debit >= 0.5 and credit < 0.5: return "debit" if credit >= 0.5 and debit < 0.5: return "credit" return "" def _is_recheck_settlement_account(account_code: Any, account_name: Any) -> bool: family = _classify_account_family(account_code, account_name) return family in {"bank", "payable", "receivable", "advance"} or any( token in normalize_text(account_name) for token in ("전도금", "보통예금", "외상매입금", "미지급금") ) def _is_recheck_vat_account(account_code: Any, account_name: Any) -> bool: return _is_vat_family(_classify_account_family(account_code, account_name)) or any( token in normalize_text(account_name) for token in ("부가세대급금", "매입세액", "부가세예수금", "매출세액") ) def _recheck_direct_account_compatible(ledger_row: Mapping[str, Any], voucher_row: Mapping[str, Any]) -> bool: ledger_code = ledger_row.get("ledger_account_code") ledger_name = ledger_row.get("ledger_account_name") voucher_code = voucher_row.get("voucher_account_code") voucher_name = voucher_row.get("voucher_account_name") if _is_recheck_vat_account(ledger_code, ledger_name) and _is_recheck_vat_account(voucher_code, voucher_name): return True if _is_recheck_settlement_account(ledger_code, ledger_name) or _is_recheck_settlement_account(voucher_code, voucher_name): return normalize_text(ledger_name) == normalize_text(voucher_name) ledger_family = _classify_account_family(ledger_code, ledger_name) voucher_family = _classify_account_family(voucher_code, voucher_name) if ledger_family and voucher_family and ledger_family == voucher_family: return True ledger_text = normalize_text(ledger_name) voucher_text = normalize_text(voucher_name) if ledger_text and voucher_text and (ledger_text in voucher_text or voucher_text in ledger_text): return True compatible_pairs = ( ("전력비", "수도광열비"), ("통신비", "통신비"), ("행사비용", "합사경비"), ) return any( left in ledger_text and right in voucher_text for left, right in compatible_pairs ) def _recheck_context_text(row: Mapping[str, Any], prefix: str) -> str: return normalize_text( " ".join( filter( None, ( clean(row.get(f"{prefix}_vendor")), clean(row.get(f"{prefix}_desc")), ), ) ) ) def _recheck_context_overlap(ledger_row: Mapping[str, Any], voucher_row: Mapping[str, Any]) -> bool: if _core_token_overlap(ledger_row.get("ledger_vendor"), voucher_row.get("voucher_vendor")): return True if _core_token_strong_overlap(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")): return True return bool( _contained_core_desc_match(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")) or _contained_core_desc_match(ledger_row.get("ledger_vendor"), voucher_row.get("voucher_desc")) ) def _raw_erp_recheck_row(raw: Mapping[str, Any], anchor_key: tuple[int, str, str]) -> dict[str, Any]: fiscal_year, ledger_date, voucher_no = anchor_key payload = { "fiscal_year": fiscal_year, "status_label": "Recheck", "ledger_date": ledger_date, "proof_date": _raw_erp_display_proof_date(raw), "voucher_no": voucher_no, "draft_no": clean(raw.get("draft_no")), "ledger_account_code": "", "ledger_account_name": "", "ledger_vendor": "", "ledger_debit": 0.0, "ledger_credit": 0.0, "ledger_desc": "", "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 ), "review_reason": RECHECK_FULL_ERP_REASON, "matched_case": RECHECK_ERP_UNASSIGNED, "ledger_row_key": "", "voucher_row_key": "", "match_identity_key": "", } payload["voucher_row_key"] = build_voucher_row_key(payload) payload["match_identity_key"] = payload["voucher_row_key"] return payload def _load_raw_erp_rows_for_recheck_candidates( conn: Any, start_year: int, end_year: int, candidate_numbers: set[str], ) -> tuple[ dict[str, str], dict[str, list[dict[str, Any]]], dict[tuple[str, float], list[dict[str, Any]]], ]: if not candidate_numbers: return {}, {}, {} erp_start_year, erp_end_year = get_hanmac_erp_candidate_year_range(start_year, end_year) rows = conn.execute( text( """ SELECT id, fiscal_year, proof_date, confirmed_no, draft_no, account_code, account_name, debit_supply, credit_supply, vendor_name, desc1, desc2, row_number FROM wehago_voucher_rows WHERE fiscal_year BETWEEN :start_year AND :end_year AND COALESCE(draft_no, '') <> '' ORDER BY fiscal_year, draft_no, row_number, id """ ), {"start_year": int(erp_start_year), "end_year": int(erp_end_year)}, ).mappings() normalized_candidates = {normalize_voucher_no(value) for value in candidate_numbers if clean(value)} alias_to_base: dict[str, str] = {} raw_rows: list[dict[str, Any]] = [] amount_index: dict[tuple[str, float], list[dict[str, Any]]] = {} for source in rows: raw = dict(source) draft_no = normalize_voucher_no(raw.get("draft_no")) confirmed_no = normalize_voucher_no(raw.get("confirmed_no")) draft_base = _erp_draft_base(draft_no) if not draft_base: continue raw_rows.append(raw) voucher_debit = abs(parse_amount(raw.get("debit_supply"))) voucher_credit = abs(parse_amount(raw.get("credit_supply"))) if voucher_debit >= 0.5 and voucher_credit < 0.5: amount_index.setdefault(("debit", round(voucher_debit, 2)), []).append(raw) elif voucher_credit >= 0.5 and voucher_debit < 0.5: amount_index.setdefault(("credit", round(voucher_credit, 2)), []).append(raw) aliases = { draft_no, confirmed_no, draft_base, _erp_draft_base(confirmed_no), } if aliases & normalized_candidates: for alias in aliases: if alias: alias_to_base[alias] = draft_base rows_by_base: dict[str, list[dict[str, Any]]] = {} for raw in raw_rows: draft_base = _erp_draft_base(raw.get("draft_no")) rows_by_base.setdefault(draft_base, []).append(raw) alias_to_base[normalize_voucher_no(raw.get("draft_no"))] = draft_base alias_to_base[normalize_voucher_no(raw.get("confirmed_no"))] = draft_base return alias_to_base, rows_by_base, amount_index def _discover_strong_recheck_candidate_bases( anchor: tuple[int, str, str], ledger_rows: list[dict[str, Any]], attached_bases: list[str], amount_index: dict[tuple[str, float], list[dict[str, Any]]], ) -> list[str]: scores: dict[str, float] = {base: 5.0 for base in attached_bases} exact_counts: dict[str, int] = {} for ledger_row in ledger_rows: if _classify_account_family( ledger_row.get("ledger_account_code"), ledger_row.get("ledger_account_name"), ) == "bank": continue ledger_amount = _erp_row_amount(ledger_row, "ledger") ledger_side = _erp_row_side(ledger_row, "ledger") if ledger_amount < 0.5 or not ledger_side: continue for raw in amount_index.get((ledger_side, round(ledger_amount, 2)), []): voucher_row = _raw_erp_recheck_row(raw, anchor) draft_base = _erp_draft_base(voucher_row.get("draft_no")) if not draft_base: continue ledger_date_value = _row_effective_wehago_date(ledger_row) voucher_date_value = _row_effective_erp_date(voucher_row) if ledger_date_value and voucher_date_value: day_gap = abs((ledger_date_value - voucher_date_value).days) if day_gap > 62: continue account_compatible = _recheck_direct_account_compatible(ledger_row, voucher_row) context_overlap = _recheck_context_overlap(ledger_row, voucher_row) same_account = normalize_text(ledger_row.get("ledger_account_name")) == normalize_text( voucher_row.get("voucher_account_name") ) is_vat_pair = _is_recheck_vat_account( ledger_row.get("ledger_account_code"), ledger_row.get("ledger_account_name"), ) and _is_recheck_vat_account( voucher_row.get("voucher_account_code"), voucher_row.get("voucher_account_name"), ) if not account_compatible: continue if not (context_overlap or is_vat_pair): continue exact_counts[draft_base] = exact_counts.get(draft_base, 0) + 1 scores[draft_base] = scores.get(draft_base, 0.0) + 35.0 if context_overlap: scores[draft_base] += 20.0 if same_account or is_vat_pair: scores[draft_base] += 15.0 if ledger_date_value and voucher_date_value: day_gap = abs((ledger_date_value - voucher_date_value).days) if day_gap == 0: scores[draft_base] += 25.0 elif day_gap <= 7: scores[draft_base] += 15.0 elif day_gap <= 45: scores[draft_base] += 10.0 if draft_base in attached_bases: scores[draft_base] += 10.0 strong = [ (score, exact_counts.get(base, 0), base) for base, score in scores.items() if exact_counts.get(base, 0) >= 1 ] if not strong: return attached_bases strong.sort(reverse=True) best_score = strong[0][0] selected = [ base for score, exact_count, base in strong if score >= best_score - 8.0 ] return selected or [strong[0][2]] def _pair_recheck_rows_with_full_erp( anchor: tuple[int, str, str], ledger_rows: list[dict[str, Any]], source_rows: list[dict[str, Any]], raw_rows: list[dict[str, Any]], ) -> list[dict[str, Any]]: erp_rows = [_raw_erp_recheck_row(raw, anchor) for raw in raw_rows] source_pair_by_erp_key: dict[tuple[Any, ...], list[dict[str, Any]]] = {} for source in source_rows: if not (_row_has_wehago_ledger_side(source) and _row_has_hanmac_candidate_side(source)): continue candidate = _as_hanmac_candidate_recheck_row(source, anchor) source_pair_by_erp_key.setdefault(_hanmac_candidate_dedupe_key(candidate), []).append(dict(source)) ledger_by_key = { clean(row.get("ledger_row_key")) or build_ledger_row_key(row): row for row in ledger_rows } consumed_ledger: set[str] = set() output: list[dict[str, Any]] = [] for erp_row in erp_rows: source_candidates = source_pair_by_erp_key.get(_hanmac_candidate_dedupe_key(erp_row), []) structural_candidates = [ { **ledger_row, "ledger_row_key": clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row), } for ledger_row in ledger_rows ] source_candidates = source_candidates + structural_candidates best: tuple[int, dict[str, Any], dict[str, Any]] | None = None for source in source_candidates: source_ledger = _as_wehago_only_recheck_row(source, anchor) ledger_key = clean(source_ledger.get("ledger_row_key")) ledger_row = ledger_by_key.get(ledger_key) if ledger_row is None or ledger_key in consumed_ledger: continue ledger_amount = _erp_row_amount(ledger_row, "ledger") voucher_amount = _erp_row_amount(erp_row, "voucher") if ledger_amount < 0.5 or voucher_amount < 0.5: continue exact_amount = abs(ledger_amount - voucher_amount) < 0.5 same_side = _erp_row_side(ledger_row, "ledger") == _erp_row_side(erp_row, "voucher") account_compatible = _recheck_direct_account_compatible(ledger_row, erp_row) context_overlap = _recheck_context_overlap(ledger_row, erp_row) if not exact_amount or not same_side: continue same_account = normalize_text(ledger_row.get("ledger_account_name")) == normalize_text( erp_row.get("voucher_account_name") ) is_settlement = not same_account and ( _is_recheck_settlement_account( ledger_row.get("ledger_account_code"), ledger_row.get("ledger_account_name"), ) or _is_recheck_settlement_account( erp_row.get("voucher_account_code"), erp_row.get("voucher_account_name"), ) ) is_vat_pair = _is_recheck_vat_account( ledger_row.get("ledger_account_code"), ledger_row.get("ledger_account_name"), ) and _is_recheck_vat_account( erp_row.get("voucher_account_code"), erp_row.get("voucher_account_name"), ) if not is_settlement and not account_compatible: continue if not (context_overlap or is_vat_pair): continue score = (30 if account_compatible else 0) + (20 if context_overlap else 0) if is_settlement: score += 10 if best is None or score > best[0]: best = (score, ledger_row, source) if best is None: account_name = erp_row.get("voucher_account_name") if _is_recheck_settlement_account(erp_row.get("voucher_account_code"), account_name): erp_row["matched_case"] = RECHECK_ERP_CONTEXT output.append(erp_row) continue _score, ledger_row, _source = best ledger_key = clean(ledger_row.get("ledger_row_key")) consumed_ledger.add(ledger_key) paired = {**erp_row} for key in ( "ledger_account_code", "ledger_account_name", "ledger_vendor", "ledger_debit", "ledger_credit", "ledger_desc", "ledger_row_key", ): paired[key] = ledger_row.get(key) same_account = normalize_text(ledger_row.get("ledger_account_name")) == normalize_text( erp_row.get("voucher_account_name") ) is_settlement = not same_account and ( _is_recheck_settlement_account( ledger_row.get("ledger_account_code"), ledger_row.get("ledger_account_name"), ) or _is_recheck_settlement_account( erp_row.get("voucher_account_code"), erp_row.get("voucher_account_name"), ) ) paired["matched_case"] = RECHECK_SETTLEMENT_BRIDGE if is_settlement else RECHECK_DIRECT_CANDIDATE paired["review_reason"] = " / ".join( filter(None, (clean(paired.get("review_reason")), paired["matched_case"])) ) paired["match_identity_key"] = f"{ledger_key}|{clean(paired.get('voucher_row_key'))}" output.append(paired) has_direct_match = any(row.get("matched_case") == RECHECK_DIRECT_CANDIDATE for row in output) if has_direct_match: for ledger_key, ledger_row in ledger_by_key.items(): if ledger_key in consumed_ledger: continue if not _is_recheck_settlement_account( ledger_row.get("ledger_account_code"), ledger_row.get("ledger_account_name"), ): continue ledger_amount = _erp_row_amount(ledger_row, "ledger") ledger_side = _erp_row_side(ledger_row, "ledger") candidates = [ row for row in output if not _row_has_wehago_ledger_side(row) and _is_recheck_settlement_account( row.get("voucher_account_code"), row.get("voucher_account_name"), ) and _erp_row_side(row, "voucher") == ledger_side and abs(_erp_row_amount(row, "voucher") - ledger_amount) < 0.5 ] if len(candidates) != 1: continue bridge = candidates[0] for key in ( "ledger_account_code", "ledger_account_name", "ledger_vendor", "ledger_debit", "ledger_credit", "ledger_desc", "ledger_row_key", ): bridge[key] = ledger_row.get(key) bridge["matched_case"] = RECHECK_SETTLEMENT_BRIDGE bridge["review_reason"] = " / ".join( filter(None, (clean(bridge.get("review_reason")), RECHECK_SETTLEMENT_BRIDGE)) ) bridge["match_identity_key"] = f"{ledger_key}|{clean(bridge.get('voucher_row_key'))}" consumed_ledger.add(ledger_key) for ledger_row in ledger_rows: ledger_key = clean(ledger_row.get("ledger_row_key")) if ledger_key not in consumed_ledger: output.insert(0, ledger_row) return output def _load_raw_wehago_ledger_rows_for_anchors( conn: Any, start_year: int | None, end_year: int | None, anchors: set[tuple[int, str, str]], ) -> dict[tuple[int, str, str], list[dict[str, Any]]]: if start_year is None or end_year is None or not anchors: return {} rows = conn.execute( text( """ SELECT id, source_file_id, sheet_name, row_number, fiscal_year, ledger_date, voucher_no, account_code, account_name, vendor_name, debit, credit, description FROM wehago_ledger_rows WHERE fiscal_year BETWEEN :start_year AND :end_year AND COALESCE(voucher_no, '') <> '' ORDER BY fiscal_year, ledger_date, voucher_no, row_number, id """ ), {"start_year": int(start_year), "end_year": int(end_year)}, ).mappings() raw_by_anchor: dict[tuple[int, str, str], list[dict[str, Any]]] = {} for raw in rows: fiscal_year = int(raw.get("fiscal_year") or 0) anchor_key = _wehago_anchor_key_from_parts( fiscal_year, raw.get("ledger_date"), raw.get("voucher_no"), ) if anchor_key not in anchors: continue payload = { "fiscal_year": fiscal_year, "ledger_date": anchor_key[1], "voucher_no": anchor_key[2], "ledger_account_code": clean(raw.get("account_code")), "ledger_account_name": clean(raw.get("account_name")), "ledger_vendor": clean(raw.get("vendor_name")), "ledger_debit": parse_amount(raw.get("debit")), "ledger_credit": parse_amount(raw.get("credit")), "ledger_desc": clean(raw.get("description")), "ledger_source_row_id": clean(raw.get("id")), "ledger_source_file_id": clean(raw.get("source_file_id")), "ledger_source_sheet_name": clean(raw.get("sheet_name")), "ledger_source_row_number": clean(raw.get("row_number")), } payload["ledger_row_key"] = build_ledger_row_key(payload) raw_by_anchor.setdefault(anchor_key, []).append(payload) return raw_by_anchor def _annotate_recheck_shared_structures(groups: list[dict[str, Any]]) -> list[dict[str, Any]]: groups_by_base: dict[str, list[dict[str, Any]]] = {} for group in groups: summary = group.get("summary") or {} for base in _split_erp_number_candidates(summary.get("draft_no")): normalized_base = clean(base) groups_by_base.setdefault(normalized_base, []).append(group) for draft_base, related_groups in groups_by_base.items(): if len(related_groups) < 2: continue used_direct_erp_rows: set[str] = set() for group in related_groups: group_rows = list(group.get("rows") or []) for direct_row in [ row for row in group_rows if row.get("matched_case") == RECHECK_DIRECT_CANDIDATE ]: draft_no = clean(direct_row.get("draft_no")) if draft_no not in used_direct_erp_rows: used_direct_erp_rows.add(draft_no) continue alternatives = [ row for row in group_rows if clean(row.get("draft_no")) not in used_direct_erp_rows and row.get("matched_case") == RECHECK_ERP_UNASSIGNED and _erp_row_side(row, "voucher") == _erp_row_side(direct_row, "voucher") and abs(_erp_row_amount(row, "voucher") - _erp_row_amount(direct_row, "voucher")) < 0.5 and _recheck_direct_account_compatible(direct_row, row) and _recheck_context_overlap(direct_row, row) ] if len(alternatives) != 1: continue replacement = alternatives[0] for key in ( "ledger_account_code", "ledger_account_name", "ledger_vendor", "ledger_debit", "ledger_credit", "ledger_desc", "ledger_row_key", ): replacement[key] = direct_row.get(key) replacement["matched_case"] = RECHECK_DIRECT_CANDIDATE replacement["review_reason"] = " / ".join( filter(None, (clean(replacement.get("review_reason")), RECHECK_DIRECT_CANDIDATE)) ) replacement["match_identity_key"] = ( f"{clean(replacement.get('ledger_row_key'))}|{clean(replacement.get('voucher_row_key'))}" ) for key in ( "ledger_account_code", "ledger_account_name", "ledger_vendor", "ledger_desc", "ledger_row_key", ): direct_row[key] = "" direct_row["ledger_debit"] = 0.0 direct_row["ledger_credit"] = 0.0 direct_row["matched_case"] = RECHECK_ERP_UNASSIGNED direct_row["match_identity_key"] = clean(direct_row.get("voucher_row_key")) used_direct_erp_rows.add(clean(replacement.get("draft_no"))) ledger_rows: list[dict[str, Any]] = [] erp_rows_by_identity: dict[tuple[Any, ...], list[dict[str, Any]]] = {} for group in related_groups: for row in list(group.get("rows") or []): if _row_has_wehago_ledger_side(row): ledger_rows.append(row) if _row_has_hanmac_candidate_side(row) and _erp_draft_base(row.get("draft_no")) == draft_base: identity = ( clean(row.get("draft_no")), clean(row.get("voucher_account_code")), clean(row.get("voucher_account_name")), round(parse_amount(row.get("voucher_debit")), 2), round(parse_amount(row.get("voucher_credit")), 2), ) erp_rows_by_identity.setdefault(identity, []).append(row) for copies in erp_rows_by_identity.values(): erp_row = copies[0] voucher_amount = _erp_row_amount(erp_row, "voucher") voucher_side = _erp_row_side(erp_row, "voucher") if voucher_amount < 0.5 or not voucher_side: continue voucher_family = _classify_account_family( erp_row.get("voucher_account_code"), erp_row.get("voucher_account_name"), ) if voucher_family == "bank": matching_ledger = [ row for row in ledger_rows if _classify_account_family( row.get("ledger_account_code"), row.get("ledger_account_name"), ) == "bank" and _erp_row_side(row, "ledger") == voucher_side ] shared_role = RECHECK_SHARED_BUNDLE elif _is_recheck_vat_account( erp_row.get("voucher_account_code"), erp_row.get("voucher_account_name"), ): matching_ledger = [ row for row in ledger_rows if _is_recheck_vat_account( row.get("ledger_account_code"), row.get("ledger_account_name"), ) and _erp_row_side(row, "ledger") == voucher_side ] shared_role = RECHECK_SHARED_ALLOCATION elif voucher_family == "payable": matching_ledger = [ row for row in ledger_rows if _classify_account_family( row.get("ledger_account_code"), row.get("ledger_account_name"), ) == "payable" and _erp_row_side(row, "ledger") == voucher_side ] shared_role = RECHECK_SHARED_ALLOCATION else: continue distinct_anchors = { ( int(row.get("fiscal_year") or 0), clean(row.get("ledger_date")), clean(row.get("voucher_no")), ) for row in matching_ledger } ledger_total = sum(_erp_row_amount(row, "ledger") for row in matching_ledger) if len(distinct_anchors) < 2 or abs(ledger_total - voucher_amount) >= 0.5: continue for copy_row in copies: copy_row["matched_case"] = shared_role copy_row["review_reason"] = " / ".join( filter(None, (clean(copy_row.get("review_reason")), shared_role)) ) for group in groups: summary = group.get("summary") or {} roles = {clean(row.get("matched_case")) for row in list(group.get("rows") or [])} direct_count = sum( 1 for row in list(group.get("rows") or []) if row.get("matched_case") == RECHECK_DIRECT_CANDIDATE ) if RECHECK_SHARED_BUNDLE in roles or RECHECK_SHARED_ALLOCATION in roles: internal_status = "matched_bundle_candidate" if direct_count else "partial_match" elif RECHECK_SETTLEMENT_BRIDGE in roles and direct_count: internal_status = "matched_with_settlement_bridge_candidate" elif direct_count: internal_status = "matched_direct_candidate" elif any(_row_has_hanmac_candidate_side(row) for row in list(group.get("rows") or [])): internal_status = "candidate_only" else: internal_status = "unmatched" summary["review_reason"] = " / ".join( filter(None, (clean(summary.get("review_reason")), f"INTERNAL_STATUS={internal_status}")) ) return groups def _build_wehago_anchored_recheck_groups( conn: Any, start_year: int | None, end_year: int | None, source_groups: list[dict[str, Any]], ) -> list[dict[str, Any]]: if start_year is None or end_year is None or not source_groups: return source_groups anchors: set[tuple[int, str, str]] = set() group_anchors: dict[int, set[tuple[int, str, str]]] = {} fallback_ledger_rows: dict[tuple[int, str, str], dict[str, dict[str, Any]]] = {} candidate_rows: dict[tuple[int, str, str], dict[tuple[Any, ...], dict[str, Any]]] = {} source_rows_by_anchor: dict[tuple[int, str, str], list[dict[str, Any]]] = {} candidate_numbers_by_anchor: dict[tuple[int, str, str], set[str]] = {} reasons_by_anchor: dict[tuple[int, str, str], list[str]] = {} for group in source_groups: summary = dict(group.get("summary") or {}) summary_anchor = _wehago_anchor_key_from_parts( summary.get("fiscal_year"), summary.get("ledger_date"), summary.get("voucher_no"), ) local_anchors: set[tuple[int, str, str]] = set() if summary_anchor[0] and summary_anchor[1] and summary_anchor[2]: local_anchors.add(summary_anchor) for row in list(group.get("rows") or []): if not _row_has_wehago_ledger_side(row): continue row_anchor = _wehago_anchor_key_from_row(row) if row_anchor[0] and row_anchor[1] and row_anchor[2]: local_anchors.add(row_anchor) fallback_row = _as_wehago_only_recheck_row(row, row_anchor) fallback_ledger_rows.setdefault(row_anchor, {})[fallback_row["ledger_row_key"]] = fallback_row if not local_anchors: continue anchors.update(local_anchors) group_anchors[id(group)] = local_anchors for anchor in local_anchors: _append_distinct_text(reasons_by_anchor.setdefault(anchor, []), summary.get("review_reason")) for anchor in local_anchors: for row in list(group.get("rows") or []): if not _row_has_hanmac_candidate_side(row): continue source_rows_by_anchor.setdefault(anchor, []).append(dict(row)) for number in _split_erp_number_candidates(row.get("draft_no")): candidate_numbers_by_anchor.setdefault(anchor, set()).add(number) candidate = _as_hanmac_candidate_recheck_row(row, anchor) key = _hanmac_candidate_dedupe_key(candidate) candidate_rows.setdefault(anchor, {})[key] = candidate for number in _split_erp_number_candidates(summary.get("draft_no")): candidate_numbers_by_anchor.setdefault(anchor, set()).add(number) if not anchors: return source_groups raw_ledger_rows = _load_raw_wehago_ledger_rows_for_anchors(conn, start_year, end_year, anchors) all_candidate_numbers = { number for numbers in candidate_numbers_by_anchor.values() for number in numbers } alias_to_base, raw_erp_rows_by_base, raw_erp_amount_index = _load_raw_erp_rows_for_recheck_candidates( conn, int(start_year), int(end_year), all_candidate_numbers, ) anchored_groups: list[dict[str, Any]] = [] for anchor in sorted(anchors): fiscal_year, ledger_date, voucher_no = anchor ledger_rows = [ _as_wehago_only_recheck_row(row, anchor) for row in raw_ledger_rows.get(anchor, []) ] if not ledger_rows: ledger_rows = list((fallback_ledger_rows.get(anchor) or {}).values()) if not ledger_rows: continue ledger_rows.sort( key=lambda row: ( clean(row.get("ledger_account_name")), clean(row.get("ledger_vendor")), -abs(parse_amount(row.get("ledger_debit")) or parse_amount(row.get("ledger_credit"))), clean(row.get("ledger_desc")), clean(row.get("ledger_row_key")), ) ) candidate_bases: list[str] = [] for number in candidate_numbers_by_anchor.get(anchor, set()): normalized_number = normalize_voucher_no(number) draft_base = alias_to_base.get(normalized_number) or _erp_draft_base(normalized_number) if draft_base and draft_base not in candidate_bases: candidate_bases.append(draft_base) candidate_bases = _discover_strong_recheck_candidate_bases( anchor, ledger_rows, candidate_bases, raw_erp_amount_index, ) raw_candidate_rows = [ raw for draft_base in candidate_bases for raw in raw_erp_rows_by_base.get(draft_base, []) if _raw_erp_candidate_in_anchor_month_window(anchor, raw) ] if raw_candidate_rows: display_rows = _pair_recheck_rows_with_full_erp( anchor, ledger_rows, source_rows_by_anchor.get(anchor, []), raw_candidate_rows, ) hanmac_rows = [row for row in display_rows if _row_has_hanmac_candidate_side(row)] ledger_rows = [row for row in display_rows if _row_has_wehago_ledger_side(row)] else: hanmac_rows = [ row for row in list((candidate_rows.get(anchor) or {}).values()) if _hanmac_recheck_row_in_anchor_month_window(anchor, row) ] display_rows = ledger_rows + hanmac_rows hanmac_rows.sort( key=lambda row: ( clean(row.get("proof_date")), clean(row.get("draft_no")), clean(row.get("voucher_account_name")), -parse_amount(row.get("voucher_debit")), -parse_amount(row.get("voucher_credit")), clean(row.get("voucher_desc")), clean(row.get("voucher_row_key")), ) ) ledger_accounts: list[str] = [] ledger_vendors: list[str] = [] voucher_accounts: list[str] = [] voucher_vendors: list[str] = [] draft_nos: list[str] = [] proof_dates: list[str] = [] for row in ledger_rows: _append_distinct_text(ledger_accounts, row.get("ledger_account_name")) _append_distinct_text(ledger_vendors, row.get("ledger_vendor")) for row in hanmac_rows: _append_distinct_text(voucher_accounts, row.get("voucher_account_name")) _append_distinct_text(voucher_vendors, row.get("voucher_vendor")) draft_base = _erp_draft_base(row.get("draft_no")) _append_distinct_text(draft_nos, draft_base or row.get("draft_no")) _append_distinct_text_parts(proof_dates, row.get("proof_date")) review_reasons: list[str] = [] for raw_reason in reasons_by_anchor.get(anchor) or []: for reason_part in re.split(r"\s*/\s*", clean(raw_reason)): if ( not reason_part or reason_part == WEHAGO_ANCHORED_RECHECK_REASON or reason_part.startswith("INTERNAL_STATUS=") ): continue _append_distinct_text(review_reasons, reason_part) _append_distinct_text(review_reasons, WEHAGO_ANCHORED_RECHECK_REASON) summary = { "fiscal_year": fiscal_year, "status_label": "Recheck", "ledger_date": ledger_date, "proof_date": ", ".join(proof_dates), "voucher_no": voucher_no, "draft_no": ", ".join(draft_nos), "ledger_row_count": len(ledger_rows), "voucher_row_count": len(hanmac_rows), "ledger_debit": sum(parse_amount(row.get("ledger_debit")) for row in ledger_rows), "ledger_credit": sum(parse_amount(row.get("ledger_credit")) for row in ledger_rows), "voucher_debit": sum(parse_amount(row.get("voucher_debit")) for row in hanmac_rows), "voucher_credit": sum(parse_amount(row.get("voucher_credit")) for row in hanmac_rows), "ledger_accounts": ", ".join(ledger_accounts), "voucher_accounts": ", ".join(voucher_accounts), "ledger_vendors": ", ".join(ledger_vendors), "voucher_vendors": ", ".join(voucher_vendors), "review_reason": " / ".join(review_reasons), } anchored_groups.append({"summary": summary, "rows": display_rows}) return _annotate_recheck_shared_structures(anchored_groups) or source_groups def _raw_erp_candidate_in_anchor_month_window( anchor: tuple[int, str, str], raw: Mapping[str, Any], *, max_month_gap: int = 2, ) -> bool: fiscal_year, ledger_date_text, _voucher_no = anchor try: if fiscal_year <= 0: return True month_part, day_part = re.split(r"[-./]", clean(ledger_date_text), maxsplit=1) anchor_date = date(int(fiscal_year), int(month_part), int(day_part)) except Exception: return True raw_date_text = clean(raw.get("proof_date")) or _hanmac_voucher_date_from_numbers( raw.get("draft_no"), raw.get("confirmed_no"), ) candidate_date = _parse_iso_date(raw_date_text) if candidate_date is None: return True return _month_gap(anchor_date, candidate_date) <= int(max_month_gap) def _hanmac_recheck_row_in_anchor_month_window( anchor: tuple[int, str, str], row: Mapping[str, Any], *, max_month_gap: int = 2, ) -> bool: raw_like = { "proof_date": row.get("proof_date"), "draft_no": row.get("draft_no"), "confirmed_no": row.get("confirmed_no"), } return _raw_erp_candidate_in_anchor_month_window(anchor, raw_like, max_month_gap=max_month_gap) 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, }, ) apply_recheck_match_overrides_to_final_projection(conn) apply_cross_signature_status_overrides_to_final_projection(conn) _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 apply_recheck_match_overrides_to_final_projection(conn: Any) -> int: try: before = int( conn.execute( text( """ WITH latest_signature AS ( SELECT p.start_year, p.end_year, p.signature FROM wehago_compare_final_status_projection p GROUP BY p.start_year, p.end_year, p.signature HAVING MAX(p.updated_at) = ( SELECT MAX(p2.updated_at) FROM wehago_compare_final_status_projection p2 WHERE p2.start_year = p.start_year AND p2.end_year = p.end_year ) ), manual_matches AS ( SELECT DISTINCT fiscal_year, CASE WHEN ledger_date GLOB '[0-9][0-9]-[0-9][0-9]' THEN printf('%04d%s', fiscal_year, REPLACE(ledger_date, '-', '')) ELSE REPLACE(ledger_date, '-', '') END || '-' || voucher_no AS compare_voucher_no FROM wehago_recheck_row_changes WHERE change_type = 'match' AND fiscal_year IS NOT NULL AND COALESCE(ledger_date, '') <> '' AND COALESCE(voucher_no, '') <> '' ) SELECT COUNT(*) FROM wehago_compare_final_status_projection p JOIN latest_signature s ON s.start_year = p.start_year AND s.end_year = p.end_year AND s.signature = p.signature JOIN manual_matches m ON m.fiscal_year = p.fiscal_year AND m.compare_voucher_no = p.compare_voucher_no WHERE p.final_status NOT IN ('voucher_matched', 'voucher_excepted') """ ) ).scalar_one() or 0 ) conn.execute( text( """ WITH latest_signature AS ( SELECT p.start_year, p.end_year, p.signature FROM wehago_compare_final_status_projection p GROUP BY p.start_year, p.end_year, p.signature HAVING MAX(p.updated_at) = ( SELECT MAX(p2.updated_at) FROM wehago_compare_final_status_projection p2 WHERE p2.start_year = p.start_year AND p2.end_year = p.end_year ) ), manual_matches AS ( SELECT DISTINCT fiscal_year, CASE WHEN ledger_date GLOB '[0-9][0-9]-[0-9][0-9]' THEN printf('%04d%s', fiscal_year, REPLACE(ledger_date, '-', '')) ELSE REPLACE(ledger_date, '-', '') END || '-' || voucher_no AS compare_voucher_no FROM wehago_recheck_row_changes WHERE change_type = 'match' AND fiscal_year IS NOT NULL AND COALESCE(ledger_date, '') <> '' AND COALESCE(voucher_no, '') <> '' ) UPDATE wehago_compare_final_status_projection SET final_status = 'voucher_matched', final_rank = 2, source_statuses = CASE WHEN INSTR(COALESCE(source_statuses, ''), 'manual_recheck_match') > 0 THEN source_statuses WHEN COALESCE(source_statuses, '') = '' THEN 'manual_recheck_match' ELSE source_statuses || ',manual_recheck_match' END, updated_at = CURRENT_TIMESTAMP WHERE EXISTS ( SELECT 1 FROM latest_signature s WHERE s.start_year = wehago_compare_final_status_projection.start_year AND s.end_year = wehago_compare_final_status_projection.end_year AND s.signature = wehago_compare_final_status_projection.signature ) AND EXISTS ( SELECT 1 FROM manual_matches m WHERE m.fiscal_year = wehago_compare_final_status_projection.fiscal_year AND m.compare_voucher_no = wehago_compare_final_status_projection.compare_voucher_no ) AND final_status <> 'voucher_excepted' """ ) ) return before except Exception: return 0 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 @lru_cache(maxsize=131072) def _extract_core_keywords_cached(text_value: str) -> tuple[str, ...]: if not text_value: return () 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 tuple(sorted(keywords)) def _extract_core_keywords(value: Any) -> set[str]: return set(_extract_core_keywords_cached(_normalize_core_similarity_text(value))) 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 @lru_cache(maxsize=32768) def _extract_month_tokens_cached(text: str) -> tuple[int, ...]: tokens: set[int] = set() for raw in re.findall(r"(? set[int]: return set(_extract_month_tokens_cached(str(value or ""))) def _has_conflicting_month_tokens(left: Any, right: Any) -> 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) @lru_cache(maxsize=65536) def _extract_desc_date_tokens_cached(text_value: str) -> tuple[str, ...]: if not text_value: return () tokens: set[str] = set() for year, month, day in re.findall(r"(? set[str]: return set(_extract_desc_date_tokens_cached(clean(value))) def _date_token_month_numbers(tokens: set[str]) -> 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)) @lru_cache(maxsize=131072) def _normalize_account_family_text_cached(text: str) -> str: return normalize_text(text) def _normalize_account_family_text(value: Any) -> str: return _normalize_account_family_text_cached(clean(value)) @lru_cache(maxsize=65536) def _classify_account_family_cached(normalized_code: str, normalized_name: str) -> str: 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 education_markers): return "education" if any(marker in normalized_name for marker in welfare_markers): return "welfare" if any(marker in normalized_name for marker in sales_markers): return "sales" if normalized_code.startswith(("251", "253", "290")): return "payable" if normalized_code.startswith(("103", "104", "106")): return "bank" if normalized_code.startswith(("108", "112", "120")): return "receivable" if normalized_code.startswith(("135",)): return "vat_input" if normalized_code.startswith(("255",)): return "vat_output" return "other" def _classify_account_family(account_code: Any, account_name: Any) -> str: normalized_name = _normalize_account_family_text(account_name) normalized_code = clean(account_code) return _classify_account_family_cached(normalized_code, normalized_name) def _classify_account_family_uncached(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 @lru_cache(maxsize=128) 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 if not ENABLE_GENERATED_RECHECK_CANDIDATES and len(recheck_rows) > 5_000: 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) sections, changed_row_reuse = _release_reused_or_unidentified_matched_rows(sections) if not (changed_matched or changed_recheck or changed_scope or changed_row_reuse): return sections if not ENABLE_GENERATED_RECHECK_CANDIDATES: return sections return _promote_direct_auto_matches(sections) def _release_reused_or_unidentified_matched_rows( sections: dict[str, dict[str, Any]], ) -> tuple[dict[str, dict[str, Any]], bool]: """Keep direct source account-row matches one-to-one and invariant-safe.""" matched_rows = list((sections or {}).get("matched", {}).get("rows", [])) if not matched_rows: return sections, False def absolute_row_amount(row: dict[str, Any], prefix: str) -> float: return max( abs(parse_amount(row.get(f"{prefix}_debit"))), abs(parse_amount(row.get(f"{prefix}_credit"))), ) def invariant_violation_reason(row: dict[str, Any]) -> str: ledger_amount = absolute_row_amount(row, "ledger") voucher_amount = absolute_row_amount(row, "voucher") if ledger_amount <= 0 or voucher_amount <= 0: return "원천 계정행 금액 누락" if abs(ledger_amount - voucher_amount) >= 0.5: return "원천 계정행 금액 불일치" if _row_sides_have_opposite_signed_amount(row): return "취소·정상 부호 반대 직접매칭" if not _matched_row_passes_account_pair_rule(row): return "계정군 허용 규칙 위반" if _direct_match_date_invariant_violation_for_guard(row): return "허용기간 62일 초과" return "" used_ledger: set[str] = set() used_voucher: set[str] = set() kept_rows: list[dict[str, Any]] = [] recheck_rows = list((sections or {}).get("amount_mismatch", {}).get("rows", [])) ledger_only_rows = list((sections or {}).get("ledger_only", {}).get("rows", [])) voucher_only_rows = list((sections or {}).get("voucher_only", {}).get("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 recheck_rows } ledger_only_seen = { clean(row.get("ledger_row_key")) or build_ledger_row_key(row) for row in ledger_only_rows } voucher_only_seen = { clean(row.get("voucher_row_key")) or build_voucher_row_key(row) for row in voucher_only_rows } changed = False for source_row in matched_rows: row = dict(source_row) 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) row["ledger_row_key"] = ledger_key row["voucher_row_key"] = voucher_key reason = "" if not ledger_key or not voucher_key: reason = "원천 계정행 식별정보 누락" elif ledger_key in used_ledger: reason = "WEHAGO 원천 계정행 중복 매칭" elif voucher_key in used_voucher: reason = "ERP 원천 계정행 중복 매칭" else: reason = invariant_violation_reason(row) if not reason: used_ledger.add(ledger_key) used_voucher.add(voucher_key) kept_rows.append(row) continue changed = True row["review_reason"] = f"{reason} / 직접 ERP 연결 제거" row["matched_case"] = "source_row_invariant_recheck" pair = (ledger_key, voucher_key) if pair not in existing_pairs: recheck_row = _restore_ledger_only_row_from_match(row) recheck_row["status_label"] = "Recheck" recheck_row["review_reason"] = row["review_reason"] recheck_row["matched_case"] = row["matched_case"] recheck_rows.append(recheck_row) existing_pairs.add(pair) restored_ledger = _restore_ledger_only_row_from_match(row) restored_ledger["review_reason"] = row["review_reason"] restored_ledger_key = clean(restored_ledger.get("ledger_row_key")) or build_ledger_row_key(restored_ledger) if restored_ledger_key and restored_ledger_key not in ledger_only_seen: ledger_only_rows.append(restored_ledger) ledger_only_seen.add(restored_ledger_key) restored_voucher = _restore_voucher_only_row_from_match(row) restored_voucher["review_reason"] = row["review_reason"] restored_voucher_key = clean(restored_voucher.get("voucher_row_key")) or build_voucher_row_key(restored_voucher) if restored_voucher_key and restored_voucher_key not in used_voucher and restored_voucher_key not in voucher_only_seen: voucher_only_rows.append(restored_voucher) voucher_only_seen.add(restored_voucher_key) 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) sections["ledger_only"]["rows"] = ledger_only_rows sections["ledger_only"]["count"] = len(ledger_only_rows) sections["voucher_only"]["rows"] = voucher_only_rows sections["voucher_only"]["count"] = len(voucher_only_rows) return sections, True 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]]: 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)) erp_start_year, erp_end_year = get_hanmac_erp_candidate_year_range(year, year) candidate_years = [ candidate_year for candidate_year in (int(erp_start_year or 0), int(erp_end_year or 0)) if candidate_year and candidate_year != int(year) and candidate_year in available_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( row for row in list(payload.get("rows", [])) if _is_adjacent_month_erp_candidate_for_range(row, year, year) ) 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 _is_adjacent_month_erp_candidate_for_wehago_row(ledger_row, voucher_row, year): 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", ""), "voucher_fiscal_year": voucher_row.get("fiscal_year", ""), "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]]: if not ENABLE_GENERATED_RECHECK_CANDIDATES: return [] 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 # Adjacent-year ERP matching is intentionally limited to boundary-like # WEHAGO rows first. The non-boundary pass is allowed only against the # adjacent-month ERP pool above, which keeps the candidate set small. apply_adjacent_candidates(ledger_rows, boundary_phase=True) apply_adjacent_candidates(ledger_rows, boundary_phase=False) 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: default_year = int(row.get("fiscal_year") or 0) or None voucher_filter_key = normalize_compare_voucher_filter(voucher_no, default_year) if not voucher_filter_key: return False row_key = _row_compare_voucher_key(row) if voucher_filter_key not in row_key: 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 id, source_file_id, sheet_name, row_number, 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, l.id AS ledger_source_row_id, l.source_file_id AS ledger_source_file_id, l.sheet_name AS ledger_source_sheet_name, l.row_number AS ledger_source_row_number, 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, v.id AS voucher_source_row_id, v.source_file_id AS voucher_source_file_id, v.sheet_name AS voucher_source_sheet_name, v.row_number AS voucher_source_row_number, 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, l.id AS ledger_source_row_id, l.source_file_id AS ledger_source_file_id, l.sheet_name AS ledger_source_sheet_name, l.row_number AS ledger_source_row_number, v.id AS voucher_source_row_id, v.source_file_id AS voucher_source_file_id, v.sheet_name AS voucher_source_sheet_name, v.row_number AS voucher_source_row_number, 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")), ) ) def traced_step(step_name: str, func: Callable[[dict[str, list[dict[str, Any]]]], dict[str, list[dict[str, Any]]]]) -> None: nonlocal voucher_sections if _trace_compare_projection_enabled(): _trace_compare_projection( f"{step_name}_start", counts={key: len(value or []) for key, value in voucher_sections.items()}, ) started_at = time.monotonic() voucher_sections = func(voucher_sections) _trace_compare_projection( f"{step_name}_done", elapsed_sec=round(time.monotonic() - started_at, 3), counts={key: len(value or []) for key, value in voucher_sections.items()}, ) return voucher_sections = func(voucher_sections) traced_step("promote_obvious_recheck_groups", _promote_obvious_recheck_groups) traced_step("retarget_obvious_recheck_groups", _retarget_obvious_recheck_groups) traced_step("apply_vat_date_anchor_review", _apply_vat_date_anchor_review) traced_step("apply_grouped_cost_counterpart_review", _apply_grouped_cost_counterpart_review) traced_step("apply_highpass_bundle_trace_review", _apply_highpass_bundle_trace_review) traced_step("apply_direct_grouped_cost_business_review", _apply_direct_grouped_cost_business_review) traced_step("apply_unmatched_unconnected_trace_review", _apply_unmatched_unconnected_trace_review) traced_step("apply_wehago_anchored_loan_recovery_multi_match", _apply_wehago_anchored_loan_recovery_multi_match) traced_step("apply_guarded_regression_case_rules", _apply_guarded_regression_case_rules) traced_step("dedupe_wehago_sections_to_single_status", _dedupe_wehago_sections_to_single_status) traced_step("dedupe_erp_sections_to_single_status", _dedupe_erp_sections_to_single_status) traced_step("move_wehago_revenue_reversal_pairs_to_excepted", _move_wehago_revenue_reversal_pairs_to_excepted) traced_step("move_wehago_confirmed_reversal_pairs_to_excepted", _move_wehago_confirmed_reversal_pairs_to_excepted) traced_step("apply_wehago_cancel_reissue_recheck", _apply_wehago_cancel_reissue_recheck) traced_step("move_wehago_purchase_sales_cancel_pairs_to_excepted", _move_wehago_purchase_sales_cancel_pairs_to_excepted) traced_step("move_wehago_offset_tax_invoice_groups_to_excepted", _move_wehago_offset_tax_invoice_groups_to_excepted) if _trace_compare_projection_enabled(): _trace_compare_projection( "boundary_excepted_split_start", counts={key: len(value or []) for key, value in voucher_sections.items()}, ) boundary_started_at = time.monotonic() retained_unmatched: list[dict[str, Any]] = [] excepted_groups: list[dict[str, Any]] = [] vat_boundary_recheck_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): vat_boundary_recheck_groups.append( _copy_group_with_status_and_reason(group, "Recheck", "VAT_BOUNDARY_REVIEW_CANDIDATE") ) 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")), ), ) if vat_boundary_recheck_groups: voucher_sections["voucher_recheck"] = sorted( list(voucher_sections.get("voucher_recheck", []) or []) + vat_boundary_recheck_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")), ), ) if _trace_compare_projection_enabled(): _trace_compare_projection( "boundary_excepted_split_done", elapsed_sec=round(time.monotonic() - boundary_started_at, 3), counts={key: len(value or []) for key, value in voucher_sections.items()}, ) traced_step("apply_final_display_projection_guard", _apply_final_display_projection_guard) if _trace_compare_projection_enabled(): _trace_compare_projection( "build_hanmac_unconnected_sections_start", counts={key: len(value or []) for key, value in voucher_sections.items()}, ) hanmac_started_at = time.monotonic() voucher_sections["hanmac_unconnected"] = build_hanmac_unconnected_sections(voucher_sections) if _trace_compare_projection_enabled(): _trace_compare_projection( "build_hanmac_unconnected_sections_done", elapsed_sec=round(time.monotonic() - hanmac_started_at, 3), counts={key: len(value or []) for key, value in voucher_sections.items()}, ) return voucher_sections _LOAN_MULTI_MATCH_REASON = "WEHAGO_ANCHORED_LOAN_RECOVERY_MULTI_MATCH" def _loan_match_family(account_code: Any, account_name: Any) -> str: code = clean(account_code) name = normalize_text(account_name) if code in {"137", "10313701"} or "대여금" in name or "대출금" in name or "주종단기채권" in name: return "loan" if code in {"901", "70110103"} or "이자수익" in name or "대출이자" in name: return "interest" if code in {"103", "10110501", "10110101", "10111901", "10113701", "10115101", "10115102", "10115301"} or "보통예금" in name: return "bank" if code in {"20111103"} or "미지급금" in name: return "payable" return "" def _loan_group_has_signal(group: dict[str, Any], side: str) -> bool: summary = group.get("summary") or {} text = normalize_text( " ".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")), *[clean(row.get("ledger_desc")) for row in group.get("rows", []) or []], *[clean(row.get("voucher_desc")) for row in group.get("rows", []) or []], ] ) ) if any(token in text for token in ("대여금", "대출금", "가불금", "주임종대여금")): return True summary_accounts = clean(summary.get("ledger_accounts") if side in {"ledger", "both"} else summary.get("voucher_accounts")) if any(account.strip() in {"137", "10313701"} for account in summary_accounts.split(",")): return True for row in group.get("rows", []) or []: if side in {"ledger", "both"}: if _loan_match_family(row.get("ledger_account_code"), row.get("ledger_account_name")) == "loan": return True if side in {"voucher", "both"}: if _loan_match_family(row.get("voucher_account_code"), row.get("voucher_account_name")) == "loan": return True return False def _loan_group_vector(group: dict[str, Any], side: str) -> dict[str, float]: vector = {"loan": 0.0, "interest": 0.0, "bank": 0.0, "payable": 0.0} for row in group.get("rows", []) or []: if side in {"ledger", "both"}: family = _loan_match_family(row.get("ledger_account_code"), row.get("ledger_account_name")) if family: vector[family] += parse_amount(row.get("ledger_debit")) - parse_amount(row.get("ledger_credit")) if side in {"voucher", "both"}: family = _loan_match_family(row.get("voucher_account_code"), row.get("voucher_account_name")) if family: vector[family] += parse_amount(row.get("voucher_debit")) - parse_amount(row.get("voucher_credit")) if not any(abs(value) >= 0.5 for value in vector.values()): summary = group.get("summary") or {} accounts = { item.strip() for item in clean(summary.get("ledger_accounts") if side == "ledger" else summary.get("voucher_accounts")).split(",") if item.strip() } debit = parse_amount(summary.get("ledger_debit") if side == "ledger" else summary.get("voucher_debit")) credit = parse_amount(summary.get("ledger_credit") if side == "ledger" else summary.get("voucher_credit")) if accounts & {"137", "10313701"} and accounts & {"103", "10110501"} and abs(debit - credit) < 0.5: amount = max(abs(debit), abs(credit)) if amount >= 1_000_000: # Summary-only fallback: infer direction from bank/loan account set. text = _loan_group_text(group) if "이자수익" in text or "901" in accounts or "70110103" in accounts: vector["bank"] = amount vector["loan"] = -min(amount, credit if credit else amount) if amount > abs(vector["loan"]): vector["interest"] = -(amount - abs(vector["loan"])) else: vector["loan"] = amount vector["bank"] = -amount return {key: round(value, 2) for key, value in vector.items() if abs(value) >= 0.5} def _loan_vector_matches(wehago_vector: dict[str, float], erp_vector: dict[str, float]) -> bool: if not wehago_vector or not erp_vector: return False if abs(wehago_vector.get("loan", 0.0)) < 1_000_000: return False compare_keys = {"loan", "interest", "bank"} for key in compare_keys: if abs(wehago_vector.get(key, 0.0) - erp_vector.get(key, 0.0)) >= 0.5: return False return abs(erp_vector.get("payable", 0.0)) < 0.5 def _loan_group_date(group: dict[str, Any], side: str) -> date | None: summary = group.get("summary") or {} candidates: list[Any] = [] if side == "ledger": candidates.extend([summary.get("ledger_date"), summary.get("proof_date")]) candidates.extend(row.get("ledger_date") for row in group.get("rows", []) or []) else: candidates.extend([summary.get("proof_date"), summary.get("ledger_date")]) candidates.extend(row.get("proof_date") for row in group.get("rows", []) or []) fiscal_year = int(summary.get("fiscal_year") or 0) or None for value in candidates: parsed_text = parse_excel_date(value, default_year=fiscal_year) parsed = _parse_iso_date(value) or _parse_iso_date(parsed_text) if parsed: return parsed return None def _loan_group_text(group: dict[str, Any]) -> str: summary = group.get("summary") or {} return normalize_text( " ".join( [ clean(summary.get("review_reason")), clean(summary.get("ledger_vendors")), clean(summary.get("voucher_vendors")), *[clean(row.get("ledger_vendor")) for row in group.get("rows", []) or []], *[clean(row.get("voucher_vendor")) for row in group.get("rows", []) or []], *[clean(row.get("ledger_desc")) for row in group.get("rows", []) or []], *[clean(row.get("voucher_desc")) for row in group.get("rows", []) or []], ] ) ) def _loan_groups_related(wehago_group: dict[str, Any], erp_groups: list[dict[str, Any]]) -> bool: wehago_date = _loan_group_date(wehago_group, "ledger") erp_dates = [_loan_group_date(group, "voucher") for group in erp_groups] if wehago_date and any(erp_date and abs((wehago_date - erp_date).days) <= 5 for erp_date in erp_dates): return True source_text = _loan_group_text(wehago_group) return bool(source_text and all(("대여금" in _loan_group_text(group) or "대출금" in _loan_group_text(group) or "가불금" in _loan_group_text(group)) for group in erp_groups)) def _loan_vector_principal_amount(vector: dict[str, float]) -> float: return round(abs(vector.get("loan", 0.0)), 2) def _loan_group_date_distance(left: dict[str, Any], right: dict[str, Any]) -> int: left_date = _loan_group_date(left, "ledger") right_date = _loan_group_date(right, "voucher") if not left_date or not right_date: return 999 return abs((left_date - right_date).days) def _copy_wehago_anchor_with_erp_groups( wehago_group: dict[str, Any], erp_groups: list[dict[str, Any]], ) -> dict[str, Any]: copied = { "summary": dict(wehago_group.get("summary") or {}), "rows": [dict(row) for row in wehago_group.get("rows", []) or []], } erp_voucher_nos: list[str] = [] erp_draft_nos: list[str] = [] erp_accounts: list[str] = [] erp_vendors: list[str] = [] for erp_group in erp_groups: erp_summary = erp_group.get("summary") or {} for value, target in ( (erp_summary.get("voucher_no"), erp_voucher_nos), (erp_summary.get("draft_no"), erp_draft_nos), (erp_summary.get("voucher_accounts"), erp_accounts), (erp_summary.get("voucher_vendors"), erp_vendors), ): for part in [item.strip() for item in clean(value).split(",") if item.strip()]: if part not in target: target.append(part) for row in erp_group.get("rows", []) or []: copied["rows"].append( { **dict(row), "status_label": "Matched", "ledger_account_code": "", "ledger_account_name": "", "ledger_vendor": "", "ledger_debit": "", "ledger_credit": "", "ledger_desc": "", "review_reason": _LOAN_MULTI_MATCH_REASON, } ) summary = copied["summary"] summary["status_label"] = "Matched" summary["voucher_no"] = clean(summary.get("voucher_no")) summary["draft_no"] = ", ".join(erp_draft_nos) or clean(summary.get("draft_no")) summary["voucher_accounts"] = ", ".join(erp_accounts) or clean(summary.get("voucher_accounts")) summary["voucher_vendors"] = ", ".join(erp_vendors) or clean(summary.get("voucher_vendors")) summary["voucher_row_count"] = sum(int((group.get("summary") or {}).get("voucher_row_count") or 0) for group in erp_groups) summary["voucher_debit"] = sum(parse_amount((group.get("summary") or {}).get("voucher_debit")) for group in erp_groups) summary["voucher_credit"] = sum(parse_amount((group.get("summary") or {}).get("voucher_credit")) for group in erp_groups) existing_reason = clean(summary.get("review_reason")) summary["review_reason"] = " / ".join(item for item in [existing_reason, _LOAN_MULTI_MATCH_REASON] if item) for row in copied["rows"]: row["status_label"] = "Matched" if clean(row.get("ledger_account_name")) or clean(row.get("voucher_account_name")): row["review_reason"] = clean(row.get("review_reason")) or _LOAN_MULTI_MATCH_REASON copied["rows"].sort( key=lambda item: ( clean(item.get("ledger_date")) or clean(item.get("proof_date")), clean(item.get("voucher_no")), clean(item.get("draft_no")), clean(item.get("ledger_account_name")) or clean(item.get("voucher_account_name")), -parse_amount(item.get("ledger_debit") or item.get("voucher_debit")), -parse_amount(item.get("ledger_credit") or item.get("voucher_credit")), ) ) return copied def _copy_erp_multi_match_group(erp_groups: list[dict[str, Any]], wehago_group: dict[str, Any]) -> dict[str, Any]: first_summary = dict((erp_groups[0].get("summary") if erp_groups else {}) or {}) rows: list[dict[str, Any]] = [] voucher_nos: list[str] = [] draft_nos: list[str] = [] for group in erp_groups: summary = group.get("summary") or {} for value, target in ((summary.get("voucher_no"), voucher_nos), (summary.get("draft_no"), draft_nos)): for part in [item.strip() for item in clean(value).split(",") if item.strip()]: if part not in target: target.append(part) rows.extend(dict(row, status_label="Matched", review_reason=clean(row.get("review_reason")) or _LOAN_MULTI_MATCH_REASON) for row in group.get("rows", []) or []) for row in wehago_group.get("rows", []) or []: rows.append( { **dict(row), "status_label": "Matched", "voucher_account_code": "", "voucher_account_name": "", "voucher_vendor": "", "voucher_debit": "", "voucher_credit": "", "voucher_desc": "", "review_reason": _LOAN_MULTI_MATCH_REASON, } ) first_summary["status_label"] = "Matched" first_summary["voucher_no"] = ", ".join(voucher_nos) or clean(first_summary.get("voucher_no")) first_summary["draft_no"] = ", ".join(draft_nos) or clean(first_summary.get("draft_no")) first_summary["review_reason"] = " / ".join( item for item in [clean(first_summary.get("review_reason")), _LOAN_MULTI_MATCH_REASON] if item ) return {"summary": first_summary, "rows": rows} def _apply_wehago_anchored_loan_recovery_multi_match( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: wehago_candidates = [ group for status_key in ("voucher_recheck", "voucher_unmatched") for group in list(voucher_sections.get(status_key) or []) if _loan_group_has_signal(group, "ledger") ] erp_candidates = [ group for status_key in ("erp_voucher_unmatched", "voucher_recheck", "erp_voucher_matched") for group in list(voucher_sections.get(status_key) or []) if _loan_group_has_signal(group, "voucher") ] if not wehago_candidates or not erp_candidates: return voucher_sections candidate_meta: list[tuple[dict[str, Any], dict[str, float], float]] = [] for group in erp_candidates: vector = _loan_group_vector(group, "voucher") principal = _loan_vector_principal_amount(vector) if principal >= 1_000_000: candidate_meta.append((group, vector, principal)) if not candidate_meta: return voucher_sections promoted: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [] used_erp_ids: set[int] = set() for wehago_group in wehago_candidates: source_vector = _loan_group_vector(wehago_group, "ledger") if not source_vector: continue source_principal = _loan_vector_principal_amount(source_vector) if source_principal < 1_000_000: continue best_groups: list[dict[str, Any]] = [] best_score: tuple[int, int, int] | None = None available = [ (group, vector) for group, vector, principal in candidate_meta if id(group) not in used_erp_ids and abs(principal - source_principal) < 0.5 and _loan_group_date_distance(wehago_group, group) <= 5 ] if not available: continue combos: list[tuple[tuple[dict[str, Any], dict[str, float]], ...]] = [(item,) for item in available] combos.extend( (available[left], available[right]) for left in range(len(available)) for right in range(left + 1, len(available)) if len(available) <= 40 ) for combo in combos: combo_groups = [item[0] for item in combo] if not _loan_groups_related(wehago_group, combo_groups): continue combined: dict[str, float] = {} for _group, vector in combo: for key, value in vector.items(): combined[key] = round(combined.get(key, 0.0) + value, 2) if not _loan_vector_matches(source_vector, combined): continue source_date = _loan_group_date(wehago_group, "ledger") distances = [] for group in combo_groups: target_date = _loan_group_date(group, "voucher") if source_date and target_date: distances.append(abs((source_date - target_date).days)) score = (len(combo_groups), max(distances or [999]), sum(len(group.get("rows", []) or []) for group in combo_groups)) if best_score is None or score < best_score: best_score = score best_groups = combo_groups if best_groups: promoted.append((wehago_group, best_groups)) used_erp_ids.update(id(group) for group in best_groups) if not promoted: return voucher_sections promoted_wehago_ids = {id(wehago_group) for wehago_group, _groups in promoted} promoted_erp_ids = {id(group) for _wehago_group, groups in promoted for group in groups} for status_key in ("voucher_recheck", "voucher_unmatched"): voucher_sections[status_key] = [ group for group in list(voucher_sections.get(status_key) or []) if id(group) not in promoted_wehago_ids ] for status_key in ("erp_voucher_unmatched", "erp_voucher_matched"): voucher_sections[status_key] = [ group for group in list(voucher_sections.get(status_key) or []) if id(group) not in promoted_erp_ids ] voucher_sections["voucher_recheck"] = [ group for group in list(voucher_sections.get("voucher_recheck") or []) if id(group) not in promoted_erp_ids ] for wehago_group, erp_groups in promoted: voucher_sections["voucher_matched"].append(_copy_wehago_anchor_with_erp_groups(wehago_group, erp_groups)) voucher_sections["erp_voucher_matched"].append(_copy_erp_multi_match_group(erp_groups, wehago_group)) return voucher_sections _SOCIAL_INSURANCE_MATCH_REASON = "SOCIAL_INSURANCE_ACCOUNT_FAMILY_MATCH" _INVALID_REVERSAL_MATCH_REASON = "INVALID_REVERSAL_TO_NORMAL_MATCH" _YEAR_OPENING_SUBSTITUTION_REASON = "WEHAGO_EXCEPTED_YEAR_OPENING_SUBSTITUTION" _GRANT_SETTLEMENT_DIRECT_MATCH_REVIEW_REASON = "GRANT_SETTLEMENT_NOT_DIRECT_SOURCE_MATCH" _WEAK_ONLY_TAX_INVOICE_MATCH_REVIEW_REASON = "TAX_INVOICE_CORE_ROWS_NOT_MATCHED" _PROJECT_TOKEN_CONFLICT_REVIEW_REASON = "PROJECT_TOKEN_CONFLICT" _PARTIAL_WEAK_MONTH_RECHECK_REASON = "PARTIAL_WEAK_MONTH_MISMATCH" _ERP_PARTIAL_ROWS_ONLY_RECHECK_REASON = "ERP_PARTIAL_VOUCHER_ROWS_ONLY" _SETTLEMENT_FAMILIES_FOR_GUARD = {"payable", "bank", "receivable", "advance"} _SETTLEMENT_ACCOUNT_TOKENS_FOR_GUARD = ( "미지급금", "외상매입금", "외상매출금", "미수금", "보통예금", "당좌예금", "현금", "카드", ) _BUSINESS_ACCOUNT_TOKENS_FOR_GUARD = ( "국고보조금", "복리후생비", "지급수수료", "여비교통비", "통신비", "지급임차료", "건물관리비", "소모품비", "광고선전비", "교육훈련비", "도서인쇄비", "세금과공과", "차량유지비", "접대비", "원가", "매출", "매입", "수익", "비용", ) _WEAK_ONLY_ACCOUNT_TOKENS_FOR_GUARD = ( "잡이익", "잡손실", "미지급금", "보통예금", "현금", "전도금", ) _PROJECT_STOP_TOKENS_FOR_GUARD = { "운영비", "정산", "전기요금", "전화요금", "난방비", "월정료", "임차료", "관리비", "공급가액", } _OPENING_SUBSTITUTION_TOKENS_FOR_GUARD = ( "전기대체", "전기전표대체", "전기계정대체", "계정대체", "거래처대체", "대체분개", ) def _group_text_for_guarded_rules(group: dict[str, Any]) -> str: summary = group.get("summary") or {} parts = [ 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")), clean(summary.get("voucher_no")), clean(summary.get("draft_no")), ] for row in list(group.get("rows") or []): parts.extend( [ clean(row.get("ledger_account_name")), clean(row.get("voucher_account_name")), clean(row.get("ledger_vendor")), clean(row.get("voucher_vendor")), clean(row.get("ledger_desc")), clean(row.get("voucher_desc")), clean(row.get("draft_no")), ] ) return normalize_text(" ".join(part for part in parts if part)) def _account_text_for_guard(account_code: Any, account_name: Any) -> str: return normalize_text(" ".join(part for part in (clean(account_code), clean(account_name)) if part)) def _is_grant_like_account_for_guard(account_code: Any, account_name: Any) -> bool: account_code_text = re.sub(r"\D", "", clean(account_code)) account_text = _account_text_for_guard(account_code, account_name) return account_code_text == "104" or "국고보조금" in account_text def _is_settlement_account_for_guard(account_code: Any, account_name: Any) -> bool: family = _classify_account_family(account_code, account_name) if family in _SETTLEMENT_FAMILIES_FOR_GUARD: return True account_text = _account_text_for_guard(account_code, account_name) return any(token in account_text for token in _SETTLEMENT_ACCOUNT_TOKENS_FOR_GUARD) def _is_business_account_for_guard(account_code: Any, account_name: Any) -> bool: if _is_grant_like_account_for_guard(account_code, account_name): return True family = _classify_account_family(account_code, account_name) category = _classify_account_category(account_code, account_name) if family in _SETTLEMENT_FAMILIES_FOR_GUARD or _is_vat_family(family): return False if category in {"expense", "revenue"}: return True account_text = _account_text_for_guard(account_code, account_name) return any(token in account_text for token in _BUSINESS_ACCOUNT_TOKENS_FOR_GUARD) def _is_trade_receivable_payable_account_for_guard(account_code: Any, account_name: Any) -> bool: account_text = _account_text_for_guard(account_code, account_name) return "외상매입금" in account_text or "외상매출금" in account_text or "용역미수금" in account_text def _is_weak_only_account_for_guard(account_code: Any, account_name: Any) -> bool: account_text = _account_text_for_guard(account_code, account_name) if _is_trade_receivable_payable_account_for_guard(account_code, account_name): return False return any(token in account_text for token in _WEAK_ONLY_ACCOUNT_TOKENS_FOR_GUARD) def _is_tax_invoice_core_account_for_guard(account_code: Any, account_name: Any) -> bool: family = _classify_account_family(account_code, account_name) if _is_vat_family(family): return True if _is_trade_receivable_payable_account_for_guard(account_code, account_name): return True if _is_business_account_for_guard(account_code, account_name): return True return False def _group_has_tax_invoice_core_structure_for_guard(group: dict[str, Any]) -> bool: rows = list(group.get("rows") or []) if not rows: return False has_vat = any( _is_vat_family(_row_account_family(row, "ledger")) or _is_vat_family(_row_account_family(row, "voucher")) for row in rows ) if not has_vat: return False has_trade = any( _is_trade_receivable_payable_account_for_guard(row.get("ledger_account_code"), row.get("ledger_account_name")) or _is_trade_receivable_payable_account_for_guard(row.get("voucher_account_code"), row.get("voucher_account_name")) for row in rows ) has_business = any( _is_business_account_for_guard(row.get("ledger_account_code"), row.get("ledger_account_name")) or _is_business_account_for_guard(row.get("voucher_account_code"), row.get("voucher_account_name")) for row in rows ) return has_trade or has_business def _row_has_direct_erp_match_for_guard(row: dict[str, Any]) -> bool: return bool( clean(row.get("draft_no")) and clean(row.get("ledger_account_name")) and clean(row.get("voucher_account_name")) and ( abs(parse_amount(row.get("ledger_debit"))) >= 0.5 or abs(parse_amount(row.get("ledger_credit"))) >= 0.5 ) and ( abs(parse_amount(row.get("voucher_debit"))) >= 0.5 or abs(parse_amount(row.get("voucher_credit"))) >= 0.5 ) ) def _group_has_unmatched_tax_invoice_core_ledger_rows_for_guard(group: dict[str, Any]) -> bool: if not _group_has_tax_invoice_core_structure_for_guard(group): return False for row in list(group.get("rows") or []): if not clean(row.get("ledger_account_name")): continue if clean(row.get("voucher_account_name")): continue amount = max(abs(parse_amount(row.get("ledger_debit"))), abs(parse_amount(row.get("ledger_credit")))) if amount < 0.5: continue if _is_tax_invoice_core_account_for_guard(row.get("ledger_account_code"), row.get("ledger_account_name")): return True return False def _group_has_only_weak_direct_matches_for_guard(group: dict[str, Any]) -> bool: direct_rows = [row for row in list(group.get("rows") or []) if _row_has_direct_erp_match_for_guard(row)] if not direct_rows: return False has_strong_direct = False for row in direct_rows: ledger_is_weak = _is_weak_only_account_for_guard(row.get("ledger_account_code"), row.get("ledger_account_name")) voucher_is_weak = _is_weak_only_account_for_guard(row.get("voucher_account_code"), row.get("voucher_account_name")) ledger_is_core = _is_tax_invoice_core_account_for_guard(row.get("ledger_account_code"), row.get("ledger_account_name")) voucher_is_core = _is_tax_invoice_core_account_for_guard(row.get("voucher_account_code"), row.get("voucher_account_name")) if ledger_is_core and voucher_is_core and not (ledger_is_weak or voucher_is_weak): has_strong_direct = True break return not has_strong_direct def _row_has_strong_non_amount_guard_evidence(row: dict[str, Any]) -> bool: if not _row_has_direct_erp_match_for_guard(row): return False ledger_amount = max(abs(parse_amount(row.get("ledger_debit"))), abs(parse_amount(row.get("ledger_credit")))) voucher_amount = max(abs(parse_amount(row.get("voucher_debit"))), abs(parse_amount(row.get("voucher_credit")))) if ledger_amount < 0.5 or abs(ledger_amount - voucher_amount) >= 0.5: return False if _erp_row_side(row, "ledger") != _erp_row_side(row, "voucher"): return False ledger_account = normalize_text(row.get("ledger_account_name")) voucher_account = normalize_text(row.get("voucher_account_name")) same_or_compatible_account = bool( ledger_account and voucher_account and ( ledger_account == voucher_account or ledger_account in voucher_account or voucher_account in ledger_account or _account_names_compatible(row, row) or ( _classify_account_family(row.get("ledger_account_code"), row.get("ledger_account_name")) == _classify_account_family(row.get("voucher_account_code"), row.get("voucher_account_name")) ) ) ) if not same_or_compatible_account: return False return bool( _core_token_overlap(row.get("ledger_vendor"), row.get("voucher_vendor")) or _contained_core_desc_match(row.get("ledger_desc"), row.get("voucher_desc")) or _core_token_strong_overlap(row.get("ledger_desc"), row.get("voucher_desc")) ) def _group_has_strong_non_amount_guard_evidence(group: dict[str, Any]) -> bool: rows = list(group.get("rows") or []) if any(_row_has_strong_non_amount_guard_evidence(row) for row in rows): return True reason_text = normalize_text(_group_text_for_guarded_rules(group)) strong_reason_tokens = ( "directcostbusinessinternalmatch", "directcostbusinessaccountmatch", "erpgroupedcostcounterpartmatch", "vatdateretargetmatch", "vatdaterecheckinternalmatch", "rawerpsourcetracematch", "recheckretargetmatch", "highpasscardbundlematch", ) return any(token in reason_text for token in strong_reason_tokens) def _project_token_for_guard(value: Any) -> str: text_value = clean(value) if not text_value: return "" if "/" in text_value: token = normalize_text(text_value.split("/", 1)[0]) else: token = "" normalized_text = normalize_text(text_value) matched = re.match(r"([0-9a-z가-힣]{2,30})\s*(?:운영비|주재비|관리비|정산|전기요금|전화요금|난방비)", normalized_text) if matched: token = matched.group(1) if not token or token in _PROJECT_STOP_TOKENS_FOR_GUARD: return "" if len(token) < 2: return "" return token def _rows_have_project_token_conflict_for_guard(left_row: dict[str, Any], right_row: dict[str, Any] | None = None) -> bool: right = right_row or left_row ledger_token = _project_token_for_guard(left_row.get("ledger_desc")) voucher_token = _project_token_for_guard(right.get("voucher_desc")) return bool(ledger_token and voucher_token and ledger_token != voucher_token) def _group_has_project_token_conflict_for_guard(group: dict[str, Any]) -> bool: ledger_tokens: set[str] = set() voucher_tokens: set[str] = set() for row in list(group.get("rows") or []): ledger_token = _project_token_for_guard(row.get("ledger_desc")) voucher_token = _project_token_for_guard(row.get("voucher_desc")) if ledger_token: ledger_tokens.add(ledger_token) if voucher_token: voucher_tokens.add(voucher_token) if not ledger_tokens or not voucher_tokens: return False return ledger_tokens.isdisjoint(voucher_tokens) def _service_month_tokens_for_guard(value: Any) -> set[tuple[int, int]]: text_value = normalize_text(value) if not text_value: return set() tokens: set[tuple[int, int]] = set() for matched in re.finditer(r"(20\d{2})\s*년?\s*0?([1-9]|1[0-2])\s*월", text_value): tokens.add((int(matched.group(1)), int(matched.group(2)))) for matched in re.finditer(r"(\d{2})\s*년\s*0?([1-9]|1[0-2])\s*월", text_value): tokens.add((2000 + int(matched.group(1)), int(matched.group(2)))) for matched in re.finditer(r"(? bool: left_tokens = _service_month_tokens_for_guard(left_value) right_tokens = _service_month_tokens_for_guard(right_value) if not left_tokens or not right_tokens: return False def normalize_token(token: tuple[int, int]) -> tuple[int, int]: year, month = token return (year or int(fiscal_year or 0), month) left_normalized = {normalize_token(token) for token in left_tokens} right_normalized = {normalize_token(token) for token in right_tokens} if any(not year for year, _month in left_normalized | right_normalized): return False return left_normalized.isdisjoint(right_normalized) def _row_has_service_month_conflict_for_guard(row: dict[str, Any]) -> bool: fiscal_year = int(row.get("fiscal_year") or 0) if not _service_months_conflict_for_guard(row.get("ledger_desc"), row.get("voucher_desc"), fiscal_year): return False ledger_date = _parse_row_date_with_year(row, "ledger_date") proof_date = _parse_row_date_with_year(row, "proof_date") if ledger_date and proof_date and ledger_date.year == proof_date.year and ledger_date.month == proof_date.month: return False return True def _normalized_service_month_tokens_for_guard(values: Iterable[Any], fiscal_year: int = 0) -> set[tuple[int, int]]: tokens: set[tuple[int, int]] = set() year_hint = int(fiscal_year or 0) for value in values: for year, month in _service_month_tokens_for_guard(value): normalized_year = year or year_hint if normalized_year: tokens.add((normalized_year, month)) return tokens def _group_has_service_month_conflict_for_guard(group: dict[str, Any]) -> bool: rows = list(group.get("rows") or []) if not rows: return False fiscal_year = int((group.get("summary") or {}).get("fiscal_year") or 0) ledger_months = _normalized_service_month_tokens_for_guard( [row.get("ledger_desc") for row in rows if clean(row.get("ledger_account_name"))], fiscal_year, ) voucher_months = _normalized_service_month_tokens_for_guard( [row.get("voucher_desc") for row in rows if clean(row.get("voucher_account_name"))], fiscal_year, ) return bool(ledger_months and voucher_months and ledger_months.isdisjoint(voucher_months)) def _group_has_tax_anchor_date_conflict_for_guard(group: dict[str, Any]) -> bool: rows = list(group.get("rows") or []) if not rows: return False ledger_dates = { parsed for row in rows if clean(row.get("ledger_account_name")) for parsed in [_parse_row_date_with_year(row, "ledger_date")] if parsed } # VAT rows often have an empty proof_date. Use explicit proof dates from sibling # ERP rows in the same voucher group as the tax-document anchor instead of # treating the VAT row as date-less. voucher_dates = { parsed for row in rows if clean(row.get("voucher_account_name")) for parsed in [_parse_row_date_with_year(row, "proof_date")] if parsed } if not ledger_dates or not voucher_dates: return False return min(abs((ledger_date - voucher_date).days) for ledger_date in ledger_dates for voucher_date in voucher_dates) > 62 def _compare_row_effective_month_gap_for_guard(row: dict[str, Any]) -> int | None: ledger_date = _parse_row_date_with_year(row, "ledger_date") voucher_date = _parse_iso_date(row.get("proof_date")) or _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 _row_has_meaningful_amount_for_guard(row: dict[str, Any], prefix: str) -> bool: return ( abs(parse_amount(row.get(f"{prefix}_debit"))) >= 0.5 or abs(parse_amount(row.get(f"{prefix}_credit"))) >= 0.5 ) def _row_has_explicit_proof_date_match_for_guard(row: Mapping[str, Any]) -> bool: ledger_date = _parse_row_date_with_year(dict(row), "ledger_date") proof_date = _parse_iso_date(row.get("proof_date")) return bool(ledger_date and proof_date and ledger_date == proof_date) def _row_has_explicit_proof_date_conflict_for_guard(row: Mapping[str, Any]) -> bool: ledger_date = _parse_row_date_with_year(dict(row), "ledger_date") proof_date = _parse_iso_date(row.get("proof_date")) if not ledger_date or not proof_date: return False return ledger_date != proof_date and abs((ledger_date - proof_date).days) > 62 def _row_is_tax_invoice_sensitive_for_guard(row: Mapping[str, Any]) -> bool: ledger_text = normalize_text(row.get("ledger_account_name")) voucher_text = normalize_text(row.get("voucher_account_name")) account_text = f"{ledger_text} {voucher_text}" if any(token in account_text for token in ("부가세", "매입세액", "매출세액", "부가세대급금", "부가세예수금")): return True return bool( _is_tax_invoice_core_account_for_guard(row.get("ledger_account_code"), row.get("ledger_account_name")) or _is_tax_invoice_core_account_for_guard(row.get("voucher_account_code"), row.get("voucher_account_name")) ) def _row_has_strong_exact_desc_direct_evidence_for_guard(row: Mapping[str, Any]) -> bool: ledger_amount = max(abs(parse_amount(row.get("ledger_debit"))), abs(parse_amount(row.get("ledger_credit")))) voucher_amount = max(abs(parse_amount(row.get("voucher_debit"))), abs(parse_amount(row.get("voucher_credit")))) if ledger_amount <= 0 or voucher_amount <= 0 or abs(ledger_amount - voucher_amount) >= 0.5: return False if not _matched_row_passes_account_pair_rule(dict(row)): return False return bool( _same_or_similar_vendor(dict(row)) and ( normalize_text(row.get("ledger_desc")) and normalize_text(row.get("ledger_desc")) == normalize_text(row.get("voucher_desc")) ) ) def _direct_match_date_invariant_violation_for_guard(row: Mapping[str, Any]) -> bool: ledger_date = _parse_row_date_with_year(dict(row), "ledger_date") if ledger_date is None: return False proof_date = _parse_iso_date(row.get("proof_date")) if proof_date is not None: return abs((ledger_date - proof_date).days) > 62 if _row_is_tax_invoice_sensitive_for_guard(row): return True if _row_has_strong_exact_desc_direct_evidence_for_guard(row): return False inferred_date = _infer_compare_row_proof_date(dict(row)) return bool(inferred_date and abs((ledger_date - inferred_date).days) > 62) def _group_is_partial_weak_month_mismatch_for_guard(group: dict[str, Any]) -> str: rows = list(group.get("rows") or []) if not rows: return "" reason_text = normalize_text(_group_text_for_guarded_rules(group)) if "directcostbusinessaccountmatch" not in reason_text and "groupedcost" not in reason_text: return "" direct_rows = [row for row in rows if _row_has_direct_erp_match_for_guard(row)] erp_only_rows = [ row for row in rows if clean(row.get("voucher_account_name")) and not clean(row.get("ledger_account_name")) and _row_has_meaningful_amount_for_guard(row, "voucher") ] ledger_only_rows = [ row for row in rows if clean(row.get("ledger_account_name")) and not clean(row.get("voucher_account_name")) and _row_has_meaningful_amount_for_guard(row, "ledger") ] if not direct_rows or not erp_only_rows: return "" strong_direct_rows = [] weak_direct_rows = [] for row in direct_rows: ledger_core = _is_tax_invoice_core_account_for_guard(row.get("ledger_account_code"), row.get("ledger_account_name")) voucher_core = _is_tax_invoice_core_account_for_guard(row.get("voucher_account_code"), row.get("voucher_account_name")) ledger_weak = _is_weak_only_account_for_guard(row.get("ledger_account_code"), row.get("ledger_account_name")) voucher_weak = _is_weak_only_account_for_guard(row.get("voucher_account_code"), row.get("voucher_account_name")) if ledger_core and voucher_core and not (ledger_weak or voucher_weak): strong_direct_rows.append(row) else: weak_direct_rows.append(row) if strong_direct_rows: return "" if not weak_direct_rows: return "" month_gaps = [ gap for gap in (_compare_row_effective_month_gap_for_guard(row) for row in rows) if gap is not None ] has_month_mismatch = bool(month_gaps and max(month_gaps) >= 1) has_vat_direct = any( _is_vat_family(_row_account_family(row, "ledger")) and _is_vat_family(_row_account_family(row, "voucher")) for row in direct_rows ) if has_month_mismatch and not has_vat_direct: return _PARTIAL_WEAK_MONTH_RECHECK_REASON if erp_only_rows and ledger_only_rows and not has_vat_direct: return _ERP_PARTIAL_ROWS_ONLY_RECHECK_REASON return "" def _group_is_unsafe_tax_invoice_match_for_guard(group: dict[str, Any]) -> str: has_strong_evidence = _group_has_strong_non_amount_guard_evidence(group) if _group_has_service_month_conflict_for_guard(group): return "MONTH_CONFLICT_RECHECK" if _group_has_tax_invoice_core_structure_for_guard(group) and _group_has_tax_anchor_date_conflict_for_guard(group): return "TAX_ANCHOR_PROOF_DATE_CONFLICT_RECHECK" if partial_reason := _group_is_partial_weak_month_mismatch_for_guard(group): if has_strong_evidence: return "" return partial_reason if _group_has_project_token_conflict_for_guard(group): if has_strong_evidence: return "" return _PROJECT_TOKEN_CONFLICT_REVIEW_REASON if _group_has_tax_invoice_core_structure_for_guard(group): if _group_has_unmatched_tax_invoice_core_ledger_rows_for_guard(group): if has_strong_evidence: return "" return _WEAK_ONLY_TAX_INVOICE_MATCH_REVIEW_REASON if _group_has_only_weak_direct_matches_for_guard(group): if has_strong_evidence: return "" return _WEAK_ONLY_TAX_INVOICE_MATCH_REVIEW_REASON return "" def _row_is_year_opening_substitution_for_guard(row: dict[str, Any]) -> bool: row_text = normalize_text( " ".join( clean(row.get(field)) for field in ( "ledger_desc", "voucher_desc", "review_reason", "ledger_account_name", "voucher_account_name", ) ) ) compact_text = row_text.replace(" ", "") if not any(token in compact_text for token in _OPENING_SUBSTITUTION_TOKENS_FOR_GUARD): return False ledger_date = _parse_row_date_with_year(row, "ledger_date") proof_date = _parse_row_date_with_year(row, "proof_date") return any(date_value and date_value.month == 1 and date_value.day == 1 for date_value in (ledger_date, proof_date)) def _group_is_year_opening_substitution_for_guard(group: dict[str, Any]) -> bool: month_days = _voucher_group_month_days(group) if "01-01" not in month_days: return False compact_text = _group_text_for_guarded_rules(group).replace(" ", "") return any(token in compact_text for token in _OPENING_SUBSTITUTION_TOKENS_FOR_GUARD) def _row_is_grant_settlement_direct_mismatch_for_guard(row: dict[str, Any]) -> bool: if not _is_grant_like_account_for_guard(row.get("ledger_account_code"), row.get("ledger_account_name")): return False if not _is_settlement_account_for_guard(row.get("voucher_account_code"), row.get("voucher_account_name")): return False return not _is_business_account_for_guard(row.get("voucher_account_code"), row.get("voucher_account_name")) def _group_is_grant_settlement_direct_mismatch_for_guard(group: dict[str, Any]) -> bool: rows = list(group.get("rows") or []) if not any( _is_grant_like_account_for_guard(row.get("ledger_account_code"), row.get("ledger_account_name")) for row in rows ): return False direct_rows = _section_direct_rows(group) if not direct_rows: return False if any( _is_business_account_for_guard(row.get("voucher_account_code"), row.get("voucher_account_name")) for row in direct_rows ): return False return any( _is_settlement_account_for_guard(row.get("voucher_account_code"), row.get("voucher_account_name")) for row in direct_rows ) def _row_social_insurance_account_match(row: dict[str, Any]) -> bool: row_text = normalize_text( " ".join( [ clean(row.get("ledger_account_name")), clean(row.get("voucher_account_name")), clean(row.get("ledger_vendor")), clean(row.get("voucher_vendor")), clean(row.get("ledger_desc")), clean(row.get("voucher_desc")), ] ) ) if "국민건강보험공단" not in row_text: return False if not any(token in row_text for token in ("산재보험", "고용보험", "건강보험", "장기요양보험", "국민연금")): return False ledger_account = normalize_text(row.get("ledger_account_name")) voucher_account = normalize_text(row.get("voucher_account_name")) allowed_account_tokens = ("보험료", "복리후생비", "예수금", "미지급금") if not any(token in ledger_account for token in allowed_account_tokens): return False if not any(token in voucher_account for token in allowed_account_tokens): 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 _has_named_reference_conflict(row, row): return False return True def _is_social_insurance_recheck_group(group: dict[str, Any]) -> bool: group_text = _group_text_for_guarded_rules(group) if "취소" in group_text: return False if "국민건강보험공단" not in group_text: return False if not any(token in group_text for token in ("산재보험", "고용보험", "건강보험", "장기요양보험", "국민연금")): return False return any(_row_social_insurance_account_match(row) for row in list(group.get("rows") or [])) def _group_has_negative_to_positive_reversal_mismatch(group: dict[str, Any]) -> bool: rows = list(group.get("rows") or []) summary = group.get("summary") or {} text_value = _group_text_for_guarded_rules(group) if not ("취소" in text_value or "500" in clean(summary.get("voucher_no")) or "매출" in text_value or "매입" in text_value): return False ledger_values = [ parse_amount(summary.get("ledger_debit")), parse_amount(summary.get("ledger_credit")), *[parse_amount(row.get("ledger_debit")) for row in rows], *[parse_amount(row.get("ledger_credit")) for row in rows], ] voucher_values = [ parse_amount(summary.get("voucher_debit")), parse_amount(summary.get("voucher_credit")), *[parse_amount(row.get("voucher_debit")) for row in rows], *[parse_amount(row.get("voucher_credit")) for row in rows], ] has_negative_ledger = any(value < -0.5 for value in ledger_values) has_negative_voucher = any(value < -0.5 for value in voucher_values) has_positive_ledger = any(value > 0.5 for value in ledger_values) has_positive_voucher = any(value > 0.5 for value in voucher_values) if has_negative_ledger and has_positive_voucher: return True if has_negative_voucher and has_positive_ledger: return True return False def _copy_group_with_status_and_reason(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 list(group.get("rows") or [])], } group_copy["summary"]["status_label"] = status_label existing_reason = clean(group_copy["summary"].get("review_reason")) group_copy["summary"]["review_reason"] = " / ".join( item for item in [existing_reason, reason] if item ) for row in group_copy["rows"]: row["status_label"] = status_label existing_row_reason = clean(row.get("review_reason")) row["review_reason"] = " / ".join( item for item in [existing_row_reason, reason] if item ) return group_copy def _copy_opening_substitution_group_as_excepted(group: dict[str, Any]) -> dict[str, Any]: group_copy = _copy_group_with_status_and_reason(group, "Excepted", _YEAR_OPENING_SUBSTITUTION_REASON) for row in group_copy["rows"]: for field in ( "proof_date", "draft_no", "voucher_account_code", "voucher_account_name", "voucher_vendor", "voucher_desc", "voucher_row_key", "voucher_confirmed_no", ): row[field] = "" row["voucher_debit"] = 0 row["voucher_credit"] = 0 row["matched_case"] = "" row["match_identity_key"] = clean(row.get("ledger_row_key")) or build_ledger_row_key(row) summary = group_copy["summary"] summary["draft_no"] = "" summary["voucher_accounts"] = "" summary["voucher_vendors"] = "" summary["voucher_debit"] = 0 summary["voucher_credit"] = 0 summary["voucher_row_count"] = 0 return group_copy def _copy_erp_side_group_as_unmatched(group: dict[str, Any], reason: str) -> dict[str, Any] | None: rows: list[dict[str, Any]] = [] for row in list(group.get("rows") or []): if not ( clean(row.get("draft_no")) or clean(row.get("voucher_account_name")) or abs(parse_amount(row.get("voucher_debit"))) >= 0.5 or abs(parse_amount(row.get("voucher_credit"))) >= 0.5 ): continue payload = dict(row) for field in ( "ledger_date", "voucher_no", "ledger_account_code", "ledger_account_name", "ledger_vendor", "ledger_desc", "ledger_row_key", ): payload[field] = "" payload["ledger_debit"] = 0 payload["ledger_credit"] = 0 payload["status_label"] = "ERP Unmatched" existing_reason = clean(payload.get("review_reason")) payload["review_reason"] = " / ".join(item for item in [existing_reason, reason] if item) payload["matched_case"] = "" payload["match_identity_key"] = clean(payload.get("voucher_row_key")) or build_voucher_row_key(payload) rows.append(payload) if not rows: return None summary = dict(group.get("summary") or {}) fiscal_year = int(summary.get("fiscal_year") or 0) draft_nos: list[str] = [] voucher_accounts: list[str] = [] voucher_vendors: list[str] = [] for row in rows: for target, value in ( (draft_nos, row.get("draft_no")), (voucher_accounts, row.get("voucher_account_name")), (voucher_vendors, row.get("voucher_vendor")), ): text_value = clean(value) if text_value and text_value not in target: target.append(text_value) return { "summary": { "fiscal_year": fiscal_year, "status_label": "ERP Unmatched", "ledger_date": "", "proof_date": clean(summary.get("proof_date")), "voucher_no": "", "draft_no": ", ".join(draft_nos), "ledger_row_count": 0, "voucher_row_count": len(rows), "ledger_debit": 0, "ledger_credit": 0, "voucher_debit": sum(parse_amount(row.get("voucher_debit")) for row in rows), "voucher_credit": sum(parse_amount(row.get("voucher_credit")) for row in rows), "ledger_accounts": "", "voucher_accounts": ", ".join(voucher_accounts), "ledger_vendors": "", "voucher_vendors": ", ".join(voucher_vendors), "review_reason": reason, }, "rows": rows, } def _copy_ledger_side_group_as_recheck(group: dict[str, Any], reason: str) -> dict[str, Any] | None: rows: list[dict[str, Any]] = [] seen_row_keys: set[str] = set() for row in list(group.get("rows") or []): if not ( clean(row.get("ledger_date")) or clean(row.get("voucher_no")) or clean(row.get("ledger_account_name")) or abs(parse_amount(row.get("ledger_debit"))) >= 0.5 or abs(parse_amount(row.get("ledger_credit"))) >= 0.5 ): continue payload = _restore_ledger_only_row_from_match(row) if clean(row.get("voucher_account_name")) else dict(row) for field in ( "proof_date", "draft_no", "voucher_account_code", "voucher_account_name", "voucher_vendor", "voucher_desc", "voucher_row_key", ): payload[field] = "" payload["voucher_debit"] = 0 payload["voucher_credit"] = 0 payload["status_label"] = "Recheck" existing_reason = clean(row.get("review_reason")) payload["review_reason"] = " / ".join(item for item in [existing_reason, reason, "직접 ERP 연결 제거"] if item) payload["matched_case"] = "source_row_invariant_recheck" payload["match_identity_key"] = clean(payload.get("ledger_row_key")) or build_ledger_row_key(payload) payload["review_key"] = build_review_key(payload) row_key = clean(payload.get("ledger_row_key")) or build_ledger_row_key(payload) if row_key and row_key in seen_row_keys: continue if row_key: seen_row_keys.add(row_key) rows.append(payload) if not rows: return None summary = dict(group.get("summary") or {}) fiscal_year = int(summary.get("fiscal_year") or 0) voucher_nos: list[str] = [] ledger_accounts: list[str] = [] ledger_vendors: list[str] = [] for row in rows: for target, value in ( (voucher_nos, row.get("voucher_no")), (ledger_accounts, row.get("ledger_account_name")), (ledger_vendors, row.get("ledger_vendor")), ): text_value = clean(value) if text_value and text_value not in target: target.append(text_value) return { "summary": { "fiscal_year": fiscal_year, "status_label": "Recheck", "ledger_date": clean(summary.get("ledger_date")), "proof_date": "", "voucher_no": ", ".join(voucher_nos) or clean(summary.get("voucher_no")), "draft_no": "", "ledger_row_count": len(rows), "voucher_row_count": 0, "ledger_debit": sum(parse_amount(row.get("ledger_debit")) for row in rows), "ledger_credit": sum(parse_amount(row.get("ledger_credit")) for row in rows), "voucher_debit": 0, "voucher_credit": 0, "ledger_accounts": ", ".join(ledger_accounts), "voucher_accounts": "", "ledger_vendors": ", ".join(ledger_vendors), "voucher_vendors": "", "review_reason": f"{reason} / 직접 ERP 연결 제거", }, "rows": rows, } def _direct_source_row_invariant_reason( row: dict[str, Any], used_ledger: set[str], used_voucher: set[str], ) -> str: ledger_key = clean(row.get("ledger_row_key")) voucher_key = clean(row.get("voucher_row_key")) if not ledger_key or not voucher_key: return "원천 계정행 식별정보 누락" if ledger_key in used_ledger: return "WEHAGO 원천 계정행 중복 매칭" if voucher_key in used_voucher: return "ERP 원천 계정행 중복 매칭" ledger_amount = max(abs(parse_amount(row.get("ledger_debit"))), abs(parse_amount(row.get("ledger_credit")))) voucher_amount = max(abs(parse_amount(row.get("voucher_debit"))), abs(parse_amount(row.get("voucher_credit")))) if ledger_amount <= 0 or voucher_amount <= 0: return "원천 계정행 금액 누락" if abs(ledger_amount - voucher_amount) >= 0.5: return "원천 계정행 금액 불일치" if _row_sides_have_opposite_signed_amount(row): return "취소·정상 부호 반대 직접매칭" if not _matched_row_passes_account_pair_rule(row): return "계정군 허용 규칙 위반" if _direct_match_date_invariant_violation_for_guard(row): return "허용기간 62일 초과" return "" def _apply_final_source_row_invariant_gate( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: demoted_recheck: list[dict[str, Any]] = [] demoted_erp_unmatched: list[dict[str, Any]] = [] for status_key in ("voucher_matched", "erp_voucher_matched"): used_ledger: set[str] = set() used_voucher: set[str] = set() retained_groups: list[dict[str, Any]] = [] for group in list(voucher_sections.get(status_key) or []): violation_reasons: list[str] = [] summary = dict(group.get("summary") or {}) direct_rows = _section_source_direct_rows(group) for row in direct_rows: for field in ("fiscal_year", "ledger_date", "proof_date", "voucher_no", "draft_no"): if not clean(row.get(field)) and clean(summary.get(field)): row[field] = summary.get(field) reason = _direct_source_row_invariant_reason(row, used_ledger, used_voucher) if reason: if reason not in violation_reasons: violation_reasons.append(reason) continue used_ledger.add(clean(row.get("ledger_row_key"))) used_voucher.add(clean(row.get("voucher_row_key"))) if not violation_reasons: retained_groups.append(group) continue reason_text = " / ".join(violation_reasons) if status_key == "voucher_matched": recheck_group = _copy_ledger_side_group_as_recheck(group, reason_text) if recheck_group is not None: demoted_recheck.append(recheck_group) erp_group = _copy_erp_side_group_as_unmatched(group, reason_text) if erp_group is not None: demoted_erp_unmatched.append(erp_group) voucher_sections[status_key] = retained_groups if demoted_recheck: voucher_sections["voucher_recheck"] = list(voucher_sections.get("voucher_recheck") or []) + demoted_recheck if demoted_erp_unmatched: voucher_sections["erp_voucher_unmatched"] = list(voucher_sections.get("erp_voucher_unmatched") or []) + demoted_erp_unmatched return voucher_sections def _group_has_attached_erp_candidate(group: Mapping[str, Any]) -> bool: if not _voucher_group_has_erp_counterpart(dict(group)): return False direct_like_rows = [ row for row in list(group.get("rows") or []) if _row_has_direct_erp_match_for_guard(dict(row)) ] if not direct_like_rows: return False if _section_amount_sides_match({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}): return True return any( _same_or_similar_vendor(dict(row)) and ( _same_or_similar_desc(dict(row)) or _contained_core_desc_match(row.get("ledger_desc"), row.get("voucher_desc")) ) for row in direct_like_rows ) def _candidate_group_complete_source_rows_pass(group: Mapping[str, Any]) -> bool: source_rows = _section_source_direct_rows(group) if not source_rows: return False direct_like_count = sum(1 for row in list(group.get("rows") or []) if _row_has_direct_erp_match_for_guard(dict(row))) if direct_like_count != len(source_rows): return False local_ledger: set[str] = set() local_voucher: set[str] = set() summary = dict(group.get("summary") or {}) for row in source_rows: row = dict(row) for field in ("fiscal_year", "ledger_date", "proof_date", "voucher_no", "draft_no"): if not clean(row.get(field)) and clean(summary.get(field)): row[field] = summary.get(field) reason = _direct_source_row_invariant_reason(row, local_ledger, local_voucher) if reason: return False local_ledger.add(clean(row.get("ledger_row_key"))) local_voucher.add(clean(row.get("voucher_row_key"))) return True def _promote_attached_candidate_unmatched_groups( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: unmatched_groups = list(voucher_sections.get("voucher_unmatched") or []) if not unmatched_groups: return voucher_sections retained: list[dict[str, Any]] = [] promoted: list[dict[str, Any]] = [] recheck: list[dict[str, Any]] = [] for group in unmatched_groups: if not _group_has_attached_erp_candidate(group): retained.append(group) continue reason = "CANDIDATE_ATTACHED_WITHOUT_ROW_KEY" if any(clean(row.get("matched_case")) == "boundary_excluded" for row in list(group.get("rows") or [])): reason = "BOUNDARY_STRONG_CANDIDATE_RECHECK" if _candidate_group_complete_source_rows_pass(group): promoted_group = _copy_group_with_status_and_reason(group, "Matched", "ATTACHED_CANDIDATE_RECOVERED_MATCH") for row in list(promoted_group.get("rows") or []): if _row_is_source_direct_match(row): row["matched_case"] = clean(row.get("matched_case")) or RECHECK_DIRECT_CANDIDATE promoted.append(promoted_group) else: recheck.append(_copy_group_with_status_and_reason(group, "Recheck", reason)) if promoted: voucher_sections["voucher_matched"] = list(voucher_sections.get("voucher_matched") or []) + promoted if recheck: voucher_sections["voucher_recheck"] = list(voucher_sections.get("voucher_recheck") or []) + recheck voucher_sections["voucher_unmatched"] = retained return voucher_sections _EXPLAINABLE_LEDGER_RESIDUAL_TOKENS = ( "이자", "미지급비용", "미지급금", "수수료", "잡손실", "잡이익", ) def _row_is_explainable_ledger_residual(row: Mapping[str, Any]) -> bool: if clean(row.get("voucher_account_name")): return False if not clean(row.get("ledger_account_name")): return False amount = max(abs(parse_amount(row.get("ledger_debit"))), abs(parse_amount(row.get("ledger_credit")))) if amount < 0.5: return False text = normalize_text( " ".join( clean(row.get(field)) for field in ("ledger_account_name", "ledger_desc", "review_reason") ) ) return any(token in text for token in _EXPLAINABLE_LEDGER_RESIDUAL_TOKENS) def _group_has_explainable_mixed_ledger_residual(group: Mapping[str, Any]) -> bool: rows = [dict(row) for row in list(group.get("rows") or [])] source_rows = _section_source_direct_rows(group) if len(source_rows) < 2: return False if any(clean(row.get("voucher_account_name")) and not clean(row.get("ledger_account_name")) for row in rows): return False residual_rows = [row for row in rows if _row_is_explainable_ledger_residual(row)] if not residual_rows: return False non_explainable_ledger_only = [ row for row in rows if clean(row.get("ledger_account_name")) and not clean(row.get("voucher_account_name")) and max(abs(parse_amount(row.get("ledger_debit"))), abs(parse_amount(row.get("ledger_credit")))) >= 0.5 and not _row_is_explainable_ledger_residual(row) ] if non_explainable_ledger_only: return False local_ledger: set[str] = set() local_voucher: set[str] = set() summary = dict(group.get("summary") or {}) for row in source_rows: row = dict(row) for field in ("fiscal_year", "ledger_date", "proof_date", "voucher_no", "draft_no"): if not clean(row.get(field)) and clean(summary.get(field)): row[field] = summary.get(field) reason = _direct_source_row_invariant_reason(row, local_ledger, local_voucher) if reason: return False local_ledger.add(clean(row.get("ledger_row_key"))) local_voucher.add(clean(row.get("voucher_row_key"))) direct_ledger_net = sum(parse_amount(row.get("ledger_debit")) - parse_amount(row.get("ledger_credit")) for row in source_rows) residual_net = sum(parse_amount(row.get("ledger_debit")) - parse_amount(row.get("ledger_credit")) for row in residual_rows) if abs(direct_ledger_net + residual_net) >= 0.5: return False direct_voucher_net = sum(parse_amount(row.get("voucher_debit")) - parse_amount(row.get("voucher_credit")) for row in source_rows) return abs(direct_ledger_net - direct_voucher_net) < 0.5 def _promote_explainable_mixed_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 retained: list[dict[str, Any]] = [] promoted: list[dict[str, Any]] = [] for group in recheck_groups: tokens = _group_reason_tokens(group) mixed_status = any(token.startswith("VOUCHER_MIXED_ROW_STATUS=") for token in tokens) or len( {clean(row.get("status_label")) for row in list(group.get("rows") or []) if clean(row.get("status_label"))} ) > 1 if not mixed_status or not _group_has_explainable_mixed_ledger_residual(group): retained.append(group) continue promoted_group = _copy_group_with_status_and_reason(group, "Matched", "MIXED_RESIDUAL_EXPLAINED_MATCH") for row in list(promoted_group.get("rows") or []): if _row_is_source_direct_match(row): row["matched_case"] = clean(row.get("matched_case")) or RECHECK_DIRECT_CANDIDATE promoted.append(promoted_group) if promoted: voucher_sections["voucher_matched"] = list(voucher_sections.get("voucher_matched") or []) + promoted voucher_sections["voucher_recheck"] = retained return voucher_sections def _promote_recoverable_source_candidate_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 candidate_source_rows: dict[int, list[dict[str, Any]]] = {} for group in recheck_groups: tokens = _group_reason_tokens(group) if tokens & _HARD_SOURCE_RECHECK_REASONS or any( token.startswith("VOUCHER_MIXED_ROW_STATUS=") for token in tokens ): continue source_rows = [ row for row in _section_source_direct_rows(group) if clean(row.get("matched_case")) in _RECHECK_SOURCE_MATCH_CASES ] if not source_rows: continue local_ledger: set[str] = set() local_voucher: set[str] = set() local_reason = "" for row in source_rows: local_reason = _direct_source_row_invariant_reason(row, local_ledger, local_voucher) if local_reason: break local_ledger.add(clean(row.get("ledger_row_key"))) local_voucher.add(clean(row.get("voucher_row_key"))) if local_reason: continue candidate_source_rows[id(group)] = source_rows promotable_groups = sorted( [group for group in recheck_groups if id(group) in candidate_source_rows], key=lambda group: ( len(candidate_source_rows.get(id(group)) or []), _recheck_group_rank_key(group), ), reverse=True, ) promotable_ids = {id(group) for group in promotable_groups} promoted: list[dict[str, Any]] = [] retained: list[dict[str, Any]] = [] for group in recheck_groups: if id(group) not in promotable_ids: retained.append(group) for group in promotable_groups: promoted_group = copy.deepcopy(group) promoted_group.setdefault("summary", {})["status_label"] = "Matched" reason = clean(promoted_group["summary"].get("review_reason")) promoted_group["summary"]["review_reason"] = " / ".join( item for item in (reason, "SOURCE_CANDIDATE_RECOVERED_MATCH") if item ) for row in list(promoted_group.get("rows") or []): row["status_label"] = "Matched" if _row_is_source_direct_match(row): row["matched_case"] = clean(row.get("matched_case")) or RECHECK_DIRECT_CANDIDATE promoted.append(promoted_group) if not promoted: return voucher_sections voucher_sections["voucher_matched"] = list(voucher_sections.get("voucher_matched") or []) + promoted voucher_sections["voucher_recheck"] = retained return voucher_sections def _strip_non_source_rows_from_matched_projection( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: for status_key in ("voucher_matched", "erp_voucher_matched"): normalized_groups: list[dict[str, Any]] = [] for group in list(voucher_sections.get(status_key) or []): source_rows = _section_source_direct_rows(group) if not source_rows: continue group_copy = { "summary": dict(group.get("summary") or {}), "rows": source_rows, } group_copy["summary"]["status_label"] = "Matched" for row in group_copy["rows"]: row["status_label"] = "Matched" normalized_groups.append(_refresh_group_summary_from_rows(group_copy)) voucher_sections[status_key] = normalized_groups return voucher_sections def _collapse_wehago_matched_to_best_group( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: groups = list(voucher_sections.get("voucher_matched") or []) if not groups: return voucher_sections grouped: dict[str, list[dict[str, Any]]] = {} for index, group in enumerate(groups): identity = _wehago_section_identity(group, f"voucher_matched:{index}") grouped.setdefault(identity, []).append(group) retained: list[dict[str, Any]] = [] demoted: list[dict[str, Any]] = [] def quality(group: dict[str, Any]) -> tuple[int, int, float]: rows = _section_source_direct_rows(group) voucher_total = sum(max(abs(parse_amount(row.get("voucher_debit"))), abs(parse_amount(row.get("voucher_credit")))) for row in rows) return ( len(rows), sum(1 for row in rows if clean(row.get("matched_case")) in _RECHECK_SOURCE_MATCH_CASES), voucher_total, ) for identity, candidates in grouped.items(): if len(candidates) == 1: retained.append(candidates[0]) continue ordered = sorted(candidates, key=quality, reverse=True) retained.append(ordered[0]) for duplicate in ordered[1:]: demoted.append(_copy_group_with_status_and_reason(duplicate, "Recheck", "DUPLICATE_WEHAGO_MATCHED_GROUP_RECHECK")) voucher_sections["voucher_matched"] = retained if demoted: voucher_sections["voucher_recheck"] = list(voucher_sections.get("voucher_recheck") or []) + demoted return voucher_sections def _apply_final_display_projection_guard( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: guard_passes = 3 if clean(os.getenv("WEHAGO_LEGACY_FINAL_DISPLAY_GUARD_PASSES", "")).lower() in {"1", "true", "yes", "y", "on"} else 1 for _iteration in range(guard_passes): voucher_sections = _demote_anchored_recheck_groups_without_erp(voucher_sections) voucher_sections = _promote_obvious_recheck_groups(voucher_sections) voucher_sections = _promote_attached_candidate_unmatched_groups(voucher_sections) voucher_sections = _promote_explainable_mixed_recheck_groups(voucher_sections) voucher_sections = _promote_recoverable_source_candidate_recheck_groups(voucher_sections) voucher_sections = _apply_final_source_row_invariant_gate(voucher_sections) voucher_sections = _strip_non_source_rows_from_matched_projection(voucher_sections) voucher_sections = _collapse_wehago_matched_to_best_group(voucher_sections) voucher_sections = _dedupe_wehago_sections_to_single_status(voucher_sections) voucher_sections = _dedupe_erp_sections_to_single_status(voucher_sections) return voucher_sections def _demote_anchored_recheck_groups_without_erp( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: def has_real_hanmac_candidate(row: Mapping[str, Any]) -> bool: if not _row_has_hanmac_candidate_side(row): return False if not clean(row.get("draft_no")): return False has_amount = ( abs(parse_amount(row.get("voucher_debit"))) >= 0.5 or abs(parse_amount(row.get("voucher_credit"))) >= 0.5 ) has_context = bool( clean(row.get("voucher_vendor")) or clean(row.get("voucher_desc")) or clean(row.get("proof_date")) ) return has_amount and has_context def copy_as_wehago_unmatched(group: Mapping[str, Any]) -> dict[str, Any]: rows: list[dict[str, Any]] = [] for row in list(group.get("rows") or []): if not _row_has_wehago_ledger_side(row): continue payload = _blank_voucher_side(dict(row)) payload["status_label"] = "Unmatched" existing_reason = clean(payload.get("review_reason")) payload["review_reason"] = " / ".join( item for item in [existing_reason, "ANCHORED_RECHECK_WITHOUT_ERP_CANDIDATE"] if item ) rows.append(payload) if not rows: rows = [ _blank_voucher_side(dict(row)) for row in list(group.get("rows") or []) if clean(row.get("ledger_account_name")) or clean(row.get("ledger_desc")) ] refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": rows}) refreshed["summary"]["status_label"] = "Unmatched" return refreshed retained_recheck: list[dict[str, Any]] = [] demoted_unmatched: list[dict[str, Any]] = [] for group in list(voucher_sections.get("voucher_recheck") or []): tokens = _group_reason_tokens(group) if WEHAGO_ANCHORED_RECHECK_REASON not in tokens: retained_recheck.append(group) continue has_erp_candidate = any(has_real_hanmac_candidate(row) for row in list(group.get("rows") or [])) if has_erp_candidate: retained_recheck.append(group) continue demoted_unmatched.append(copy_as_wehago_unmatched(group)) if demoted_unmatched: voucher_sections["voucher_recheck"] = retained_recheck voucher_sections["voucher_unmatched"] = list(voucher_sections.get("voucher_unmatched") or []) + demoted_unmatched return voucher_sections def _merge_wehago_voucher_groups_to_single_status( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: status_keys = ("voucher_matched", "voucher_recheck", "voucher_unmatched", "voucher_excepted") groups_by_identity: dict[str, list[tuple[str, dict[str, Any]]]] = {} for status_key in status_keys: for index, group in enumerate(list(voucher_sections.get(status_key) or [])): identity = _wehago_section_identity(group, f"{status_key}:{index}") groups_by_identity.setdefault(identity, []).append((status_key, group)) consolidated = {status_key: [] for status_key in status_keys} status_label_map = { "voucher_matched": "Matched", "voucher_recheck": "Recheck", "voucher_unmatched": "Unmatched", "voucher_excepted": "Excepted", } for identity, candidates in groups_by_identity.items(): source_statuses = {status_key for status_key, _group in candidates} if source_statuses == {"voucher_matched"}: final_status = "voucher_matched" elif source_statuses == {"voucher_excepted"}: final_status = "voucher_excepted" elif source_statuses == {"voucher_unmatched"}: final_status = "voucher_unmatched" else: final_status = "voucher_recheck" rows: list[dict[str, Any]] = [] seen_rows: set[tuple[Any, ...]] = set() source_reasons: list[str] = [] base_summary: dict[str, Any] = {} for source_status, group in sorted( candidates, key=lambda item: status_keys.index(item[0]), ): summary = dict(group.get("summary") or {}) if not base_summary: base_summary = summary _append_distinct_text(source_reasons, summary.get("review_reason")) for row in list(group.get("rows") or []): payload = dict(row) payload["status_label"] = clean(payload.get("status_label")) or status_label_map[source_status] if len(source_statuses) > 1: existing_reason = clean(payload.get("review_reason")) auxiliary_reason = f"VOUCHER_ROW_SOURCE_STATUS={source_status}" payload["review_reason"] = " / ".join( item for item in (existing_reason, auxiliary_reason) if item ) row_identity = ( clean(payload.get("ledger_row_key")), clean(payload.get("voucher_row_key")), clean(payload.get("ledger_account_name")), clean(payload.get("voucher_account_name")), round(parse_amount(payload.get("ledger_debit")), 2), round(parse_amount(payload.get("ledger_credit")), 2), round(parse_amount(payload.get("voucher_debit")), 2), round(parse_amount(payload.get("voucher_credit")), 2), clean(payload.get("matched_case")), clean(payload.get("status_label")), ) if row_identity in seen_rows: continue seen_rows.add(row_identity) rows.append(payload) if len(source_statuses) > 1: _append_distinct_text( source_reasons, f"VOUCHER_MIXED_ROW_STATUS={','.join(sorted(source_statuses))}", ) merged_group = _refresh_group_summary_from_rows( { "summary": { **base_summary, "status_label": status_label_map[final_status], "review_reason": " / ".join(source_reasons), "voucher_identity": identity, }, "rows": rows, } ) merged_group["summary"]["status_label"] = status_label_map[final_status] if source_reasons: merged_group["summary"]["review_reason"] = " / ".join(source_reasons) consolidated[final_status].append(merged_group) for status_key in status_keys: consolidated[status_key].sort( key=lambda group: ( int((group.get("summary") or {}).get("fiscal_year") or 0), clean((group.get("summary") or {}).get("ledger_date")), clean((group.get("summary") or {}).get("voucher_no")), ) ) voucher_sections[status_key] = consolidated[status_key] return voucher_sections def _prepare_final_display_projection( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: prepared = copy.deepcopy(voucher_sections) assembly_passes = 2 if clean(os.getenv("WEHAGO_LEGACY_FINAL_DISPLAY_ASSEMBLY_PASSES", "")).lower() in {"1", "true", "yes", "y", "on"} else 1 normalization_passes = 2 if clean(os.getenv("WEHAGO_LEGACY_FINAL_DISPLAY_NORMALIZATION_PASSES", "")).lower() in {"1", "true", "yes", "y", "on"} else 1 for _assembly_pass in range(assembly_passes): for _normalization_pass in range(normalization_passes): for status_key, groups in list(prepared.items()): normalized_groups: list[dict[str, Any]] = [] for group in list(groups or []): group_copy = { "summary": dict(group.get("summary") or {}), "rows": _sanitize_voucher_group_rows(list(group.get("rows") or [])), } normalized_groups.append(_refresh_group_summary_from_rows(group_copy)) prepared[status_key] = normalized_groups prepared = _apply_final_display_projection_guard(prepared) prepared = _merge_wehago_voucher_groups_to_single_status(prepared) return prepared def _filter_standard_rows_for_final_projection( rows_by_status: dict[str, list[dict[str, Any]]], voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: final_ledger_keys = { clean(row.get("ledger_row_key")) for status_key in ("voucher_matched", "voucher_recheck", "voucher_excepted") for group in list(voucher_sections.get(status_key) or []) for row in list(group.get("rows") or []) if clean(row.get("ledger_row_key")) } final_voucher_keys = { clean(row.get("voucher_row_key")) for status_key in ("voucher_matched", "erp_voucher_matched") for group in list(voucher_sections.get(status_key) or []) for row in list(group.get("rows") or []) if clean(row.get("voucher_row_key")) } final_wehago_identities = { _wehago_section_identity(group, f"{status_key}:{index}") for status_key in ("voucher_matched", "voucher_recheck", "voucher_excepted") for index, group in enumerate(list(voucher_sections.get(status_key) or [])) } filtered = { status_key: [dict(row) for row in list(rows_by_status.get(status_key) or [])] for status_key in QUERY_STANDARD_ROW_STATUS_KEYS } for status_key in ("ledger_only", "amount_mismatch"): filtered[status_key] = [ row for row in filtered[status_key] if clean(row.get("ledger_row_key")) not in final_ledger_keys and normalize_wehago_voucher_identity( int(row.get("fiscal_year") or 0), row.get("voucher_no"), row.get("ledger_date"), clean(row.get("ledger_row_key")), ) not in final_wehago_identities ] filtered["voucher_only"] = [ row for row in filtered["voucher_only"] if clean(row.get("voucher_row_key")) not in final_voucher_keys ] return filtered def _apply_guarded_regression_case_rules( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: opening_excepted: list[dict[str, Any]] = [] for status_key in ("voucher_matched", "voucher_recheck", "voucher_unmatched"): retained_groups: list[dict[str, Any]] = [] for group in list(voucher_sections.get(status_key) or []): if _group_is_year_opening_substitution_for_guard(group): opening_excepted.append(_copy_opening_substitution_group_as_excepted(group)) else: retained_groups.append(group) voucher_sections[status_key] = retained_groups if opening_excepted: voucher_sections["voucher_excepted"] = list(voucher_sections.get("voucher_excepted") or []) + opening_excepted promoted_social: list[dict[str, Any]] = [] retained_recheck: list[dict[str, Any]] = [] for group in list(voucher_sections.get("voucher_recheck") or []): if _is_social_insurance_recheck_group(group): promoted_social.append(_copy_group_with_status_and_reason(group, "Matched", _SOCIAL_INSURANCE_MATCH_REASON)) elif _group_has_negative_to_positive_reversal_mismatch(group): retained_recheck.append(_copy_group_with_status_and_reason(group, "Recheck", _INVALID_REVERSAL_MATCH_REASON)) else: retained_recheck.append(group) voucher_sections["voucher_recheck"] = retained_recheck if promoted_social: voucher_sections["voucher_matched"] = list(voucher_sections.get("voucher_matched") or []) + promoted_social retained_matched: list[dict[str, Any]] = [] demoted_recheck: list[dict[str, Any]] = [] for group in list(voucher_sections.get("voucher_matched") or []): if _group_has_negative_to_positive_reversal_mismatch(group): demoted_recheck.append(_copy_group_with_status_and_reason(group, "Recheck", _INVALID_REVERSAL_MATCH_REASON)) elif _group_is_grant_settlement_direct_mismatch_for_guard(group): demoted_recheck.append( _copy_group_with_status_and_reason(group, "Recheck", _GRANT_SETTLEMENT_DIRECT_MATCH_REVIEW_REASON) ) elif unsafe_reason := _group_is_unsafe_tax_invoice_match_for_guard(group): demoted_recheck.append(_copy_group_with_status_and_reason(group, "Recheck", unsafe_reason)) else: retained_matched.append(group) voucher_sections["voucher_matched"] = retained_matched if demoted_recheck: voucher_sections["voucher_recheck"] = list(voucher_sections.get("voucher_recheck") or []) + demoted_recheck retained_erp_matched: list[dict[str, Any]] = [] erp_unmatched_from_guard: list[dict[str, Any]] = [] for group in list(voucher_sections.get("erp_voucher_matched") or []): guarded_reason = "" if _group_is_grant_settlement_direct_mismatch_for_guard(group): guarded_reason = _GRANT_SETTLEMENT_DIRECT_MATCH_REVIEW_REASON elif unsafe_reason := _group_is_unsafe_tax_invoice_match_for_guard(group): guarded_reason = unsafe_reason if guarded_reason: erp_group = _copy_erp_side_group_as_unmatched(group, guarded_reason) if erp_group is not None: erp_unmatched_from_guard.append(erp_group) else: retained_erp_matched.append(group) voucher_sections["erp_voucher_matched"] = retained_erp_matched if erp_unmatched_from_guard: voucher_sections["erp_voucher_unmatched"] = list(voucher_sections.get("erp_voucher_unmatched") or []) + erp_unmatched_from_guard 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")) ] _RECHECK_SOURCE_MATCH_CASES = {RECHECK_DIRECT_CANDIDATE, RECHECK_SETTLEMENT_BRIDGE} _DISPLAY_CONTEXT_MATCH_CASES = { RECHECK_ERP_CONTEXT, RECHECK_ERP_UNASSIGNED, RECHECK_SHARED_ALLOCATION, RECHECK_SHARED_BUNDLE, } _SOFT_RECHECK_PROMOTION_REASONS = { RECHECK_FULL_ERP_REASON, WEHAGO_ANCHORED_RECHECK_REASON, RECHECK_DIRECT_CANDIDATE, RECHECK_SETTLEMENT_BRIDGE, RECHECK_SHARED_ALLOCATION, RECHECK_SHARED_BUNDLE, "2단계자동매칭", "직접 ERP 연결 제거", "WEAK_MATCH_RECHECK", "RECHECK_OBVIOUS_GROUP_MATCH", } _HARD_SOURCE_RECHECK_REASONS = { "INVALID_REVERSAL_TO_NORMAL_MATCH", "MATCHED_CANCEL_TARGET_RECHECK", "MONTH_CONFLICT_RECHECK", "PROOF_DATE_RECHECK", } def _group_reason_tokens(group: Mapping[str, Any]) -> set[str]: tokens: set[str] = set() summary = dict(group.get("summary") or {}) values = [summary.get("review_reason")] values.extend(row.get("review_reason") for row in list(group.get("rows") or [])) for value in values: for token in re.split(r"\s*/\s*", clean(value)): if token: tokens.add(token) return tokens def _row_is_source_direct_match(row: Mapping[str, Any]) -> bool: if not (clean(row.get("ledger_account_name")) and clean(row.get("voucher_account_name"))): return False if not (clean(row.get("ledger_row_key")) and clean(row.get("voucher_row_key"))): return False matched_case = clean(row.get("matched_case")) if matched_case in _DISPLAY_CONTEXT_MATCH_CASES: return False if matched_case and matched_case not in _RECHECK_SOURCE_MATCH_CASES and matched_case != "boundary_excluded": return False return True def _section_source_direct_rows(group: Mapping[str, Any]) -> list[dict[str, Any]]: return [dict(row) for row in list(group.get("rows") or []) if _row_is_source_direct_match(row)] def _group_has_complete_source_direct_rows(group: Mapping[str, Any]) -> bool: rows = _section_source_direct_rows(group) return bool(rows) and all(clean(row.get("ledger_row_key")) and clean(row.get("voucher_row_key")) for row in rows) def _refresh_group_summary_from_rows(group: dict[str, Any]) -> dict[str, Any]: summary = dict(group.get("summary") or {}) rows = [dict(row) for row in list(group.get("rows") or [])] fiscal_year = int(summary.get("fiscal_year") or next((row.get("fiscal_year") for row in rows if row.get("fiscal_year")), 0) or 0) ledger_accounts: list[str] = [] voucher_accounts: list[str] = [] ledger_vendors: list[str] = [] voucher_vendors: list[str] = [] draft_nos: list[str] = [] proof_dates: list[str] = [] voucher_nos: list[str] = [] ledger_dates: list[str] = [] reasons: list[str] = [] for row in rows: _append_distinct_text(ledger_accounts, row.get("ledger_account_name")) _append_distinct_text(voucher_accounts, row.get("voucher_account_name")) _append_distinct_text(ledger_vendors, row.get("ledger_vendor")) _append_distinct_text(voucher_vendors, row.get("voucher_vendor")) _append_distinct_text(draft_nos, row.get("draft_no")) _append_distinct_text_parts(proof_dates, row.get("proof_date")) _append_distinct_text(voucher_nos, row.get("voucher_no")) _append_distinct_text(ledger_dates, row.get("ledger_date")) _append_distinct_text(reasons, row.get("review_reason")) if not reasons: _append_distinct_text(reasons, summary.get("review_reason")) summary.update( { "fiscal_year": fiscal_year, "ledger_date": ledger_dates[0] if ledger_dates else clean(summary.get("ledger_date")), "proof_date": ", ".join(proof_dates) or clean(summary.get("proof_date")), "voucher_no": ", ".join(voucher_nos) or clean(summary.get("voucher_no")), "draft_no": ", ".join(draft_nos) or clean(summary.get("draft_no")), "ledger_row_count": sum(1 for row in rows if clean(row.get("ledger_account_name")) or clean(row.get("ledger_desc"))), "voucher_row_count": sum(1 for row in rows if clean(row.get("voucher_account_name")) or clean(row.get("voucher_desc"))), "ledger_debit": sum(parse_amount(row.get("ledger_debit")) for row in rows), "ledger_credit": sum(parse_amount(row.get("ledger_credit")) for row in rows), "voucher_debit": sum(parse_amount(row.get("voucher_debit")) for row in rows), "voucher_credit": sum(parse_amount(row.get("voucher_credit")) for row in rows), "ledger_accounts": ", ".join(ledger_accounts), "voucher_accounts": ", ".join(voucher_accounts), "ledger_vendors": ", ".join(ledger_vendors), "voucher_vendors": ", ".join(voucher_vendors), "review_reason": ", ".join(reasons) if len(reasons) > 3 else " / ".join(reasons), } ) return {"summary": summary, "rows": rows} 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 if strong_rows >= 2 or vat_rows >= 1: return True reason_text = normalize_text( " ".join( [ clean(summary.get("review_reason")), *(clean(row.get("review_reason")) for row in list(group.get("rows") or [])), ] ) ) split_context = any( token in reason_text for token in ( "erpgrouprepeatedcontextmatch", "spliterpdraftcontextmatch", "splitdraft", "projectionreconcilesplitdraft", "groupedcost", "bundlematch", ) ) return bool(split_context and strong_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) if not draft_no: return "" matched = re.fullmatch(r"(11-\d{8}-[^-]+-\d+)-\d+", draft_no) return matched.group(1) if matched else draft_no def _row_erp_candidate_year(row: dict[str, Any]) -> int: draft_year = _erp_draft_year(row.get("draft_no")) if draft_year: return draft_year return int(row.get("voucher_fiscal_year") or row.get("erp_fiscal_year") or row.get("fiscal_year") or 0) 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: return _is_adjacent_month_erp_candidate_for_range(row, fiscal_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_CANDIDATE_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 erp_start_year, erp_end_year = get_hanmac_erp_candidate_year_range(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(erp_start_year or start_year), "end_year": int(erp_end_year or 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 MATCH_WITHOUT_ERP_DETAIL_REASON = "MATCH_WITHOUT_ERP_DETAIL" def _row_has_erp_detail(row: Mapping[str, Any]) -> bool: return bool( clean(row.get("draft_no")) or clean(row.get("proof_date")) 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 abs(parse_amount(row.get("voucher_debit"))) >= 0.5 or abs(parse_amount(row.get("voucher_credit"))) >= 0.5 ) def _group_has_erp_detail(group: Mapping[str, Any]) -> bool: summary = dict(group.get("summary") or {}) if ( clean(summary.get("draft_no")) or clean(summary.get("proof_date")) or clean(summary.get("voucher_accounts")) or clean(summary.get("voucher_vendors")) or abs(parse_amount(summary.get("voucher_debit"))) >= 0.5 or abs(parse_amount(summary.get("voucher_credit"))) >= 0.5 or int(summary.get("voucher_row_count") or 0) > 0 ): return True return any(_row_has_erp_detail(dict(row)) for row in list(group.get("rows") or [])) def _copy_group_as_wehago_unmatched_without_erp_detail(group: Mapping[str, Any]) -> dict[str, Any] | None: rows: list[dict[str, Any]] = [] for row in list(group.get("rows") or []): 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 payload = _blank_voucher_side(dict(row)) payload["status_label"] = "Unmatched" existing_reason = clean(payload.get("review_reason")) payload["review_reason"] = " / ".join( part for part in [existing_reason, MATCH_WITHOUT_ERP_DETAIL_REASON] if part ) payload["matched_case"] = "" payload["match_identity_key"] = clean(payload.get("ledger_row_key")) or build_ledger_row_key(payload) rows.append(payload) if not rows: return None summary = dict(group.get("summary") or {}) fiscal_year = int(summary.get("fiscal_year") or rows[0].get("fiscal_year") or 0) voucher_no = clean(summary.get("voucher_no") or rows[0].get("voucher_no")) rebuilt = _build_group_summary_from_rows( fiscal_year=fiscal_year, voucher_no=voucher_no, draft_no="", rows=rows, status_label="Unmatched", review_reason=MATCH_WITHOUT_ERP_DETAIL_REASON, ) return {"summary": rebuilt, "rows": rows} def _demote_matched_groups_without_erp_detail( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: retained: list[dict[str, Any]] = [] demoted: list[dict[str, Any]] = [] for group in list(voucher_sections.get("voucher_matched") or []): if _group_has_erp_detail(group): retained.append(group) continue unmatched_group = _copy_group_as_wehago_unmatched_without_erp_detail(group) if unmatched_group is not None: demoted.append(unmatched_group) voucher_sections["voucher_matched"] = retained if demoted: voucher_sections["voucher_unmatched"] = list(voucher_sections.get("voucher_unmatched") or []) + demoted return voucher_sections def _wehago_group_compare_key_from_summary(summary: Mapping[str, Any]) -> str: return normalize_wehago_voucher_identity( summary.get("fiscal_year"), summary.get("voucher_no"), summary.get("ledger_date"), "", ) def _load_status_projection_group_by_compare_key( conn: Any, start_year: int | None, end_year: int | None, *, compare_no: str, fiscal_year: int, voucher_no: str, preferred_statuses: Iterable[str], ) -> dict[str, Any]: compare_key = clean(compare_no) if start_year is None or end_year is None or not compare_key or fiscal_year <= 0 or not clean(voucher_no): return {} statuses = [clean(status) for status in preferred_statuses if clean(status)] statuses = list(dict.fromkeys(statuses)) if not statuses: statuses = list(WEHAGO_FINAL_STATUS_ORDER) status_placeholders = ", ".join(f":status_{index}" for index, _status in enumerate(statuses)) params: dict[str, Any] = { "start_year": int(start_year), "end_year": int(end_year), "fiscal_year": int(fiscal_year), "voucher_no": clean(voucher_no), **{f"status_{index}": status for index, status in enumerate(statuses)}, } rows = conn.execute( text( f""" SELECT * FROM wehago_status_projection_groups WHERE start_year = :start_year AND end_year = :end_year AND fiscal_year = :fiscal_year AND voucher_no = :voucher_no AND status_key IN ({status_placeholders}) ORDER BY CASE status_key WHEN 'voucher_excepted' THEN 1 WHEN 'voucher_matched' THEN 2 WHEN 'voucher_recheck' THEN 3 ELSE 4 END, updated_at DESC, group_index ASC LIMIT 500 """ ), params, ).mappings().all() fallback: dict[str, Any] = {} for row in rows: try: summary = json.loads(clean(row.get("summary_json")) or "{}") except Exception: summary = {} try: detail_rows = json.loads(clean(row.get("rows_json")) or "[]") except Exception: detail_rows = [] if not isinstance(summary, dict): summary = {} if not isinstance(detail_rows, list): detail_rows = [] if not summary: continue group = {"summary": summary, "rows": detail_rows} if not fallback and _group_has_erp_detail(group): fallback = dict(row) if _wehago_group_compare_key_from_summary(summary) == compare_key: return dict(row) return fallback 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 def group_fiscal_year(group: dict[str, Any]) -> int: summary = group.get("summary") or {} return int(summary.get("fiscal_year") or 0) def positive_row_amounts(group: dict[str, Any], prefix: str) -> set[float]: amounts: set[float] = set() for row in group.get("rows", []) or []: for field in (f"{prefix}_debit", f"{prefix}_credit"): value = round(parse_amount(row.get(field)), 2) if value > 0: amounts.add(value) return amounts # _build_retargeted_group_match can only produce a match when at least one # WEHAGO row amount and one ERP row amount are equal. Index ERP groups by # those amounts first so the projection rebuild does not perform a full # Recheck × ERP-unmatched scan. erp_candidates_by_year_amount: dict[tuple[int, float], list[dict[str, Any]]] = {} for candidate_group in erp_unmatched_groups: fiscal_year = group_fiscal_year(candidate_group) for amount in positive_row_amounts(candidate_group, "voucher"): erp_candidates_by_year_amount.setdefault((fiscal_year, amount), []).append(candidate_group) 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_candidate_match: dict[str, Any] | None = None best_score = -1.0 candidate_groups: list[dict[str, Any]] = [] seen_candidate_ids: set[int] = set() for amount in positive_row_amounts(recheck_group, "ledger"): for candidate_group in erp_candidates_by_year_amount.get((group_fiscal_year(recheck_group), amount), []): candidate_id = id(candidate_group) if candidate_id in seen_candidate_ids: continue seen_candidate_ids.add(candidate_id) candidate_groups.append(candidate_group) for candidate_group in candidate_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_candidate_match = candidate_match best_score = score if best_candidate is None: remaining_recheck.append(recheck_group) continue promoted_group = best_candidate_match 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 _row_signed_amount(row: dict[str, Any], prefix: str) -> float: return parse_amount(row.get(f"{prefix}_debit")) - parse_amount(row.get(f"{prefix}_credit")) def _row_sides_have_opposite_signed_amount(row: dict[str, Any]) -> bool: ledger_amount = _row_signed_amount(row, "ledger") voucher_amount = _row_signed_amount(row, "voucher") if ( abs(ledger_amount) >= 0.5 and abs(voucher_amount) >= 0.5 and abs(abs(ledger_amount) - abs(voucher_amount)) < 0.5 and ledger_amount * voucher_amount < -0.25 ): return True ledger_values = [ parse_amount(row.get("ledger_debit")), parse_amount(row.get("ledger_credit")), ] voucher_values = [ parse_amount(row.get("voucher_debit")), parse_amount(row.get("voucher_credit")), ] for ledger_value in ledger_values: if abs(ledger_value) < 0.5: continue for voucher_value in voucher_values: if abs(voucher_value) < 0.5: continue if abs(abs(ledger_value) - abs(voucher_value)) >= 0.5: continue if ledger_value * voucher_value < -0.25: return True return False 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"} or _is_expense_like_account_name(row.get("ledger_account_name")) 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"} or _is_expense_like_account_name(row.get("voucher_account_name")) def _is_expense_like_account_name(value: Any) -> bool: text_value = normalize_text(value) return any( marker in text_value for marker in ( "보험료", "산재보험", "고용보험", "건강보험", "국민연금", "복리후생", "세금과공과", ) ) 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 _direct_grouped_cost_business_match_score( ledger_row: dict[str, Any], voucher_row: dict[str, Any], ) -> float: if not _is_grouped_cost_source_row(ledger_row): return 0.0 if not _is_grouped_cost_business_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 ledger_category = _row_account_category(ledger_row, "ledger") voucher_category = _row_account_category(voucher_row, "voucher") if ledger_category and voucher_category and ledger_category != voucher_category: return 0.0 account_match = ( _account_base_names_compatible(ledger_row.get("ledger_account_name"), voucher_row.get("voucher_account_name")) or _account_base_desc_approved( { **ledger_row, "voucher_account_code": voucher_row.get("voucher_account_code", ""), "voucher_account_name": voucher_row.get("voucher_account_name", ""), } ) ) 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")) ) vendor_match = _meaningful_cross_source_overlap(ledger_row.get("ledger_vendor"), voucher_row.get("voucher_vendor")) if _has_named_reference_conflict(ledger_row, voucher_row) and not (vendor_match and desc_match): return 0.0 # Amount/account equality is too weak for direct cost retargeting. It caused # unrelated expense vouchers with identical amounts to be promoted as matches. if not (vendor_match or desc_match): return 0.0 if not (account_match or desc_match or vendor_match): return 0.0 ledger_date = _parse_iso_date(ledger_row.get("ledger_date")) voucher_date = _infer_compare_row_proof_date(voucher_row) day_gap = abs((ledger_date - voucher_date).days) if ledger_date and voucher_date else None month_gap = _month_gap(ledger_date, voucher_date) if ledger_date and voucher_date else None if month_gap is not None and month_gap > 1: return 0.0 if day_gap is not None and day_gap > 45 and not (account_match and desc_match): return 0.0 score = 85.0 if account_match: score += 18 if desc_match: score += 16 if vendor_match: score += 8 if day_gap == 0: score += 12 elif day_gap is not None and day_gap <= 3: score += 9 elif day_gap is not None and day_gap <= 10: score += 5 return score def _build_direct_grouped_cost_business_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 = _direct_grouped_cost_business_match_score(ledger_row, voucher_row) 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 _account_name_contains_core(left: Any, right: Any) -> bool: left_text = re.sub(r"[^0-9A-Za-z가-힣]+", "", normalize_text(left)) right_text = re.sub(r"[^0-9A-Za-z가-힣]+", "", normalize_text(right)) if not left_text or not right_text: return False return left_text == right_text or left_text in right_text or right_text in left_text def _is_direct_financial_internal_match_group(group: dict[str, Any]) -> bool: direct_rows = _section_direct_rows(group) if not direct_rows: return False for row in direct_rows: 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_category = _row_account_category(row, "ledger") voucher_category = _row_account_category(row, "voucher") ledger_family = _row_account_family(row, "ledger") voucher_family = _row_account_family(row, "voucher") if not ( {ledger_category, voucher_category} <= {"asset", ""} and {ledger_family, voucher_family} <= {"bank", ""} ): return False if not ( _account_base_names_compatible(row.get("ledger_account_name"), row.get("voucher_account_name")) or _account_name_contains_core(row.get("ledger_account_name"), row.get("voucher_account_name")) ): return False return True def _promote_group_as_matched(group: dict[str, Any], review_reason: str) -> dict[str, Any]: promoted_group = copy.deepcopy(group) summary = dict(promoted_group.get("summary") or {}) summary["status_label"] = "Matched" summary["review_reason"] = clean(summary.get("review_reason")) or review_reason promoted_group["summary"] = summary for row in list(promoted_group.get("rows") or []): row["status_label"] = "Matched" row["review_reason"] = clean(row.get("review_reason")) or review_reason return promoted_group def _apply_direct_grouped_cost_business_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_candidates = erp_unmatched_groups + 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: for voucher_row in _section_side_rows(candidate_group, "voucher"): if not _is_grouped_cost_business_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() used_erp_group_ids: set[int] = set() erp_unmatched_group_ids = {id(group) for group in erp_unmatched_groups} for group in list(voucher_sections.get("voucher_recheck") or []): matched_group = _build_direct_grouped_cost_business_match( group, group, review_reason="DIRECT_COST_BUSINESS_INTERNAL_MATCH", ) if matched_group is None and _is_direct_financial_internal_match_group(group): matched_group = _promote_group_as_matched(group, "DIRECT_FINANCIAL_INTERNAL_MATCH") if matched_group is None: continue promoted_groups.append(matched_group) moved_wehago_group_ids.add(id(group)) for status_key in ("voucher_unmatched", "voucher_recheck"): for group in list(voucher_sections.get(status_key) or []): if id(group) in moved_wehago_group_ids: continue 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 or id(candidate_group) in used_erp_group_ids: 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_direct_grouped_cost_business_match( group, candidate_group, review_reason="DIRECT_COST_BUSINESS_ACCOUNT_MATCH", ) if candidate_match is None: continue direct_rows = _section_direct_rows(candidate_match) score = len(direct_rows) * 1_000_000_000 + sum( _get_row_match_amount(row, "ledger") for row in direct_rows ) 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 id(best_group) in erp_unmatched_group_ids: used_erp_group_ids.add(id(best_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["erp_voucher_unmatched"] = [ group for group in list(voucher_sections.get("erp_voucher_unmatched") or []) 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 _row_amount_side(row: dict[str, Any], prefix: str) -> str: 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 "" def _trace_identity_tokens(*values: Any) -> set[str]: text_value = " ".join(clean(value) for value in values if clean(value)) normalized = normalize_text(text_value) if not normalized: return set() tokens = set(_raw_erp_vehicle_reference_tokens(normalized)) tokens.update(_extract_named_reference_tokens(text_value)) stop_tokens = { "법인카드", "하이패스", "후불하이패스", "통행료", "교통비", "출장비", "차량유지비", "여비교통비", "기타", "사용", "대금", } for token in re.findall(r"[0-9a-z가-힣]+", normalized): if len(token) < 3 or token in stop_tokens: continue if token.isdigit(): continue if re.fullmatch(r"\d{1,2}(년|월|일|건)", token): continue tokens.add(token) return tokens def _is_allowed_business_account_substitution(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -> bool: ledger_name = normalize_text(ledger_row.get("ledger_account_name")) voucher_name = normalize_text(voucher_row.get("voucher_account_name")) pair_text = f"{ledger_name}|{voucher_name}" allowed_pairs = ( ("여비교통", "차량유지"), ("여비교통", "통행료"), ("차량유지", "통행료"), ("지급수수료", "보증수수료"), ("세금과공과", "보험료"), ("복리후생", "식대"), ) if any(left in pair_text and right in pair_text for left, right in allowed_pairs): return True return False def _unmatched_unconnected_trace_score( ledger_row: dict[str, Any], voucher_row: dict[str, Any], ) -> tuple[float, bool]: if not _is_grouped_cost_source_row(ledger_row): return 0.0, False if not _is_grouped_cost_business_row(voucher_row): return 0.0, False 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, False ledger_side = _row_amount_side(ledger_row, "ledger") voucher_side = _row_amount_side(voucher_row, "voucher") if ledger_side and voucher_side and ledger_side != voucher_side: return 0.0, False score = 40.0 if ledger_side and voucher_side and ledger_side == voucher_side: score += 20 ledger_category = _row_account_category(ledger_row, "ledger") voucher_category = _row_account_category(voucher_row, "voucher") if ledger_category and voucher_category: if ledger_category == voucher_category: score += 20 else: score -= 60 merged_account_row = { **ledger_row, "voucher_account_code": voucher_row.get("voucher_account_code", ""), "voucher_account_name": voucher_row.get("voucher_account_name", ""), } account_match = _account_names_compatible(merged_account_row, merged_account_row) account_base_match = _account_base_names_compatible( ledger_row.get("ledger_account_name"), voucher_row.get("voucher_account_name"), ) account_substitution = _is_allowed_business_account_substitution(ledger_row, voucher_row) if account_match: score += 20 elif account_base_match: score += 16 elif account_substitution: score += 12 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")) ) desc_overlap = _meaningful_cross_source_overlap(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")) vendor_to_desc_match = ( _contained_core_desc_match(ledger_row.get("ledger_vendor"), voucher_row.get("voucher_desc")) or _core_token_strong_overlap(ledger_row.get("ledger_vendor"), voucher_row.get("voucher_desc")) or _meaningful_cross_source_overlap(ledger_row.get("ledger_vendor"), voucher_row.get("voucher_desc")) ) vendor_match = _meaningful_cross_source_overlap(ledger_row.get("ledger_vendor"), voucher_row.get("voucher_vendor")) if desc_match: score += 25 elif desc_overlap: score += 18 if vendor_to_desc_match: score += 25 elif vendor_match: score += 14 ledger_tokens = _trace_identity_tokens(ledger_row.get("ledger_vendor"), ledger_row.get("ledger_desc")) voucher_tokens = _trace_identity_tokens(voucher_row.get("voucher_vendor"), voucher_row.get("voucher_desc")) identity_overlap = ledger_tokens & voucher_tokens identity_conflict = bool(ledger_tokens and voucher_tokens and not identity_overlap) if identity_overlap: score += 35 elif identity_conflict: score -= 35 ledger_date = _parse_row_date_with_year(ledger_row, "ledger_date") voucher_date = _infer_compare_row_proof_date(voucher_row) if ledger_date and voucher_date: month_gap = _month_gap(ledger_date, voucher_date) day_gap = abs((ledger_date - voucher_date).days) if day_gap == 0: score += 20 elif month_gap <= 1: score += 10 elif day_gap > 75: score -= 20 has_strong_evidence = bool( identity_overlap or desc_match or vendor_to_desc_match or (account_match and (desc_overlap or vendor_match)) or (account_substitution and (desc_match or vendor_to_desc_match or identity_overlap)) ) if not has_strong_evidence: return 0.0, identity_conflict return score, identity_conflict def _voucher_line_identity(row: dict[str, Any]) -> str: row_key = clean(row.get("voucher_row_key")) if row_key: return f"key:{row_key}" return "|".join( [ str(int(row.get("fiscal_year") or 0)), clean(row.get("proof_date")), clean(row.get("voucher_no")), clean(row.get("draft_no")), clean(row.get("voucher_account_code")) or clean(row.get("voucher_account_name")), f"{parse_amount(row.get('voucher_debit')):.2f}", f"{parse_amount(row.get('voucher_credit')):.2f}", clean(row.get("voucher_vendor")), clean(row.get("voucher_desc")), ] ) def _ledger_line_identity(row: dict[str, Any]) -> str: row_key = clean(row.get("ledger_row_key")) if row_key: return f"key:{row_key}" return "|".join( [ str(int(row.get("fiscal_year") or 0)), clean(row.get("ledger_date")), clean(row.get("voucher_no")), clean(row.get("ledger_account_code")) or clean(row.get("ledger_account_name")), f"{parse_amount(row.get('ledger_debit')):.2f}", f"{parse_amount(row.get('ledger_credit')):.2f}", clean(row.get("ledger_vendor")), clean(row.get("ledger_desc")), ] ) def _build_unmatched_unconnected_trace_match( source_group: dict[str, Any], candidate_group: dict[str, Any], used_voucher_ids: set[str], ) -> tuple[dict[str, Any] | None, set[str], float]: 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, set(), 0.0 pair_candidates: list[tuple[float, bool, dict[str, Any], dict[str, Any]]] = [] for ledger_row in ledger_rows: for voucher_row in voucher_rows: voucher_id = _voucher_line_identity(voucher_row) if voucher_id in used_voucher_ids: continue score, identity_conflict = _unmatched_unconnected_trace_score(ledger_row, voucher_row) threshold = 120.0 if identity_conflict else 100.0 if score < threshold: continue pair_candidates.append((score, identity_conflict, ledger_row, voucher_row)) if not pair_candidates: return None, set(), 0.0 pair_candidates.sort(key=lambda item: item[0], reverse=True) if len(pair_candidates) >= 2 and pair_candidates[0][0] - pair_candidates[1][0] < 8: first_ledger = _ledger_line_identity(pair_candidates[0][2]) first_voucher = _voucher_line_identity(pair_candidates[0][3]) second_ledger = _ledger_line_identity(pair_candidates[1][2]) second_voucher = _voucher_line_identity(pair_candidates[1][3]) if first_ledger == second_ledger or first_voucher == second_voucher: return None, set(), 0.0 used_ledger: set[str] = set() local_used_voucher: set[str] = set() rows: list[dict[str, Any]] = [] total_score = 0.0 for score, _identity_conflict, ledger_row, voucher_row in pair_candidates: ledger_id = _ledger_line_identity(ledger_row) voucher_id = _voucher_line_identity(voucher_row) if ledger_id in used_ledger or voucher_id in local_used_voucher or voucher_id in used_voucher_ids: continue merged = dict(ledger_row) voucher_row_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_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_key, "status_label": "Matched", "review_reason": "UNMATCHED_UNCONNECTED_TRACE_MATCH", "matched_case": "UNMATCHED_UNCONNECTED_TRACE_MATCH", } ) rows.append(merged) used_ledger.add(ledger_id) local_used_voucher.add(voucher_id) total_score += score if not rows: return None, set(), 0.0 for ledger_row in ledger_rows: ledger_id = _ledger_line_identity(ledger_row) if ledger_id in used_ledger: continue payload = _blank_voucher_side(ledger_row) payload["status_label"] = "Unmatched" payload["review_reason"] = "UNMATCHED_UNCONNECTED_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 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 {}) draft_nos: list[str] = [] for row in rows: draft_no = clean(row.get("draft_no")) if draft_no and draft_no not in draft_nos: draft_nos.append(draft_no) summary = _build_group_summary_from_rows( fiscal_year=int(source_summary.get("fiscal_year") or 0), voucher_no=clean(source_summary.get("voucher_no")), draft_no=", ".join(draft_nos), rows=rows, status_label="Matched", review_reason="UNMATCHED_UNCONNECTED_TRACE_MATCH", ) return {"summary": summary, "rows": rows}, local_used_voucher, total_score def _rebuild_erp_unmatched_group_without_used_rows( group: dict[str, Any], used_voucher_ids: set[str], ) -> dict[str, Any] | None: remaining_rows = [ dict(row) for row in list(group.get("rows") or []) if _voucher_line_identity(row) not in used_voucher_ids ] if not remaining_rows: return None summary = dict(group.get("summary") or {}) voucher_no = ", ".join( dict.fromkeys(clean(row.get("voucher_no")) for row in remaining_rows if clean(row.get("voucher_no"))) ) draft_no = ", ".join( dict.fromkeys(clean(row.get("draft_no")) for row in remaining_rows if clean(row.get("draft_no"))) ) rebuilt_summary = _build_group_summary_from_rows( fiscal_year=int(summary.get("fiscal_year") or remaining_rows[0].get("fiscal_year") or 0), voucher_no=voucher_no or clean(summary.get("voucher_no")), draft_no=draft_no or clean(summary.get("draft_no")), rows=remaining_rows, status_label="ERP Unmatched", review_reason=clean(summary.get("review_reason")) or "ERP_UNMATCHED_REMAINING_AFTER_TRACE", ) for row in remaining_rows: row["status_label"] = "ERP Unmatched" return {"summary": rebuilt_summary, "rows": remaining_rows} def _highpass_bundle_text(value: Any) -> str: return normalize_text(value) def _row_has_highpass_bundle_signal(row: dict[str, Any], prefix: str) -> bool: text_value = _highpass_bundle_text( " ".join( clean(row.get(field)) for field in ( f"{prefix}_account_name", f"{prefix}_vendor", f"{prefix}_desc", ) ) ) if not text_value: return False if any(token in text_value for token in ("하이패스", "통행료", "업무용차량")): return True if "차량유지" in text_value and _raw_erp_vehicle_reference_tokens(text_value): return True if "차량유지" in text_value and any(token in text_value for token in ("투싼", "제네시스", "스타리아", "그랜저", "스타렉스")): return True return False def _is_highpass_bundle_ledger_row(row: dict[str, Any]) -> bool: if not _is_grouped_cost_source_row(row): return False if _row_amount_side(row, "ledger") != "debit": return False return _row_has_highpass_bundle_signal(row, "ledger") def _is_highpass_bundle_voucher_business_row(row: dict[str, Any]) -> bool: if not _is_grouped_cost_business_row(row): return False if _row_amount_side(row, "voucher") != "debit": return False return _row_has_highpass_bundle_signal(row, "voucher") def _is_highpass_bundle_voucher_payable_row(row: dict[str, Any]) -> bool: if _row_account_family(row, "voucher") not in {"payable", "bank", "advance"}: return False if _row_amount_side(row, "voucher") != "credit": return False text_value = _highpass_bundle_text( " ".join( clean(row.get(field)) for field in ( "voucher_account_name", "voucher_vendor", "voucher_desc", ) ) ) return any(token in text_value for token in ("하이패스", "통행료", "법인카드", "하나카드", "업무용차량")) def _amount_multiset(values: Iterable[float]) -> dict[float, int]: result: dict[float, int] = {} for value in values: amount = round(abs(float(value or 0)), 2) if amount <= 0: continue result[amount] = result.get(amount, 0) + 1 return result def _highpass_bundle_candidate_rows(group: dict[str, Any], prefix: str) -> list[dict[str, Any]]: rows = _section_side_rows(group, prefix) if prefix == "ledger": return [row for row in rows if _is_highpass_bundle_ledger_row(row)] return [row for row in rows if _is_highpass_bundle_voucher_business_row(row)] def _highpass_bundle_group_score( source_group: dict[str, Any], candidate_group: dict[str, Any], ) -> tuple[float, list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: ledger_rows = _highpass_bundle_candidate_rows(source_group, "ledger") voucher_rows = _highpass_bundle_candidate_rows(candidate_group, "voucher") payable_rows = [ row for row in _section_side_rows(candidate_group, "voucher") if _is_highpass_bundle_voucher_payable_row(row) ] if len(ledger_rows) < 2 or len(voucher_rows) < 2: return 0.0, [], [], [] ledger_amounts = [_get_row_match_amount(row, "ledger") for row in ledger_rows] voucher_amounts = [_get_row_match_amount(row, "voucher") for row in voucher_rows] ledger_total = round(sum(ledger_amounts), 2) voucher_total = round(sum(voucher_amounts), 2) if ledger_total <= 0 or abs(ledger_total - voucher_total) >= 0.5: return 0.0, [], [], [] if _amount_multiset(ledger_amounts) != _amount_multiset(voucher_amounts): return 0.0, [], [], [] payable_total = round(sum(_get_row_match_amount(row, "voucher") for row in payable_rows), 2) if payable_rows and abs(payable_total - voucher_total) >= 0.5: return 0.0, [], [], [] source_summary = dict(source_group.get("summary") or {}) candidate_summary = dict(candidate_group.get("summary") or {}) source_date = _parse_row_date_with_year( { "fiscal_year": source_summary.get("fiscal_year"), "ledger_date": source_summary.get("ledger_date"), }, "ledger_date", ) candidate_date = _infer_compare_row_proof_date(voucher_rows[0]) if voucher_rows else None day_bonus = 0.0 if source_date and candidate_date: gap = abs((source_date - candidate_date).days) if gap == 0: day_bonus = 25 elif gap <= 45: day_bonus = 12 elif _month_gap(source_date, candidate_date) <= 1: day_bonus = 6 else: return 0.0, [], [], [] base_score = 180.0 + len(ledger_rows) * 20.0 + min(voucher_total / 10000.0, 80.0) + day_bonus if clean(candidate_summary.get("draft_no")): base_score += 10 if payable_rows: base_score += 20 return base_score, ledger_rows, voucher_rows, payable_rows def _pair_highpass_bundle_rows( ledger_rows: list[dict[str, Any]], voucher_rows: list[dict[str, Any]], ) -> list[tuple[dict[str, Any], dict[str, Any]]]: voucher_by_amount: dict[float, list[dict[str, Any]]] = {} for row in voucher_rows: voucher_by_amount.setdefault(round(_get_row_match_amount(row, "voucher"), 2), []).append(row) pairs: list[tuple[dict[str, Any], dict[str, Any]]] = [] for ledger_row in sorted( ledger_rows, key=lambda row: ( round(_get_row_match_amount(row, "ledger"), 2), clean(row.get("ledger_vendor")), clean(row.get("ledger_desc")), ), ): amount = round(_get_row_match_amount(ledger_row, "ledger"), 2) candidates = voucher_by_amount.get(amount) or [] if not candidates: continue def rank(voucher_row: dict[str, Any]) -> tuple[int, int, str]: ledger_tokens = _trace_identity_tokens(ledger_row.get("ledger_vendor"), ledger_row.get("ledger_desc")) voucher_tokens = _trace_identity_tokens(voucher_row.get("voucher_vendor"), voucher_row.get("voucher_desc")) overlap_count = len(ledger_tokens & voucher_tokens) desc_match = 1 if ( _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")) ) else 0 return (-overlap_count, -desc_match, clean(voucher_row.get("draft_no"))) candidates.sort(key=rank) voucher_row = candidates.pop(0) pairs.append((ledger_row, voucher_row)) return pairs def _build_highpass_bundle_match( source_group: dict[str, Any], candidate_group: dict[str, Any], ledger_rows: list[dict[str, Any]], voucher_rows: list[dict[str, Any]], payable_rows: list[dict[str, Any]], ) -> tuple[dict[str, Any] | None, set[str]]: pairs = _pair_highpass_bundle_rows(ledger_rows, voucher_rows) if len(pairs) != len(ledger_rows) or len(pairs) != len(voucher_rows): return None, set() rows: list[dict[str, Any]] = [] used_voucher_ids: set[str] = set() for ledger_row, voucher_row in pairs: voucher_id = _voucher_line_identity(voucher_row) voucher_row_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) 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_key, "status_label": "Matched", "review_reason": "HIGH_PASS_CARD_BUNDLE_MATCH", "matched_case": "HIGH_PASS_CARD_BUNDLE_MATCH", } ) rows.append(merged) used_voucher_ids.add(voucher_id) for payable_row in payable_rows: voucher_id = _voucher_line_identity(payable_row) payable_payload = _blank_ledger_side(payable_row) payable_payload["status_label"] = "Matched" payable_payload["review_reason"] = "HIGH_PASS_CARD_BUNDLE_CONTEXT" payable_payload["matched_case"] = "HIGH_PASS_CARD_BUNDLE_CONTEXT" rows.append(payable_payload) used_voucher_ids.add(voucher_id) 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 {}) draft_nos = list(dict.fromkeys(clean(row.get("draft_no")) for row in rows if clean(row.get("draft_no")))) summary = _build_group_summary_from_rows( fiscal_year=int(source_summary.get("fiscal_year") or 0), voucher_no=clean(source_summary.get("voucher_no")), draft_no=", ".join(draft_nos), rows=rows, status_label="Matched", review_reason="HIGH_PASS_CARD_BUNDLE_MATCH", ) return {"summary": summary, "rows": rows}, used_voucher_ids def _apply_highpass_bundle_trace_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 []) if not erp_unmatched_groups: return voucher_sections candidate_groups: list[dict[str, Any]] = [] for group in erp_unmatched_groups: voucher_rows = _highpass_bundle_candidate_rows(group, "voucher") payable_rows = [ row for row in _section_side_rows(group, "voucher") if _is_highpass_bundle_voucher_payable_row(row) ] if len(voucher_rows) >= 2 and (payable_rows or len(voucher_rows) >= 3): candidate_groups.append(group) if not candidate_groups: return voucher_sections promoted_groups: list[dict[str, Any]] = [] moved_source_ids: set[int] = set() used_voucher_ids: set[str] = set() for status_key in ("voucher_unmatched", "voucher_recheck", "voucher_matched"): for source_group in list(voucher_sections.get(status_key) or []): if status_key == "voucher_matched" and _section_direct_rows(source_group): continue if id(source_group) in moved_source_ids: continue best: tuple[float, dict[str, Any], list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]] | None = None for candidate_group in candidate_groups: if any(_voucher_line_identity(row) in used_voucher_ids for row in _section_side_rows(candidate_group, "voucher")): continue score, ledger_rows, voucher_rows, payable_rows = _highpass_bundle_group_score(source_group, candidate_group) if score <= 0: continue if best is None or score > best[0]: best = (score, candidate_group, ledger_rows, voucher_rows, payable_rows) if best is None: continue _score, candidate_group, ledger_rows, voucher_rows, payable_rows = best matched_group, matched_voucher_ids = _build_highpass_bundle_match( source_group, candidate_group, ledger_rows, voucher_rows, payable_rows, ) if matched_group is None or not matched_voucher_ids: continue promoted_groups.append(matched_group) moved_source_ids.add(id(source_group)) used_voucher_ids.update(matched_voucher_ids) 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_source_ids ] rebuilt_erp_unmatched: list[dict[str, Any]] = [] for group in erp_unmatched_groups: rebuilt_group = _rebuild_erp_unmatched_group_without_used_rows(group, used_voucher_ids) if rebuilt_group is not None: rebuilt_erp_unmatched.append(rebuilt_group) voucher_sections["erp_voucher_unmatched"] = rebuilt_erp_unmatched 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 _apply_unmatched_unconnected_trace_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 []) if not erp_unmatched_groups: return voucher_sections amount_candidate_index: dict[float, list[dict[str, Any]]] = {} for candidate_group in erp_unmatched_groups: for voucher_row in _section_side_rows(candidate_group, "voucher"): if not _is_grouped_cost_business_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) if not amount_candidate_index: return voucher_sections source_statuses = ("voucher_unmatched", "voucher_recheck", "voucher_matched") promoted_groups: list[dict[str, Any]] = [] moved_source_ids: set[int] = set() used_voucher_ids: set[str] = set() for status_key in source_statuses: for source_group in list(voucher_sections.get(status_key) or []): if status_key == "voucher_matched" and _section_direct_rows(source_group): continue candidate_pool: list[dict[str, Any]] = [] seen_candidates: set[int] = set() for ledger_row in _section_side_rows(source_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_match: dict[str, Any] | None = None best_used_vouchers: set[str] = set() best_rank = -1.0 for candidate_group in candidate_pool: matched_group, matched_voucher_ids, score = _build_unmatched_unconnected_trace_match( source_group, candidate_group, used_voucher_ids, ) if matched_group is None or not matched_voucher_ids: continue direct_count = len(_section_direct_rows(matched_group)) rank = direct_count * 1_000_000_000 + score if rank > best_rank: best_match = matched_group best_used_vouchers = matched_voucher_ids best_rank = rank if best_match is None or not best_used_vouchers: continue promoted_groups.append(best_match) moved_source_ids.add(id(source_group)) used_voucher_ids.update(best_used_vouchers) if not promoted_groups: return voucher_sections for status_key in source_statuses: voucher_sections[status_key] = [ group for group in list(voucher_sections.get(status_key) or []) if id(group) not in moved_source_ids ] rebuilt_erp_unmatched: list[dict[str, Any]] = [] for group in erp_unmatched_groups: rebuilt_group = _rebuild_erp_unmatched_group_without_used_rows(group, used_voucher_ids) if rebuilt_group is not None: rebuilt_erp_unmatched.append(rebuilt_group) voucher_sections["erp_voucher_unmatched"] = rebuilt_erp_unmatched 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 {} erp_start_year, erp_end_year = get_hanmac_erp_candidate_year_range(start_year, end_year) 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(erp_start_year or start_year), "end_year": int(erp_end_year or 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) if _row_is_year_opening_substitution_for_guard(candidate_row): return 0.0, candidate_row if _row_is_grant_settlement_direct_mismatch_for_guard(candidate_row): return 0.0, candidate_row if _rows_have_project_token_conflict_for_guard(candidate_row): return 0.0, candidate_row if _row_has_service_month_conflict_for_guard(candidate_row): return 0.0, candidate_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 _raw_erp_entry_account_family_keys(entry: dict[str, Any]) -> set[str]: account_code = entry.get("account_code") account_name = entry.get("account_name") if entry.get("_raw_tax_evidence"): side = clean(entry.get("_raw_amount_side")) account_name = "매입세액" if side == "debit" else "매출세액" return _account_candidate_family_keys(account_code, account_name) def _raw_erp_entries_for_ledger_row( ledger_row: dict[str, Any], entries: list[dict[str, Any]], ) -> list[dict[str, Any]]: if len(entries) <= 180: return entries direct_ref_entries: list[dict[str, Any]] = [] cheap_date_entries: list[dict[str, Any]] = [] ledger_dates = [ value for value in ( _parse_row_date_with_year(ledger_row, "ledger_date"), _parse_row_date_with_year(ledger_row, "proof_date"), ) if value ] ledger_keys = _account_candidate_family_keys( ledger_row.get("ledger_account_code"), ledger_row.get("ledger_account_name"), ) ledger_family = _classify_account_family( ledger_row.get("ledger_account_code"), ledger_row.get("ledger_account_name"), ) ledger_category = _classify_account_category( ledger_row.get("ledger_account_code"), ledger_row.get("ledger_account_name"), ) account_entries: list[dict[str, Any]] = [] for entry in entries: direct_ref_matched, _direct_ref_strong = _raw_erp_direct_voucher_reference_matches(ledger_row, entry) if direct_ref_matched: direct_ref_entries.append(entry) continue entry_dates = tuple(entry.get("_raw_entry_dates") or _raw_erp_entry_date_values(entry)) if ledger_dates and any( abs((ledger_date - entry_date).days) <= 62 for ledger_date in ledger_dates for entry_date in entry_dates ): cheap_date_entries.append(entry) entry_family = _classify_account_family(entry.get("account_code"), entry.get("account_name")) entry_category = _classify_account_category(entry.get("account_code"), entry.get("account_name")) if entry.get("_raw_tax_evidence") and _is_vat_family(ledger_family): account_entries.append(entry) elif ledger_keys & _raw_erp_entry_account_family_keys(entry): account_entries.append(entry) elif ledger_category in {"expense", "revenue"} and entry_family in {"payable", "bank", "receivable", "advance"}: account_entries.append(entry) elif ledger_family in {"payable", "bank", "receivable", "advance"} and entry_category in {"expense", "revenue"}: account_entries.append(entry) if direct_ref_entries: return direct_ref_entries + [entry for entry in cheap_date_entries if entry in direct_ref_entries] if cheap_date_entries and len(cheap_date_entries) <= 180: return cheap_date_entries if account_entries and len(account_entries) <= 180: return account_entries if cheap_date_entries and account_entries: account_ids = {id(entry) for entry in account_entries} intersected = [entry for entry in cheap_date_entries if id(entry) in account_ids] if intersected: return intersected[:180] return [] 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 = _raw_erp_entries_for_ledger_row(ledger_row, entries) if not entries: continue 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 if _row_sides_have_opposite_signed_amount(candidate_row): 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) 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]]]: if not _enable_raw_erp_trace_promotions(): return voucher_sections 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(" ", "") vendor_text = clean(summary.get("ledger_vendors") or summary.get("group_ledger_vendors")) compact_vendor_text = vendor_text.replace(" ", "") if "감가상각" in compact_ledger_accounts: return True, "WEHAGO_EXCEPTED_DEPRECIATION_ACCOUNT" if "01-01" in month_days and any( token in compact_ledger_desc for token in _OPENING_SUBSTITUTION_TOKENS_FOR_GUARD ): return True, _YEAR_OPENING_SUBSTITUTION_REASON 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(" ", "") strong_excepted_text = re.sub(r"상계\s*\d+(?:-\d+)?\s*소구(?:역)?", "", haystack).replace(" ", "") has_strong_accounting_exception = any( token in strong_excepted_text for token in _WEHAGO_STRONG_ACCOUNTING_EXCEPTION_TOKENS ) or any( token in compact_vendor_text for token in _WEHAGO_STRONG_ACCOUNTING_EXCEPTION_VENDOR_TOKENS ) if has_strong_accounting_exception: return True, "WEHAGO_EXCEPTED_ACCOUNTING_EXCEPTION_ENTRY" 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 _offset_vector_abs_key(vector: dict[tuple[str, str, str], float]) -> tuple[tuple[tuple[str, str, str], float], ...]: return tuple(sorted((row_key, round(abs(amount), 4)) for row_key, amount in vector.items())) def _offset_vectors_have_opposite_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(left[key] * right[key] < -0.25 for key in left) def _voucher_group_has_negative_ledger_direction(group: dict[str, Any]) -> bool: return any( _row_signed_amount(row, "ledger") < -0.5 for row in list(group.get("rows") or []) if clean(row.get("ledger_account_name")) ) def _voucher_group_has_negative_ledger_field(group: dict[str, Any]) -> bool: return any( parse_amount(row.get(field)) < -0.5 for row in list(group.get("rows") or []) for field in ("ledger_debit", "ledger_credit") ) def _voucher_group_has_positive_ledger_direction(group: dict[str, Any]) -> bool: return any( _row_signed_amount(row, "ledger") > 0.5 for row in list(group.get("rows") or []) if clean(row.get("ledger_account_name")) ) def _voucher_group_has_purchase_sales_cancel_signal(group: dict[str, Any]) -> bool: if _voucher_group_has_negative_ledger_field(group): return True compact_haystack = _voucher_group_text(group).replace(" ", "") return any( token in compact_haystack for token in ( "취소", "취소전표", "매입취소", "매출취소", "환입", "마이너스", "수정세금계산서", "계산서취소", ) ) def _voucher_group_date_distance(left: dict[str, Any], right: dict[str, Any]) -> int: left_dates = _voucher_group_dates(left) right_dates = _voucher_group_dates(right) if not left_dates or not right_dates: return 999999 return min(abs((left_date - right_date).days) for left_date in left_dates for right_date in right_dates) def _voucher_group_has_wehago_revenue_signal(group: dict[str, Any]) -> bool: rows = list(group.get("rows") or []) has_revenue_or_vat = False has_receivable = False for row in rows: family = _classify_account_family(row.get("ledger_account_code"), row.get("ledger_account_name")) if family in {"sales", "vat_output"}: has_revenue_or_vat = True if family == "receivable": has_receivable = True haystack = _voucher_group_text(group) return has_revenue_or_vat or (has_receivable and any(token in haystack for token in ("매출", "수입", "계산서", "부가세"))) def _move_wehago_revenue_reversal_pairs_to_excepted( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: candidate_statuses = ("voucher_matched", "voucher_unmatched", "voucher_recheck") candidates: 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(candidates) < 2: return voucher_sections candidate_vectors: dict[int, dict[tuple[str, str, str], float]] = {} buckets: dict[tuple[tuple[tuple[str, str, str], float], ...], list[tuple[str, dict[str, Any]]]] = {} for status_key, group in candidates: if not _voucher_group_has_wehago_revenue_signal(group): continue vector = _offset_group_vector(group) if not vector: continue candidate_vectors[id(group)] = vector buckets.setdefault(_offset_vector_abs_key(vector), []).append((status_key, group)) moved_ids: set[int] = set() for grouped_candidates in buckets.values(): remaining = [(status_key, group) for status_key, group in grouped_candidates if id(group) not in moved_ids] if len(remaining) < 2: continue pairs: list[tuple[int, int, int, dict[str, Any], dict[str, Any]]] = [] for left_index, (_left_status, left_group) in enumerate(remaining): left_vector = candidate_vectors.get(id(left_group)) or {} for right_index, (_right_status, right_group) in enumerate(remaining[left_index + 1:], start=left_index + 1): right_vector = candidate_vectors.get(id(right_group)) or {} if not _offset_vectors_have_opposite_direction(left_vector, right_vector): continue date_distance = _voucher_group_date_distance(left_group, right_group) if date_distance > 400: continue pairs.append((date_distance, left_index, right_index, left_group, right_group)) if not pairs: continue pairs.sort(key=lambda item: (item[0], item[1], item[2])) used_indexes: set[int] = set() for _distance, left_index, right_index, left_group, right_group in pairs: if left_index in used_indexes or right_index in used_indexes: continue used_indexes.update((left_index, right_index)) moved_ids.add(id(left_group)) moved_ids.add(id(right_group)) 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 candidates: if id(group) not in moved_ids: retained_by_status[status_key].append(group) continue moved_excepted.append( _copy_wehago_group_with_status( group, status_label="Excepted", reason="WEHAGO_EXCEPTED_REVENUE_REVERSAL_PAIR", ) ) 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 _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_purchase_sales_cancel_pairs_to_excepted( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: candidate_statuses = ("voucher_matched", "voucher_unmatched", "voucher_recheck") candidates: 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(candidates) < 2: return voucher_sections vectors: dict[int, dict[tuple[str, str, str], float]] = {} buckets: dict[tuple[tuple[tuple[str, str, str], float], ...], list[tuple[str, dict[str, Any]]]] = {} for status_key, group in candidates: vector = _offset_group_vector(group) if not vector: continue vectors[id(group)] = vector buckets.setdefault(_offset_vector_abs_key(vector), []).append((status_key, group)) moved_ids: set[int] = set() for grouped_candidates in buckets.values(): available = [(status_key, group) for status_key, group in grouped_candidates if id(group) not in moved_ids] if len(available) < 2: continue pair_candidates: list[tuple[int, int, int, dict[str, Any], dict[str, Any]]] = [] for left_index, (_left_status, left_group) in enumerate(available): left_vector = vectors.get(id(left_group)) or {} for right_index, (_right_status, right_group) in enumerate(available[left_index + 1:], start=left_index + 1): right_vector = vectors.get(id(right_group)) or {} if not _offset_vectors_have_opposite_direction(left_vector, right_vector): continue left_negative = _voucher_group_has_negative_ledger_field(left_group) right_negative = _voucher_group_has_negative_ledger_field(right_group) if left_negative == right_negative: continue if not ( _voucher_group_has_purchase_sales_cancel_signal(left_group) or _voucher_group_has_purchase_sales_cancel_signal(right_group) ): continue date_distance = _voucher_group_date_distance(left_group, right_group) if date_distance > 62: continue pair_candidates.append((date_distance, left_index, right_index, left_group, right_group)) if not pair_candidates: continue pair_candidates.sort(key=lambda item: (item[0], item[1], item[2])) used_indexes: set[int] = set() for _distance, left_index, right_index, left_group, right_group in pair_candidates: if left_index in used_indexes or right_index in used_indexes: continue used_indexes.update((left_index, right_index)) moved_ids.add(id(left_group)) moved_ids.add(id(right_group)) 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 candidates: if id(group) not in moved_ids: retained_by_status[status_key].append(group) continue moved_excepted.append( _copy_wehago_group_with_status( group, status_label="Excepted", reason="WEHAGO_EXCEPTED_PURCHASE_SALES_CANCEL_PAIR", ) ) 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 _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]: if status in {"voucher_matched", "voucher_unmatched"}: voucher_sections = { key: list(value or []) for key, value in (voucher_sections or {}).items() } voucher_sections = _demote_matched_groups_without_erp_detail(voucher_sections) 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 OR COALESCE(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 _expand_status_payload_full_erp_vouchers( conn: Any, payload: dict[str, Any], status_key: str, ) -> dict[str, Any]: if status_key not in {"voucher_matched", "voucher_recheck"}: return payload groups = payload.get("groups") if not isinstance(groups, list) or not groups: return payload def full_draft_base(value: Any) -> str: draft_number = clean(value) matched = re.fullmatch(r"(11-\d{8}-[^-]+-\d+)-\d+", draft_number) return matched.group(1) if matched else draft_number group_bases: dict[int, set[str]] = {} requested_bases: set[str] = set() for group_index, group in enumerate(groups): if not isinstance(group, dict): continue summary = group.get("summary") if isinstance(group.get("summary"), dict) else {} bases: set[str] = set() draft_values = [ summary.get("draft_no"), *(row.get("draft_no") for row in list(group.get("rows") or []) if isinstance(row, dict)), ] for draft_value in draft_values: for value in _split_erp_number_candidates(draft_value): base = full_draft_base(value) if base: bases.add(base) for value in _split_erp_number_candidates(summary.get("draft_no")): base = full_draft_base(value) if base: bases.add(base) if bases: group_bases[group_index] = bases requested_bases.update(bases) if not requested_bases: return payload conditions: list[str] = [] params: dict[str, Any] = {} for index, base in enumerate(sorted(requested_bases)): key = f"draft_base_{index}" conditions.append( f"(COALESCE(draft_no, '') = :{key} OR COALESCE(draft_no, '') LIKE :{key}_suffix " f"OR COALESCE(confirmed_no, '') = :{key} OR COALESCE(confirmed_no, '') LIKE :{key}_suffix)" ) params[key] = base params[f"{key}_suffix"] = f"{base}-%" raw_rows = conn.execute( text( f""" SELECT fiscal_year, proof_date, confirmed_no, draft_no, account_code, account_name, debit_supply, credit_supply, vendor_name, desc1, desc2, row_number, id FROM wehago_voucher_rows WHERE {' OR '.join(conditions)} ORDER BY fiscal_year, draft_no, row_number, id """ ), params, ).mappings().all() raw_by_base: dict[str, list[dict[str, Any]]] = {} for raw in raw_rows: raw_dict = dict(raw) base = full_draft_base(raw_dict.get("draft_no") or raw_dict.get("confirmed_no")) if base: raw_by_base.setdefault(base, []).append(raw_dict) status_label = "Matched" if status_key == "voucher_matched" else "Recheck" flattened_rows: list[dict[str, Any]] = [] for group_index, group in enumerate(groups): if not isinstance(group, dict): continue summary = group.get("summary") if isinstance(group.get("summary"), dict) else {} anchor = _wehago_anchor_key_from_parts( summary.get("fiscal_year"), summary.get("ledger_date"), summary.get("voucher_no"), ) rows = [dict(row) for row in list(group.get("rows") or []) if isinstance(row, dict)] seen_draft_rows = { clean(row.get("draft_no")) for row in rows if _row_has_hanmac_candidate_side(row) and clean(row.get("draft_no")) } seen_voucher_content = { _display_voucher_content_key(row) for row in rows if _row_has_hanmac_candidate_side(row) } for base in sorted(group_bases.get(group_index, set())): for raw in raw_by_base.get(base, []): full_row = _raw_erp_recheck_row(raw, anchor) full_row["status_label"] = status_label full_row = _blank_wehago_identity_for_erp_only_display(full_row) draft_number = clean(full_row.get("draft_no")) content_key = _display_voucher_content_key(full_row) if (draft_number and draft_number in seen_draft_rows) or content_key in seen_voucher_content: continue rows.append(full_row) if draft_number: seen_draft_rows.add(draft_number) seen_voucher_content.add(content_key) group["rows"] = _sanitize_voucher_group_rows(rows) summary["voucher_row_count"] = sum( 1 for row in group["rows"] if _row_has_hanmac_candidate_side(row) ) summary["voucher_debit"] = sum( parse_amount(row.get("voucher_debit")) for row in group["rows"] ) summary["voucher_credit"] = sum( parse_amount(row.get("voucher_credit")) for row in group["rows"] ) group["summary"] = summary flattened_rows.extend(dict(row) for row in group["rows"]) payload["rows"] = flattened_rows return payload 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("상태 값이 올바르지 않습니다.") raw_voucher_filter = clean(voucher_no) voucher_filter = raw_voucher_filter if raw_voucher_filter: default_filter_year = int(start_year) if int(start_year or 0) == int(end_year or 0) else None voucher_filter = normalize_compare_voucher_filter(raw_voucher_filter, default_filter_year) if not voucher_filter: return { "columns": DETAIL_COLUMN_MAP.get(normalized_status, []), "rows": [], "groups": [], "total_count": 0, "shown_count": 0, "offset": 0, "limit": limit, "has_more": False, "next_offset": 0, "next_cursor": "", "notice": "전표번호 조회는 연월일과 전표번호를 함께 입력해야 합니다. 예: 2025-01-10 00200", "pending": False, } 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 == "hanmac_unconnected": with engine.begin() as conn: _ensure_hanmac_unconnected_status_projection(conn, start_year, end_year) hanmac_projection_signature = _active_status_projection_signature( conn, "hanmac_unconnected", start_year, end_year, ) query_page = _load_hanmac_unconnected_compact_projection_page( conn, start_year, end_year, voucher_filter, draft_filter, erp_account_filter, clean(erp_amount), 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, "count_unit": "ERP 전표", "projection_signature": hanmac_projection_signature, "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, ) if normalized_status in QUERY_VOUCHER_STATUS_KEYS and normalized_status not in WEHAGO_FINAL_STATUS_ORDER 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 isinstance(payload, dict): if normalized_status in {"voucher_matched", "voucher_recheck"}: try: with engine.begin() as conn: payload = _expand_status_payload_full_erp_vouchers( conn, payload, normalized_status, ) except Exception: pass if normalized_status in WEHAGO_FINAL_STATUS_ORDER: payload.setdefault("count_unit", "전표") elif normalized_status == "hanmac_unconnected": payload.setdefault("count_unit", "ERP 전표") else: payload.setdefault("count_unit", "전표그룹" if normalized_status in QUERY_VOUCHER_STATUS_KEYS else "건") payload.setdefault("projection_signature", "") if not clean(payload.get("projection_signature")) and normalized_status in {"hanmac_unconnected", *COMPACT_EXPORT_STATUS_KEYS, *WEHAGO_FINAL_STATUS_ORDER}: try: with engine.begin() as conn: if normalized_status in WEHAGO_FINAL_STATUS_ORDER: payload["projection_signature"] = _load_latest_query_projection_signature_for_range( conn, start_year, end_year, ) else: payload["projection_signature"] = _active_status_projection_signature( conn, normalized_status, start_year, end_year, ) except Exception: payload["projection_signature"] = "" 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 == "voucher_recheck" and os.getenv("WEHAGO_RECHECK_DETAIL_USE_QUERY_GROUPS") == "1": 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": "Recheck 상세는 최종 상태 요약이 아니라 검토 후보 projection을 표시합니다.", "pending": False, "ready_years": [], "pending_years": [], "bank_payable_case_count": 0, "boundary_excluded_count": 0, }) if normalized_status in WEHAGO_FINAL_STATUS_ORDER: with engine.begin() as conn: status_projection_signature = _active_status_projection_signature( conn, normalized_status, start_year, end_year, ) compact_page = _load_compact_status_projection_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 compact_page is not None: shown_groups, total_count, next_cursor = compact_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, "projection_signature": status_projection_signature, "notice": "", "pending": False, "ready_years": [], "pending_years": [], "bank_payable_case_count": 0, "boundary_excluded_count": 0, }) if normalized_status in COMPACT_EXPORT_STATUS_KEYS: with engine.begin() as conn: compact_page = _load_compact_status_projection_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 compact_page is not None: shown_groups, total_count, next_cursor = compact_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 and normalized_status != "hanmac_unconnected" and not has_voucher_group_filters: persisted_page_payload: dict[str, Any] | None = None 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) 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 and normalized_status != "hanmac_unconnected": 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 = [], 0, "" else: shown_groups, total_count, next_cursor = query_page if query_page is None and _allow_sync_detail_rebuild_fallback(): 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"] = bool(pending_years) fallback_payload["ready_years"] = ready_years fallback_payload["pending_years"] = pending_years return finalize_status_detail_payload(fallback_payload) if _allow_stale_export_detail_fallback(): with engine.begin() as conn: 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 ), ) needs_fallback = total_count == 0 or (total_count > 0 and not shown_groups) if needs_fallback and not pending_years and _allow_sync_detail_rebuild_fallback(): 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" and _allow_sync_detail_rebuild_fallback(): 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 = [], 0, "" if _allow_stale_export_detail_fallback(): 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" and _allow_sync_detail_rebuild_fallback(): 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 _allow_sync_detail_rebuild_fallback(): 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, projection_signature: 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, projection_signature, ) 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": f"{QUERY_PROJECTION_VERSION}|final-detail-v2", "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) with engine.begin() as conn: projection_signature = _status_export_projection_signature( conn, start_year, end_year, normalized_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, projection_signature, ) 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, 'projection_signature': projection_signature, } 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 if normalized_status in COMPACT_EXPORT_STATUS_KEYS and normalized_status != "hanmac_unconnected": compact_page = _load_compact_status_projection_page( 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, 0, 1_000_000, ) if compact_page is not None: groups, total_count, _next_cursor = compact_page export_rows: list[dict[str, Any]] = [] for group_sort, group in enumerate(groups): summary = dict(group.get("summary") or {}) for row_sort, row in enumerate(_sanitize_voucher_group_rows(list(group.get("rows") or []))): export_rows.append( { **dict(row), "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")), "group_sort": group_sort, "row_sort": row_sort, } ) return export_rows, int(total_count or 0) 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 _hanmac_voucher_date_from_numbers(*values: Any) -> str: for value in values: match = re.search(r"(20\d{2})(\d{2})(\d{2})", clean(value)) if not match: continue year, month, day = match.groups() try: parsed = date(int(year), int(month), int(day)) except ValueError: continue return parsed.isoformat() return "" 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, group_draft_no, voucher_account_name, ledger_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(voucher_account_name, '') <> '' AND COALESCE(ledger_account_name, '') <> '' """ ), {"fiscal_year": year, "signature": signature}, ).mappings().all() for row in rows: voucher_base = _hanmac_voucher_base(row.get("draft_no")) or _hanmac_voucher_base(row.get("group_draft_no")) if voucher_base: draft_year = _erp_draft_year(row.get("draft_no")) or _erp_draft_year(row.get("group_draft_no")) matched_bases.add((draft_year or 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_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")) or _hanmac_voucher_base(row.get("confirmed_no")) draft_year = _erp_draft_year(row.get("draft_no")) if fiscal_year <= 0 or not voucher_base: continue if draft_year and not (int(start_year) <= draft_year <= int(end_year)): continue key_year = draft_year or fiscal_year if not (int(start_year) <= key_year <= int(end_year)): continue key = (key_year, voucher_base) current = grouped.setdefault( key, { "fiscal_year": key_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 매치 계정 없음", "voucher_nos": [], "draft_nos": [], "rows": [], }, ) proof_date = clean(row.get("proof_date")) or _hanmac_voucher_date_from_numbers(row.get("draft_no"), row.get("confirmed_no")) 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["draft_nos"], row.get("draft_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 current["rows"].append( { "fiscal_year": key_year, "status_label": "Hanmac unconnected", "ledger_date": "", "proof_date": proof_date, "voucher_no": confirmed_no, "draft_no": clean(row.get("draft_no")), "ledger_account_code": "", "ledger_account_name": "", "voucher_account_code": account_code, "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 key in matched_bases: continue current["voucher_no"] = ", ".join(current.pop("voucher_nos")) current["draft_no"] = ", ".join(current.pop("draft_nos")) or current["draft_no"] current["voucher_accounts"] = ", ".join(current["voucher_accounts"]) current["voucher_vendors"] = ", ".join(current["voucher_vendors"]) rows = list(current.pop("rows")) rows.sort( key=lambda row: ( clean(row.get("proof_date")), clean(row.get("draft_no")), clean(row.get("voucher_no")), clean(row.get("voucher_account_name")), -parse_amount(row.get("voucher_debit")), -parse_amount(row.get("voucher_credit")), ) ) groups.append({"summary": current, "rows": 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 _ensure_hanmac_unconnected_status_projection( conn: Any, start_year: int | None, end_year: int | None, ) -> str: if start_year is None or end_year is None: return "" signature = _hanmac_unconnected_status_projection_signature(conn, start_year, end_year) if not signature: return "" active_signature = _active_status_projection_signature(conn, "hanmac_unconnected", start_year, end_year) if active_signature == signature: return signature existing_count = conn.execute( text( """ SELECT COUNT(*) FROM wehago_status_projection_groups WHERE start_year = :start_year AND end_year = :end_year AND status_key = 'hanmac_unconnected' AND signature = :signature """ ), {"start_year": int(start_year), "end_year": int(end_year), "signature": signature}, ).scalar_one() if int(existing_count or 0) > 0: _store_active_status_projection_signature( conn, "hanmac_unconnected", start_year, end_year, signature, int(existing_count or 0), ) return signature groups = _load_hanmac_unconnected_source_groups(conn, int(start_year), int(end_year)) _store_hanmac_unconnected_compact_projection( conn, start_year, end_year, signature, groups, ) return signature def _store_hanmac_unconnected_compact_projection( conn: Any, start_year: int | None, end_year: int | None, signature: str, groups: list[dict[str, Any]], ) -> None: if start_year is None or end_year is None or not clean(signature): return conn.execute( text( """ DELETE FROM wehago_status_projection_groups WHERE start_year = :start_year AND end_year = :end_year AND status_key = 'hanmac_unconnected' AND signature = :signature """ ), {"start_year": int(start_year), "end_year": int(end_year), "signature": clean(signature)}, ) payloads: list[dict[str, Any]] = [] 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("voucher_accounts")), clean(summary.get("voucher_vendors")), clean(summary.get("review_reason")), " ".join(clean(row.get("voucher_desc")) for row in rows), ], ) ) payloads.append( { "start_year": int(start_year), "end_year": int(end_year), "status_key": "hanmac_unconnected", "signature": clean(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, "summary_json": json.dumps(_json_safe_projection_payload(summary), ensure_ascii=False, separators=(",", ":")), "rows_json": json.dumps(_json_safe_projection_payload(rows), ensure_ascii=False, separators=(",", ":")), } ) insert_sql = text( """ INSERT INTO wehago_status_projection_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, summary_json, rows_json, 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, :summary_json, :rows_json, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) """ ) for chunk in _chunked(payloads): conn.execute(insert_sql, chunk) _store_active_status_projection_signature( conn, "hanmac_unconnected", start_year, end_year, signature, len(groups), ) def _load_hanmac_unconnected_status_projection_count( conn: Any, start_year: int | None, end_year: int | None, ) -> int | None: if start_year is None or end_year is None: return None signature = _active_status_projection_signature(conn, "hanmac_unconnected", start_year, end_year) if not signature: signature = _ensure_hanmac_unconnected_status_projection(conn, start_year, end_year) if not signature: return None count = conn.execute( text( """ SELECT COUNT(*) FROM wehago_status_projection_groups WHERE start_year = :start_year AND end_year = :end_year AND status_key = 'hanmac_unconnected' AND signature = :signature """ ), {"start_year": int(start_year), "end_year": int(end_year), "signature": signature}, ).scalar_one() return int(count or 0) def _load_hanmac_unconnected_compact_projection_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 = "", ) -> tuple[list[dict[str, Any]], int, str] | None: if start_year is None or end_year is None: return None signature = _active_status_projection_signature(conn, "hanmac_unconnected", start_year, end_year) if not signature: return None voucher_filter = clean(voucher_no) draft_filter = clean(draft_no) erp_account_filter = normalize_text(erp_account) erp_vendor_filter = normalize_text(erp_vendor) desc_filter = normalize_text(desc_keyword) erp_amount_filter = clean(erp_amount) params = { "start_year": int(start_year), "end_year": int(end_year), "signature": signature, "voucher_keyword": voucher_filter, "voucher_like": f"%{voucher_filter}%", "draft_keyword": draft_filter, "draft_like": f"%{draft_filter}%", "erp_account_keyword": erp_account_filter, "erp_account_like": f"%{erp_account_filter}%", "erp_vendor_keyword": erp_vendor_filter, "erp_vendor_like": f"%{erp_vendor_filter}%", "desc_keyword": desc_filter, "desc_like": f"%{desc_filter}%", "erp_amount_keyword": erp_amount_filter, "limit": int(limit), "offset": int(offset), } where_sql = """ start_year = :start_year AND end_year = :end_year AND status_key = 'hanmac_unconnected' AND signature = :signature AND fiscal_year BETWEEN :start_year AND :end_year AND (:voucher_keyword = '' OR COALESCE(voucher_no, '') LIKE :voucher_like) AND (:draft_keyword = '' OR COALESCE(draft_no, '') LIKE :draft_like) AND (:erp_account_keyword = '' OR COALESCE(voucher_accounts, '') LIKE :erp_account_like) AND (:erp_vendor_keyword = '' OR COALESCE(voucher_vendors, '') LIKE :erp_vendor_like) AND (:desc_keyword = '' OR COALESCE(search_text, '') LIKE :desc_like) 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 || '%' ) """ total_count = int( conn.execute( text(f"SELECT COUNT(*) FROM wehago_status_projection_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_status_projection_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 += " OFFSET :offset" rows = conn.execute(text(query_sql), params).mappings().all() groups: list[dict[str, Any]] = [] for row in rows: try: summary = json.loads(clean(row.get("summary_json")) or "{}") except Exception: summary = {} try: detail_rows = json.loads(clean(row.get("rows_json")) or "[]") except Exception: detail_rows = [] if not isinstance(summary, dict): summary = {} if not isinstance(detail_rows, list): detail_rows = [] groups.append({"summary": summary, "rows": _sanitize_voucher_group_rows(detail_rows)}) last_row = rows[-1] next_cursor = f"{int(last_row.get('fiscal_year') or 0)}:{int(last_row.get('group_index') or 0)}" if len(rows) >= int(limit or 0) else "" return groups, total_count, next_cursor def _store_export_compact_status_projection( conn: Any, start_year: int | None, end_year: int | None, status_key: str, signature: str, groups: list[dict[str, Any]], ) -> None: if start_year is None or end_year is None or clean(status_key) not in COMPACT_EXPORT_STATUS_KEYS or not clean(signature): return normalized_status = clean(status_key) conn.execute( text( """ DELETE FROM wehago_status_projection_groups WHERE start_year = :start_year AND end_year = :end_year AND status_key = :status_key AND signature = :signature """ ), { "start_year": int(start_year), "end_year": int(end_year), "status_key": normalized_status, "signature": clean(signature), }, ) payloads: list[dict[str, Any]] = [] 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), ], ) ) payloads.append( { "start_year": int(start_year), "end_year": int(end_year), "status_key": normalized_status, "signature": clean(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, "summary_json": json.dumps(_json_safe_projection_payload(summary), ensure_ascii=False, separators=(",", ":")), "rows_json": json.dumps(_json_safe_projection_payload(rows), ensure_ascii=False, separators=(",", ":")), } ) insert_sql = text( """ INSERT INTO wehago_status_projection_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, summary_json, rows_json, 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, :summary_json, :rows_json, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) """ ) for chunk in _chunked(payloads): conn.execute(insert_sql, chunk) _store_active_status_projection_signature( conn, normalized_status, start_year, end_year, signature, len(groups), ) def _ensure_export_compact_status_projection( conn: Any, start_year: int | None, end_year: int | None, status_key: str, ) -> str: normalized_status = clean(status_key) if start_year is None or end_year is None or normalized_status not in COMPACT_EXPORT_STATUS_KEYS: return "" signature = _status_projection_signature(conn, normalized_status, start_year, end_year) if not signature: return "" active_signature = _active_status_projection_signature(conn, normalized_status, start_year, end_year) if active_signature == signature: return signature existing_count = conn.execute( text( """ SELECT COUNT(*) FROM wehago_status_projection_groups WHERE start_year = :start_year AND end_year = :end_year AND status_key = :status_key AND signature = :signature """ ), { "start_year": int(start_year), "end_year": int(end_year), "status_key": normalized_status, "signature": signature, }, ).scalar_one() if int(existing_count or 0) > 0: _store_active_status_projection_signature( conn, normalized_status, start_year, end_year, signature, int(existing_count or 0), ) return signature export_rows, _row_count = _iter_cached_export_rows( conn, start_year, end_year, normalized_status, "", "", "", "", "", "", "", "", "", ) groups = _build_voucher_groups_from_cached_export_rows(export_rows, normalized_status) _store_export_compact_status_projection( conn, start_year, end_year, normalized_status, signature, groups, ) return signature def _load_compact_status_projection_count( conn: Any, start_year: int | None, end_year: int | None, status_key: str, ) -> int | None: normalized_status = clean(status_key) if start_year is None or end_year is None or normalized_status not in COMPACT_EXPORT_STATUS_KEYS: return None signature = _active_status_projection_signature(conn, normalized_status, start_year, end_year) if not signature: signature = _ensure_export_compact_status_projection(conn, start_year, end_year, normalized_status) if not signature: return None count = conn.execute( text( """ SELECT COUNT(*) FROM wehago_status_projection_groups WHERE start_year = :start_year AND end_year = :end_year AND status_key = :status_key AND signature = :signature """ ), { "start_year": int(start_year), "end_year": int(end_year), "status_key": normalized_status, "signature": signature, }, ).scalar_one() return int(count or 0) def _load_active_status_projection_counts_for_range( conn: Any, start_year: int | None, end_year: int | None, status_keys: Iterable[str], ) -> dict[str, int]: if start_year is None or end_year is None: return {} active_signatures = _active_status_projection_signatures_for_range( conn, start_year, end_year, status_keys, ) if not active_signatures: return {} status_placeholders = ", ".join(f":status_{index}" for index, _status in enumerate(active_signatures)) params: dict[str, Any] = { "start_year": int(start_year), "end_year": int(end_year), **{f"status_{index}": status for index, status in enumerate(active_signatures)}, } signature_conditions: list[str] = [] for index, (status_key, signature) in enumerate(active_signatures.items()): params[f"signature_{index}"] = signature signature_conditions.append(f"(status_key = :status_{index} AND signature = :signature_{index})") rows = conn.execute( text( f""" SELECT status_key, COUNT(*) AS count FROM wehago_status_projection_groups WHERE start_year = :start_year AND end_year = :end_year AND status_key IN ({status_placeholders}) AND ({" OR ".join(signature_conditions)}) GROUP BY status_key """ ), params, ).mappings().all() return {clean(row.get("status_key")): int(row.get("count") or 0) for row in rows} def _all_voucher_status_projections_ready( conn: Any, start_year: int | None, end_year: int | None, ) -> bool: if start_year is None or end_year is None: return False required_statuses = {"hanmac_unconnected", *COMPACT_EXPORT_STATUS_KEYS} active = _active_status_projection_signatures_for_range(conn, start_year, end_year, required_statuses) return required_statuses.issubset(set(active)) def _load_compact_status_projection_page( conn: Any, start_year: int | None, end_year: int | None, status_key: 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 = "", ) -> tuple[list[dict[str, Any]], int, str] | None: normalized_status = clean(status_key) if start_year is None or end_year is None or normalized_status not in COMPACT_EXPORT_STATUS_KEYS: return None signature = _active_status_projection_signature(conn, normalized_status, start_year, end_year) if not signature: return None 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 = { "start_year": int(start_year), "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 = :start_year AND end_year = :end_year AND status_key = :status_key AND signature = :signature AND fiscal_year BETWEEN :start_year AND :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 || '%' ) """ total_count = int(conn.execute(text(f"SELECT COUNT(*) FROM wehago_status_projection_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_status_projection_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 += " OFFSET :offset" rows = conn.execute(text(query_sql), params).mappings().all() groups: list[dict[str, Any]] = [] for row in rows: try: summary = json.loads(clean(row.get("summary_json")) or "{}") except Exception: summary = {} try: detail_rows = json.loads(clean(row.get("rows_json")) or "[]") except Exception: detail_rows = [] if not isinstance(summary, dict): summary = {} if not isinstance(detail_rows, list): detail_rows = [] groups.append({"summary": summary, "rows": _sanitize_voucher_group_rows(detail_rows)}) last_row = rows[-1] next_cursor = f"{int(last_row.get('fiscal_year') or 0)}:{int(last_row.get('group_index') or 0)}" if len(rows) >= int(limit or 0) else "" return groups, total_count, next_cursor 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, projection_signature: 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", clean(projection_signature) or "no-active-status-projection", 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 _status_export_projection_signature( conn: Any, start_year: int | None, end_year: int | None, normalized_status: str, ) -> str: if start_year is None or end_year is None: return "" if normalized_status in COMPACT_EXPORT_STATUS_KEYS or normalized_status == "hanmac_unconnected": return _active_status_projection_export_token(conn, normalized_status, start_year, end_year) return _load_latest_query_projection_signature_for_range(conn, start_year, end_year) 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) with engine.begin() as conn: projection_signature = _status_export_projection_signature( conn, start_year, end_year, normalized_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, projection_signature, ) 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]) physical_row_count = len(export_rows) _apply_voucher_export_sheet_layout(worksheet, physical_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} pending = False if include_metric_counts: 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 ] compact_status_ready = False with engine.begin() as conn: compact_status_ready = _all_voucher_status_projections_ready(conn, start_year, end_year) if compact_status_ready: pending = False if warm_caches and not compact_status_ready: warm_metric_counts_async(engine, start_year, end_year) if start_year == end_year: warm_status_cache_async(engine, start_year, end_year) with engine.begin() as conn: final_status_summary = get_wehago_final_status_summary_from_conn( conn, start_year, end_year, metric_counts, ) if bool(final_status_summary.get("ready")): pending = False if clean(final_status_summary.get("source")) == "active_status_projection_missing": pending = True metric_sections = apply_wehago_final_status_counts_to_metric_sections(metric_sections, final_status_summary) with engine.begin() as conn: metric_sections = annotate_metric_sections_with_active_projection_metadata(conn, metric_sections, start_year, end_year) erp_candidate_start_year, erp_candidate_end_year = get_hanmac_erp_candidate_year_range(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, "hanmac_erp_candidate_start_year": erp_candidate_start_year, "hanmac_erp_candidate_end_year": erp_candidate_end_year, "metric_sections": metric_sections, "pending": pending, "wehago_final_status_summary": final_status_summary, "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 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")) counts, count_pending = get_dashboard_metric_counts_nonblocking(engine, start_year, end_year) pending = pending or count_pending with engine.begin() as conn: if _all_voucher_status_projections_ready(conn, start_year, end_year): pending = False 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 ] with engine.begin() as conn: final_status_summary = get_wehago_final_status_summary_from_conn( conn, start_year, end_year, counts, ) if bool(final_status_summary.get("ready")): pending = False if clean(final_status_summary.get("source")) == "active_status_projection_missing": pending = True metric_sections = apply_wehago_final_status_counts_to_metric_sections(metric_sections, final_status_summary) with engine.begin() as conn: metric_sections = annotate_metric_sections_with_active_projection_metadata(conn, metric_sections, start_year, end_year) erp_candidate_start_year, erp_candidate_end_year = get_hanmac_erp_candidate_year_range(start_year, end_year) 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, "hanmac_erp_candidate_start_year": erp_candidate_start_year, "hanmac_erp_candidate_end_year": erp_candidate_end_year, "metric_sections": metric_sections, "wehago_final_status_summary": final_status_summary, "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, ) with engine.begin() as conn: if _all_voucher_status_projections_ready(conn, start_year, end_year): pending = False 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 ] with engine.begin() as conn: final_status_summary = get_wehago_final_status_summary_from_conn( conn, start_year, end_year, metric_counts, ) if bool(final_status_summary.get("ready")): pending = False if clean(final_status_summary.get("source")) == "active_status_projection_missing": pending = True metric_sections = apply_wehago_final_status_counts_to_metric_sections(metric_sections, final_status_summary) with engine.begin() as conn: metric_sections = annotate_metric_sections_with_active_projection_metadata(conn, metric_sections, start_year, end_year) erp_candidate_start_year, erp_candidate_end_year = get_hanmac_erp_candidate_year_range(start_year, end_year) payload = { "selected_start_year": start_year, "selected_end_year": end_year, "hanmac_erp_candidate_start_year": erp_candidate_start_year, "hanmac_erp_candidate_end_year": erp_candidate_end_year, "metric_sections": metric_sections, "wehago_final_status_summary": final_status_summary, "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