Files
HM_project_Viewer_Board/wehago_compare.py
T

12500 lines
516 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import copy
import hashlib
from io import BytesIO
import json
import re
import threading
import time
from dataclasses import dataclass
from datetime import date, datetime
from difflib import SequenceMatcher
from functools import lru_cache
from pathlib import Path
from typing import Any, Iterable
from openpyxl import Workbook, load_workbook
from sqlalchemy import text
from sqlalchemy.exc import OperationalError
WEHAGO_SOURCE_ROOT = Path(Path(__file__).resolve().parent.parent / "WEHAGO_DB")
WEHAGO_COMPARE_EXPORT_ROOT = Path(__file__).resolve().parent / "static" / "exports" / "wehago_compare"
VOUCHER_HEADERS = [
"결재상태",
"가전표번호",
"계정코드",
"계정명칭",
"차변공급가",
"차변부가세",
"대변공급가",
"대변부가세",
"발의부서코드",
"발의부서명칭",
"확정전표번호",
"지원부서코드",
"지원부서명칭",
"원가부서코드",
"원가부서명칭",
"적요1",
"적요2",
"거래처코드",
"거래처명칭",
"세무코드",
"증빙일자",
"전표종류",
"관리항목",
]
LEDGER_HEADERS = [
"일자",
"적요",
"거래처",
"차변",
"대변",
"잔액",
"전표번호",
"계정코드",
"계정명",
]
CONSOLIDATED_LEDGER_PREFIX_HEADERS = ["계정코드", "계정명", "원본파일", "원본시트", "원본행번호"]
STATUS_META = [
("matched", "Matched", "WEHAGO 기준으로 정상 매칭된 항목"),
("ledger_only", "Unmatched", "WEHAGO에는 있으나 ERP와 연결되지 않은 항목"),
("voucher_only", "ERP Unmatched", "ERP 기준으로 매치되지 않은 항목"),
("amount_mismatch", "Recheck", "전표번호는 같지만 금액이 달라 다시 확인이 필요한 항목"),
("voucher_matched", "WEHAGO", "WEHAGO 전표 기준으로 매칭된 결과"),
("erp_voucher_matched", "Voucher", "Hanmac ERP 전표 기준으로 매칭된 결과"),
("bridge_expense_review", "2단계 비교", "보통예금 출금을 미지급금 결제와 원 비용 전표까지 추적한 검토 후보"),
("voucher_unmatched", "WEHAGO Unmatched", "WEHAGO 전표 기준으로 매칭되지 않은 결과"),
("erp_voucher_unmatched", "Voucher Unmatched", "Hanmac ERP 전표 기준으로 매칭되지 않은 결과"),
("voucher_recheck", "WEHAGO Recheck", "WEHAGO 전표 기준으로 재검토가 필요한 결과"),
]
DETAIL_COLUMN_MAP = {
"matched": [
("fiscal_year", "연도"),
("ledger_date", "일자"),
("voucher_no", "전표번호"),
("draft_no", "가전표번호"),
("ledger_account_name", "계정"),
("voucher_account_name", "ERP 계정"),
("ledger_vendor", "거래처"),
("voucher_vendor", "ERP 거래처"),
("ledger_debit", "차변"),
("ledger_credit", "대변"),
("voucher_debit", "ERP 차변"),
("voucher_credit", "ERP 대변"),
("ledger_desc", "WEHAGO 적요"),
("voucher_desc", "ERP 적요"),
],
"bridge_expense_review": [
("fiscal_year", "연도"),
("ledger_date", "WEHAGO 일자"),
("voucher_no", "WEHAGO 전표"),
("ledger_account_name", "WEHAGO 계정"),
("ledger_credit", "WEHAGO 출금"),
("ledger_desc", "WEHAGO 적요"),
("payment_date", "ERP 지급일"),
("draft_no", "ERP 지급전표"),
("voucher_account_name", "ERP 지급계정"),
("voucher_debit", "ERP 미지급금 차변"),
("voucher_credit", "ERP 보통예금 대변"),
("inferred_expense_accounts", "추정 원 비용계정"),
("expense_source_vouchers", "원 비용전표"),
("expense_source_drafts", "원 비용 가전표"),
("voucher_desc", "ERP 지급 적요"),
("expense_source_desc", "원 비용 적요"),
("bridge_reason", "추적 근거"),
],
"ledger_only": [
("fiscal_year", "연도"),
("voucher_no", "전표번호"),
("ledger_date", "일자"),
("ledger_account_name", "WEHAGO 계정"),
("ledger_vendor", "WEHAGO 거래처"),
("ledger_debit", "차변"),
("ledger_credit", "대변"),
("ledger_desc", "적요"),
],
"amount_mismatch": [
("fiscal_year", "연도"),
("ledger_date", "일자"),
("voucher_no", "전표번호"),
("draft_no", "가전표번호"),
("ledger_account_name", "계정"),
("voucher_account_name", "ERP 계정"),
("ledger_vendor", "거래처"),
("voucher_vendor", "ERP 거래처"),
("ledger_debit", "차변"),
("ledger_credit", "대변"),
("voucher_debit", "ERP 차변"),
("voucher_credit", "ERP 대변"),
("substitution_hint", "대체 검토"),
("ledger_desc", "WEHAGO 적요"),
("voucher_desc", "ERP 적요"),
],
"voucher_only": [
("fiscal_year", "연도"),
("voucher_no", "전표번호"),
("proof_date", "증빙일자"),
("voucher_account_name", "ERP 계정"),
("voucher_vendor", "ERP 거래처"),
("voucher_debit", "차변"),
("voucher_credit", "대변"),
("voucher_desc", "적요"),
],
"voucher_matched": [
("fiscal_year", "연도"),
("ledger_date", "WEHAGO 일자"),
("proof_date", "ERP 증빙일자"),
("voucher_no", "전표번호"),
("draft_no", "가전표번호"),
("ledger_row_count", "WEHAGO 행수"),
("voucher_row_count", "ERP 행수"),
("ledger_debit", "WEHAGO 차변"),
("ledger_credit", "WEHAGO 대변"),
("voucher_debit", "ERP 차변"),
("voucher_credit", "ERP 대변"),
("ledger_accounts", "WEHAGO 계정"),
("voucher_accounts", "ERP 계정"),
("ledger_vendors", "WEHAGO 거래처"),
("voucher_vendors", "ERP 거래처"),
("review_reason", "검증근거"),
],
"voucher_unmatched": [
("fiscal_year", "연도"),
("status_label", "구분"),
("ledger_date", "WEHAGO 일자"),
("proof_date", "ERP 증빙일자"),
("voucher_no", "전표번호"),
("draft_no", "가전표번호"),
("ledger_row_count", "WEHAGO 행수"),
("voucher_row_count", "ERP 행수"),
("ledger_debit", "WEHAGO 차변"),
("ledger_credit", "WEHAGO 대변"),
("voucher_debit", "ERP 차변"),
("voucher_credit", "ERP 대변"),
("ledger_accounts", "WEHAGO 계정"),
("voucher_accounts", "ERP 계정"),
("ledger_vendors", "WEHAGO 거래처"),
("voucher_vendors", "ERP 거래처"),
("review_reason", "검토사유"),
],
"voucher_recheck": [
("fiscal_year", "연도"),
("status_label", "구분"),
("ledger_date", "WEHAGO 일자"),
("proof_date", "ERP 증빙일자"),
("voucher_no", "전표번호"),
("draft_no", "가전표번호"),
("ledger_row_count", "WEHAGO 행수"),
("voucher_row_count", "ERP 행수"),
("ledger_debit", "WEHAGO 차변"),
("ledger_credit", "WEHAGO 대변"),
("voucher_debit", "ERP 차변"),
("voucher_credit", "ERP 대변"),
("ledger_accounts", "WEHAGO 계정"),
("voucher_accounts", "ERP 계정"),
("ledger_vendors", "WEHAGO 거래처"),
("voucher_vendors", "ERP 거래처"),
("review_reason", "검토사유"),
],
"erp_voucher_unmatched": [
("fiscal_year", "연도"),
("status_label", "구분"),
("ledger_date", "WEHAGO 일자"),
("proof_date", "ERP 증빙일자"),
("voucher_no", "전표번호"),
("draft_no", "가전표번호"),
("ledger_row_count", "WEHAGO 행수"),
("voucher_row_count", "ERP 행수"),
("ledger_debit", "WEHAGO 차변"),
("ledger_credit", "WEHAGO 대변"),
("voucher_debit", "ERP 차변"),
("voucher_credit", "ERP 대변"),
("ledger_accounts", "WEHAGO 계정"),
("voucher_accounts", "ERP 계정"),
("ledger_vendors", "WEHAGO 거래처"),
("voucher_vendors", "ERP 거래처"),
("review_reason", "검토사유"),
],
"erp_voucher_matched": [
("fiscal_year", "연도"),
("ledger_date", "WEHAGO 일자"),
("proof_date", "ERP 증빙일자"),
("voucher_no", "전표번호"),
("draft_no", "가전표번호"),
("ledger_row_count", "WEHAGO 행수"),
("voucher_row_count", "ERP 행수"),
("ledger_debit", "WEHAGO 차변"),
("ledger_credit", "WEHAGO 대변"),
("voucher_debit", "ERP 차변"),
("voucher_credit", "ERP 대변"),
("ledger_accounts", "WEHAGO 계정"),
("voucher_accounts", "ERP 계정"),
("ledger_vendors", "WEHAGO 거래처"),
("voucher_vendors", "ERP 거래처"),
("review_reason", "검증근거"),
],
}
WEHAGO_COLUMNS = [
("ledger_date", "일자"),
("voucher_no", "전표번호"),
("account_code", "계정코드"),
("account_name", "계정명"),
("vendor_name", "거래처"),
("description", "적요"),
("debit", "차변"),
("credit", "대변"),
]
ERP_COLUMNS = [
("proof_date", "증빙일자"),
("confirmed_no", "확정전표번호"),
("draft_no", "가전표번호"),
("account_code", "계정코드"),
("account_name", "계정명"),
("vendor_name", "거래처"),
("desc1", "적요1"),
("desc2", "적요2"),
("debit_supply", "차변공급가"),
("credit_supply", "대변공급가"),
]
_DASHBOARD_CACHE: dict[str, dict[str, Any]] = {}
_DASHBOARD_CACHE_TTL_SEC = 20
_SUGGEST_CACHE: dict[str, dict[str, Any]] = {}
_SUGGEST_CACHE_TTL_SEC = 20
_STATUS_ROWS_CACHE: dict[str, dict[str, Any]] = {}
_STATUS_ROWS_CACHE_TTL_SEC = 300
_VOUCHER_SECTION_CACHE: dict[str, dict[str, Any]] = {}
_VOUCHER_SECTION_CACHE_TTL_SEC = 300
_VOUCHER_RECHECK_CACHE: dict[str, dict[str, Any]] = {}
_PAIR_RECOMMEND_CACHE: dict[str, dict[str, Any]] = {}
_PAIR_RECOMMEND_CACHE_TTL_SEC = 120
_STATUS_CACHE_WARMING: set[str] = set()
_STATUS_CACHE_WARMING_LOCK = threading.Lock()
_METRIC_COUNTS_WARMING: set[str] = set()
_METRIC_COUNTS_WARMING_LOCK = threading.Lock()
_COMPARE_SNAPSHOT_JOB_EVENT = threading.Event()
_COMPARE_SNAPSHOT_WORKER_LOCK = threading.Lock()
_COMPARE_SNAPSHOT_WORKER_STARTED = False
_COMPARE_BACKGROUND_JOBS_NORMALIZED = False
_COMPARE_BACKGROUND_JOBS_NORMALIZE_LOCK = threading.Lock()
_COMPARE_BACKGROUND_JOBS_NORMALIZED_AT = 0.0
_COMPARE_BACKGROUND_JOBS_NORMALIZE_TTL_SEC = 60.0
_WEHAGO_COMPARE_DB_READY = False
_WEHAGO_COMPARE_DB_LOCK = threading.Lock()
_STATUS_EXPORT_CACHE: dict[str, dict[str, Any]] = {}
_STATUS_EXPORT_CACHE_TTL_SEC = 300
_SNAPSHOT_STATUS_CACHE: dict[str, dict[str, Any]] = {}
_SNAPSHOT_STATUS_CACHE_TTL_SEC = 45.0
_COMPARE_SUMMARY_CACHE: dict[str, dict[str, Any]] = {}
_COMPARE_SUMMARY_CACHE_TTL_SEC = 20.0
COMPARE_BACKFILL_HINT_START_YEAR = 2016
COMPARE_ROLLING_UPDATE_START_YEAR = 2026
COMPARE_MONTHLY_EXPECTED_READY_DAY = 10
COMPARE_YEAR_REQUEUE_COOLDOWN_RECENT_SEC = 15 * 60
COMPARE_YEAR_REQUEUE_COOLDOWN_ROLLING_SEC = 60 * 60
COMPARE_YEAR_REQUEUE_COOLDOWN_BACKFILL_SEC = 6 * 60 * 60
COMPARE_JOB_STALE_RUNNING_SEC = 45 * 60
ENABLE_GENERATED_RECHECK_CANDIDATES = False
RECHECK_RESOLUTION_POLICY_VERSION = "recheck-flex-strong-conditions-v13-cross-year-and-match-validation"
METRIC_COUNT_CACHE_VERSION = "voucher-summary-v5"
_PAIR_RECOMMEND_PERSIST_TTL_SEC = 1800
_PAIR_RECOMMEND_JOB_EVENT = threading.Event()
_PAIR_RECOMMEND_WORKER_LOCK = threading.Lock()
_PAIR_RECOMMEND_WORKER_STARTED = False
PAIR_RECOMMEND_POLICY_VERSION = "date-window-8m-no-month-token-gate-v1"
PAIR_RECOMMEND_DATE_WINDOW_MONTHS = 8
BRIDGE_REVIEW_SETTINGS_VERSION = "bridge-review-settings-v1"
DEFAULT_BRIDGE_REVIEW_SETTINGS: dict[str, Any] = {
"enable_promotion": False,
"require_exact_amount": True,
"require_expense_nature": True,
"require_strong_text": True,
"ambiguous_handling": "recheck",
"preview_limit": 100,
}
VOUCHER_EXPORT_STATUS_KEYS = {"voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched"}
VOUCHER_EXPORT_LINE_COLUMNS: list[tuple[str, str]] = [
("fiscal_year", "연도"),
("ledger_date", "WEHAGO 일자"),
("voucher_no", "전표번호"),
("draft_no", "가전표번호"),
("ledger_account_name", "WEHAGO 계정"),
("voucher_account_name", "ERP 계정"),
("ledger_vendor", "WEHAGO 거래처"),
("voucher_vendor", "ERP 거래처"),
("ledger_debit", "WEHAGO 차변"),
("ledger_credit", "WEHAGO 대변"),
("voucher_debit", "ERP 차변"),
("voucher_credit", "ERP 대변"),
("ledger_desc", "WEHAGO 적요"),
("voucher_desc", "ERP 적요"),
]
EXPORT_ROW_CACHE_STATUS_KEYS = {"voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched", "voucher_recheck"}
@lru_cache(maxsize=8)
def _file_content_signature(path_text: str, mtime_ns: int, size: int) -> str:
path = Path(path_text)
try:
return hashlib.sha1(path.read_bytes()).hexdigest()
except Exception:
return f"{mtime_ns}:{size}"
def _current_logic_signature() -> str:
module_path = Path(__file__).resolve()
stat = module_path.stat()
file_sig = _file_content_signature(str(module_path), int(stat.st_mtime_ns), int(stat.st_size))
return "|".join(
[
METRIC_COUNT_CACHE_VERSION,
RECHECK_RESOLUTION_POLICY_VERSION,
PAIR_RECOMMEND_POLICY_VERSION,
"generated-recheck-on" if ENABLE_GENERATED_RECHECK_CANDIDATES else "generated-recheck-off",
file_sig,
]
)
def _normalize_bridge_review_settings(payload: Any) -> dict[str, Any]:
raw = payload if isinstance(payload, dict) else {}
ambiguous = clean(raw.get("ambiguous_handling")).lower()
if ambiguous not in {"recheck", "review_only"}:
ambiguous = clean(DEFAULT_BRIDGE_REVIEW_SETTINGS["ambiguous_handling"])
preview_limit = raw.get("preview_limit", DEFAULT_BRIDGE_REVIEW_SETTINGS["preview_limit"])
try:
preview_limit = int(preview_limit)
except (TypeError, ValueError):
preview_limit = int(DEFAULT_BRIDGE_REVIEW_SETTINGS["preview_limit"])
preview_limit = max(20, min(500, preview_limit))
return {
"enable_promotion": bool(raw.get("enable_promotion", DEFAULT_BRIDGE_REVIEW_SETTINGS["enable_promotion"])),
"require_exact_amount": bool(raw.get("require_exact_amount", DEFAULT_BRIDGE_REVIEW_SETTINGS["require_exact_amount"])),
"require_expense_nature": bool(raw.get("require_expense_nature", DEFAULT_BRIDGE_REVIEW_SETTINGS["require_expense_nature"])),
"require_strong_text": bool(raw.get("require_strong_text", DEFAULT_BRIDGE_REVIEW_SETTINGS["require_strong_text"])),
"ambiguous_handling": ambiguous,
"preview_limit": preview_limit,
}
def _bridge_review_settings_signature(settings: dict[str, Any]) -> str:
normalized = _normalize_bridge_review_settings(settings)
return hashlib.sha1(json.dumps(normalized, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
def _today_local_date() -> date:
return datetime.now().date()
def _is_after_monthly_expected_ready_day(today: date | None = None) -> bool:
resolved_today = today or _today_local_date()
return resolved_today.day >= COMPARE_MONTHLY_EXPECTED_READY_DAY
def _classify_compare_year_mode(year: int, today: date | None = None) -> str:
resolved_today = today or _today_local_date()
if year >= COMPARE_ROLLING_UPDATE_START_YEAR:
if year > resolved_today.year + 1:
return "future"
return "rolling"
if year >= COMPARE_BACKFILL_HINT_START_YEAR:
return "backfill"
return "legacy"
def _compare_year_requeue_cooldown_seconds(year: int, today: date | None = None) -> int:
resolved_today = today or _today_local_date()
mode = _classify_compare_year_mode(year, resolved_today)
if mode == "rolling":
if year == resolved_today.year and _is_after_monthly_expected_ready_day(resolved_today):
return COMPARE_YEAR_REQUEUE_COOLDOWN_RECENT_SEC
if year >= resolved_today.year - 1:
return COMPARE_YEAR_REQUEUE_COOLDOWN_ROLLING_SEC
return COMPARE_YEAR_REQUEUE_COOLDOWN_ROLLING_SEC
if mode == "backfill":
return COMPARE_YEAR_REQUEUE_COOLDOWN_BACKFILL_SEC
return COMPARE_YEAR_REQUEUE_COOLDOWN_ROLLING_SEC
def _compare_year_job_priority(year: int, today: date | None = None) -> int:
resolved_today = today or _today_local_date()
mode = _classify_compare_year_mode(year, resolved_today)
if mode == "rolling":
if year == resolved_today.year:
return 10 if _is_after_monthly_expected_ready_day(resolved_today) else 14
if year == resolved_today.year - 1:
return 12 if _is_after_monthly_expected_ready_day(resolved_today) else 16
if year == resolved_today.year + 1:
return 20
return 30 + abs(resolved_today.year - year)
if mode == "backfill":
return 90 + max(0, resolved_today.year - year)
return 140 + max(0, COMPARE_BACKFILL_HINT_START_YEAR - year)
def _compare_range_job_priority(start_year: int | None, end_year: int | None, today: date | None = None) -> int:
resolved_today = today or _today_local_date()
if start_year is None or end_year is None:
return 80
span = max(0, int(end_year) - int(start_year))
if start_year <= resolved_today.year <= end_year and end_year >= COMPARE_ROLLING_UPDATE_START_YEAR:
return 25 + min(span, 20)
if end_year >= COMPARE_ROLLING_UPDATE_START_YEAR:
return 35 + min(span, 20)
return 70 + min(span, 50)
def _parse_db_timestamp(value: Any) -> datetime | None:
text_value = clean(value)
if not text_value:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
try:
return datetime.strptime(text_value, fmt)
except ValueError:
continue
return None
def _fast_metric_counts_from_result_files(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int]:
counts = {"matched": 0, "ledger_only": 0, "voucher_only": 0, "amount_mismatch": 0}
if start_year is None or end_year is None:
return counts
for year in range(start_year, end_year + 1):
bundle = discover_compare_result_bundle(year)
if not bundle:
continue
ledger_wb = load_workbook(bundle["ledger_result"], read_only=True, data_only=True)
ledger_ws = ledger_wb.worksheets[0]
ledger_header = [clean(v) for v in next(ledger_ws.iter_rows(min_row=1, max_row=1, values_only=True))]
ledger_idx = {name: idx for idx, name in enumerate(ledger_header)}
matched_col = ledger_idx.get("matched_확정전표번호")
if matched_col is not None:
for row in ledger_ws.iter_rows(min_row=2, values_only=True):
matched_no = clean(row[matched_col]) if matched_col < len(row) else ""
if matched_no:
counts["matched"] += 1
else:
counts["ledger_only"] += 1
voucher_wb = load_workbook(bundle["voucher_result"], read_only=True, data_only=True)
voucher_ws = voucher_wb.worksheets[0]
voucher_header = [clean(v) for v in next(voucher_ws.iter_rows(min_row=1, max_row=1, values_only=True))]
voucher_idx = {name: idx for idx, name in enumerate(voucher_header)}
matched_col = voucher_idx.get("matched_전표번호")
reason_col = voucher_idx.get("matched_검증근거")
flag_col = voucher_idx.get("review_flag")
for row in voucher_ws.iter_rows(min_row=2, values_only=True):
matched_no = clean(row[matched_col]) if (matched_col is not None and matched_col < len(row)) else ""
if not matched_no:
counts["voucher_only"] += 1
continue
review_reason = clean(row[reason_col]) if (reason_col is not None and reason_col < len(row)) else ""
review_flag = row[flag_col] if (flag_col is not None and flag_col < len(row)) else ""
if _normalize_flag(review_flag) or ("REVIEW" in review_reason):
counts["amount_mismatch"] += 1
reviewed_count = int(
conn.execute(
text(
"""
SELECT COUNT(*)
FROM wehago_recheck_reviews
WHERE (:start_year IS NULL OR fiscal_year >= :start_year)
AND (:end_year IS NULL OR fiscal_year <= :end_year)
"""
),
{"start_year": start_year, "end_year": end_year},
).scalar_one()
)
pair_count = int(
conn.execute(
text(
"""
SELECT COUNT(*)
FROM wehago_manual_pair_matches
WHERE (:start_year IS NULL OR fiscal_year >= :start_year)
AND (:end_year IS NULL OR fiscal_year <= :end_year)
"""
),
{"start_year": start_year, "end_year": end_year},
).scalar_one()
)
counts["matched"] = max(0, counts["matched"] + reviewed_count + pair_count)
counts["amount_mismatch"] = max(0, counts["amount_mismatch"] - reviewed_count)
counts["ledger_only"] = max(0, counts["ledger_only"] - pair_count)
counts["voucher_only"] = max(0, counts["voucher_only"] - pair_count)
return counts
def _empty_metric_counts() -> dict[str, int]:
return {status_key: 0 for status_key, _, _ in STATUS_META}
def _apply_cached_adjacent_year_demotion_count_adjustment(
conn: Any,
counts: dict[str, int],
start_year: int | None,
end_year: int | None,
) -> dict[str, int]:
if start_year is None or end_year is None:
return counts
adjusted = {status_key: int(counts.get(status_key, 0) or 0) for status_key, _, _ in STATUS_META}
for year in range(int(start_year), int(end_year) + 1):
sections = _load_latest_year_resolved_sections_cache_any_signature(conn, year) or {}
matched_rows = list((sections.get("matched") or {}).get("rows") or [])
ledger_only_rows = list((sections.get("ledger_only") or {}).get("rows") or [])
if not matched_rows or not ledger_only_rows:
continue
residual_by_group: dict[tuple[int, str, str], list[dict[str, Any]]] = {}
for row in ledger_only_rows:
key = (int(row.get("fiscal_year") or 0), clean(row.get("voucher_no")), clean(row.get("ledger_date")))
residual_by_group.setdefault(key, []).append(row)
demote_groups: set[tuple[int, str, str]] = set()
demote_line_count = 0
important_families = {"vat_input", "vat_output", "payable", "receivable"}
for row in matched_rows:
if clean(row.get("matched_case")) not in {"previous_year_erp", "next_year_erp"}:
continue
key = (int(row.get("fiscal_year") or 0), clean(row.get("voucher_no")), clean(row.get("ledger_date")))
residual_rows = residual_by_group.get(key, [])
if not residual_rows:
continue
residual_families = {
_classify_account_family(item.get("ledger_account_code"), item.get("ledger_account_name"))
for item in residual_rows
}
residual_families.discard("")
if residual_families & important_families:
demote_groups.add(key)
demote_line_count += 1
if not demote_groups:
continue
adjusted["matched"] = max(0, adjusted.get("matched", 0) - demote_line_count)
adjusted["amount_mismatch"] = adjusted.get("amount_mismatch", 0) + demote_line_count
adjusted["voucher_matched"] = max(0, adjusted.get("voucher_matched", 0) - len(demote_groups))
adjusted["voucher_recheck"] = adjusted.get("voucher_recheck", 0) + len(demote_groups)
return adjusted
def _empty_status_rows_by_status() -> dict[str, list[dict[str, Any]]]:
return {
"matched": [],
"bridge_expense_review": [],
"ledger_only": [],
"amount_mismatch": [],
"voucher_only": [],
}
def _count_bridge_expense_review_candidates(conn: Any, start_year: int | None, end_year: int | None) -> int:
if start_year is None or end_year is None:
return 0
return int(
conn.execute(
text(f"SELECT COUNT(*) FROM ({_bridge_expense_candidates_sql()}) b"),
{"start_year": start_year, "end_year": end_year},
).scalar_one()
or 0
)
def _metric_counts_signature(conn: Any, start_year: int | None, end_year: int | None) -> str:
return "|".join(
[
_current_logic_signature(),
_build_bundle_signature(start_year, end_year),
_build_db_state_signature(conn, start_year, end_year),
]
)
def _load_metric_counts_cache(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int] | None:
if start_year is None or end_year is None:
return _empty_metric_counts()
signature = _metric_counts_signature(conn, start_year, end_year)
cached = conn.execute(
text(
"""
SELECT counts_json
FROM wehago_metric_count_cache
WHERE start_year = :start_year
AND end_year = :end_year
AND signature = :signature
LIMIT 1
"""
),
{"start_year": start_year, "end_year": end_year, "signature": signature},
).first()
if not cached or not cached[0]:
return None
try:
payload = json.loads(str(cached[0]))
if isinstance(payload, dict):
return {status_key: int(payload.get(status_key, 0) or 0) for status_key, _, _ in STATUS_META}
except Exception:
return None
return None
def _load_latest_metric_counts_cache_any_signature(
conn: Any,
start_year: int | None,
end_year: int | None,
) -> dict[str, int] | None:
if start_year is None or end_year is None:
return _empty_metric_counts()
cached = conn.execute(
text(
"""
SELECT counts_json
FROM wehago_metric_count_cache
WHERE start_year = :start_year
AND end_year = :end_year
ORDER BY updated_at DESC, created_at DESC
LIMIT 1
"""
),
{"start_year": start_year, "end_year": end_year},
).first()
if not cached or not cached[0]:
return None
try:
payload = json.loads(str(cached[0]))
if isinstance(payload, dict):
return {status_key: int(payload.get(status_key, 0) or 0) for status_key, _, _ in STATUS_META}
except Exception:
return None
return None
def _store_metric_counts_cache(
conn: Any,
start_year: int | None,
end_year: int | None,
counts: dict[str, int],
) -> None:
if start_year is None or end_year is None:
return
signature = _metric_counts_signature(conn, start_year, end_year)
conn.execute(
text(
"""
INSERT INTO wehago_metric_count_cache (
start_year, end_year, signature, counts_json, created_at, updated_at
) VALUES (
:start_year, :end_year, :signature, :counts_json, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
ON CONFLICT(start_year, end_year, signature) DO UPDATE SET
counts_json = excluded.counts_json,
updated_at = CURRENT_TIMESTAMP
"""
),
{
"start_year": start_year,
"end_year": end_year,
"signature": signature,
"counts_json": json.dumps(counts, ensure_ascii=False),
},
)
def _load_summary_range_cache(
conn: Any,
start_year: int | None,
end_year: int | None,
) -> dict[str, Any] | None:
if start_year is None or end_year is None:
return None
signature = _metric_counts_signature(conn, start_year, end_year)
cached = conn.execute(
text(
"""
SELECT payload_json
FROM wehago_summary_range_cache
WHERE start_year = :start_year
AND end_year = :end_year
AND signature = :signature
LIMIT 1
"""
),
{"start_year": start_year, "end_year": end_year, "signature": signature},
).first()
if not cached or not cached[0]:
return None
try:
payload = json.loads(str(cached[0]))
except Exception:
return None
return payload if isinstance(payload, dict) else None
def _load_latest_summary_range_cache_any_signature(
conn: Any,
start_year: int | None,
end_year: int | None,
) -> dict[str, Any] | None:
if start_year is None or end_year is None:
return None
cached = conn.execute(
text(
"""
SELECT payload_json
FROM wehago_summary_range_cache
WHERE start_year = :start_year
AND end_year = :end_year
ORDER BY updated_at DESC, created_at DESC
LIMIT 1
"""
),
{"start_year": start_year, "end_year": end_year},
).first()
if not cached or not cached[0]:
return None
try:
payload = json.loads(str(cached[0]))
except Exception:
return None
return payload if isinstance(payload, dict) else None
def _store_summary_range_cache(
conn: Any,
start_year: int | None,
end_year: int | None,
counts: dict[str, int],
snapshot_state: dict[str, list[int]],
) -> None:
if start_year is None or end_year is None:
return
signature = _metric_counts_signature(conn, start_year, end_year)
payload = {
"counts": {status_key: int(counts.get(status_key, 0) or 0) for status_key, _, _ in STATUS_META},
"snapshot_state": {
"ready": [int(year) for year in snapshot_state.get("ready", [])],
"stale": [int(year) for year in snapshot_state.get("stale", [])],
"missing": [int(year) for year in snapshot_state.get("missing", [])],
},
}
conn.execute(
text(
"""
INSERT INTO wehago_summary_range_cache (
start_year, end_year, signature, payload_json, created_at, updated_at
) VALUES (
:start_year, :end_year, :signature, :payload_json, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
ON CONFLICT(start_year, end_year, signature) DO UPDATE SET
payload_json = excluded.payload_json,
updated_at = CURRENT_TIMESTAMP
"""
),
{
"start_year": start_year,
"end_year": end_year,
"signature": signature,
"payload_json": json.dumps(payload, ensure_ascii=False),
},
)
def _fallback_metric_counts_from_db(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int]:
year_sql = build_year_filter_sql("c.fiscal_year")
return {
"matched": int(
conn.execute(
text(
"""
SELECT COUNT(*)
FROM wehago_ledger_rows l
JOIN wehago_comparison_results c
ON c.fiscal_year = l.fiscal_year AND c.voucher_no = l.compare_voucher_no
WHERE c.status = 'matched'
AND """
+ year_sql
),
{"start_year": start_year, "end_year": end_year},
).scalar_one()
or 0
),
"ledger_only": int(
conn.execute(
text(
"""
SELECT COUNT(*)
FROM wehago_ledger_rows l
JOIN wehago_comparison_results c
ON c.fiscal_year = l.fiscal_year AND c.voucher_no = l.compare_voucher_no
WHERE c.status = 'ledger_only'
AND """
+ year_sql
),
{"start_year": start_year, "end_year": end_year},
).scalar_one()
or 0
),
"amount_mismatch": int(
conn.execute(
text(
"""
SELECT COUNT(*)
FROM wehago_ledger_rows l
JOIN wehago_comparison_results c
ON c.fiscal_year = l.fiscal_year AND c.voucher_no = l.compare_voucher_no
WHERE c.status = 'amount_mismatch'
AND """
+ year_sql
),
{"start_year": start_year, "end_year": end_year},
).scalar_one()
or 0
),
"voucher_only": int(
conn.execute(
text(
"""
SELECT COUNT(*)
FROM wehago_voucher_rows v
JOIN wehago_comparison_results c
ON c.fiscal_year = v.fiscal_year AND c.voucher_no = v.compare_voucher_no
WHERE c.status = 'voucher_only'
AND """
+ year_sql
),
{"start_year": start_year, "end_year": end_year},
).scalar_one()
or 0
),
}
def _voucher_metric_counts_from_db(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int]:
year_sql = build_year_filter_sql("fiscal_year")
matched_count = int(
conn.execute(
text(
"""
SELECT COUNT(*)
FROM wehago_comparison_results
WHERE status = 'matched'
AND """
+ year_sql
),
{"start_year": start_year, "end_year": end_year},
).scalar_one()
or 0
)
unmatched_count = int(
conn.execute(
text(
"""
SELECT COUNT(*)
FROM wehago_comparison_results
WHERE status = 'ledger_only'
AND """
+ year_sql
),
{"start_year": start_year, "end_year": end_year},
).scalar_one()
or 0
)
erp_unmatched_count = int(
conn.execute(
text(
"""
SELECT COUNT(*)
FROM wehago_comparison_results
WHERE status = 'voucher_only'
AND """
+ year_sql
),
{"start_year": start_year, "end_year": end_year},
).scalar_one()
or 0
)
recheck_count = int(
conn.execute(
text(
"""
SELECT COUNT(*)
FROM wehago_comparison_results
WHERE status = 'amount_mismatch'
AND """
+ year_sql
),
{"start_year": start_year, "end_year": end_year},
).scalar_one()
or 0
)
return {
"voucher_matched": matched_count,
"erp_voucher_matched": matched_count,
"voucher_unmatched": unmatched_count,
"erp_voucher_unmatched": erp_unmatched_count,
"voucher_recheck": recheck_count,
}
def _resolved_metric_counts(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int]:
counts = _empty_metric_counts()
if start_year is None or end_year is None:
return counts
if start_year != end_year:
rows_by_status: dict[str, list[dict[str, Any]]] = {
"matched": [],
"ledger_only": [],
"amount_mismatch": [],
"voucher_only": [],
}
for year in range(start_year, end_year + 1):
sections = _get_or_create_year_resolved_sections(conn, year)
if not sections:
db_counts = _fallback_metric_counts_from_db(conn, year, year)
for status_key, value in db_counts.items():
counts[status_key] += value
continue
for status_key in rows_by_status:
rows_by_status[status_key].extend(sections.get(status_key, {}).get("rows", []))
section_payload = {
status_key: {
"rows": list(rows),
"count": len(rows),
"columns": DETAIL_COLUMN_MAP[status_key],
}
for status_key, rows in rows_by_status.items()
}
section_payload = _promote_cross_year_auto_matches(section_payload)
for status_key in counts:
counts[status_key] = len(section_payload.get(status_key, {}).get("rows", []))
voucher_sections = _build_voucher_sections_from_rows_by_status(
{
"matched": list(section_payload.get("matched", {}).get("rows", [])),
"ledger_only": list(section_payload.get("ledger_only", {}).get("rows", [])),
"amount_mismatch": list(section_payload.get("amount_mismatch", {}).get("rows", [])),
"voucher_only": list(section_payload.get("voucher_only", {}).get("rows", [])),
}
)
voucher_sections = _apply_bridge_expense_promotions_to_voucher_sections(conn, start_year, end_year, voucher_sections)
counts["voucher_matched"] = len(voucher_sections.get("voucher_matched", []))
counts["erp_voucher_matched"] = len(voucher_sections.get("erp_voucher_matched", []))
counts["voucher_unmatched"] = len(voucher_sections.get("voucher_unmatched", []))
counts["erp_voucher_unmatched"] = len(voucher_sections.get("erp_voucher_unmatched", []))
counts["voucher_recheck"] = len(voucher_sections.get("voucher_recheck", []))
if not counts["voucher_matched"] and not counts["erp_voucher_matched"] and not counts["voucher_unmatched"] and not counts["erp_voucher_unmatched"] and not counts["voucher_recheck"] and any(counts.get(key) for key in ("matched", "ledger_only", "voucher_only", "amount_mismatch")):
counts.update(_voucher_metric_counts_from_db(conn, start_year, end_year))
return counts
rows_by_status: dict[str, list[dict[str, Any]]] = {
"matched": [],
"ledger_only": [],
"amount_mismatch": [],
"voucher_only": [],
}
for year in range(start_year, end_year + 1):
sections = _get_or_create_year_resolved_sections(conn, year)
if not sections:
db_counts = _fallback_metric_counts_from_db(conn, year, year)
for status_key, value in db_counts.items():
counts[status_key] += value
continue
for status_key in ("matched", "ledger_only", "amount_mismatch", "voucher_only"):
counts[status_key] += len(sections.get(status_key, {}).get("rows", []))
rows_by_status[status_key].extend(sections.get(status_key, {}).get("rows", []))
voucher_sections = _build_voucher_sections_from_rows_by_status(rows_by_status)
voucher_sections = _apply_bridge_expense_promotions_to_voucher_sections(conn, start_year, end_year, voucher_sections)
counts["voucher_matched"] = len(voucher_sections.get("voucher_matched", []))
counts["erp_voucher_matched"] = len(voucher_sections.get("erp_voucher_matched", []))
counts["voucher_unmatched"] = len(voucher_sections.get("voucher_unmatched", []))
counts["erp_voucher_unmatched"] = len(voucher_sections.get("erp_voucher_unmatched", []))
counts["voucher_recheck"] = len(voucher_sections.get("voucher_recheck", []))
if not counts["voucher_matched"] and not counts["erp_voucher_matched"] and not counts["voucher_unmatched"] and not counts["erp_voucher_unmatched"] and not counts["voucher_recheck"] and any(counts.get(key) for key in ("matched", "ledger_only", "voucher_only", "amount_mismatch")):
counts.update(_voucher_metric_counts_from_db(conn, start_year, end_year))
return counts
def get_dashboard_metric_counts(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int]:
cached = _load_metric_counts_cache(conn, start_year, end_year)
if cached is not None:
return cached
counts = _resolved_metric_counts(conn, start_year, end_year)
counts["bridge_expense_review"] = _count_bridge_expense_review_candidates(conn, start_year, end_year)
_store_metric_counts_cache(conn, start_year, end_year, counts)
return counts
def _merge_bridge_expense_count(
conn: Any,
counts: dict[str, int],
start_year: int | None,
end_year: int | None,
) -> dict[str, int]:
merged = {status_key: int(counts.get(status_key, 0) or 0) for status_key, _, _ in STATUS_META}
if "bridge_expense_review" not in counts:
merged["bridge_expense_review"] = _count_bridge_expense_review_candidates(conn, start_year, end_year)
return merged
def _rebuild_metric_count_caches(
conn: Any,
start_year: int | None,
end_year: int | None,
) -> tuple[dict[str, int], dict[str, list[int]]]:
counts = _resolved_metric_counts(conn, start_year, end_year)
counts["bridge_expense_review"] = _count_bridge_expense_review_candidates(conn, start_year, end_year)
counts = _apply_cached_adjacent_year_demotion_count_adjustment(conn, counts, start_year, end_year)
snapshot_state = _get_compare_snapshot_state(conn, start_year, end_year)
_store_metric_counts_cache(conn, start_year, end_year, counts)
_store_summary_range_cache(conn, start_year, end_year, counts, snapshot_state)
return counts, snapshot_state
def _restore_metric_counts_from_cached_year_payloads(
conn: Any,
start_year: int | None,
end_year: int | None,
) -> dict[str, int] | None:
if start_year is None or end_year is None:
return None
counts = _empty_metric_counts()
rows_by_status: dict[str, list[dict[str, Any]]] = {
"matched": [],
"ledger_only": [],
"amount_mismatch": [],
"voucher_only": [],
}
for year in range(int(start_year), int(end_year) + 1):
signature = _build_db_state_signature(conn, year, year)
sections = _load_year_resolved_sections_cache(conn, year, signature)
if sections is None:
sections = _load_latest_year_resolved_sections_cache_any_signature(conn, year)
if sections is None:
return None
for status_key in rows_by_status:
rows = list((sections.get(status_key) or {}).get("rows", []))
counts[status_key] += len(rows)
status_row = _load_snapshot_status_map(conn, [year]).get(year) or {}
snapshot_signature = clean(status_row.get("snapshot_signature")) or signature
grouped = conn.execute(
text(
"""
SELECT status_key, COUNT(DISTINCT group_sort)
FROM wehago_compare_export_row_cache
WHERE fiscal_year = :fiscal_year
AND snapshot_signature = :snapshot_signature
AND status_key IN ('voucher_matched', 'erp_voucher_matched', 'voucher_unmatched', 'erp_voucher_unmatched', 'voucher_recheck')
GROUP BY status_key
"""
),
{"fiscal_year": year, "snapshot_signature": snapshot_signature},
).fetchall()
if not grouped:
grouped = conn.execute(
text(
"""
SELECT status_key, COUNT(DISTINCT group_sort)
FROM wehago_compare_export_row_cache
WHERE fiscal_year = :fiscal_year
AND status_key IN ('voucher_matched', 'erp_voucher_matched', 'voucher_unmatched', 'erp_voucher_unmatched', 'voucher_recheck')
GROUP BY status_key
"""
),
{"fiscal_year": year},
).fetchall()
local_counts = {
"voucher_matched": 0,
"erp_voucher_matched": 0,
"voucher_unmatched": 0,
"erp_voucher_unmatched": 0,
"voucher_recheck": 0,
}
for status_key, value in grouped:
if status_key in local_counts:
local_counts[status_key] = int(value or 0)
for status_key, value in local_counts.items():
counts[status_key] += value
counts["bridge_expense_review"] = _count_bridge_expense_review_candidates(conn, start_year, end_year)
counts = _apply_cached_adjacent_year_demotion_count_adjustment(conn, counts, start_year, end_year)
return counts
def get_dashboard_metric_counts_nonblocking(
engine: Any,
start_year: int | None,
end_year: int | None,
) -> tuple[dict[str, int], bool]:
init_wehago_compare_db(engine)
pending = False
years_to_enqueue: list[int] = []
with engine.begin() as conn:
cached_summary = _load_summary_range_cache(conn, start_year, end_year)
if cached_summary:
counts = cached_summary.get("counts")
if isinstance(counts, dict):
return counts, False
cached = _load_metric_counts_cache(conn, start_year, end_year)
if cached is not None:
return cached, False
snapshot_state = _get_compare_snapshot_state(conn, start_year, end_year)
pending = bool(
(snapshot_state or {}).get("missing")
or (snapshot_state or {}).get("stale")
or (snapshot_state or {}).get("queued")
or (snapshot_state or {}).get("running")
)
fallback_summary = _load_latest_summary_range_cache_any_signature(conn, start_year, end_year)
fallback_counts = None
if fallback_summary and isinstance(fallback_summary.get("counts"), dict):
fallback_counts = {
status_key: int(fallback_summary["counts"].get(status_key, 0) or 0)
for status_key, _, _ in STATUS_META
}
fallback = fallback_counts or _load_latest_metric_counts_cache_any_signature(conn, start_year, end_year)
if fallback is None and start_year is not None and end_year is not None:
counts, snapshot_state = _rebuild_metric_count_caches(conn, start_year, end_year)
pending = bool(
(snapshot_state or {}).get("missing")
or (snapshot_state or {}).get("stale")
or (snapshot_state or {}).get("queued")
or (snapshot_state or {}).get("running")
)
return counts, pending
# If older fallback caches claim ERP voucher-unmatched is zero while raw voucher-only
# rows clearly exist, refresh only the voucher-group counts from cached status rows so
# the dashboard card and grouped detail table stay consistent without a heavy rebuild.
if (
fallback
and int(fallback.get("voucher_only", 0) or 0) > 0
and int(fallback.get("erp_voucher_unmatched", 0) or 0) == 0
and start_year is not None
and end_year is not None
):
rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year)
voucher_sections = _get_cached_voucher_sections_by_range(engine, start_year, end_year, rows_by_status)
voucher_sections = _apply_bridge_expense_promotions_to_voucher_sections(
conn,
start_year,
end_year,
voucher_sections,
)
refreshed = dict(fallback)
refreshed["voucher_matched"] = len(voucher_sections.get("voucher_matched", []))
refreshed["erp_voucher_matched"] = len(voucher_sections.get("erp_voucher_matched", []))
refreshed["voucher_unmatched"] = len(voucher_sections.get("voucher_unmatched", []))
refreshed["erp_voucher_unmatched"] = len(voucher_sections.get("erp_voucher_unmatched", []))
refreshed["voucher_recheck"] = len(voucher_sections.get("voucher_recheck", []))
refreshed = _merge_bridge_expense_count(conn, refreshed, start_year, end_year)
_store_metric_counts_cache(conn, start_year, end_year, refreshed)
_store_summary_range_cache(conn, start_year, end_year, refreshed, snapshot_state or {"ready": [], "stale": [], "missing": []})
return refreshed, pending
if fallback is None:
fallback = _empty_metric_counts()
fallback = _merge_bridge_expense_count(conn, fallback, start_year, end_year)
years_to_enqueue = [
int(year)
for year in ((snapshot_state or {}).get("missing") or [])
+ ((snapshot_state or {}).get("stale") or [])
]
try:
if years_to_enqueue:
enqueue_year_snapshot_rebuild(engine, years_to_enqueue)
enqueue_metric_count_rebuild(engine, start_year, end_year)
except Exception:
pending = True
return fallback or _empty_metric_counts(), pending
def _warm_metric_counts_worker(engine: Any, start_year: int, end_year: int, warm_key: str) -> None:
try:
init_wehago_compare_db(engine)
with engine.begin() as conn:
_rebuild_metric_count_caches(conn, start_year, end_year)
finally:
with _METRIC_COUNTS_WARMING_LOCK:
_METRIC_COUNTS_WARMING.discard(warm_key)
def warm_metric_counts_async(engine: Any, start_year: int | None, end_year: int | None) -> None:
if start_year is None or end_year is None:
return
if start_year > end_year:
start_year, end_year = end_year, start_year
init_wehago_compare_db(engine)
with engine.begin() as conn:
signature = _metric_counts_signature(conn, start_year, end_year)
if _load_metric_counts_cache(conn, start_year, end_year) is not None:
return
warm_key = f"{start_year}:{end_year}:{signature}"
with _METRIC_COUNTS_WARMING_LOCK:
if warm_key in _METRIC_COUNTS_WARMING:
return
_METRIC_COUNTS_WARMING.add(warm_key)
try:
enqueue_metric_count_rebuild(engine, start_year, end_year)
finally:
with _METRIC_COUNTS_WARMING_LOCK:
_METRIC_COUNTS_WARMING.discard(warm_key)
@dataclass
class WehagoImportSummary:
scanned_files: int = 0
imported_files: int = 0
skipped_files: int = 0
voucher_rows: int = 0
ledger_rows: int = 0
comparison_rows: int = 0
@dataclass(slots=True)
class MatchRowFeatures:
row: dict[str, Any]
row_key: str
account_code: str
account_name: str
vendor_name: str
desc_text: str
account_tokens: set[str]
vendor_tokens: set[str]
desc_tokens: set[str]
month_tokens: set[int]
date_value: date | None
debit_amount: float
credit_amount: float
match_amount: float
primary_side: str
positive_amounts: tuple[float, ...]
tax_context_months: set[int]
tax_context_dates: set[str]
vat_sensitive: bool
def clean(value: Any) -> str:
return "" if value is None else str(value).strip()
def normalize_text(value: Any) -> str:
text_value = clean(value).lower()
text_value = re.sub(r"\s+", "", text_value)
return re.sub(r"[\(\)\[\]\{\},._\-/\\:;*×%◇]", "", text_value)
def normalize_voucher_no(value: Any) -> str:
return clean(value).replace(" ", "")
def _format_compare_date_key(value: Any) -> str:
parsed = parse_excel_date(value)
if not parsed:
return ""
return parsed.replace("-", "")
def normalize_compare_voucher_no(value: Any, date_value: Any = None) -> str:
voucher_no = normalize_voucher_no(value)
if not voucher_no:
return ""
full_match = re.match(r"^11-(\d{8})-(\d+)-\d+$", voucher_no)
if full_match:
return f"{full_match.group(1)}-{int(full_match.group(2)):05d}"
draft_match = re.match(r"^11-(\d{8})-[^-]+-(\d+)-\d+$", voucher_no)
if draft_match:
return f"{draft_match.group(1)}-{int(draft_match.group(2)):05d}"
if re.fullmatch(r"\d+", voucher_no):
date_key = _format_compare_date_key(date_value)
if date_key:
return f"{date_key}-{int(voucher_no):05d}"
return voucher_no
def _voucher_no_year(value: Any) -> int | None:
voucher_no = normalize_voucher_no(value)
match = re.match(r"^11-((?:19|20)\d{2})\d{4}-", voucher_no)
if match:
return int(match.group(1))
return None
def choose_effective_erp_voucher_no(confirmed_no: Any, draft_no: Any, year_hint: int | None = None) -> str:
confirmed = normalize_voucher_no(confirmed_no)
draft = normalize_voucher_no(draft_no)
if year_hint:
confirmed_year = _voucher_no_year(confirmed)
draft_year = _voucher_no_year(draft)
if confirmed and confirmed_year == year_hint:
return confirmed
if draft and draft_year == year_hint:
return draft
return confirmed or draft
def infer_voucher_fiscal_year(
confirmed_no: Any,
draft_no: Any,
proof_date: Any,
year_hint: int | None,
) -> int | None:
effective_no = choose_effective_erp_voucher_no(confirmed_no, draft_no, year_hint)
return _voucher_no_year(effective_no) or year_from_date_text(proof_date) or year_hint
def parse_amount(value: Any) -> float:
text_value = clean(value).replace(",", "")
if not text_value:
return 0.0
try:
return float(text_value)
except ValueError:
return 0.0
def format_amount(value: Any) -> str:
amount = parse_amount(value)
if abs(amount - round(amount)) < 0.000001:
return f"{int(round(amount)):,}"
return f"{amount:,.2f}"
def parse_excel_date(value: Any, default_year: int | None = None) -> str | None:
if value is None or value == "":
return None
if isinstance(value, datetime):
return value.date().isoformat()
if isinstance(value, date):
return value.isoformat()
text_value = clean(value).replace(".0", "")
for pattern in (r"^(\d{4})(\d{2})(\d{2})$", r"^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$"):
match = re.match(pattern, text_value)
if match:
return f"{int(match.group(1)):04d}-{int(match.group(2)):02d}-{int(match.group(3)):02d}"
match = re.match(r"^(\d{1,2})[-/.](\d{1,2})$", text_value)
if match and default_year:
return f"{default_year:04d}-{int(match.group(1)):02d}-{int(match.group(2)):02d}"
return None
def year_from_date_text(value: str | None) -> int | None:
if not value:
return None
match = re.match(r"^(\d{4})-", value)
return int(match.group(1)) if match else None
def compute_file_hash(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def detect_file_kind(path: Path) -> tuple[str | None, list[str], str]:
workbook = load_workbook(path, read_only=True, data_only=True)
sheet = workbook.worksheets[0]
header = [clean(item) for item in next(sheet.iter_rows(min_row=1, max_row=1, values_only=True))]
if header[: len(VOUCHER_HEADERS)] == VOUCHER_HEADERS:
return "voucher", header, sheet.title
if header[: len(LEDGER_HEADERS)] == LEDGER_HEADERS:
return "ledger", header, sheet.title
if (
header[: len(CONSOLIDATED_LEDGER_PREFIX_HEADERS)] == CONSOLIDATED_LEDGER_PREFIX_HEADERS
and header[len(CONSOLIDATED_LEDGER_PREFIX_HEADERS) : len(CONSOLIDATED_LEDGER_PREFIX_HEADERS) + len(LEDGER_HEADERS)]
== LEDGER_HEADERS
):
return "ledger", header, sheet.title
return None, header, sheet.title
def iter_voucher_files(source_root: Path) -> Iterable[Path]:
for pattern in ("*.xlsx", "*.xlsm"):
for path in sorted(source_root.glob(pattern)):
if path.name.startswith("~$"):
continue
yield path
def iter_ledger_files(source_root: Path) -> Iterable[Path]:
data_download_dir = source_root / "data_download"
if not data_download_dir.exists():
return
for year_dir in sorted(
path for path in data_download_dir.iterdir() if path.is_dir() and path.name.isdigit() and len(path.name) == 4
):
for pattern in ("*.xlsx", "*.xlsm"):
for path in sorted(year_dir.glob(pattern)):
if path.name.startswith("~$"):
continue
yield path
def iter_source_files(source_root: Path) -> Iterable[Path]:
seen: set[Path] = set()
for path in iter_voucher_files(source_root):
if path not in seen:
seen.add(path)
yield path
for path in iter_ledger_files(source_root):
if path not in seen:
seen.add(path)
yield path
def infer_year_hint(path: Path, file_kind: str, sample_rows: list[tuple[Any, ...]]) -> int | None:
for part in reversed(path.parts):
if part.isdigit() and len(part) == 4:
return int(part)
match = re.search(r"(19|20)\d{2}", path.name)
if match:
return int(match.group(0))
if file_kind == "voucher":
for row in sample_rows[:50]:
proof_date = parse_excel_date(row[20] if len(row) > 20 else None)
year_value = year_from_date_text(proof_date)
if year_value:
return year_value
for row in sample_rows[:50]:
voucher_no = normalize_compare_voucher_no(row[10] if len(row) > 10 else row[1] if len(row) > 1 else "")
match = re.search(r"-(\d{4})\d{4}-", voucher_no)
if match:
return int(match.group(1))
if file_kind == "ledger":
for row in sample_rows[:50]:
date_text = parse_excel_date(row[0] if row else None)
year_value = year_from_date_text(date_text)
if year_value:
return year_value
return None
def table_columns(conn: Any, table_name: str) -> set[str]:
return {row[1] for row in conn.execute(text(f"PRAGMA table_info({table_name})")).fetchall()}
def ensure_column(conn: Any, table_name: str, column_name: str, column_sql: str) -> None:
if column_name in table_columns(conn, table_name):
return
conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_sql}"))
def init_wehago_compare_db(engine: Any) -> None:
global _WEHAGO_COMPARE_DB_READY
if _WEHAGO_COMPARE_DB_READY:
return
with _WEHAGO_COMPARE_DB_LOCK:
if _WEHAGO_COMPARE_DB_READY:
return
with engine.begin() as conn:
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS wehago_source_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_kind TEXT NOT NULL CHECK (file_kind IN ('voucher', 'ledger')),
file_path TEXT NOT NULL UNIQUE,
file_name TEXT NOT NULL,
relative_path TEXT NOT NULL,
year_hint INTEGER,
sheet_name TEXT,
file_size INTEGER NOT NULL,
modified_ts REAL NOT NULL,
file_hash TEXT NOT NULL,
header_json TEXT NOT NULL,
row_count INTEGER NOT NULL DEFAULT 0,
imported_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS wehago_voucher_rows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_file_id INTEGER NOT NULL REFERENCES wehago_source_files(id) ON DELETE CASCADE,
sheet_name TEXT NOT NULL,
row_number INTEGER NOT NULL,
approval_status TEXT,
draft_no TEXT,
confirmed_no TEXT,
account_code TEXT,
account_name TEXT,
debit_supply REAL NOT NULL DEFAULT 0,
debit_tax REAL NOT NULL DEFAULT 0,
credit_supply REAL NOT NULL DEFAULT 0,
credit_tax REAL NOT NULL DEFAULT 0,
issue_dept_code TEXT,
issue_dept_name TEXT,
support_dept_code TEXT,
support_dept_name TEXT,
cost_dept_code TEXT,
cost_dept_name TEXT,
desc1 TEXT,
desc2 TEXT,
vendor_code TEXT,
vendor_name TEXT,
tax_code TEXT,
proof_date TEXT,
voucher_type TEXT,
management_item TEXT,
compare_voucher_no TEXT,
compare_amount REAL NOT NULL DEFAULT 0,
compare_side TEXT,
compare_vendor TEXT,
compare_desc TEXT,
fiscal_year INTEGER
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS wehago_ledger_rows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_file_id INTEGER NOT NULL REFERENCES wehago_source_files(id) ON DELETE CASCADE,
sheet_name TEXT NOT NULL,
row_number INTEGER NOT NULL,
ledger_date TEXT,
description TEXT,
vendor_name TEXT,
debit REAL NOT NULL DEFAULT 0,
credit REAL NOT NULL DEFAULT 0,
balance REAL NOT NULL DEFAULT 0,
voucher_no TEXT,
account_code TEXT,
account_name TEXT,
compare_voucher_no TEXT,
compare_amount REAL NOT NULL DEFAULT 0,
compare_side TEXT,
compare_vendor TEXT,
compare_desc TEXT,
fiscal_year INTEGER
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS wehago_comparison_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fiscal_year INTEGER,
voucher_no TEXT NOT NULL,
voucher_row_count INTEGER NOT NULL DEFAULT 0,
ledger_row_count INTEGER NOT NULL DEFAULT 0,
voucher_debit REAL NOT NULL DEFAULT 0,
voucher_credit REAL NOT NULL DEFAULT 0,
ledger_debit REAL NOT NULL DEFAULT 0,
ledger_credit REAL NOT NULL DEFAULT 0,
voucher_accounts TEXT NOT NULL DEFAULT '',
ledger_accounts TEXT NOT NULL DEFAULT '',
voucher_vendors TEXT NOT NULL DEFAULT '',
ledger_vendors TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
notes TEXT NOT NULL DEFAULT ''
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS wehago_recheck_reviews (
review_key TEXT PRIMARY KEY,
fiscal_year INTEGER,
voucher_no TEXT NOT NULL DEFAULT '',
draft_no TEXT NOT NULL DEFAULT '',
voucher_account_code TEXT NOT NULL DEFAULT '',
voucher_account_name TEXT NOT NULL DEFAULT '',
voucher_vendor TEXT NOT NULL DEFAULT '',
voucher_desc TEXT NOT NULL DEFAULT '',
review_reason TEXT NOT NULL DEFAULT '',
review_memo TEXT NOT NULL DEFAULT '',
reviewed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS wehago_manual_pair_matches (
pair_key TEXT PRIMARY KEY,
fiscal_year INTEGER,
ledger_row_key TEXT NOT NULL UNIQUE,
voucher_row_key TEXT NOT NULL UNIQUE,
ledger_voucher_no TEXT NOT NULL DEFAULT '',
ledger_account_code TEXT NOT NULL DEFAULT '',
ledger_account_name TEXT NOT NULL DEFAULT '',
ledger_vendor TEXT NOT NULL DEFAULT '',
ledger_debit REAL NOT NULL DEFAULT 0,
ledger_credit REAL NOT NULL DEFAULT 0,
ledger_desc TEXT NOT NULL DEFAULT '',
voucher_no TEXT NOT NULL DEFAULT '',
draft_no TEXT NOT NULL DEFAULT '',
voucher_account_code TEXT NOT NULL DEFAULT '',
voucher_account_name TEXT NOT NULL DEFAULT '',
voucher_vendor TEXT NOT NULL DEFAULT '',
voucher_debit REAL NOT NULL DEFAULT 0,
voucher_credit REAL NOT NULL DEFAULT 0,
voucher_desc TEXT NOT NULL DEFAULT '',
match_source TEXT NOT NULL DEFAULT 'manual',
confidence_score REAL NOT NULL DEFAULT 0,
confidence_level TEXT NOT NULL DEFAULT '',
match_reason TEXT NOT NULL DEFAULT '',
pair_note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS wehago_action_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action_type TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS wehago_result_row_cache (
fiscal_year INTEGER NOT NULL,
ledger_result_path TEXT NOT NULL,
ledger_result_mtime REAL NOT NULL,
voucher_result_path TEXT NOT NULL,
voucher_result_mtime REAL NOT NULL,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (
fiscal_year,
ledger_result_path,
ledger_result_mtime,
voucher_result_path,
voucher_result_mtime
)
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS wehago_pair_recommend_cache (
cache_key TEXT PRIMARY KEY,
start_year INTEGER,
end_year INTEGER,
ledger_voucher_no TEXT NOT NULL DEFAULT '',
ledger_review_reason TEXT NOT NULL DEFAULT '',
voucher_voucher_no TEXT NOT NULL DEFAULT '',
voucher_review_reason TEXT NOT NULL DEFAULT '',
row_limit INTEGER NOT NULL DEFAULT 300,
payload_json TEXT NOT NULL DEFAULT '',
pair_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_accessed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS wehago_metric_count_cache (
start_year INTEGER NOT NULL,
end_year INTEGER NOT NULL,
signature TEXT NOT NULL,
counts_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (start_year, end_year, signature)
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS wehago_background_jobs (
job_key TEXT PRIMARY KEY,
job_type TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
priority INTEGER NOT NULL DEFAULT 100,
state TEXT NOT NULL DEFAULT 'queued',
error_message TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at TEXT NOT NULL DEFAULT '',
finished_at TEXT NOT NULL DEFAULT ''
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS wehago_summary_range_cache (
start_year INTEGER NOT NULL,
end_year INTEGER NOT NULL,
signature TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (start_year, end_year, signature)
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS wehago_snapshot_status (
fiscal_year INTEGER PRIMARY KEY,
snapshot_signature TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT 'missing',
source_kind TEXT NOT NULL DEFAULT 'resolved',
row_counts_json TEXT NOT NULL DEFAULT '{}',
error_message TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_requested_at TEXT NOT NULL DEFAULT '',
last_built_at TEXT NOT NULL DEFAULT ''
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS wehago_compare_settings (
setting_key TEXT PRIMARY KEY,
setting_json TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS wehago_compare_export_jobs (
job_key TEXT PRIMARY KEY,
export_key TEXT NOT NULL,
status_key TEXT NOT NULL,
start_year INTEGER,
end_year INTEGER,
payload_json TEXT NOT NULL DEFAULT '{}',
file_name TEXT NOT NULL DEFAULT '',
file_path TEXT NOT NULL DEFAULT '',
row_count INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'queued',
error_message TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at TEXT NOT NULL DEFAULT '',
finished_at TEXT NOT NULL DEFAULT ''
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS wehago_compare_export_row_cache (
fiscal_year INTEGER NOT NULL,
status_key TEXT NOT NULL,
snapshot_signature TEXT NOT NULL,
group_sort INTEGER NOT NULL,
row_sort INTEGER NOT NULL,
group_voucher_no TEXT NOT NULL DEFAULT '',
group_draft_no TEXT NOT NULL DEFAULT '',
group_ledger_accounts TEXT NOT NULL DEFAULT '',
group_voucher_accounts TEXT NOT NULL DEFAULT '',
group_ledger_vendors TEXT NOT NULL DEFAULT '',
group_voucher_vendors TEXT NOT NULL DEFAULT '',
group_ledger_debit REAL NOT NULL DEFAULT 0,
group_ledger_credit REAL NOT NULL DEFAULT 0,
group_voucher_debit REAL NOT NULL DEFAULT 0,
group_voucher_credit REAL NOT NULL DEFAULT 0,
ledger_date TEXT NOT NULL DEFAULT '',
voucher_no TEXT NOT NULL DEFAULT '',
draft_no TEXT NOT NULL DEFAULT '',
ledger_account_name TEXT NOT NULL DEFAULT '',
voucher_account_name TEXT NOT NULL DEFAULT '',
ledger_vendor TEXT NOT NULL DEFAULT '',
voucher_vendor TEXT NOT NULL DEFAULT '',
ledger_debit REAL NOT NULL DEFAULT 0,
ledger_credit REAL NOT NULL DEFAULT 0,
voucher_debit REAL NOT NULL DEFAULT 0,
voucher_credit REAL NOT NULL DEFAULT 0,
ledger_desc TEXT NOT NULL DEFAULT '',
voucher_desc TEXT NOT NULL DEFAULT '',
PRIMARY KEY (fiscal_year, status_key, snapshot_signature, group_sort, row_sort)
)
"""
)
)
ensure_column(conn, "wehago_result_row_cache", "resolved_state_signature", "TEXT NOT NULL DEFAULT ''")
ensure_column(conn, "wehago_result_row_cache", "parse_state_signature", "TEXT NOT NULL DEFAULT ''")
ensure_column(conn, "wehago_result_row_cache", "resolved_payload_json", "TEXT NOT NULL DEFAULT ''")
ensure_column(conn, "wehago_result_row_cache", "resolved_created_at", "TEXT NOT NULL DEFAULT ''")
ensure_column(conn, "wehago_source_files", "source_origin", "TEXT NOT NULL DEFAULT 'filesystem'")
ensure_column(conn, "wehago_manual_pair_matches", "match_source", "TEXT NOT NULL DEFAULT 'manual'")
ensure_column(conn, "wehago_manual_pair_matches", "confidence_score", "REAL NOT NULL DEFAULT 0")
ensure_column(conn, "wehago_manual_pair_matches", "confidence_level", "TEXT NOT NULL DEFAULT ''")
ensure_column(conn, "wehago_manual_pair_matches", "match_reason", "TEXT NOT NULL DEFAULT ''")
ensure_column(conn, "wehago_background_jobs", "priority", "INTEGER NOT NULL DEFAULT 100")
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_voucher_source ON wehago_voucher_rows(source_file_id)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_voucher_year_vno ON wehago_voucher_rows(fiscal_year, compare_voucher_no)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_ledger_source ON wehago_ledger_rows(source_file_id)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_ledger_year_vno ON wehago_ledger_rows(fiscal_year, compare_voucher_no)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_compare_year_status ON wehago_comparison_results(fiscal_year, status)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_source_origin_kind ON wehago_source_files(source_origin, file_kind)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_recheck_reviews_year ON wehago_recheck_reviews(fiscal_year, reviewed_at)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_manual_pairs_year ON wehago_manual_pair_matches(fiscal_year, created_at)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_action_history_created ON wehago_action_history(created_at)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_pair_recommend_cache_updated ON wehago_pair_recommend_cache(updated_at)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_metric_count_cache_range ON wehago_metric_count_cache(start_year, end_year, updated_at)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_background_jobs_state_created ON wehago_background_jobs(state, priority, created_at)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_summary_range_cache_range ON wehago_summary_range_cache(start_year, end_year, updated_at)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_snapshot_status_state_updated ON wehago_snapshot_status(state, updated_at)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_compare_export_jobs_lookup ON wehago_compare_export_jobs(export_key, state, updated_at)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_compare_export_row_cache_lookup ON wehago_compare_export_row_cache(status_key, fiscal_year, snapshot_signature, group_sort, row_sort)"))
bridge_review_exists = conn.execute(
text(
"""
SELECT 1
FROM wehago_compare_settings
WHERE setting_key = 'bridge_review'
LIMIT 1
"""
)
).first()
if not bridge_review_exists:
try:
conn.execute(
text(
"""
INSERT INTO wehago_compare_settings (setting_key, setting_json, updated_at)
VALUES ('bridge_review', :setting_json, CURRENT_TIMESTAMP)
ON CONFLICT(setting_key) DO NOTHING
"""
),
{"setting_json": json.dumps(DEFAULT_BRIDGE_REVIEW_SETTINGS, ensure_ascii=False)},
)
except OperationalError:
pass
_try_normalize_compare_background_jobs(conn)
_WEHAGO_COMPARE_DB_READY = True
def upsert_source_file(
conn: Any,
path: Path,
file_kind: str,
year_hint: int | None,
sheet_name: str,
header: list[str],
) -> tuple[int, bool]:
row = conn.execute(
text(
"""
SELECT id, file_hash, modified_ts
FROM wehago_source_files
WHERE file_path = :file_path
"""
),
{"file_path": str(path)},
).mappings().first()
file_hash = compute_file_hash(path)
modified_ts = path.stat().st_mtime
payload = {
"file_kind": file_kind,
"file_path": str(path),
"file_name": path.name,
"relative_path": str(path.relative_to(WEHAGO_SOURCE_ROOT)),
"year_hint": year_hint,
"sheet_name": sheet_name,
"file_size": path.stat().st_size,
"modified_ts": modified_ts,
"file_hash": file_hash,
"header_json": str(header),
"source_origin": "filesystem",
}
if row is None:
result = conn.execute(
text(
"""
INSERT INTO wehago_source_files (
file_kind, file_path, file_name, relative_path, year_hint, sheet_name,
file_size, modified_ts, file_hash, header_json, source_origin
) VALUES (
:file_kind, :file_path, :file_name, :relative_path, :year_hint, :sheet_name,
:file_size, :modified_ts, :file_hash, :header_json, :source_origin
)
"""
),
payload,
)
return int(result.lastrowid), True
changed = row["file_hash"] != file_hash or float(row["modified_ts"]) != float(modified_ts)
conn.execute(
text(
"""
UPDATE wehago_source_files
SET file_kind = :file_kind,
file_name = :file_name,
relative_path = :relative_path,
year_hint = :year_hint,
sheet_name = :sheet_name,
file_size = :file_size,
modified_ts = :modified_ts,
file_hash = :file_hash,
header_json = :header_json,
source_origin = :source_origin,
imported_at = CURRENT_TIMESTAMP
WHERE id = :id
"""
),
{**payload, "id": row["id"]},
)
return int(row["id"]), changed
def build_voucher_signature(values: list[Any], year_hint: int | None) -> str:
proof_date = parse_excel_date(values[20] if len(values) > 20 else None)
draft_no = clean(values[1] if len(values) > 1 else "")
confirmed_no = clean(values[10] if len(values) > 10 else "")
fiscal_year = infer_voucher_fiscal_year(confirmed_no, draft_no, proof_date, year_hint) or ""
parts = [
clean(values[0] if len(values) > 0 else ""),
draft_no,
confirmed_no,
clean(values[2] if len(values) > 2 else ""),
clean(values[3] if len(values) > 3 else ""),
f"{parse_amount(values[4] if len(values) > 4 else 0):.2f}",
f"{parse_amount(values[5] if len(values) > 5 else 0):.2f}",
f"{parse_amount(values[6] if len(values) > 6 else 0):.2f}",
f"{parse_amount(values[7] if len(values) > 7 else 0):.2f}",
clean(values[8] if len(values) > 8 else ""),
clean(values[11] if len(values) > 11 else ""),
clean(values[13] if len(values) > 13 else ""),
clean(values[15] if len(values) > 15 else ""),
clean(values[16] if len(values) > 16 else ""),
clean(values[17] if len(values) > 17 else ""),
clean(values[18] if len(values) > 18 else ""),
clean(values[19] if len(values) > 19 else ""),
proof_date or "",
clean(values[21] if len(values) > 21 else ""),
clean(values[22] if len(values) > 22 else ""),
str(fiscal_year),
]
return hashlib.sha1("|".join(parts).encode("utf-8", "ignore")).hexdigest()
def build_voucher_signature_from_mapping(row: dict[str, Any]) -> str:
parts = [
clean(row.get("approval_status")),
clean(row.get("draft_no")),
clean(row.get("confirmed_no")),
clean(row.get("account_code")),
clean(row.get("account_name")),
f"{parse_amount(row.get('debit_supply')):.2f}",
f"{parse_amount(row.get('debit_tax')):.2f}",
f"{parse_amount(row.get('credit_supply')):.2f}",
f"{parse_amount(row.get('credit_tax')):.2f}",
clean(row.get("issue_dept_code")),
clean(row.get("support_dept_code")),
clean(row.get("cost_dept_code")),
clean(row.get("desc1")),
clean(row.get("desc2")),
clean(row.get("vendor_code")),
clean(row.get("vendor_name")),
clean(row.get("tax_code")),
clean(row.get("proof_date")),
clean(row.get("voucher_type")),
clean(row.get("management_item")),
clean(row.get("fiscal_year")),
]
return hashlib.sha1("|".join(parts).encode("utf-8", "ignore")).hexdigest()
def import_voucher_rows(conn: Any, source_id: int, sheet_name: str, rows: Iterable[tuple[Any, ...]], year_hint: int | None) -> int:
inserted = 0
insert_sql = text(
"""
INSERT INTO wehago_voucher_rows (
source_file_id, sheet_name, row_number, approval_status, draft_no, confirmed_no, account_code, account_name,
debit_supply, debit_tax, credit_supply, credit_tax, issue_dept_code, issue_dept_name, support_dept_code, support_dept_name,
cost_dept_code, cost_dept_name, desc1, desc2, vendor_code, vendor_name, tax_code, proof_date, voucher_type, management_item,
compare_voucher_no, compare_amount, compare_side, compare_vendor, compare_desc, fiscal_year
) VALUES (
:source_file_id, :sheet_name, :row_number, :approval_status, :draft_no, :confirmed_no, :account_code, :account_name,
:debit_supply, :debit_tax, :credit_supply, :credit_tax, :issue_dept_code, :issue_dept_name, :support_dept_code, :support_dept_name,
:cost_dept_code, :cost_dept_name, :desc1, :desc2, :vendor_code, :vendor_name, :tax_code, :proof_date, :voucher_type, :management_item,
:compare_voucher_no, :compare_amount, :compare_side, :compare_vendor, :compare_desc, :fiscal_year
)
"""
)
for row_number, row in enumerate(rows, start=2):
values = list(row)
if not any(item is not None and clean(item) for item in values):
continue
draft_no = clean(values[1] if len(values) > 1 else "")
confirmed_no = clean(values[10] if len(values) > 10 else "")
proof_date = parse_excel_date(values[20] if len(values) > 20 else None)
fiscal_year = infer_voucher_fiscal_year(confirmed_no, draft_no, proof_date, year_hint)
effective_no = choose_effective_erp_voucher_no(confirmed_no, draft_no, year_hint)
debit_supply = parse_amount(values[4] if len(values) > 4 else 0)
credit_supply = parse_amount(values[6] if len(values) > 6 else 0)
conn.execute(
insert_sql,
{
"source_file_id": source_id,
"sheet_name": sheet_name,
"row_number": row_number,
"approval_status": clean(values[0] if len(values) > 0 else ""),
"draft_no": draft_no,
"confirmed_no": confirmed_no,
"account_code": clean(values[2] if len(values) > 2 else ""),
"account_name": clean(values[3] if len(values) > 3 else ""),
"debit_supply": debit_supply,
"debit_tax": parse_amount(values[5] if len(values) > 5 else 0),
"credit_supply": credit_supply,
"credit_tax": parse_amount(values[7] if len(values) > 7 else 0),
"issue_dept_code": clean(values[8] if len(values) > 8 else ""),
"issue_dept_name": clean(values[9] if len(values) > 9 else ""),
"support_dept_code": clean(values[11] if len(values) > 11 else ""),
"support_dept_name": clean(values[12] if len(values) > 12 else ""),
"cost_dept_code": clean(values[13] if len(values) > 13 else ""),
"cost_dept_name": clean(values[14] if len(values) > 14 else ""),
"desc1": clean(values[15] if len(values) > 15 else ""),
"desc2": clean(values[16] if len(values) > 16 else ""),
"vendor_code": clean(values[17] if len(values) > 17 else ""),
"vendor_name": clean(values[18] if len(values) > 18 else ""),
"tax_code": clean(values[19] if len(values) > 19 else ""),
"proof_date": proof_date,
"voucher_type": clean(values[21] if len(values) > 21 else ""),
"management_item": clean(values[22] if len(values) > 22 else ""),
"compare_voucher_no": normalize_compare_voucher_no(effective_no, proof_date),
"compare_amount": debit_supply if debit_supply else credit_supply,
"compare_side": "debit" if debit_supply else ("credit" if credit_supply else ""),
"compare_vendor": normalize_text(values[18] if len(values) > 18 else ""),
"compare_desc": normalize_text(
" ".join(clean(values[index]) for index in (15, 16, 22) if index < len(values) and clean(values[index]))
),
"fiscal_year": fiscal_year,
},
)
inserted += 1
return inserted
def import_ledger_rows(conn: Any, source_id: int, sheet_name: str, rows: Iterable[tuple[Any, ...]], year_hint: int | None) -> int:
inserted = 0
insert_sql = text(
"""
INSERT INTO wehago_ledger_rows (
source_file_id, sheet_name, row_number, ledger_date, description, vendor_name, debit, credit, balance, voucher_no, account_code, account_name,
compare_voucher_no, compare_amount, compare_side, compare_vendor, compare_desc, fiscal_year
) VALUES (
:source_file_id, :sheet_name, :row_number, :ledger_date, :description, :vendor_name, :debit, :credit, :balance, :voucher_no, :account_code, :account_name,
:compare_voucher_no, :compare_amount, :compare_side, :compare_vendor, :compare_desc, :fiscal_year
)
"""
)
for row_number, row in enumerate(rows, start=2):
values = list(row)
if len(values) >= len(CONSOLIDATED_LEDGER_PREFIX_HEADERS) + len(LEDGER_HEADERS):
values = values[len(CONSOLIDATED_LEDGER_PREFIX_HEADERS) : len(CONSOLIDATED_LEDGER_PREFIX_HEADERS) + len(LEDGER_HEADERS)]
if not any(item is not None and clean(item) for item in values):
continue
normalized_values = {clean(item).replace(" ", "") for item in values}
if normalized_values & {"[월계]", "[누계]", "월계", "누계"}:
continue
ledger_date = parse_excel_date(values[0] if len(values) > 0 else None, default_year=year_hint)
fiscal_year = year_from_date_text(ledger_date) or year_hint
debit = parse_amount(values[3] if len(values) > 3 else 0)
credit = parse_amount(values[4] if len(values) > 4 else 0)
conn.execute(
insert_sql,
{
"source_file_id": source_id,
"sheet_name": sheet_name,
"row_number": row_number,
"ledger_date": ledger_date,
"description": clean(values[1] if len(values) > 1 else ""),
"vendor_name": clean(values[2] if len(values) > 2 else ""),
"debit": debit,
"credit": credit,
"balance": parse_amount(values[5] if len(values) > 5 else 0),
"voucher_no": clean(values[6] if len(values) > 6 else ""),
"account_code": clean(values[7] if len(values) > 7 else ""),
"account_name": clean(values[8] if len(values) > 8 else ""),
"compare_voucher_no": normalize_compare_voucher_no(values[6] if len(values) > 6 else "", ledger_date),
"compare_amount": debit if debit else credit,
"compare_side": "debit" if debit else ("credit" if credit else ""),
"compare_vendor": normalize_text(values[2] if len(values) > 2 else ""),
"compare_desc": normalize_text(values[1] if len(values) > 1 else ""),
"fiscal_year": fiscal_year,
},
)
inserted += 1
return inserted
def rebuild_comparison_results(conn: Any) -> None:
conn.execute(text("DELETE FROM wehago_comparison_results"))
conn.execute(
text(
"""
WITH voucher_groups AS (
SELECT
COALESCE(fiscal_year, 0) AS fiscal_year,
compare_voucher_no AS voucher_no,
COUNT(*) AS voucher_row_count,
SUM(debit_supply) AS voucher_debit,
SUM(credit_supply) AS voucher_credit,
GROUP_CONCAT(DISTINCT account_code) AS voucher_accounts,
GROUP_CONCAT(DISTINCT vendor_name) AS voucher_vendors
FROM wehago_voucher_rows
WHERE COALESCE(compare_voucher_no, '') <> ''
GROUP BY COALESCE(fiscal_year, 0), compare_voucher_no
),
ledger_groups AS (
SELECT
COALESCE(fiscal_year, 0) AS fiscal_year,
compare_voucher_no AS voucher_no,
COUNT(*) AS ledger_row_count,
SUM(debit) AS ledger_debit,
SUM(credit) AS ledger_credit,
GROUP_CONCAT(DISTINCT account_code) AS ledger_accounts,
GROUP_CONCAT(DISTINCT vendor_name) AS ledger_vendors
FROM wehago_ledger_rows
WHERE COALESCE(compare_voucher_no, '') <> ''
GROUP BY COALESCE(fiscal_year, 0), compare_voucher_no
),
joined AS (
SELECT
COALESCE(v.fiscal_year, l.fiscal_year) AS fiscal_year,
COALESCE(v.voucher_no, l.voucher_no) AS voucher_no,
COALESCE(v.voucher_row_count, 0) AS voucher_row_count,
COALESCE(l.ledger_row_count, 0) AS ledger_row_count,
COALESCE(v.voucher_debit, 0) AS voucher_debit,
COALESCE(v.voucher_credit, 0) AS voucher_credit,
COALESCE(l.ledger_debit, 0) AS ledger_debit,
COALESCE(l.ledger_credit, 0) AS ledger_credit,
COALESCE(v.voucher_accounts, '') AS voucher_accounts,
COALESCE(l.ledger_accounts, '') AS ledger_accounts,
COALESCE(v.voucher_vendors, '') AS voucher_vendors,
COALESCE(l.ledger_vendors, '') AS ledger_vendors
FROM voucher_groups v
LEFT JOIN ledger_groups l
ON l.fiscal_year = v.fiscal_year AND l.voucher_no = v.voucher_no
UNION ALL
SELECT
l.fiscal_year,
l.voucher_no,
0,
l.ledger_row_count,
0,
0,
l.ledger_debit,
l.ledger_credit,
'',
l.ledger_accounts,
'',
l.ledger_vendors
FROM ledger_groups l
LEFT JOIN voucher_groups v
ON v.fiscal_year = l.fiscal_year AND v.voucher_no = l.voucher_no
WHERE v.voucher_no IS NULL
)
INSERT INTO wehago_comparison_results (
fiscal_year, voucher_no, voucher_row_count, ledger_row_count,
voucher_debit, voucher_credit, ledger_debit, ledger_credit,
voucher_accounts, ledger_accounts, voucher_vendors, ledger_vendors,
status, notes
)
SELECT
fiscal_year,
voucher_no,
voucher_row_count,
ledger_row_count,
voucher_debit,
voucher_credit,
ledger_debit,
ledger_credit,
voucher_accounts,
ledger_accounts,
voucher_vendors,
ledger_vendors,
CASE
WHEN voucher_row_count > 0 AND ledger_row_count > 0
AND ABS(voucher_debit - ledger_debit) < 0.5
AND ABS(voucher_credit - ledger_credit) < 0.5
THEN 'matched'
WHEN voucher_row_count > 0 AND ledger_row_count = 0
THEN 'voucher_only'
WHEN voucher_row_count = 0 AND ledger_row_count > 0
THEN 'ledger_only'
ELSE 'amount_mismatch'
END AS status,
CASE
WHEN voucher_row_count > 0 AND ledger_row_count > 0
AND (ABS(voucher_debit - ledger_debit) >= 0.5 OR ABS(voucher_credit - ledger_credit) >= 0.5)
THEN '전표번호는 같지만 차/대변 합계가 다릅니다.'
WHEN voucher_row_count > 0 AND ledger_row_count = 0
THEN 'ERP에는 있으나 WEHAGO에서 찾지 못했습니다.'
WHEN voucher_row_count = 0 AND ledger_row_count > 0
THEN 'WEHAGO에는 있으나 ERP에서 찾지 못했습니다.'
ELSE ''
END AS notes
FROM joined
"""
)
)
boundary_sql = _boundary_excluded_sql("l.ledger_date", "l.description", "l.account_name")
conn.execute(
text(
f"""
UPDATE wehago_comparison_results
SET status = 'ledger_only',
notes = CASE
WHEN COALESCE(notes, '') = '' THEN '연초/연말 대체·이월 전표는 매칭 대상에서 제외했습니다.'
ELSE notes || ' / 연초/연말 대체·이월 전표는 매칭 대상에서 제외했습니다.'
END
WHERE EXISTS (
SELECT 1
FROM wehago_ledger_rows l
WHERE l.fiscal_year = wehago_comparison_results.fiscal_year
AND l.compare_voucher_no = wehago_comparison_results.voucher_no
AND {boundary_sql}
)
"""
)
)
def refresh_wehago_compare_data(engine: Any, source_root: Path | None = None) -> dict[str, int]:
source_root = source_root or WEHAGO_SOURCE_ROOT
summary = WehagoImportSummary()
with engine.begin() as conn:
init_wehago_compare_db(engine)
for path in iter_source_files(source_root):
summary.scanned_files += 1
file_kind, header, sheet_name = detect_file_kind(path)
if not file_kind:
summary.skipped_files += 1
continue
workbook = load_workbook(path, read_only=True, data_only=True)
sheet = workbook.worksheets[0]
sample_rows = list(sheet.iter_rows(min_row=2, max_row=51, values_only=True))
year_hint = infer_year_hint(path, file_kind, sample_rows)
source_id, changed = upsert_source_file(conn, path, file_kind, year_hint, sheet_name, header)
if not changed:
summary.skipped_files += 1
continue
table_name = "wehago_voucher_rows" if file_kind == "voucher" else "wehago_ledger_rows"
conn.execute(text(f"DELETE FROM {table_name} WHERE source_file_id = :source_id"), {"source_id": source_id})
rows = sheet.iter_rows(min_row=2, values_only=True)
if file_kind == "voucher":
inserted = import_voucher_rows(conn, source_id, sheet_name, rows, year_hint)
summary.voucher_rows += inserted
else:
inserted = import_ledger_rows(conn, source_id, sheet_name, rows, year_hint)
summary.ledger_rows += inserted
conn.execute(
text(
"""
UPDATE wehago_source_files
SET row_count = :row_count, imported_at = CURRENT_TIMESTAMP
WHERE id = :source_id
"""
),
{"row_count": inserted, "source_id": source_id},
)
summary.imported_files += 1
rebuild_comparison_results(conn)
summary.comparison_rows = int(conn.execute(text("SELECT COUNT(*) FROM wehago_comparison_results")).scalar_one())
_DASHBOARD_CACHE.clear()
_SUGGEST_CACHE.clear()
_STATUS_ROWS_CACHE.clear()
_VOUCHER_RECHECK_CACHE.clear()
_PAIR_RECOMMEND_CACHE.clear()
clear_persisted_pair_recommend_cache(engine)
return {
"scanned_files": summary.scanned_files,
"imported_files": summary.imported_files,
"skipped_files": summary.skipped_files,
"voucher_rows": summary.voucher_rows,
"ledger_rows": summary.ledger_rows,
"comparison_rows": summary.comparison_rows,
}
def fetch_existing_voucher_signatures(conn: Any) -> set[str]:
rows = conn.execute(
text(
"""
SELECT
approval_status, draft_no, confirmed_no, account_code, account_name,
debit_supply, debit_tax, credit_supply, credit_tax,
issue_dept_code, support_dept_code, cost_dept_code,
desc1, desc2, vendor_code, vendor_name, tax_code,
proof_date, voucher_type, management_item, fiscal_year
FROM wehago_voucher_rows
"""
)
).mappings()
return {build_voucher_signature_from_mapping(dict(row)) for row in rows}
def count_nonempty_rows(sheet: Any) -> int:
count = 0
for row in sheet.iter_rows(min_row=2, values_only=True):
values = list(row)
if any(item is not None and clean(item) for item in values):
count += 1
return count
def import_uploaded_erp_voucher_file(engine: Any, upload_path: Path, original_filename: str) -> dict[str, Any]:
init_wehago_compare_db(engine)
file_kind, header, sheet_name = detect_file_kind(upload_path)
if file_kind != "voucher":
raise ValueError("선택한 파일이 ERP 전표 형식이 아닙니다.")
workbook = load_workbook(upload_path, read_only=True, data_only=True)
sheet = workbook.worksheets[0]
sample_rows = list(sheet.iter_rows(min_row=2, max_row=51, values_only=True))
year_hint = infer_year_hint(upload_path, "voucher", sample_rows)
file_hash = compute_file_hash(upload_path)
pseudo_path = f"upload://voucher/{file_hash}"
file_size = upload_path.stat().st_size
modified_ts = upload_path.stat().st_mtime
with engine.begin() as conn:
existing_source = conn.execute(
text(
"""
SELECT id, row_count
FROM wehago_source_files
WHERE file_path = :file_path
"""
),
{"file_path": pseudo_path},
).mappings().first()
if existing_source is not None:
return {
"source_id": int(existing_source["id"]),
"inserted_rows": 0,
"duplicate_rows": count_nonempty_rows(sheet),
"file_name": original_filename,
"comparison_rows": int(conn.execute(text("SELECT COUNT(*) FROM wehago_comparison_results")).scalar_one()),
}
existing_signatures = fetch_existing_voucher_signatures(conn)
seen_signatures: set[str] = set()
insert_sql = text(
"""
INSERT INTO wehago_voucher_rows (
source_file_id, sheet_name, row_number, approval_status, draft_no, confirmed_no, account_code, account_name,
debit_supply, debit_tax, credit_supply, credit_tax, issue_dept_code, issue_dept_name, support_dept_code, support_dept_name,
cost_dept_code, cost_dept_name, desc1, desc2, vendor_code, vendor_name, tax_code, proof_date, voucher_type, management_item,
compare_voucher_no, compare_amount, compare_side, compare_vendor, compare_desc, fiscal_year
) VALUES (
:source_file_id, :sheet_name, :row_number, :approval_status, :draft_no, :confirmed_no, :account_code, :account_name,
:debit_supply, :debit_tax, :credit_supply, :credit_tax, :issue_dept_code, :issue_dept_name, :support_dept_code, :support_dept_name,
:cost_dept_code, :cost_dept_name, :desc1, :desc2, :vendor_code, :vendor_name, :tax_code, :proof_date, :voucher_type, :management_item,
:compare_voucher_no, :compare_amount, :compare_side, :compare_vendor, :compare_desc, :fiscal_year
)
"""
)
source_result = conn.execute(
text(
"""
INSERT INTO wehago_source_files (
file_kind, file_path, file_name, relative_path, year_hint, sheet_name,
file_size, modified_ts, file_hash, header_json, row_count, source_origin
) VALUES (
'voucher', :file_path, :file_name, :relative_path, :year_hint, :sheet_name,
:file_size, :modified_ts, :file_hash, :header_json, 0, 'upload'
)
"""
),
{
"file_path": pseudo_path,
"file_name": original_filename,
"relative_path": f"uploaded/{original_filename}",
"year_hint": year_hint,
"sheet_name": sheet_name,
"file_size": file_size,
"modified_ts": modified_ts,
"file_hash": file_hash,
"header_json": str(header),
},
)
source_id = int(source_result.lastrowid)
inserted = 0
duplicate_rows = 0
for row_number, row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
values = list(row)
if not any(item is not None and clean(item) for item in values):
continue
signature = build_voucher_signature(values, year_hint)
if signature in existing_signatures or signature in seen_signatures:
duplicate_rows += 1
continue
seen_signatures.add(signature)
draft_no = clean(values[1] if len(values) > 1 else "")
confirmed_no = clean(values[10] if len(values) > 10 else "")
proof_date = parse_excel_date(values[20] if len(values) > 20 else None)
fiscal_year = infer_voucher_fiscal_year(confirmed_no, draft_no, proof_date, year_hint)
effective_no = choose_effective_erp_voucher_no(confirmed_no, draft_no, year_hint)
debit_supply = parse_amount(values[4] if len(values) > 4 else 0)
credit_supply = parse_amount(values[6] if len(values) > 6 else 0)
conn.execute(
insert_sql,
{
"source_file_id": source_id,
"sheet_name": sheet_name,
"row_number": row_number,
"approval_status": clean(values[0] if len(values) > 0 else ""),
"draft_no": draft_no,
"confirmed_no": confirmed_no,
"account_code": clean(values[2] if len(values) > 2 else ""),
"account_name": clean(values[3] if len(values) > 3 else ""),
"debit_supply": debit_supply,
"debit_tax": parse_amount(values[5] if len(values) > 5 else 0),
"credit_supply": credit_supply,
"credit_tax": parse_amount(values[7] if len(values) > 7 else 0),
"issue_dept_code": clean(values[8] if len(values) > 8 else ""),
"issue_dept_name": clean(values[9] if len(values) > 9 else ""),
"support_dept_code": clean(values[11] if len(values) > 11 else ""),
"support_dept_name": clean(values[12] if len(values) > 12 else ""),
"cost_dept_code": clean(values[13] if len(values) > 13 else ""),
"cost_dept_name": clean(values[14] if len(values) > 14 else ""),
"desc1": clean(values[15] if len(values) > 15 else ""),
"desc2": clean(values[16] if len(values) > 16 else ""),
"vendor_code": clean(values[17] if len(values) > 17 else ""),
"vendor_name": clean(values[18] if len(values) > 18 else ""),
"tax_code": clean(values[19] if len(values) > 19 else ""),
"proof_date": proof_date,
"voucher_type": clean(values[21] if len(values) > 21 else ""),
"management_item": clean(values[22] if len(values) > 22 else ""),
"compare_voucher_no": normalize_compare_voucher_no(effective_no, proof_date),
"compare_amount": debit_supply if debit_supply else credit_supply,
"compare_side": "debit" if debit_supply else ("credit" if credit_supply else ""),
"compare_vendor": normalize_text(values[18] if len(values) > 18 else ""),
"compare_desc": normalize_text(
" ".join(clean(values[index]) for index in (15, 16, 22) if index < len(values) and clean(values[index]))
),
"fiscal_year": fiscal_year,
},
)
inserted += 1
conn.execute(
text(
"""
UPDATE wehago_source_files
SET row_count = :row_count,
imported_at = CURRENT_TIMESTAMP
WHERE id = :source_id
"""
),
{"row_count": inserted, "source_id": source_id},
)
rebuild_comparison_results(conn)
comparison_rows = int(conn.execute(text("SELECT COUNT(*) FROM wehago_comparison_results")).scalar_one())
_DASHBOARD_CACHE.clear()
_SUGGEST_CACHE.clear()
_STATUS_ROWS_CACHE.clear()
_VOUCHER_RECHECK_CACHE.clear()
_PAIR_RECOMMEND_CACHE.clear()
clear_persisted_pair_recommend_cache(engine)
return {
"source_id": source_id,
"inserted_rows": inserted,
"duplicate_rows": duplicate_rows,
"file_name": original_filename,
"comparison_rows": comparison_rows,
}
def build_year_filter_sql(column_name: str = "fiscal_year") -> str:
return (
f"(:start_year IS NULL OR {column_name} >= :start_year) "
f"AND (:end_year IS NULL OR {column_name} <= :end_year)"
)
def fetch_metric_sections(conn: Any, start_year: int | None, end_year: int | None) -> list[dict[str, Any]]:
sections: list[dict[str, Any]] = []
voucher_counts = _voucher_metric_counts_from_db(conn, start_year, end_year)
for status_key, label, description in STATUS_META:
if status_key in {"voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched"}:
count = int(voucher_counts.get(status_key, 0) or 0)
elif status_key == "bridge_expense_review":
count = _count_bridge_expense_review_candidates(conn, start_year, end_year)
else:
count = int(
conn.execute(
text(
"""
SELECT COUNT(*)
FROM wehago_comparison_results
WHERE status = :status
AND """
+ build_year_filter_sql()
+ """
"""
),
{"status": status_key, "start_year": start_year, "end_year": end_year},
).scalar_one()
)
sections.append(
{
"key": status_key,
"label": label,
"description": description,
"count": count,
"columns": DETAIL_COLUMN_MAP[status_key],
"rows": [],
}
)
return sections
def fetch_wehago_rows(conn: Any, start_year: int | None, end_year: int | None) -> list[dict[str, Any]]:
return [
dict(row._mapping)
for row in conn.execute(
text(
"""
SELECT
ledger_date, voucher_no, account_code, account_name,
vendor_name, description, debit, credit
FROM wehago_ledger_rows
WHERE """
+ build_year_filter_sql()
+ """
ORDER BY COALESCE(ledger_date, '') DESC, voucher_no DESC, row_number DESC
LIMIT 300
"""
),
{"start_year": start_year, "end_year": end_year},
).fetchall()
]
def fetch_erp_rows(conn: Any, start_year: int | None, end_year: int | None) -> list[dict[str, Any]]:
return [
dict(row._mapping)
for row in conn.execute(
text(
"""
SELECT
proof_date, confirmed_no, draft_no, account_code, account_name,
vendor_name, desc1, desc2, debit_supply, credit_supply
FROM wehago_voucher_rows
WHERE """
+ build_year_filter_sql()
+ """
ORDER BY COALESCE(proof_date, '') DESC, confirmed_no DESC, draft_no DESC, row_number DESC
LIMIT 300
"""
),
{"start_year": start_year, "end_year": end_year},
).fetchall()
]
def fetch_latest_upload_meta(conn: Any) -> dict[str, Any] | None:
row = conn.execute(
text(
"""
SELECT file_name, imported_at, row_count
FROM wehago_source_files
WHERE file_kind = 'voucher' AND source_origin = 'upload'
ORDER BY imported_at DESC, id DESC
LIMIT 1
"""
)
).mappings().first()
return dict(row) if row else None
def count_rows(conn: Any, table_name: str, start_year: int | None, end_year: int | None) -> int:
return int(
conn.execute(
text(
f"""
SELECT COUNT(*)
FROM {table_name}
WHERE {build_year_filter_sql()}
"""
),
{"start_year": start_year, "end_year": end_year},
).scalar_one()
)
def discover_available_years(source_root: Path | None = None) -> list[int]:
source_root = source_root or WEHAGO_SOURCE_ROOT
data_download_dir = source_root / "data_download"
if not data_download_dir.exists():
return []
years = sorted(
[
int(path.name)
for path in data_download_dir.iterdir()
if path.is_dir() and path.name.isdigit() and len(path.name) == 4
],
reverse=True,
)
return years
def get_default_year(available_years: list[int]) -> int | None:
return available_years[0] if available_years else None
def discover_compare_result_bundle(year: int, source_root: Path | None = None) -> dict[str, Path] | None:
source_root = source_root or WEHAGO_SOURCE_ROOT
data_download_dir = source_root / "data_download"
if not data_download_dir.exists():
return None
candidates: list[tuple[float, dict[str, Path]]] = []
for folder in data_download_dir.iterdir():
if not folder.is_dir() or (folder.name.isdigit() and len(folder.name) == 4):
continue
ledger_raw = next(folder.glob(f"ledger_{year}_*.xlsx"), None)
voucher_raw = next(folder.glob(f"voucher_{year}_*.xlsx"), None)
ledger_result = folder / "ledger_with_matched_voucher_detected_context60_v10_20260422.xlsx"
voucher_result = folder / "voucher_with_matched_ledger_detected_context60_v10_20260422.xlsx"
if ledger_raw and voucher_raw and ledger_result.exists() and voucher_result.exists():
candidates.append(
(
max(ledger_result.stat().st_mtime, voucher_result.stat().st_mtime, folder.stat().st_mtime),
{
"ledger_result": ledger_result,
"voucher_result": voucher_result,
},
)
)
if not candidates:
return None
candidates.sort(key=lambda item: item[0], reverse=True)
return candidates[0][1]
def _normalize_flag(value: Any) -> bool:
text_value = clean(value).strip().lower()
return text_value not in {"", "0", "false", "n", "none"}
def _append_limited(bucket: list[dict[str, Any]], item: dict[str, Any], limit: int = 200) -> None:
if len(bucket) < limit:
bucket.append(item)
def build_review_key(row: dict[str, Any]) -> str:
parts = [
clean(row.get("fiscal_year")),
clean(row.get("voucher_no")),
clean(row.get("draft_no")),
clean(row.get("voucher_account_code")),
clean(row.get("voucher_account_name")),
clean(row.get("voucher_vendor")),
clean(row.get("voucher_desc")),
clean(row.get("review_reason")),
clean(row.get("review_memo")),
f"{parse_amount(row.get('voucher_debit')):.2f}",
f"{parse_amount(row.get('voucher_credit')):.2f}",
]
return hashlib.sha1("|".join(parts).encode("utf-8", "ignore")).hexdigest()
def build_match_identity_key(row: dict[str, Any]) -> str:
parts = [
clean(row.get("fiscal_year")),
clean(row.get("draft_no")),
clean(row.get("voucher_account_code")),
clean(row.get("voucher_account_name")),
clean(row.get("voucher_vendor")),
clean(row.get("voucher_desc")),
f"{parse_amount(row.get('voucher_debit')):.2f}",
f"{parse_amount(row.get('voucher_credit')):.2f}",
]
return hashlib.sha1("|".join(parts).encode("utf-8", "ignore")).hexdigest()
def build_ledger_row_key(row: dict[str, Any]) -> str:
parts = [
clean(row.get("fiscal_year")),
clean(row.get("voucher_no")),
clean(row.get("ledger_date")),
clean(row.get("ledger_account_code")),
clean(row.get("ledger_account_name")),
clean(row.get("ledger_vendor")),
clean(row.get("ledger_desc")),
f"{parse_amount(row.get('ledger_debit')):.2f}",
f"{parse_amount(row.get('ledger_credit')):.2f}",
]
return hashlib.sha1("|".join(parts).encode("utf-8", "ignore")).hexdigest()
def build_voucher_row_key(row: dict[str, Any]) -> str:
parts = [
clean(row.get("fiscal_year")),
clean(row.get("voucher_no")),
clean(row.get("draft_no")),
clean(row.get("proof_date")),
clean(row.get("voucher_account_code")),
clean(row.get("voucher_account_name")),
clean(row.get("voucher_vendor")),
clean(row.get("voucher_desc")),
f"{parse_amount(row.get('voucher_debit')):.2f}",
f"{parse_amount(row.get('voucher_credit')):.2f}",
]
return hashlib.sha1("|".join(parts).encode("utf-8", "ignore")).hexdigest()
def build_manual_pair_key(ledger_row_key: str, voucher_row_key: str) -> str:
return hashlib.sha1(f"{clean(ledger_row_key)}|{clean(voucher_row_key)}".encode("utf-8", "ignore")).hexdigest()
@lru_cache(maxsize=16)
def parse_compare_result_bundle(
ledger_path_str: str,
ledger_mtime: float,
voucher_path_str: str,
voucher_mtime: float,
year: int,
) -> dict[str, Any]:
del ledger_mtime, voucher_mtime
ledger_path = Path(ledger_path_str)
voucher_path = Path(voucher_path_str)
metric_map = {
"matched": {
"count": 0,
"columns": [
("fiscal_year", "연도"),
("ledger_date", "일자"),
("voucher_no", "전표번호"),
("draft_no", "가전표번호"),
("ledger_account_name", "계정"),
("voucher_account_name", "ERP 계정"),
("ledger_vendor", "거래처"),
("voucher_vendor", "ERP 거래처"),
("ledger_debit", "차변"),
("ledger_credit", "대변"),
("voucher_debit", "ERP 차변"),
("voucher_credit", "ERP 대변"),
("ledger_desc", "WEHAGO 적요"),
("voucher_desc", "ERP 적요"),
],
"rows": [],
},
"ledger_only": {
"count": 0,
"columns": [
("fiscal_year", "연도"),
("voucher_no", "전표번호"),
("ledger_date", "일자"),
("ledger_account_name", "WEHAGO 계정"),
("ledger_vendor", "WEHAGO 거래처"),
("ledger_debit", "차변"),
("ledger_credit", "대변"),
("ledger_desc", "적요"),
],
"rows": [],
},
"amount_mismatch": {
"count": 0,
"columns": [
("fiscal_year", "연도"),
("ledger_date", "일자"),
("voucher_no", "전표번호"),
("draft_no", "가전표번호"),
("ledger_account_name", "계정"),
("voucher_account_name", "ERP 계정"),
("ledger_vendor", "거래처"),
("voucher_vendor", "ERP 거래처"),
("ledger_debit", "차변"),
("ledger_credit", "대변"),
("voucher_debit", "ERP 차변"),
("voucher_credit", "ERP 대변"),
("ledger_desc", "WEHAGO 적요"),
("voucher_desc", "ERP 적요"),
],
"rows": [],
},
"voucher_only": {
"count": 0,
"columns": [
("fiscal_year", "연도"),
("voucher_no", "전표번호"),
("proof_date", "증빙일자"),
("voucher_account_name", "ERP 계정"),
("voucher_vendor", "ERP 거래처"),
("voucher_debit", "차변"),
("voucher_credit", "대변"),
("voucher_desc", "적요"),
],
"rows": [],
},
}
ledger_wb = load_workbook(ledger_path, read_only=True, data_only=True)
ledger_ws = ledger_wb.worksheets[0]
ledger_header = [clean(v) for v in next(ledger_ws.iter_rows(min_row=1, max_row=1, values_only=True))]
ledger_idx = {name: idx for idx, name in enumerate(ledger_header)}
ledger_rows: list[tuple[Any, ...]] = list(ledger_ws.iter_rows(min_row=2, values_only=True))
ledger_by_matched_voucher_no: dict[str, dict[str, Any]] = {}
for row in ledger_rows:
matched_voucher_no = clean(row[ledger_idx["matched_확정전표번호"]]) if "matched_확정전표번호" in ledger_idx else ""
if not matched_voucher_no or matched_voucher_no in ledger_by_matched_voucher_no:
continue
ledger_by_matched_voucher_no[matched_voucher_no] = {
"voucher_no": clean(row[ledger_idx["전표번호"]]) if "전표번호" in ledger_idx else "",
"ledger_date": clean(row[ledger_idx["일자"]]) if "일자" in ledger_idx else "",
"ledger_account_code": clean(row[ledger_idx["계정코드"]]) if "계정코드" in ledger_idx else "",
"ledger_account_name": clean(row[ledger_idx["계정명"]]) if "계정명" in ledger_idx else "",
"ledger_vendor": clean(row[ledger_idx["거래처"]]) if "거래처" in ledger_idx else "",
"ledger_desc": clean(row[ledger_idx["적요"]]) if "적요" in ledger_idx else "",
"ledger_debit": parse_amount(row[ledger_idx["차변"]]) if "차변" in ledger_idx else 0,
"ledger_credit": parse_amount(row[ledger_idx["대변"]]) if "대변" in ledger_idx else 0,
}
voucher_wb = load_workbook(voucher_path, read_only=True, data_only=True)
voucher_ws = voucher_wb.worksheets[0]
voucher_header = [clean(v) for v in next(voucher_ws.iter_rows(min_row=1, max_row=1, values_only=True))]
voucher_idx = {name: idx for idx, name in enumerate(voucher_header)}
voucher_by_confirmed_no: dict[str, dict[str, Any]] = {}
for row in voucher_ws.iter_rows(min_row=2, values_only=True):
matched_voucher_no = clean(row[voucher_idx["matched_전표번호"]]) if "matched_전표번호" in voucher_idx else ""
review_reason = clean(row[voucher_idx["matched_검증근거"]]) if "matched_검증근거" in voucher_idx else ""
review_flag = row[voucher_idx["review_flag"]] if "review_flag" in voucher_idx else ""
confirmed_no = clean(row[voucher_idx["확정전표번호"]]) if "확정전표번호" in voucher_idx else ""
desc1 = clean(row[voucher_idx["적요1"]]) if "적요1" in voucher_idx else ""
matched_ledger = ledger_by_matched_voucher_no.get(confirmed_no, {})
item = {
"fiscal_year": year,
"voucher_no": (
matched_ledger.get("voucher_no", "")
if matched_voucher_no
else (confirmed_no or (clean(row[voucher_idx["가전표번호"]]) if "가전표번호" in voucher_idx else ""))
),
"confirmed_no": confirmed_no,
"proof_date": clean(row[voucher_idx["증빙일자"]]) if "증빙일자" in voucher_idx else "",
"ledger_date": matched_ledger.get("ledger_date", ""),
"draft_no": clean(row[voucher_idx["가전표번호"]]) if "가전표번호" in voucher_idx else "",
"ledger_account_code": matched_ledger.get("ledger_account_code", ""),
"ledger_account_name": matched_ledger.get("ledger_account_name", ""),
"voucher_account_code": clean(row[voucher_idx["계정코드"]]) if "계정코드" in voucher_idx else "",
"voucher_account_name": clean(row[voucher_idx["계정명칭"]]) if "계정명칭" in voucher_idx else "",
"ledger_vendor": matched_ledger.get("ledger_vendor", ""),
"voucher_vendor": clean(row[voucher_idx["거래처명칭"]]) if "거래처명칭" in voucher_idx else "",
"ledger_desc": matched_ledger.get("ledger_desc", ""),
"voucher_debit": parse_amount(row[voucher_idx["차변공급가"]]) if "차변공급가" in voucher_idx else 0,
"voucher_credit": parse_amount(row[voucher_idx["대변공급가"]]) if "대변공급가" in voucher_idx else 0,
"voucher_desc": desc1,
"ledger_debit": matched_ledger.get("ledger_debit", 0),
"ledger_credit": matched_ledger.get("ledger_credit", 0),
"review_reason": review_reason,
"review_memo": clean(row[voucher_idx["review_memo"]]) if "review_memo" in voucher_idx else "",
"substitution_hint": _build_account_substitution_hint(
matched_ledger.get("ledger_account_code", ""),
matched_ledger.get("ledger_account_name", ""),
clean(row[voucher_idx["계정코드"]]) if "계정코드" in voucher_idx else "",
clean(row[voucher_idx["계정명칭"]]) if "계정명칭" in voucher_idx else "",
),
}
item["review_key"] = build_review_key(item)
item["match_identity_key"] = build_match_identity_key(item)
item["voucher_row_key"] = build_voucher_row_key(item)
if confirmed_no and confirmed_no not in voucher_by_confirmed_no:
voucher_by_confirmed_no[confirmed_no] = item
if not matched_voucher_no:
metric_map["voucher_only"]["count"] += 1
metric_map["voucher_only"]["rows"].append(item)
has_wehago_link = bool(
item.get("voucher_no")
or item.get("ledger_date")
or item.get("ledger_account_name")
or item.get("ledger_vendor")
)
if has_wehago_link and (_normalize_flag(review_flag) or ("REVIEW" in review_reason)):
metric_map["amount_mismatch"]["count"] += 1
metric_map["amount_mismatch"]["rows"].append(item)
for row in ledger_rows:
matched_voucher_no = clean(row[ledger_idx["matched_확정전표번호"]]) if "matched_확정전표번호" in ledger_idx else ""
matched_voucher = voucher_by_confirmed_no.get(matched_voucher_no, {})
item = {
"fiscal_year": year,
"voucher_no": clean(row[ledger_idx["전표번호"]]) if "전표번호" in ledger_idx else "",
"ledger_date": clean(row[ledger_idx["일자"]]) if "일자" in ledger_idx else "",
"draft_no": matched_voucher.get("draft_no", ""),
"ledger_account_code": clean(row[ledger_idx["계정코드"]]) if "계정코드" in ledger_idx else "",
"ledger_account_name": clean(row[ledger_idx["계정명"]]) if "계정명" in ledger_idx else "",
"voucher_account_code": matched_voucher.get("voucher_account_code")
or (clean(row[ledger_idx["matched_계정코드"]]) if "matched_계정코드" in ledger_idx else ""),
"voucher_account_name": matched_voucher.get("voucher_account_name")
or (clean(row[ledger_idx["matched_계정명칭"]]) if "matched_계정명칭" in ledger_idx else ""),
"ledger_vendor": clean(row[ledger_idx["거래처"]]) if "거래처" in ledger_idx else "",
"voucher_vendor": matched_voucher.get("voucher_vendor", ""),
"voucher_debit": matched_voucher.get("voucher_debit", 0),
"voucher_credit": matched_voucher.get("voucher_credit", 0),
"ledger_desc": clean(row[ledger_idx["적요"]]) if "적요" in ledger_idx else "",
"voucher_desc": matched_voucher.get("voucher_desc", ""),
"ledger_debit": parse_amount(row[ledger_idx["차변"]]) if "차변" in ledger_idx else 0,
"ledger_credit": parse_amount(row[ledger_idx["대변"]]) if "대변" in ledger_idx else 0,
}
item["review_key"] = build_review_key(item)
item["match_identity_key"] = build_match_identity_key(item)
item["ledger_row_key"] = build_ledger_row_key(item)
if matched_voucher_no:
metric_map["matched"]["count"] += 1
metric_map["matched"]["rows"].append(item)
else:
metric_map["ledger_only"]["count"] += 1
metric_map["ledger_only"]["rows"].append(item)
return _append_substitution_review_rows(metric_map)
def build_metric_sections_from_results(start_year: int | None, end_year: int | None) -> list[dict[str, Any]]:
if start_year is None or end_year is None:
return []
aggregate = {
"matched": {"count": 0, "columns": None, "rows": []},
"ledger_only": {"count": 0, "columns": None, "rows": []},
"amount_mismatch": {"count": 0, "columns": None, "rows": []},
"voucher_only": {"count": 0, "columns": None, "rows": []},
}
for year in range(start_year, end_year + 1):
bundle = discover_compare_result_bundle(year)
if not bundle:
continue
parsed = parse_compare_result_bundle(
str(bundle["ledger_result"]),
bundle["ledger_result"].stat().st_mtime,
str(bundle["voucher_result"]),
bundle["voucher_result"].stat().st_mtime,
year,
)
for key, section in aggregate.items():
section["count"] += parsed[key]["count"]
section["columns"] = parsed[key]["columns"]
built_sections: list[dict[str, Any]] = []
for status_key, label, description in STATUS_META:
section = aggregate.get(status_key, {"count": 0, "columns": None, "rows": []})
built_sections.append(
{
"key": status_key,
"label": label,
"description": description,
"count": section["count"],
"columns": section["columns"] or DETAIL_COLUMN_MAP[status_key],
"rows": [],
}
)
return built_sections
def build_metric_sections_with_review_state(conn: Any, start_year: int | None, end_year: int | None) -> list[dict[str, Any]]:
if start_year is None or end_year is None:
return []
aggregate = {
"matched": {"count": 0, "columns": None, "rows": []},
"ledger_only": {"count": 0, "columns": None, "rows": []},
"amount_mismatch": {"count": 0, "columns": None, "rows": []},
"voucher_only": {"count": 0, "columns": None, "rows": []},
}
for year in range(start_year, end_year + 1):
bundle = discover_compare_result_bundle(year)
if not bundle:
continue
parsed = parse_compare_result_bundle(
str(bundle["ledger_result"]),
bundle["ledger_result"].stat().st_mtime,
str(bundle["voucher_result"]),
bundle["voucher_result"].stat().st_mtime,
year,
)
parsed = _repair_invalid_cross_category_matches(parsed)
parsed = apply_saved_recheck_reviews(parsed, get_saved_recheck_review_keys(conn, year, year))
parsed = apply_saved_manual_pair_matches(parsed, get_saved_manual_pair_matches(conn, year, year))
parsed = _repair_invalid_cross_category_matches(parsed)
for key, section in aggregate.items():
section["count"] += parsed[key]["count"]
section["columns"] = parsed[key]["columns"]
built_sections: list[dict[str, Any]] = []
for status_key, label, description in STATUS_META:
section = aggregate.get(status_key, {"count": 0, "columns": None, "rows": []})
built_sections.append(
{
"key": status_key,
"label": label,
"description": description,
"count": section["count"],
"columns": section["columns"] or DETAIL_COLUMN_MAP[status_key],
"rows": [],
}
)
return built_sections
def build_account_options_from_results(start_year: int | None, end_year: int | None) -> dict[str, list[str]]:
wehago_items: set[str] = set()
erp_items: set[str] = set()
if start_year is None or end_year is None:
return {"wehago": [], "erp": []}
for year in range(start_year, end_year + 1):
bundle = discover_compare_result_bundle(year)
if not bundle:
continue
parsed = parse_compare_result_bundle(
str(bundle["ledger_result"]),
bundle["ledger_result"].stat().st_mtime,
str(bundle["voucher_result"]),
bundle["voucher_result"].stat().st_mtime,
year,
)
for section in parsed.values():
for row in section["rows"]:
wehago_code = clean(row.get("ledger_account_code"))
wehago_name = clean(row.get("ledger_account_name"))
erp_code = clean(row.get("voucher_account_code"))
erp_name = clean(row.get("voucher_account_name"))
if wehago_code or wehago_name:
wehago_items.add(" ".join(part for part in (wehago_code, wehago_name) if part))
if erp_code or erp_name:
erp_items.add(" ".join(part for part in (erp_code, erp_name) if part))
return {"wehago": sorted(wehago_items), "erp": sorted(erp_items)}
def build_account_options_from_rows(rows_by_status: dict[str, list[dict[str, Any]]]) -> dict[str, list[str]]:
wehago_items: set[str] = set()
erp_items: set[str] = set()
for rows in rows_by_status.values():
for row in rows:
wehago_code = clean(row.get("ledger_account_code"))
wehago_name = clean(row.get("ledger_account_name"))
erp_code = clean(row.get("voucher_account_code"))
erp_name = clean(row.get("voucher_account_name"))
if wehago_code or wehago_name:
wehago_items.add(" ".join(part for part in (wehago_code, wehago_name) if part))
if erp_code or erp_name:
erp_items.add(" ".join(part for part in (erp_code, erp_name) if part))
return {"wehago": sorted(wehago_items), "erp": sorted(erp_items)}
def build_account_options_from_db(conn: Any, start_year: int | None, end_year: int | None, limit: int = 3000) -> dict[str, list[str]]:
wehago_rows = conn.execute(
text(
f"""
SELECT DISTINCT COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name
FROM wehago_ledger_rows
WHERE {build_year_filter_sql()}
AND (COALESCE(account_code, '') <> '' OR COALESCE(account_name, '') <> '')
ORDER BY account_code, account_name
LIMIT :limit
"""
),
{"start_year": start_year, "end_year": end_year, "limit": limit},
).fetchall()
erp_rows = conn.execute(
text(
f"""
SELECT DISTINCT COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name
FROM wehago_voucher_rows
WHERE {build_year_filter_sql()}
AND (COALESCE(account_code, '') <> '' OR COALESCE(account_name, '') <> '')
ORDER BY account_code, account_name
LIMIT :limit
"""
),
{"start_year": start_year, "end_year": end_year, "limit": limit},
).fetchall()
wehago = sorted({" ".join(part for part in (clean(row[0]), clean(row[1])) if part) for row in wehago_rows})
erp = sorted({" ".join(part for part in (clean(row[0]), clean(row[1])) if part) for row in erp_rows})
return {"wehago": wehago, "erp": erp}
def get_saved_recheck_review_keys(conn: Any, start_year: int | None, end_year: int | None) -> set[str]:
rows = conn.execute(
text(
"""
SELECT review_key
FROM wehago_recheck_reviews
WHERE (:start_year IS NULL OR fiscal_year >= :start_year)
AND (:end_year IS NULL OR fiscal_year <= :end_year)
"""
),
{"start_year": start_year, "end_year": end_year},
).fetchall()
return {str(row[0]) for row in rows}
def apply_saved_recheck_reviews(
sections: dict[str, dict[str, Any]],
reviewed_keys: set[str],
) -> dict[str, dict[str, Any]]:
if not reviewed_keys:
return sections
adjusted: dict[str, dict[str, Any]] = {}
for key, section in sections.items():
copied = {
"count": section["count"],
"columns": section["columns"],
"rows": list(section["rows"]),
}
adjusted[key] = copied
original_recheck_rows = adjusted["amount_mismatch"]["rows"]
remaining_recheck_rows = [row for row in original_recheck_rows if row.get("review_key") not in reviewed_keys]
reviewed_rows = [row for row in original_recheck_rows if row.get("review_key") in reviewed_keys]
adjusted["amount_mismatch"]["rows"] = remaining_recheck_rows
adjusted["amount_mismatch"]["count"] = len(remaining_recheck_rows)
matched_rows = adjusted["matched"]["rows"]
matched_identity = {row.get("match_identity_key") for row in matched_rows}
for row in reviewed_rows:
identity = row.get("match_identity_key")
if identity in matched_identity:
continue
matched_identity.add(identity)
matched_rows.append(row)
adjusted["matched"]["count"] = len(matched_rows)
return adjusted
def get_saved_manual_pair_matches(conn: Any, start_year: int | None, end_year: int | None) -> list[dict[str, Any]]:
rows = conn.execute(
text(
"""
SELECT
pair_key, fiscal_year, ledger_row_key, voucher_row_key, pair_note
FROM wehago_manual_pair_matches
WHERE (:start_year IS NULL OR fiscal_year >= :start_year)
AND (:end_year IS NULL OR fiscal_year <= :end_year)
"""
),
{"start_year": start_year, "end_year": end_year},
).mappings()
return [dict(row) for row in rows]
def _build_bundle_signature(start_year: int | None, end_year: int | None) -> str:
if start_year is None or end_year is None:
return ""
parts: list[str] = []
for year in range(start_year, end_year + 1):
bundle = discover_compare_result_bundle(year)
if not bundle:
parts.append(f"{year}:db")
continue
ledger_mtime = f"{bundle['ledger_result'].stat().st_mtime:.3f}"
voucher_mtime = f"{bundle['voucher_result'].stat().st_mtime:.3f}"
parts.append(f"{year}:{ledger_mtime}:{voucher_mtime}")
return "|".join(parts)
def _empty_status_sections() -> dict[str, dict[str, Any]]:
return {
status_key: {
"count": 0,
"columns": DETAIL_COLUMN_MAP[status_key],
"rows": [],
}
for status_key, _label, _description in STATUS_META
}
def _build_db_status_sections(conn: Any, year: int) -> dict[str, dict[str, Any]]:
sections = _empty_status_sections()
for status_key in sections:
payload = _fetch_status_detail_rows_from_db(
conn,
year,
year,
status_key,
"",
"",
"",
"",
"",
"",
"",
"",
"",
False,
0,
1_000_000,
)
rows = payload["rows"]
for row in rows:
if status_key in {"matched", "ledger_only", "amount_mismatch"}:
row["ledger_row_key"] = build_ledger_row_key(row)
if status_key in {"matched", "voucher_only", "amount_mismatch"}:
row["voucher_row_key"] = build_voucher_row_key(row)
row["review_key"] = build_review_key(row)
row["match_identity_key"] = build_match_identity_key(row)
sections[status_key]["rows"] = rows
sections[status_key]["count"] = len(rows)
sections = _repair_invalid_cross_category_matches(sections)
sections = _append_substitution_review_rows(sections)
sections = _promote_direct_auto_matches(sections)
sections = _apply_previous_year_erp_candidates(conn, year, sections)
sections = apply_saved_recheck_reviews(sections, get_saved_recheck_review_keys(conn, year, year))
sections = apply_saved_manual_pair_matches(sections, get_saved_manual_pair_matches(conn, year, year))
sections = _repair_invalid_cross_category_matches(sections)
return sections
def _get_or_create_year_cached_sections(conn: Any, year: int) -> dict[str, dict[str, Any]] | None:
bundle = discover_compare_result_bundle(year)
if not bundle:
return None
parse_signature = _current_logic_signature()
ledger_path = str(bundle["ledger_result"])
voucher_path = str(bundle["voucher_result"])
ledger_mtime = float(bundle["ledger_result"].stat().st_mtime)
voucher_mtime = float(bundle["voucher_result"].stat().st_mtime)
cached = conn.execute(
text(
"""
SELECT payload_json
FROM wehago_result_row_cache
WHERE fiscal_year = :fiscal_year
AND ledger_result_path = :ledger_result_path
AND ledger_result_mtime = :ledger_result_mtime
AND voucher_result_path = :voucher_result_path
AND voucher_result_mtime = :voucher_result_mtime
AND parse_state_signature = :parse_signature
LIMIT 1
"""
),
{
"fiscal_year": year,
"ledger_result_path": ledger_path,
"ledger_result_mtime": ledger_mtime,
"voucher_result_path": voucher_path,
"voucher_result_mtime": voucher_mtime,
"parse_signature": parse_signature,
},
).first()
if cached and cached[0]:
try:
payload = json.loads(str(cached[0]))
if isinstance(payload, dict):
return _append_substitution_review_rows(payload)
except Exception:
pass
parsed = parse_compare_result_bundle(
ledger_path,
ledger_mtime,
voucher_path,
voucher_mtime,
year,
)
conn.execute(
text(
"""
INSERT OR REPLACE INTO wehago_result_row_cache (
fiscal_year,
ledger_result_path,
ledger_result_mtime,
voucher_result_path,
voucher_result_mtime,
parse_state_signature,
payload_json,
created_at
) VALUES (
:fiscal_year,
:ledger_result_path,
:ledger_result_mtime,
:voucher_result_path,
:voucher_result_mtime,
:parse_signature,
:payload_json,
CURRENT_TIMESTAMP
)
"""
),
{
"fiscal_year": year,
"ledger_result_path": ledger_path,
"ledger_result_mtime": ledger_mtime,
"voucher_result_path": voucher_path,
"voucher_result_mtime": voucher_mtime,
"parse_signature": parse_signature,
"payload_json": json.dumps(parsed, ensure_ascii=False),
},
)
conn.execute(
text(
"""
DELETE FROM wehago_result_row_cache
WHERE fiscal_year = :fiscal_year
AND NOT (
ledger_result_path = :ledger_result_path
AND ledger_result_mtime = :ledger_result_mtime
AND voucher_result_path = :voucher_result_path
AND voucher_result_mtime = :voucher_result_mtime
AND parse_state_signature = :parse_signature
)
"""
),
{
"fiscal_year": year,
"ledger_result_path": ledger_path,
"ledger_result_mtime": ledger_mtime,
"voucher_result_path": voucher_path,
"voucher_result_mtime": voucher_mtime,
"parse_signature": parse_signature,
},
)
return parsed
def _get_or_create_year_resolved_sections(conn: Any, year: int) -> dict[str, dict[str, Any]] | None:
signature = _build_db_state_signature(conn, year, year)
cached_payload = _load_year_resolved_sections_cache(conn, year, signature)
if cached_payload is not None:
return cached_payload
parsed_sections = _get_or_create_year_cached_sections(conn, year)
if parsed_sections is not None:
sections = _apply_boundary_exclusions_to_sections(conn, parsed_sections)
sections = _repair_invalid_cross_category_matches(sections)
sections = apply_saved_recheck_reviews(sections, get_saved_recheck_review_keys(conn, year, year))
sections = apply_saved_manual_pair_matches(sections, get_saved_manual_pair_matches(conn, year, year))
sections = _repair_invalid_cross_category_matches(sections)
sections = _apply_previous_year_erp_candidates(conn, year, sections)
else:
sections = _build_db_status_sections(conn, year)
_store_year_resolved_sections_cache(conn, year, signature, sections)
return sections
def _load_year_resolved_sections_cache(
conn: Any,
year: int,
signature: str | None = None,
) -> dict[str, dict[str, Any]] | None:
signature = signature or _build_db_state_signature(conn, year, year)
cached = conn.execute(
text(
"""
SELECT resolved_payload_json
FROM wehago_result_row_cache
WHERE fiscal_year = :fiscal_year
AND ledger_result_path = 'db'
AND voucher_result_path = 'db'
AND resolved_state_signature = :signature
LIMIT 1
"""
),
{"fiscal_year": year, "signature": signature},
).first()
if cached and cached[0]:
try:
payload = json.loads(str(cached[0]))
if isinstance(payload, dict):
payload = _repair_invalid_cross_category_matches(payload)
return _demote_incomplete_adjacent_year_matches(payload)
except Exception:
pass
return None
def _load_latest_year_resolved_sections_cache_any_signature(
conn: Any,
year: int,
) -> dict[str, dict[str, Any]] | None:
cached = conn.execute(
text(
"""
SELECT resolved_payload_json
FROM wehago_result_row_cache
WHERE fiscal_year = :fiscal_year
AND ledger_result_path = 'db'
AND voucher_result_path = 'db'
AND COALESCE(resolved_payload_json, '') <> ''
ORDER BY COALESCE(resolved_created_at, created_at) DESC, created_at DESC
LIMIT 1
"""
),
{"fiscal_year": year},
).first()
if cached and cached[0]:
try:
payload = json.loads(str(cached[0]))
if isinstance(payload, dict):
payload = _repair_invalid_cross_category_matches(payload)
return _demote_incomplete_adjacent_year_matches(payload)
except Exception:
pass
return None
def _status_row_counts_from_sections(sections: dict[str, dict[str, Any]] | None) -> dict[str, int]:
counts = {
"matched": 0,
"ledger_only": 0,
"amount_mismatch": 0,
"voucher_only": 0,
}
if not sections:
return counts
for status_key in counts:
counts[status_key] = len(sections.get(status_key, {}).get("rows", []))
return counts
def _upsert_snapshot_status(
conn: Any,
year: int,
*,
signature: str,
state: str,
source_kind: str = "resolved",
row_counts: dict[str, int] | None = None,
error_message: str = "",
touch_requested: bool = False,
built_now: bool = False,
) -> None:
row_counts_json = json.dumps(row_counts or {}, ensure_ascii=False)
conn.execute(
text(
"""
INSERT INTO wehago_snapshot_status (
fiscal_year,
snapshot_signature,
state,
source_kind,
row_counts_json,
error_message,
created_at,
updated_at,
last_requested_at,
last_built_at
) VALUES (
:fiscal_year,
:snapshot_signature,
:state,
:source_kind,
:row_counts_json,
:error_message,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP,
CASE WHEN :touch_requested = 1 THEN CURRENT_TIMESTAMP ELSE '' END,
CASE WHEN :built_now = 1 THEN CURRENT_TIMESTAMP ELSE '' END
)
ON CONFLICT(fiscal_year) DO UPDATE SET
snapshot_signature = excluded.snapshot_signature,
state = excluded.state,
source_kind = excluded.source_kind,
row_counts_json = excluded.row_counts_json,
error_message = excluded.error_message,
updated_at = CURRENT_TIMESTAMP,
last_requested_at = CASE
WHEN :touch_requested = 1 THEN CURRENT_TIMESTAMP
ELSE wehago_snapshot_status.last_requested_at
END,
last_built_at = CASE
WHEN :built_now = 1 THEN CURRENT_TIMESTAMP
ELSE wehago_snapshot_status.last_built_at
END
"""
),
{
"fiscal_year": year,
"snapshot_signature": signature,
"state": state,
"source_kind": source_kind,
"row_counts_json": row_counts_json,
"error_message": clean(error_message),
"touch_requested": 1 if touch_requested else 0,
"built_now": 1 if built_now else 0,
},
)
def _get_compare_snapshot_state(
conn: Any,
start_year: int | None,
end_year: int | None,
) -> dict[str, list[int]]:
state = {"missing": [], "stale": [], "ready": [], "queued": [], "running": [], "failed": []}
if start_year is None or end_year is None:
return state
years = list(range(start_year, end_year + 1))
status_map = _load_snapshot_status_map(conn, years)
for year in years:
row = status_map.get(year) or {}
persisted_state = clean(row.get("state")).lower()
if persisted_state in {"queued", "running", "failed"}:
state[persisted_state].append(year)
continue
signature = _build_db_state_signature(conn, year, year)
stored_signature = clean(row.get("snapshot_signature"))
if stored_signature and stored_signature == clean(signature):
state["ready"].append(year)
elif stored_signature:
state["stale"].append(year)
else:
state["missing"].append(year)
return state
def _store_year_resolved_sections_cache(
conn: Any,
year: int,
signature: str,
sections: dict[str, dict[str, Any]],
) -> None:
conn.execute(
text(
"""
INSERT OR REPLACE INTO wehago_result_row_cache (
fiscal_year,
ledger_result_path,
ledger_result_mtime,
voucher_result_path,
voucher_result_mtime,
payload_json,
resolved_state_signature,
resolved_payload_json,
resolved_created_at,
created_at
) VALUES (
:fiscal_year,
'db',
0,
'db',
0,
'{}',
:signature,
:payload_json,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
"""
),
{
"fiscal_year": year,
"signature": signature,
"payload_json": json.dumps(sections, ensure_ascii=False),
},
)
conn.execute(
text(
"""
DELETE FROM wehago_result_row_cache
WHERE fiscal_year = :fiscal_year
AND ledger_result_path = 'db'
AND voucher_result_path = 'db'
AND resolved_state_signature <> :signature
"""
),
{"fiscal_year": year, "signature": signature},
)
def _refresh_year_resolved_sections(conn: Any, year: int) -> dict[str, dict[str, Any]] | None:
signature = _build_db_state_signature(conn, year, year)
parsed_sections = _get_or_create_year_cached_sections(conn, year)
if parsed_sections is not None:
sections = _apply_boundary_exclusions_to_sections(conn, parsed_sections)
sections = _repair_invalid_cross_category_matches(sections)
sections = apply_saved_recheck_reviews(sections, get_saved_recheck_review_keys(conn, year, year))
sections = apply_saved_manual_pair_matches(sections, get_saved_manual_pair_matches(conn, year, year))
sections = _repair_invalid_cross_category_matches(sections)
sections = _apply_previous_year_erp_candidates(conn, year, sections)
else:
sections = _build_db_status_sections(conn, year)
_store_year_resolved_sections_cache(conn, year, signature, sections)
rows_by_status = {
"matched": list((sections or {}).get("matched", {}).get("rows", [])),
"ledger_only": list((sections or {}).get("ledger_only", {}).get("rows", [])),
"amount_mismatch": list((sections or {}).get("amount_mismatch", {}).get("rows", [])),
"voucher_only": list((sections or {}).get("voucher_only", {}).get("rows", [])),
}
voucher_sections = _build_voucher_sections_with_bridge_promotions(conn, year, year, rows_by_status)
_store_year_export_row_cache(conn, year, signature, voucher_sections)
_upsert_snapshot_status(
conn,
year,
signature=signature,
state="ready",
row_counts=_status_row_counts_from_sections(sections),
built_now=True,
)
return sections
def _build_db_state_signature(conn: Any, start_year: int | None, end_year: int | None) -> str:
source = conn.execute(
text(
"""
SELECT COALESCE(MAX(imported_at), ''), COUNT(*)
FROM wehago_source_files
WHERE (:start_year IS NULL OR year_hint >= :start_year)
AND (:end_year IS NULL OR year_hint <= :end_year)
"""
),
{"start_year": start_year, "end_year": end_year},
).first()
comparison = conn.execute(
text(
"""
SELECT COUNT(*)
FROM wehago_comparison_results
WHERE (:start_year IS NULL OR fiscal_year >= :start_year)
AND (:end_year IS NULL OR fiscal_year <= :end_year)
"""
),
{"start_year": start_year, "end_year": end_year},
).first()
recheck = conn.execute(
text(
"""
SELECT COALESCE(MAX(reviewed_at), ''), COUNT(*)
FROM wehago_recheck_reviews
WHERE (:start_year IS NULL OR fiscal_year >= :start_year)
AND (:end_year IS NULL OR fiscal_year <= :end_year)
"""
),
{"start_year": start_year, "end_year": end_year},
).first()
pair = conn.execute(
text(
"""
SELECT COALESCE(MAX(created_at), ''), COUNT(*)
FROM wehago_manual_pair_matches
WHERE (:start_year IS NULL OR fiscal_year >= :start_year)
AND (:end_year IS NULL OR fiscal_year <= :end_year)
"""
),
{"start_year": start_year, "end_year": end_year},
).first()
bridge_settings_row = conn.execute(
text(
"""
SELECT setting_json, updated_at
FROM wehago_compare_settings
WHERE setting_key = 'bridge_review'
"""
)
).mappings().first()
bridge_payload: dict[str, Any] = {}
if bridge_settings_row:
try:
decoded = json.loads(clean((bridge_settings_row or {}).get("setting_json")) or "{}")
if isinstance(decoded, dict):
bridge_payload = decoded
except Exception:
bridge_payload = {}
bridge_settings = _normalize_bridge_review_settings(bridge_payload or DEFAULT_BRIDGE_REVIEW_SETTINGS)
bridge_settings_sig = _bridge_review_settings_signature(bridge_settings)
return (
f"{_current_logic_signature()}|"
f"s:{source[0]}:{source[1]}|c:{comparison[0]}|"
f"r:{recheck[0]}:{recheck[1]}|p:{pair[0]}:{pair[1]}|"
f"b:{bridge_settings_sig}:{clean((bridge_settings_row or {}).get('updated_at'))}"
)
def _build_year_snapshot_job_key(year: int, signature: str) -> str:
return f"year_snapshot:{year}:{signature}"
def _build_metric_rebuild_job_key(start_year: int | None, end_year: int | None, signature: str) -> str:
return f"metric_counts:{start_year}:{end_year}:{signature}"
def _compare_job_scope(job_type: str, payload: dict[str, Any]) -> tuple[Any, ...] | None:
if job_type == "year_snapshot_rebuild":
year = int(payload.get("fiscal_year") or 0)
return ("year_snapshot_rebuild", year) if year > 0 else None
if job_type == "metric_counts_rebuild":
return ("metric_counts_rebuild", payload.get("start_year"), payload.get("end_year"))
return None
def _prune_duplicate_queued_compare_jobs(
conn: Any,
*,
job_type: str,
scope: tuple[Any, ...] | None,
keep_job_key: str,
) -> None:
if scope is None:
return
rows = conn.execute(
text(
"""
SELECT job_key, payload_json
FROM wehago_background_jobs
WHERE job_type = :job_type
AND state = 'queued'
"""
),
{"job_type": job_type},
).mappings().all()
delete_keys: list[str] = []
for row in rows:
job_key = clean(row.get("job_key"))
if not job_key or job_key == keep_job_key:
continue
try:
payload = json.loads(clean(row.get("payload_json")) or "{}")
except Exception:
payload = {}
if not isinstance(payload, dict):
payload = {}
if _compare_job_scope(job_type, payload) == scope:
delete_keys.append(job_key)
for job_key in delete_keys:
conn.execute(
text("DELETE FROM wehago_background_jobs WHERE job_key = :job_key"),
{"job_key": job_key},
)
def _normalize_compare_background_jobs(conn: Any) -> None:
rows = conn.execute(
text(
"""
SELECT job_key, job_type, payload_json, state, created_at, updated_at, started_at
FROM wehago_background_jobs
WHERE job_type IN ('year_snapshot_rebuild', 'metric_counts_rebuild', 'status_export_xlsx')
AND state IN ('queued', 'running')
ORDER BY updated_at DESC, created_at DESC
"""
)
).mappings().all()
now = datetime.now()
queued_keep: dict[tuple[Any, ...], str] = {}
queued_drop: list[str] = []
stale_running: list[str] = []
for row in rows:
job_key = clean(row.get("job_key"))
job_type = clean(row.get("job_type"))
try:
payload = json.loads(clean(row.get("payload_json")) or "{}")
except Exception:
payload = {}
if not isinstance(payload, dict):
payload = {}
scope = _compare_job_scope(job_type, payload)
state = clean(row.get("state")).lower()
if state == "running":
started_at = _parse_db_timestamp(row.get("started_at")) or _parse_db_timestamp(row.get("updated_at")) or _parse_db_timestamp(row.get("created_at"))
if started_at and (now - started_at).total_seconds() >= COMPARE_JOB_STALE_RUNNING_SEC:
stale_running.append(job_key)
continue
if state != "queued" or scope is None:
continue
if scope in queued_keep:
queued_drop.append(job_key)
continue
queued_keep[scope] = job_key
for job_key in stale_running:
conn.execute(
text(
"""
UPDATE wehago_background_jobs
SET state = 'failed',
error_message = 'abandoned stale running job cleaned during startup',
finished_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE job_key = :job_key
"""
),
{"job_key": job_key},
)
for job_key in queued_drop:
conn.execute(
text("DELETE FROM wehago_background_jobs WHERE job_key = :job_key"),
{"job_key": job_key},
)
def _normalize_compare_background_jobs_once(conn: Any, *, force: bool = False) -> None:
global _COMPARE_BACKGROUND_JOBS_NORMALIZED, _COMPARE_BACKGROUND_JOBS_NORMALIZED_AT
with _COMPARE_BACKGROUND_JOBS_NORMALIZE_LOCK:
now = time.time()
if (
not force
and _COMPARE_BACKGROUND_JOBS_NORMALIZED
and (now - float(_COMPARE_BACKGROUND_JOBS_NORMALIZED_AT or 0.0)) < _COMPARE_BACKGROUND_JOBS_NORMALIZE_TTL_SEC
):
return
_normalize_compare_background_jobs(conn)
_COMPARE_BACKGROUND_JOBS_NORMALIZED = True
_COMPARE_BACKGROUND_JOBS_NORMALIZED_AT = now
def _try_normalize_compare_background_jobs(conn: Any, *, force: bool = False) -> None:
try:
_normalize_compare_background_jobs_once(conn, force=force)
except OperationalError:
return
except Exception:
return
def _ensure_compare_snapshot_worker(engine: Any) -> None:
global _COMPARE_SNAPSHOT_WORKER_STARTED
with _COMPARE_SNAPSHOT_WORKER_LOCK:
if _COMPARE_SNAPSHOT_WORKER_STARTED:
return
worker = threading.Thread(
target=_compare_snapshot_worker_loop,
args=(engine,),
daemon=True,
name="wehago-compare-snapshot-worker",
)
worker.start()
_COMPARE_SNAPSHOT_WORKER_STARTED = True
def enqueue_year_snapshot_rebuild(
engine: Any,
years: Iterable[int],
) -> None:
years = sorted({int(year) for year in years if int(year or 0) > 0})
if not years:
return
init_wehago_compare_db(engine)
with engine.begin() as conn:
status_map = _load_snapshot_status_map(conn, years)
for year in years:
signature = _build_db_state_signature(conn, year, year)
if not _should_enqueue_year_snapshot(conn, year, signature, status_map.get(year)):
continue
job_key = _build_year_snapshot_job_key(year, signature)
priority = _compare_year_job_priority(year)
conn.execute(
text(
"""
INSERT INTO wehago_background_jobs (
job_key, job_type, payload_json, priority, state, error_message, created_at, updated_at
) VALUES (
:job_key, 'year_snapshot_rebuild', :payload_json, :priority, 'queued', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
ON CONFLICT(job_key) DO UPDATE SET
state = CASE
WHEN wehago_background_jobs.state = 'running' THEN wehago_background_jobs.state
ELSE 'queued'
END,
priority = excluded.priority,
payload_json = excluded.payload_json,
error_message = '',
updated_at = CURRENT_TIMESTAMP
"""
),
{
"job_key": job_key,
"priority": priority,
"payload_json": json.dumps({"fiscal_year": year, "signature": signature}, ensure_ascii=False),
},
)
_upsert_snapshot_status(
conn,
year,
signature=signature,
state="queued",
touch_requested=True,
)
_prune_duplicate_queued_compare_jobs(
conn,
job_type="year_snapshot_rebuild",
scope=("year_snapshot_rebuild", year),
keep_job_key=job_key,
)
_ensure_compare_snapshot_worker(engine)
_COMPARE_SNAPSHOT_JOB_EVENT.set()
def enqueue_metric_count_rebuild(
engine: Any,
start_year: int | None,
end_year: int | None,
) -> None:
if start_year is None or end_year is None:
return
init_wehago_compare_db(engine)
with engine.begin() as conn:
signature = _metric_counts_signature(conn, start_year, end_year)
job_key = _build_metric_rebuild_job_key(start_year, end_year, signature)
# metric 집계는 연도 스냅샷이 먼저 최신화된 뒤에 도는 편이
# 카드/상세 수치 수렴이 훨씬 안정적이다.
priority = max(140, _compare_range_job_priority(start_year, end_year) + 120)
conn.execute(
text(
"""
INSERT INTO wehago_background_jobs (
job_key, job_type, payload_json, priority, state, error_message, created_at, updated_at
) VALUES (
:job_key, 'metric_counts_rebuild', :payload_json, :priority, 'queued', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
ON CONFLICT(job_key) DO UPDATE SET
state = CASE
WHEN wehago_background_jobs.state = 'running' THEN wehago_background_jobs.state
ELSE 'queued'
END,
priority = excluded.priority,
payload_json = excluded.payload_json,
error_message = '',
updated_at = CURRENT_TIMESTAMP
"""
),
{
"job_key": job_key,
"priority": priority,
"payload_json": json.dumps(
{
"start_year": start_year,
"end_year": end_year,
"signature": signature,
},
ensure_ascii=False,
),
},
)
_prune_duplicate_queued_compare_jobs(
conn,
job_type="metric_counts_rebuild",
scope=("metric_counts_rebuild", start_year, end_year),
keep_job_key=job_key,
)
_ensure_compare_snapshot_worker(engine)
_COMPARE_SNAPSHOT_JOB_EVENT.set()
def _compare_snapshot_worker_loop(engine: Any) -> None:
while True:
_COMPARE_SNAPSHOT_JOB_EVENT.wait(timeout=5.0)
_COMPARE_SNAPSHOT_JOB_EVENT.clear()
while True:
init_wehago_compare_db(engine)
with engine.begin() as conn:
_normalize_compare_background_jobs(conn)
job = conn.execute(
text(
"""
SELECT job_key, job_type, payload_json
FROM wehago_background_jobs
WHERE job_type IN ('year_snapshot_rebuild', 'metric_counts_rebuild', 'status_export_xlsx')
AND state = 'queued'
ORDER BY priority ASC, created_at ASC
LIMIT 1
"""
)
).mappings().first()
if not job:
break
conn.execute(
text(
"""
UPDATE wehago_background_jobs
SET state = 'running',
error_message = '',
started_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE job_key = :job_key
"""
),
{"job_key": job["job_key"]},
)
if clean(job.get("job_type")) == 'status_export_xlsx':
conn.execute(
text(
"""
UPDATE wehago_compare_export_jobs
SET state = 'running',
error_message = '',
started_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE job_key = :job_key
"""
),
{"job_key": job["job_key"]},
)
try:
payload = json.loads(job.get("payload_json") or "{}")
if not isinstance(payload, dict):
payload = {}
with engine.begin() as conn:
if job["job_type"] == "year_snapshot_rebuild":
year = int(payload.get("fiscal_year") or 0)
if year > 0:
_refresh_year_resolved_sections(conn, year)
elif job["job_type"] == "metric_counts_rebuild":
start_year = payload.get("start_year")
end_year = payload.get("end_year")
counts = _resolved_metric_counts(conn, start_year, end_year)
_store_metric_counts_cache(conn, start_year, end_year, counts)
_store_summary_range_cache(
conn,
start_year,
end_year,
counts,
_get_compare_snapshot_state(conn, start_year, end_year),
)
elif job["job_type"] == "status_export_xlsx":
WEHAGO_COMPARE_EXPORT_ROOT.mkdir(parents=True, exist_ok=True)
normalized_status = _normalize_voucher_export_status_key(clean(payload.get("status")))
start_year = payload.get("start_year")
end_year = payload.get("end_year")
file_name, file_bytes, row_count = export_wehago_status_rows_xlsx(
engine,
start_year=start_year,
end_year=end_year,
status=normalized_status,
voucher_no=clean(payload.get("voucher_no")),
draft_no=clean(payload.get("draft_no")),
wehago_account=clean(payload.get("wehago_account")),
erp_account=clean(payload.get("erp_account")),
wehago_amount=clean(payload.get("wehago_amount")),
erp_amount=clean(payload.get("erp_amount")),
wehago_vendor=clean(payload.get("wehago_vendor")),
erp_vendor=clean(payload.get("erp_vendor")),
desc_keyword=clean(payload.get("desc_keyword")),
)
file_path = WEHAGO_COMPARE_EXPORT_ROOT / file_name
file_path.write_bytes(file_bytes)
export_job = _load_status_export_job(conn, clean(job.get("job_key"))) or {}
_store_status_export_job_file(
conn,
clean(job.get("job_key")),
file_name,
file_path,
row_count,
)
_cleanup_old_compare_export_files(keep_paths={str(file_path)})
conn.execute(
text(
"""
UPDATE wehago_background_jobs
SET state = 'done',
error_message = '',
finished_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE job_key = :job_key
"""
),
{"job_key": job["job_key"]},
)
except Exception as exc:
with engine.begin() as conn:
payload = {}
try:
payload = json.loads(job.get("payload_json") or "{}")
except Exception:
payload = {}
if job["job_type"] == "year_snapshot_rebuild":
year = int(payload.get("fiscal_year") or 0)
if year > 0:
_upsert_snapshot_status(
conn,
year,
signature=clean(payload.get("signature")),
state="failed",
error_message=clean(exc),
)
conn.execute(
text(
"""
UPDATE wehago_background_jobs
SET state = 'failed',
error_message = :error_message,
finished_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE job_key = :job_key
"""
),
{"job_key": job["job_key"], "error_message": clean(exc)},
)
def _discover_available_fiscal_years(conn: Any) -> list[int]:
rows = conn.execute(
text(
"""
SELECT DISTINCT fiscal_year
FROM (
SELECT fiscal_year FROM wehago_ledger_rows
UNION
SELECT fiscal_year FROM wehago_voucher_rows
UNION
SELECT fiscal_year FROM wehago_comparison_results
)
WHERE fiscal_year IS NOT NULL
AND fiscal_year > 0
ORDER BY fiscal_year
"""
)
).fetchall()
return [int(row[0]) for row in rows if str(row[0] or "").isdigit()]
def _load_snapshot_status_map(conn: Any, years: Iterable[int]) -> dict[int, dict[str, Any]]:
year_list = sorted({int(year) for year in years if int(year or 0) > 0})
if not year_list:
return {}
placeholders = ", ".join(f":year_{idx}" for idx in range(len(year_list)))
params = {f"year_{idx}": year for idx, year in enumerate(year_list)}
rows = conn.execute(
text(
f"""
SELECT fiscal_year, snapshot_signature, state, row_counts_json, error_message,
created_at, updated_at, last_requested_at, last_built_at
FROM wehago_snapshot_status
WHERE fiscal_year IN ({placeholders})
"""
),
params,
).mappings().all()
return {int(row["fiscal_year"]): dict(row) for row in rows}
def _should_enqueue_year_snapshot(
conn: Any,
year: int,
signature: str,
status_row: dict[str, Any] | None = None,
*,
force: bool = False,
today: date | None = None,
) -> bool:
if year <= 0:
return False
if force:
return True
row = status_row or {}
if clean(row.get("snapshot_signature")) == clean(signature) and clean(row.get("state")) == "ready":
return False
cooldown = _compare_year_requeue_cooldown_seconds(year, today)
state_text = clean(row.get("state")).lower()
recent_requested_at = _parse_db_timestamp(row.get("last_requested_at"))
if state_text in {"queued", "running"} and recent_requested_at:
if (datetime.now() - recent_requested_at).total_seconds() < cooldown:
return False
if recent_requested_at and (datetime.now() - recent_requested_at).total_seconds() < cooldown:
return False
return True
def _build_compare_snapshot_policy(
start_year: int | None,
end_year: int | None,
available_years: Iterable[int] | None = None,
) -> dict[str, Any]:
year_list = sorted({int(year) for year in (available_years or []) if int(year or 0) > 0})
if start_year is not None and end_year is not None and not year_list:
year_list = list(range(start_year, end_year + 1))
resolved_today = _today_local_date()
return {
"backfill_hint_start_year": COMPARE_BACKFILL_HINT_START_YEAR,
"rolling_update_start_year": COMPARE_ROLLING_UPDATE_START_YEAR,
"monthly_expected_ready_day": COMPARE_MONTHLY_EXPECTED_READY_DAY,
"range_start_year": start_year,
"range_end_year": end_year,
"year_modes": [
{"year": int(year), "mode": _classify_compare_year_mode(int(year), resolved_today)}
for year in year_list
if start_year is None or end_year is None or start_year <= int(year) <= end_year
],
}
def _compute_year_snapshot_runtime_state(
conn: Any,
year: int,
status_row: dict[str, Any] | None = None,
) -> dict[str, Any]:
signature = _build_db_state_signature(conn, year, year)
row = status_row or {}
persisted_state = clean(row.get("state")).lower()
stored_signature = clean(row.get("snapshot_signature"))
signature_matches = bool(stored_signature) and stored_signature == clean(signature)
if persisted_state in {"queued", "running", "failed"}:
display_state = persisted_state
cache_state = "stale" if stored_signature else "missing"
elif signature_matches:
display_state = "ready"
cache_state = "exact"
elif stored_signature:
display_state = "stale"
cache_state = "stale"
else:
display_state = "missing"
cache_state = "missing"
row_counts: dict[str, int] = {}
try:
parsed_counts = json.loads(clean(row.get("row_counts_json")) or "{}")
if isinstance(parsed_counts, dict):
row_counts = {str(key): int(value or 0) for key, value in parsed_counts.items()}
except Exception:
row_counts = {}
return {
"fiscal_year": int(year),
"mode": _classify_compare_year_mode(year),
"state": display_state,
"cache_state": cache_state,
"signature_matches": signature_matches,
"row_counts": row_counts,
"last_requested_at": clean(row.get("last_requested_at")),
"last_built_at": clean(row.get("last_built_at")),
"updated_at": clean(row.get("updated_at")),
"error_message": clean(row.get("error_message")),
}
def _load_recent_compare_jobs(
conn: Any,
start_year: int | None,
end_year: int | None,
limit: int = 20,
) -> list[dict[str, Any]]:
rows = conn.execute(
text(
"""
SELECT job_key, job_type, payload_json, priority, state, error_message,
created_at, updated_at, started_at, finished_at
FROM wehago_background_jobs
WHERE job_type IN ('year_snapshot_rebuild', 'metric_counts_rebuild', 'status_export_xlsx')
ORDER BY updated_at DESC, created_at DESC
LIMIT :limit
"""
),
{"limit": int(max(limit, 1))},
).mappings().all()
filtered: list[dict[str, Any]] = []
for row in rows:
payload = {}
try:
payload = json.loads(clean(row.get("payload_json")) or "{}")
except Exception:
payload = {}
job_type = clean(row.get("job_type"))
include = True
if job_type == "year_snapshot_rebuild":
year = int(payload.get("fiscal_year") or 0)
if start_year is not None and year < start_year:
include = False
if end_year is not None and year > end_year:
include = False
elif job_type == "metric_counts_rebuild":
payload_start = payload.get("start_year")
payload_end = payload.get("end_year")
if start_year is not None and end_year is not None:
include = payload_start == start_year and payload_end == end_year
if not include:
continue
filtered.append(
{
"job_key": clean(row.get("job_key")),
"job_type": job_type,
"priority": int(row.get("priority") or 0),
"state": clean(row.get("state")),
"error_message": clean(row.get("error_message")),
"created_at": clean(row.get("created_at")),
"updated_at": clean(row.get("updated_at")),
"started_at": clean(row.get("started_at")),
"finished_at": clean(row.get("finished_at")),
"payload": payload if isinstance(payload, dict) else {},
}
)
return filtered
def get_compare_snapshot_status(
engine: Any,
start_year: int | None,
end_year: int | None,
*,
force: bool = False,
) -> dict[str, Any]:
return _get_cached_snapshot_status_payload(engine, start_year, end_year, force=force)
def request_compare_snapshot_rebuild(
engine: Any,
start_year: int | None,
end_year: int | None,
*,
include_metric_counts: bool = True,
) -> dict[str, Any]:
if start_year is None or end_year is None:
raise ValueError("재생성할 기간이 필요합니다.")
years = list(range(min(start_year, end_year), max(start_year, end_year) + 1))
enqueue_year_snapshot_rebuild(engine, years)
if include_metric_counts:
enqueue_metric_count_rebuild(engine, start_year, end_year)
return {
"queued_years": years,
"include_metric_counts": bool(include_metric_counts),
}
def _clear_compare_runtime_caches() -> None:
global _COMPARE_BACKGROUND_JOBS_NORMALIZED, _COMPARE_BACKGROUND_JOBS_NORMALIZED_AT
_DASHBOARD_CACHE.clear()
_SUGGEST_CACHE.clear()
_STATUS_ROWS_CACHE.clear()
_VOUCHER_SECTION_CACHE.clear()
_VOUCHER_RECHECK_CACHE.clear()
_SNAPSHOT_STATUS_CACHE.clear()
_COMPARE_SUMMARY_CACHE.clear()
_COMPARE_BACKGROUND_JOBS_NORMALIZED = False
_COMPARE_BACKGROUND_JOBS_NORMALIZED_AT = 0.0
def _load_bridge_review_settings_from_conn(conn: Any) -> dict[str, Any]:
row = conn.execute(
text(
"""
SELECT setting_json, updated_at
FROM wehago_compare_settings
WHERE setting_key = 'bridge_review'
"""
)
).mappings().first()
parsed: dict[str, Any] = {}
if row:
try:
candidate = json.loads(clean(row.get("setting_json")) or "{}")
if isinstance(candidate, dict):
parsed = candidate
except Exception:
parsed = {}
settings = _normalize_bridge_review_settings(parsed)
settings["updated_at"] = clean((row or {}).get("updated_at"))
settings["settings_version"] = BRIDGE_REVIEW_SETTINGS_VERSION
return settings
def load_bridge_review_settings(engine: Any) -> dict[str, Any]:
init_wehago_compare_db(engine)
with engine.begin() as conn:
return _load_bridge_review_settings_from_conn(conn)
def save_bridge_review_settings(engine: Any, payload: dict[str, Any]) -> dict[str, Any]:
init_wehago_compare_db(engine)
settings = _normalize_bridge_review_settings(payload)
settings_json = json.dumps(settings, ensure_ascii=False, sort_keys=True)
with engine.begin() as conn:
conn.execute(
text(
"""
INSERT INTO wehago_compare_settings (setting_key, setting_json, updated_at)
VALUES ('bridge_review', :setting_json, CURRENT_TIMESTAMP)
ON CONFLICT(setting_key) DO UPDATE SET
setting_json = excluded.setting_json,
updated_at = CURRENT_TIMESTAMP
"""
),
{"setting_json": settings_json},
)
_clear_compare_runtime_caches()
return load_bridge_review_settings(engine)
def _collect_cached_status_rows_by_range(
conn: Any,
start_year: int | None,
end_year: int | None,
) -> tuple[dict[str, list[dict[str, Any]]], dict[str, list[int]]]:
rows_by_status = _empty_status_rows_by_status()
snapshot_state = {"missing": [], "stale": [], "ready": []}
if start_year is None or end_year is None:
return rows_by_status, snapshot_state
for year in range(start_year, end_year + 1):
signature = _build_db_state_signature(conn, year, year)
sections = _load_year_resolved_sections_cache(conn, year, signature)
state_name = "ready"
if sections is None:
sections = _load_latest_year_resolved_sections_cache_any_signature(conn, year)
state_name = "stale" if sections is not None else "missing"
snapshot_state[state_name].append(year)
if not sections:
continue
for status_key in rows_by_status:
rows_by_status[status_key].extend(sections.get(status_key, {}).get("rows", []))
return rows_by_status, snapshot_state
def _get_cached_status_rows_by_range(engine: Any, start_year: int | None, end_year: int | None) -> dict[str, list[dict[str, Any]]]:
if start_year is None or end_year is None:
return _empty_status_rows_by_status()
init_wehago_compare_db(engine)
with engine.begin() as conn:
cache_key = "|".join(
[
str(start_year),
str(end_year),
_build_bundle_signature(start_year, end_year),
_build_db_state_signature(conn, start_year, end_year),
]
)
now = time.time()
cached = _STATUS_ROWS_CACHE.get(cache_key)
cache_ttl = float(cached.get("ttl", _STATUS_ROWS_CACHE_TTL_SEC)) if cached else _STATUS_ROWS_CACHE_TTL_SEC
if cached and (now - float(cached.get("ts", 0))) <= cache_ttl:
return cached["rows_by_status"]
rows_by_status, snapshot_state = _collect_cached_status_rows_by_range(conn, start_year, end_year)
if start_year != end_year:
section_payload = {
status_key: {
"rows": list(rows),
"count": len(rows),
"columns": DETAIL_COLUMN_MAP[status_key],
}
for status_key, rows in rows_by_status.items()
}
section_payload = _promote_cross_year_auto_matches(section_payload)
rows_by_status = {
status_key: list(section_payload[status_key]["rows"])
for status_key in rows_by_status
}
pending_years = snapshot_state["missing"] + snapshot_state["stale"]
ttl_seconds = 15 if pending_years else _STATUS_ROWS_CACHE_TTL_SEC
_STATUS_ROWS_CACHE.clear()
_VOUCHER_RECHECK_CACHE.clear()
_STATUS_ROWS_CACHE[cache_key] = {"ts": now, "rows_by_status": rows_by_status, "ttl": ttl_seconds}
if pending_years:
try:
enqueue_year_snapshot_rebuild(engine, pending_years)
except Exception:
pass
return rows_by_status
return rows_by_status
def _build_voucher_section_cache_key(conn: Any, start_year: int, end_year: int) -> str:
return "|".join(
[
str(start_year),
str(end_year),
_build_bundle_signature(start_year, end_year),
_build_db_state_signature(conn, start_year, end_year),
]
)
def _get_cached_voucher_sections_by_range(
engine: Any,
start_year: int | None,
end_year: int | None,
rows_by_status: dict[str, list[dict[str, Any]]] | None = None,
) -> dict[str, list[dict[str, Any]]]:
if start_year is None or end_year is None:
return {
"voucher_matched": [],
"erp_voucher_matched": [],
"voucher_unmatched": [],
"voucher_recheck": [],
}
init_wehago_compare_db(engine)
with engine.begin() as conn:
cache_key = _build_voucher_section_cache_key(conn, start_year, end_year)
now = time.time()
cached = _VOUCHER_SECTION_CACHE.get(cache_key)
if cached and (now - float(cached.get("ts", 0))) <= _VOUCHER_SECTION_CACHE_TTL_SEC:
return cached["sections"]
if rows_by_status is None:
rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year)
with engine.begin() as conn:
sections = _build_voucher_sections_from_rows_by_status(rows_by_status)
sections = _apply_bridge_expense_promotions_to_voucher_sections(conn, start_year, end_year, sections)
_VOUCHER_SECTION_CACHE.clear()
_VOUCHER_RECHECK_CACHE.clear()
_VOUCHER_SECTION_CACHE[cache_key] = {"ts": now, "sections": sections}
return sections
def _build_voucher_recheck_groups_from_rows_by_status(
rows_by_status: dict[str, list[dict[str, Any]]],
) -> list[dict[str, Any]]:
grouped: dict[tuple[str, int, str, str], dict[str, Any]] = {}
matched_ledger_keys: set[str] = set()
matched_voucher_keys: set[str] = set()
matched_wehago_group_keys: set[tuple[int, str, str]] = set()
def erp_set_key_from_values(fiscal_year: Any, draft_no: Any, voucher_no: Any, proof_date: Any) -> tuple[int, str]:
normalized_draft = clean(draft_no)
normalized_voucher = clean(voucher_no)
normalized_date = clean(proof_date)
base = normalized_draft or normalized_voucher
if base:
base = re.sub(r"-\d+$", "", base)
if not base and normalized_date and normalized_voucher:
base = f"{normalized_date}|{normalized_voucher}"
return (int(fiscal_year or 0), base)
def wehago_group_key(row: dict[str, Any]) -> tuple[int, str, str]:
return (
int(row.get("fiscal_year") or 0),
clean(row.get("voucher_no")),
clean(row.get("ledger_date")),
)
def erp_group_key(row: dict[str, Any]) -> tuple[int, str]:
return erp_set_key_from_values(
row.get("fiscal_year"),
row.get("draft_no"),
row.get("voucher_no"),
row.get("proof_date"),
)
def row_origin_rank(row: dict[str, Any]) -> int:
status_label = clean(row.get("status_label"))
if status_label == "Matched":
return 0
if status_label == "Unmatched":
return 1
if status_label == "ERP Unmatched":
return 2
if status_label == "Recheck":
return 3
return 4
def ensure_group(row: dict[str, Any]) -> dict[str, Any]:
fiscal_year = int(row.get("fiscal_year") or 0)
voucher_no = clean(row.get("voucher_no"))
draft_no = clean(row.get("draft_no"))
ledger_date = clean(row.get("ledger_date"))
proof_date = clean(row.get("proof_date"))
fallback_voucher_key = clean(
row.get("ledger_row_key")
or row.get("voucher_row_key")
or row.get("review_key")
or ledger_date
or proof_date
or row.get("ledger_desc")
or row.get("voucher_desc")
)
group_key_parts = [part for part in [voucher_no, draft_no, ledger_date, proof_date] if part]
if voucher_no and ledger_date:
group_key = "|".join([str(fiscal_year), ledger_date, voucher_no])
elif voucher_no and proof_date:
group_key = "|".join([str(fiscal_year), proof_date, voucher_no])
elif group_key_parts:
group_key = "|".join([str(fiscal_year), *group_key_parts])
else:
group_key = fallback_voucher_key
key = ("amount_mismatch", fiscal_year, group_key, "")
current = grouped.get(key)
if current is None:
current = {
"fiscal_year": fiscal_year,
"status_label": row_status_label,
"ledger_date": ledger_date,
"proof_date": proof_date,
"voucher_no": voucher_no,
"draft_no": draft_no,
"ledger_row_count": 0,
"voucher_row_count": 0,
"ledger_debit": 0.0,
"ledger_credit": 0.0,
"voucher_debit": 0.0,
"voucher_credit": 0.0,
"ledger_accounts": [],
"voucher_accounts": [],
"ledger_vendors": [],
"voucher_vendors": [],
"review_reason": [],
"rows": [],
"wehago_group_key": wehago_group_key(row),
"erp_group_keys": set(),
"draft_nos": [],
}
grouped[key] = current
return current
def append_unique(target: list[str], value: Any) -> None:
text_value = clean(value)
if text_value and text_value not in target:
target.append(text_value)
for row in rows_by_status.get("matched", []):
matched_wehago_group_keys.add(wehago_group_key(row))
ledger_key = clean(row.get("ledger_row_key"))
voucher_key = clean(row.get("voucher_row_key"))
if ledger_key:
matched_ledger_keys.add(ledger_key)
if voucher_key:
matched_voucher_keys.add(voucher_key)
for row in rows_by_status.get("amount_mismatch", []):
current = ensure_group(row)
if not current.get("ledger_date"):
current["ledger_date"] = clean(row.get("ledger_date"))
if not current.get("proof_date"):
current["proof_date"] = clean(row.get("proof_date"))
if clean(row.get("ledger_account_name")) or clean(row.get("ledger_desc")):
current["ledger_row_count"] += 1
if clean(row.get("voucher_account_name")) or clean(row.get("voucher_desc")):
current["voucher_row_count"] += 1
current["ledger_debit"] += parse_amount(row.get("ledger_debit"))
current["ledger_credit"] += parse_amount(row.get("ledger_credit"))
current["voucher_debit"] += parse_amount(row.get("voucher_debit"))
current["voucher_credit"] += parse_amount(row.get("voucher_credit"))
append_unique(current["ledger_accounts"], row.get("ledger_account_name"))
append_unique(current["voucher_accounts"], row.get("voucher_account_name"))
append_unique(current["ledger_vendors"], row.get("ledger_vendor"))
append_unique(current["voucher_vendors"], row.get("voucher_vendor"))
append_unique(current["review_reason"], row.get("review_reason"))
append_unique(current["draft_nos"], row.get("draft_no"))
row_payload = dict(row)
row_payload.setdefault("status_label", current.get("status_label"))
current["rows"].append(row_payload)
current["erp_group_keys"].add(erp_group_key(row))
ledger_only_index: dict[tuple[int, str, str], list[dict[str, Any]]] = {}
for row in rows_by_status.get("ledger_only", []):
ledger_only_index.setdefault(wehago_group_key(row), []).append(dict(row))
voucher_only_index: dict[tuple[int, str], list[dict[str, Any]]] = {}
for row in rows_by_status.get("voucher_only", []):
voucher_only_index.setdefault(erp_group_key(row), []).append(dict(row))
recheck_by_erp_index: dict[tuple[int, str], list[dict[str, Any]]] = {}
for row in rows_by_status.get("amount_mismatch", []):
recheck_by_erp_index.setdefault(erp_group_key(row), []).append(dict(row))
def recheck_identity(row: dict[str, Any]) -> str:
return (
clean(row.get("review_key"))
or "|".join(
[
clean(row.get("ledger_row_key")),
clean(row.get("voucher_row_key")),
clean(row.get("voucher_no")),
clean(row.get("draft_no")),
clean(row.get("ledger_account_name")),
clean(row.get("voucher_account_name")),
clean(row.get("ledger_desc")),
clean(row.get("voucher_desc")),
]
)
)
def append_recheck_row(target_rows: list[dict[str, Any]], extra_recheck: dict[str, Any], appended_recheck_ids: set[str]) -> None:
identity = recheck_identity(extra_recheck)
if identity and identity in appended_recheck_ids:
return
if identity:
appended_recheck_ids.add(identity)
recheck_payload = dict(extra_recheck)
recheck_payload["status_label"] = "Recheck"
target_rows.append(recheck_payload)
groups: list[dict[str, Any]] = []
for (_status_key, _fiscal_year, _group_key, _), row in grouped.items():
if row.get("wehago_group_key") in matched_wehago_group_keys:
continue
for extra_ledger in ledger_only_index.get(row.get("wehago_group_key"), []):
ledger_key = clean(extra_ledger.get("ledger_row_key"))
if ledger_key and ledger_key in matched_ledger_keys:
continue
row["rows"].append(
{
**dict(extra_ledger),
"status_label": "Unmatched",
"voucher_account_code": "",
"voucher_account_name": "",
"voucher_vendor": "",
"voucher_debit": "",
"voucher_credit": "",
"voucher_desc": "",
"draft_no": "",
"proof_date": "",
}
)
appended_recheck_ids: set[str] = {recheck_identity(existing_row) for existing_row in row["rows"]}
appended_voucher_keys: set[str] = set()
for erp_key in row.get("erp_group_keys", set()):
for extra_voucher in voucher_only_index.get(erp_key, []):
voucher_key = clean(extra_voucher.get("voucher_row_key"))
if voucher_key and voucher_key in matched_voucher_keys:
continue
if voucher_key and voucher_key in appended_voucher_keys:
continue
if voucher_key:
appended_voucher_keys.add(voucher_key)
row["rows"].append(
{
**dict(extra_voucher),
"status_label": "ERP Unmatched",
"ledger_account_code": "",
"ledger_account_name": "",
"ledger_vendor": "",
"ledger_debit": "",
"ledger_credit": "",
"ledger_desc": "",
"ledger_date": row.get("ledger_date", ""),
"voucher_no": row.get("voucher_no", extra_voucher.get("voucher_no", "")),
}
)
for extra_recheck in recheck_by_erp_index.get(erp_key, []):
append_recheck_row(row["rows"], extra_recheck, appended_recheck_ids)
row["rows"].sort(
key=lambda item: (
clean(item.get("ledger_date")) or clean(item.get("proof_date")),
clean(item.get("voucher_no")),
row_origin_rank(item),
0 if (clean(item.get("ledger_account_name")) and clean(item.get("voucher_account_name"))) else 1 if clean(item.get("ledger_account_name")) else 2,
clean(item.get("ledger_account_name")) or clean(item.get("voucher_account_name")),
clean(item.get("draft_no")),
-parse_amount(item.get("ledger_debit") or item.get("voucher_debit")),
-parse_amount(item.get("ledger_credit") or item.get("voucher_credit")),
clean(item.get("ledger_desc")) or clean(item.get("voucher_desc")),
)
)
normalized_summary = dict(row)
normalized_summary["ledger_accounts"] = ", ".join(row["ledger_accounts"])
normalized_summary["voucher_accounts"] = ", ".join(row["voucher_accounts"])
normalized_summary["ledger_vendors"] = ", ".join(row["ledger_vendors"])
normalized_summary["voucher_vendors"] = ", ".join(row["voucher_vendors"])
normalized_summary["review_reason"] = " / ".join(row["review_reason"])
normalized_summary["draft_no"] = ", ".join(row["draft_nos"])
groups.append({"summary": normalized_summary, "rows": list(row["rows"])})
groups.sort(
key=lambda group: (
int(group.get("summary", {}).get("fiscal_year") or 0),
clean(group.get("summary", {}).get("ledger_date")) or clean(group.get("summary", {}).get("proof_date")),
clean(group.get("summary", {}).get("voucher_no")),
clean(group.get("summary", {}).get("draft_no")),
)
)
return groups
def _get_cached_voucher_recheck_groups_by_range(
engine: Any,
start_year: int | None,
end_year: int | None,
) -> list[dict[str, Any]]:
if start_year is None or end_year is None:
return []
init_wehago_compare_db(engine)
with engine.begin() as conn:
cache_key = "|".join(
[
str(start_year),
str(end_year),
_build_bundle_signature(start_year, end_year),
_build_db_state_signature(conn, start_year, end_year),
]
)
now = time.time()
cached = _VOUCHER_RECHECK_CACHE.get(cache_key)
if cached and (now - float(cached.get("ts", 0))) <= _VOUCHER_SECTION_CACHE_TTL_SEC:
return cached["groups"]
rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year)
groups = _build_voucher_recheck_groups_from_rows_by_status(rows_by_status)
_VOUCHER_RECHECK_CACHE.clear()
_VOUCHER_RECHECK_CACHE[cache_key] = {"ts": now, "groups": groups}
return groups
def _build_voucher_sections_with_bridge_promotions(
conn: Any,
start_year: int | None,
end_year: int | None,
rows_by_status: dict[str, list[dict[str, Any]]],
) -> dict[str, list[dict[str, Any]]]:
sections = _build_voucher_sections_from_rows_by_status(rows_by_status)
return _apply_bridge_expense_promotions_to_voucher_sections(conn, start_year, end_year, sections)
def _push_action_history(conn: Any, action_type: str, payload: dict[str, Any]) -> int:
result = conn.execute(
text(
"""
INSERT INTO wehago_action_history (action_type, payload_json, created_at)
VALUES (:action_type, :payload_json, CURRENT_TIMESTAMP)
"""
),
{"action_type": action_type, "payload_json": json.dumps(payload, ensure_ascii=False)},
)
return int(result.lastrowid)
def apply_saved_manual_pair_matches(
sections: dict[str, dict[str, Any]],
pair_matches: list[dict[str, Any]],
) -> dict[str, dict[str, Any]]:
if not pair_matches:
return sections
adjusted: dict[str, dict[str, Any]] = {}
for key, section in sections.items():
adjusted[key] = {
"count": section["count"],
"columns": section["columns"],
"rows": list(section["rows"]),
}
ledger_rows = adjusted["ledger_only"]["rows"]
voucher_rows = adjusted["voucher_only"]["rows"]
matched_rows = adjusted["matched"]["rows"]
ledger_by_key = {clean(row.get("ledger_row_key")): row for row in ledger_rows if clean(row.get("ledger_row_key"))}
voucher_by_key = {clean(row.get("voucher_row_key")): row for row in voucher_rows if clean(row.get("voucher_row_key"))}
ledger_remove: set[str] = set()
voucher_remove: set[str] = set()
matched_pair_keys = {clean(row.get("pair_match_key")) for row in matched_rows if clean(row.get("pair_match_key"))}
for pair in pair_matches:
ledger_key = clean(pair.get("ledger_row_key"))
voucher_key = clean(pair.get("voucher_row_key"))
pair_key = clean(pair.get("pair_key"))
if not ledger_key and not voucher_key:
continue
ledger_row = ledger_by_key.get(ledger_key)
voucher_row = voucher_by_key.get(voucher_key)
if not ledger_row or not voucher_row:
continue
if not _account_category_pair_allowed(
ledger_row.get("ledger_account_code"),
ledger_row.get("ledger_account_name"),
voucher_row.get("voucher_account_code"),
voucher_row.get("voucher_account_name"),
):
continue
if pair_key and pair_key in matched_pair_keys:
continue
if ledger_key:
ledger_remove.add(ledger_key)
if voucher_key:
voucher_remove.add(voucher_key)
merged = dict(ledger_row)
merged.update(
{
"proof_date": voucher_row.get("proof_date", ""),
"draft_no": voucher_row.get("draft_no", ""),
"voucher_account_code": voucher_row.get("voucher_account_code", ""),
"voucher_account_name": voucher_row.get("voucher_account_name", ""),
"voucher_vendor": voucher_row.get("voucher_vendor", ""),
"voucher_debit": voucher_row.get("voucher_debit", 0),
"voucher_credit": voucher_row.get("voucher_credit", 0),
"voucher_desc": voucher_row.get("voucher_desc", ""),
"voucher_row_key": voucher_row.get("voucher_row_key", ""),
"review_reason": "수동쌍매칭",
"review_memo": clean(pair.get("pair_note")),
"pair_match_key": pair_key,
"match_identity_key": build_manual_pair_key(ledger_key, voucher_key),
}
)
matched_rows.append(merged)
if pair_key:
matched_pair_keys.add(pair_key)
if ledger_remove:
adjusted["ledger_only"]["rows"] = [row for row in ledger_rows if clean(row.get("ledger_row_key")) not in ledger_remove]
if voucher_remove:
adjusted["voucher_only"]["rows"] = [row for row in voucher_rows if clean(row.get("voucher_row_key")) not in voucher_remove]
adjusted["matched"]["count"] = len(adjusted["matched"]["rows"])
adjusted["ledger_only"]["count"] = len(adjusted["ledger_only"]["rows"])
adjusted["voucher_only"]["count"] = len(adjusted["voucher_only"]["rows"])
return adjusted
def save_recheck_review_rows(engine: Any, rows: list[dict[str, Any]]) -> int:
init_wehago_compare_db(engine)
normalized_rows = []
for row in rows:
if not isinstance(row, dict):
continue
review_key = clean(row.get("review_key"))
if not review_key and not clean(row.get("voucher_no")):
continue
normalized_rows.append(
{
"review_key": review_key,
"fiscal_year": int(row.get("fiscal_year") or 0) or None,
"voucher_no": clean(row.get("voucher_no")),
"draft_no": clean(row.get("draft_no")),
"ledger_date": clean(row.get("ledger_date")),
"proof_date": clean(row.get("proof_date")),
"ledger_account_name": clean(row.get("ledger_account_name")),
"voucher_account_code": clean(row.get("voucher_account_code")),
"voucher_account_name": clean(row.get("voucher_account_name")),
"ledger_vendor": clean(row.get("ledger_vendor")),
"voucher_vendor": clean(row.get("voucher_vendor")),
"ledger_desc": clean(row.get("ledger_desc")),
"voucher_desc": clean(row.get("voucher_desc")),
"review_reason": clean(row.get("review_reason")),
"review_memo": clean(row.get("review_memo")),
}
)
if not normalized_rows:
return 0
selected_by_year: dict[int, list[dict[str, Any]]] = {}
for row in normalized_rows:
fiscal_year = int(row.get("fiscal_year") or 0)
if fiscal_year <= 0:
continue
selected_by_year.setdefault(fiscal_year, []).append(row)
expanded_rows_by_key: dict[str, dict[str, Any]] = {}
saved_keys: list[str] = []
with engine.begin() as conn:
for year, year_rows in selected_by_year.items():
sections = _get_or_create_year_resolved_sections(conn, year) or {}
recheck_rows = list((sections.get("amount_mismatch") or {}).get("rows") or [])
if not recheck_rows:
continue
recheck_by_key = {
clean(item.get("review_key")): item
for item in recheck_rows
if clean(item.get("review_key"))
}
for selected in year_rows:
selected_key = clean(selected.get("review_key"))
source_row = recheck_by_key.get(selected_key) if selected_key else None
if not source_row:
source_row = next(
(
item
for item in recheck_rows
if int(item.get("fiscal_year") or 0) == year
and clean(item.get("voucher_no")) == clean(selected.get("voucher_no"))
and (
not clean(selected.get("draft_no"))
or clean(item.get("draft_no")) == clean(selected.get("draft_no"))
)
and (
not clean(selected.get("ledger_date"))
or clean(item.get("ledger_date")) == clean(selected.get("ledger_date"))
)
and (
not clean(selected.get("ledger_account_name"))
or clean(item.get("ledger_account_name")) == clean(selected.get("ledger_account_name"))
)
and (
not clean(selected.get("voucher_account_name"))
or clean(item.get("voucher_account_name")) == clean(selected.get("voucher_account_name"))
)
),
None,
)
if not source_row:
wehago_key = _wehago_group_key_for_row(selected)
erp_key = _erp_group_key_for_row(selected)
for candidate in recheck_rows:
if (
_wehago_group_key_for_row(candidate) == wehago_key
or _erp_group_key_for_row(candidate) == erp_key
):
candidate_key = clean(candidate.get("review_key"))
if not candidate_key:
continue
expanded_rows_by_key[candidate_key] = {
"review_key": candidate_key,
"fiscal_year": int(candidate.get("fiscal_year") or 0) or None,
"voucher_no": clean(candidate.get("voucher_no")),
"draft_no": clean(candidate.get("draft_no")),
"voucher_account_code": clean(candidate.get("voucher_account_code")),
"voucher_account_name": clean(candidate.get("voucher_account_name")),
"voucher_vendor": clean(candidate.get("voucher_vendor")),
"voucher_desc": clean(candidate.get("voucher_desc")),
"review_reason": clean(candidate.get("review_reason")) or "수동매칭승인",
"review_memo": clean(candidate.get("review_memo")),
}
continue
wehago_key = _wehago_group_key_for_row(source_row)
erp_key = _erp_group_key_for_row(source_row)
for candidate in recheck_rows:
if (
_wehago_group_key_for_row(candidate) == wehago_key
or _erp_group_key_for_row(candidate) == erp_key
):
candidate_key = clean(candidate.get("review_key"))
if not candidate_key:
continue
expanded_rows_by_key[candidate_key] = {
"review_key": candidate_key,
"fiscal_year": int(candidate.get("fiscal_year") or 0) or None,
"voucher_no": clean(candidate.get("voucher_no")),
"draft_no": clean(candidate.get("draft_no")),
"voucher_account_code": clean(candidate.get("voucher_account_code")),
"voucher_account_name": clean(candidate.get("voucher_account_name")),
"voucher_vendor": clean(candidate.get("voucher_vendor")),
"voucher_desc": clean(candidate.get("voucher_desc")),
"review_reason": clean(candidate.get("review_reason")) or "수동매칭승인",
"review_memo": clean(candidate.get("review_memo")),
}
if expanded_rows_by_key:
normalized_rows = list(expanded_rows_by_key.values())
for item in normalized_rows:
conn.execute(
text(
"""
INSERT INTO wehago_recheck_reviews (
review_key, fiscal_year, voucher_no, draft_no,
voucher_account_code, voucher_account_name, voucher_vendor,
voucher_desc, review_reason, review_memo, reviewed_at
) VALUES (
:review_key, :fiscal_year, :voucher_no, :draft_no,
:voucher_account_code, :voucher_account_name, :voucher_vendor,
:voucher_desc, :review_reason, :review_memo, CURRENT_TIMESTAMP
)
ON CONFLICT(review_key) DO UPDATE SET
fiscal_year = excluded.fiscal_year,
voucher_no = excluded.voucher_no,
draft_no = excluded.draft_no,
voucher_account_code = excluded.voucher_account_code,
voucher_account_name = excluded.voucher_account_name,
voucher_vendor = excluded.voucher_vendor,
voucher_desc = excluded.voucher_desc,
review_reason = excluded.review_reason,
review_memo = excluded.review_memo,
reviewed_at = CURRENT_TIMESTAMP
"""
),
item,
)
saved_keys.append(item["review_key"])
_push_action_history(
conn,
"recheck_save",
{
"review_keys": saved_keys,
"count": len(saved_keys),
},
)
_DASHBOARD_CACHE.clear()
_SUGGEST_CACHE.clear()
_STATUS_ROWS_CACHE.clear()
_VOUCHER_RECHECK_CACHE.clear()
_PAIR_RECOMMEND_CACHE.clear()
clear_persisted_pair_recommend_cache(engine)
return len(normalized_rows)
def save_manual_pair_matches(
engine: Any,
ledger_rows: list[dict[str, Any]],
voucher_rows: list[dict[str, Any]],
pair_meta_by_key: dict[str, dict[str, Any]] | None = None,
match_source: str = "manual",
) -> int:
init_wehago_compare_db(engine)
normalized_ledger = [row for row in ledger_rows if isinstance(row, dict) and clean(row.get("ledger_row_key"))]
normalized_voucher = [row for row in voucher_rows if isinstance(row, dict) and clean(row.get("voucher_row_key"))]
if not normalized_ledger or not normalized_voucher:
return 0
if len(normalized_ledger) != len(normalized_voucher):
raise ValueError("WEHAGO/ERP 선택 건수를 동일하게 맞춘 뒤 저장해주세요.")
rows_to_save: list[dict[str, Any]] = []
for ledger_row, voucher_row in zip(normalized_ledger, normalized_voucher):
ledger_key = clean(ledger_row.get("ledger_row_key"))
voucher_key = clean(voucher_row.get("voucher_row_key"))
pair_key = build_manual_pair_key(ledger_key, voucher_key)
if not _account_category_pair_allowed(
ledger_row.get("ledger_account_code"),
ledger_row.get("ledger_account_name"),
voucher_row.get("voucher_account_code"),
voucher_row.get("voucher_account_name"),
):
raise ValueError("자산/부채/비용/수익 계열이 다른 계정끼리는 수동 매칭할 수 없습니다.")
meta = (pair_meta_by_key or {}).get(pair_key, {})
fiscal_year = int(ledger_row.get("fiscal_year") or voucher_row.get("fiscal_year") or 0) or None
rows_to_save.append(
{
"pair_key": pair_key,
"fiscal_year": fiscal_year,
"ledger_row_key": ledger_key,
"voucher_row_key": voucher_key,
"ledger_voucher_no": clean(ledger_row.get("voucher_no")),
"ledger_account_code": clean(ledger_row.get("ledger_account_code")),
"ledger_account_name": clean(ledger_row.get("ledger_account_name")),
"ledger_vendor": clean(ledger_row.get("ledger_vendor")),
"ledger_debit": parse_amount(ledger_row.get("ledger_debit")),
"ledger_credit": parse_amount(ledger_row.get("ledger_credit")),
"ledger_desc": clean(ledger_row.get("ledger_desc")),
"voucher_no": clean(voucher_row.get("voucher_no")),
"draft_no": clean(voucher_row.get("draft_no")),
"voucher_account_code": clean(voucher_row.get("voucher_account_code")),
"voucher_account_name": clean(voucher_row.get("voucher_account_name")),
"voucher_vendor": clean(voucher_row.get("voucher_vendor")),
"voucher_debit": parse_amount(voucher_row.get("voucher_debit")),
"voucher_credit": parse_amount(voucher_row.get("voucher_credit")),
"voucher_desc": clean(voucher_row.get("voucher_desc")),
"match_source": clean(meta.get("match_source")) or clean(match_source) or "manual",
"confidence_score": float(meta.get("score") or 0),
"confidence_level": clean(meta.get("confidence_level")),
"match_reason": clean(meta.get("reason")),
"pair_note": "",
}
)
saved_pair_keys: list[str] = []
with engine.begin() as conn:
for item in rows_to_save:
conn.execute(
text(
"""
INSERT INTO wehago_manual_pair_matches (
pair_key, fiscal_year, ledger_row_key, voucher_row_key,
ledger_voucher_no, ledger_account_code, ledger_account_name, ledger_vendor,
ledger_debit, ledger_credit, ledger_desc,
voucher_no, draft_no, voucher_account_code, voucher_account_name, voucher_vendor,
voucher_debit, voucher_credit, voucher_desc,
match_source, confidence_score, confidence_level, match_reason,
pair_note, created_at
) VALUES (
:pair_key, :fiscal_year, :ledger_row_key, :voucher_row_key,
:ledger_voucher_no, :ledger_account_code, :ledger_account_name, :ledger_vendor,
:ledger_debit, :ledger_credit, :ledger_desc,
:voucher_no, :draft_no, :voucher_account_code, :voucher_account_name, :voucher_vendor,
:voucher_debit, :voucher_credit, :voucher_desc,
:match_source, :confidence_score, :confidence_level, :match_reason,
:pair_note, CURRENT_TIMESTAMP
)
ON CONFLICT(pair_key) DO UPDATE SET
fiscal_year = excluded.fiscal_year,
ledger_row_key = excluded.ledger_row_key,
voucher_row_key = excluded.voucher_row_key,
ledger_voucher_no = excluded.ledger_voucher_no,
ledger_account_code = excluded.ledger_account_code,
ledger_account_name = excluded.ledger_account_name,
ledger_vendor = excluded.ledger_vendor,
ledger_debit = excluded.ledger_debit,
ledger_credit = excluded.ledger_credit,
ledger_desc = excluded.ledger_desc,
voucher_no = excluded.voucher_no,
draft_no = excluded.draft_no,
voucher_account_code = excluded.voucher_account_code,
voucher_account_name = excluded.voucher_account_name,
voucher_vendor = excluded.voucher_vendor,
voucher_debit = excluded.voucher_debit,
voucher_credit = excluded.voucher_credit,
voucher_desc = excluded.voucher_desc,
match_source = excluded.match_source,
confidence_score = excluded.confidence_score,
confidence_level = excluded.confidence_level,
match_reason = excluded.match_reason,
pair_note = excluded.pair_note,
created_at = CURRENT_TIMESTAMP
"""
),
item,
)
saved_pair_keys.append(item["pair_key"])
_push_action_history(
conn,
"pair_save",
{
"pair_keys": saved_pair_keys,
"count": len(saved_pair_keys),
"match_source": clean(match_source) or "manual",
},
)
_DASHBOARD_CACHE.clear()
_SUGGEST_CACHE.clear()
_STATUS_ROWS_CACHE.clear()
_VOUCHER_RECHECK_CACHE.clear()
_PAIR_RECOMMEND_CACHE.clear()
clear_persisted_pair_recommend_cache(engine)
return len(rows_to_save)
def get_last_action_summary(engine: Any | None = None, conn: Any | None = None) -> dict[str, Any] | None:
if conn is None:
if engine is None:
return None
init_wehago_compare_db(engine)
with engine.begin() as local_conn:
return get_last_action_summary(conn=local_conn)
row = conn.execute(
text(
"""
SELECT id, action_type, payload_json, created_at
FROM wehago_action_history
ORDER BY id DESC
LIMIT 1
"""
)
).mappings().first()
if not row:
return None
payload = {}
try:
payload = json.loads(row["payload_json"] or "{}")
except json.JSONDecodeError:
payload = {}
count = int(payload.get("count") or 0)
return {
"id": int(row["id"]),
"action_type": clean(row["action_type"]),
"count": count,
"created_at": clean(row["created_at"]),
}
def undo_last_action(engine: Any) -> dict[str, Any]:
init_wehago_compare_db(engine)
with engine.begin() as conn:
row = conn.execute(
text(
"""
SELECT id, action_type, payload_json
FROM wehago_action_history
ORDER BY id DESC
LIMIT 1
"""
)
).mappings().first()
if not row:
return {"undone": False, "message": "되돌릴 작업이 없습니다."}
action_type = clean(row["action_type"])
payload = {}
try:
payload = json.loads(row["payload_json"] or "{}")
except json.JSONDecodeError:
payload = {}
affected = 0
if action_type == "recheck_save":
keys = [clean(key) for key in payload.get("review_keys", []) if clean(key)]
if keys:
placeholders = ", ".join(f":k{i}" for i in range(len(keys)))
params = {f"k{i}": key for i, key in enumerate(keys)}
result = conn.execute(text(f"DELETE FROM wehago_recheck_reviews WHERE review_key IN ({placeholders})"), params)
affected = int(result.rowcount or 0)
elif action_type == "pair_save":
keys = [clean(key) for key in payload.get("pair_keys", []) if clean(key)]
if keys:
placeholders = ", ".join(f":k{i}" for i in range(len(keys)))
params = {f"k{i}": key for i, key in enumerate(keys)}
result = conn.execute(text(f"DELETE FROM wehago_manual_pair_matches WHERE pair_key IN ({placeholders})"), params)
affected = int(result.rowcount or 0)
conn.execute(text("DELETE FROM wehago_action_history WHERE id = :id"), {"id": row["id"]})
_DASHBOARD_CACHE.clear()
_SUGGEST_CACHE.clear()
_STATUS_ROWS_CACHE.clear()
_VOUCHER_RECHECK_CACHE.clear()
_PAIR_RECOMMEND_CACHE.clear()
clear_persisted_pair_recommend_cache(engine)
return {"undone": True, "action_type": action_type, "affected": affected}
def _parse_iso_date(value: Any) -> date | None:
parsed = parse_excel_date(value)
if not parsed:
return None
try:
return datetime.strptime(parsed, "%Y-%m-%d").date()
except ValueError:
return None
def _tokenize_for_similarity(value: Any) -> set[str]:
normalized = normalize_text(value)
if not normalized:
return set()
normalized = _normalize_core_similarity_text(normalized)
if not normalized:
return set()
if len(normalized) <= 2:
return {normalized}
tokens = {normalized[i : i + 2] for i in range(len(normalized) - 1)}
tokens.add(normalized)
for keyword in _extract_core_keywords(normalized):
tokens.add(keyword)
return tokens
def _normalize_core_similarity_text(value: Any) -> str:
text_value = normalize_text(value)
replacements = [
"주식회사",
"유한회사",
"합자회사",
"합명회사",
"재단법인",
"사단법인",
"농업회사법인",
"회사",
"법인",
"부가가치세",
"전자세금계산서",
"세금계산서",
]
for token in replacements:
text_value = text_value.replace(token, "")
return text_value
def _extract_core_keywords(value: Any) -> set[str]:
text_value = _normalize_core_similarity_text(value)
if not text_value:
return set()
keywords: set[str] = set()
candidates = [
"미수금",
"매출금",
"외상매출금",
"매입세액",
"부가세대급금",
"부가세예수금",
"대급금",
"예수금",
"보통예금",
"미지급금",
"급여",
"퇴직급여",
"복리후생",
"여비교통",
"접대",
"통신",
"전력",
"수도광열",
"차량유지",
"보험",
"교육훈련",
"도서인쇄",
"사무용품",
"소모품",
"지급수수료",
"수수료",
"감가상각",
"이자",
"배당",
"잡이익",
"잡손실",
]
for keyword in candidates:
normalized_keyword = normalize_text(keyword)
if normalized_keyword and normalized_keyword in text_value:
keywords.add(normalized_keyword)
for size in (4, 3):
if len(text_value) >= size:
keywords.update(text_value[i : i + size] for i in range(len(text_value) - size + 1))
return keywords
def _row_has_tax_signal(
account_code: Any,
account_name: Any,
desc_text: Any = None,
*,
extra_text: Any = None,
tax_code: Any = None,
) -> bool:
family = _classify_account_family(account_code, account_name)
if _is_vat_family(family):
return True
normalized_desc = normalize_text(desc_text)
normalized_extra = normalize_text(extra_text)
normalized_tax_code = normalize_text(tax_code)
search_text = " ".join(
part for part in (normalized_desc, normalized_extra, normalized_tax_code) if part
)
return any(token in search_text for token in (
normalize_text('세금계산서'),
normalize_text('전자세금계산서'),
normalize_text('계산서'),
normalize_text('매입세액'),
normalize_text('매출세액'),
normalize_text('부가세'),
normalize_text('세액'),
normalize_text('공급가'),
normalize_text('과세'),
))
def _group_voucher_context_key(row: dict[str, Any], prefix: str) -> tuple[str, str, str]:
fiscal_year = clean(row.get('fiscal_year'))
if prefix == 'ledger':
return (fiscal_year, clean(row.get('ledger_date')), clean(row.get('voucher_no')))
voucher_no = clean(row.get('voucher_no'))
draft_no = clean(row.get('draft_no')) or voucher_no
# ERP 전표는 같은 가전표 안에서도 일부 행만 증빙일자가 들어오는 경우가 있어
# 증빙일자를 그룹 키에 넣으면 VAT/세금계산서 컨텍스트가 전표 전체로 퍼지지 못한다.
return (fiscal_year, draft_no, '')
def _collect_row_tax_context(row: dict[str, Any], prefix: str) -> tuple[bool, set[int], set[str]]:
account_code = row.get(f'{prefix}_account_code')
account_name = row.get(f'{prefix}_account_name')
desc_text = clean(row.get(f'{prefix}_desc'))
extra_text = ""
tax_code = ""
if prefix == 'voucher':
extra_text = " ".join(
clean(part)
for part in (
row.get('management_item'),
row.get('desc2'),
)
if clean(part)
)
tax_code = clean(row.get('tax_code'))
source_text = " ".join(part for part in (desc_text, extra_text) if part)
vat_sensitive = _row_has_tax_signal(
account_code,
account_name,
desc_text,
extra_text=extra_text,
tax_code=tax_code,
)
date_field = 'proof_date' if prefix == 'voucher' else 'ledger_date'
date_text = parse_excel_date(row.get(date_field), default_year=int(row.get('fiscal_year') or 0) or None) or ''
desc_date_tokens = _extract_desc_date_tokens(source_text)
months = _extract_month_tokens(source_text) | _date_token_month_numbers(desc_date_tokens)
dates = set(desc_date_tokens)
if date_text and vat_sensitive:
dates.add(date_text)
months.add(int(date_text[5:7]))
dates.add(date_text[:7])
return vat_sensitive, months, dates
def _format_int_set(values: set[int]) -> str:
return ','.join(str(int(value)) for value in sorted({int(v) for v in values if int(v) > 0}))
def _format_text_set(values: set[str]) -> str:
return '|'.join(sorted({clean(v) for v in values if clean(v)}))
def _parse_int_set_text(value: Any) -> set[int]:
text_value = clean(value)
if not text_value:
return set()
built: set[int] = set()
for token in re.split(r'[|,]', text_value):
token = clean(token)
if not token:
continue
try:
month = int(token)
except ValueError:
continue
if 1 <= month <= 12:
built.add(month)
return built
def _parse_text_set_text(value: Any) -> set[str]:
text_value = clean(value)
if not text_value:
return set()
return {clean(token) for token in text_value.split('|') if clean(token)}
def _extract_exact_tax_dates(tokens: set[str]) -> set[str]:
return {
clean(token)
for token in tokens
if re.fullmatch(r"(?:19|20)\d{2}-\d{2}-\d{2}", clean(token))
}
def _annotate_tax_context_to_sections(sections: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
ledger_group_context: dict[tuple[str, str, str], dict[str, Any]] = {}
voucher_group_context: dict[tuple[str, str, str], dict[str, Any]] = {}
for section in sections.values():
for row in section.get('rows', []):
ledger_sensitive, ledger_months, ledger_dates = _collect_row_tax_context(row, 'ledger')
voucher_sensitive, voucher_months, voucher_dates = _collect_row_tax_context(row, 'voucher')
row['_ledger_vat_sensitive'] = ledger_sensitive
row['_ledger_tax_months'] = set(ledger_months)
row['_ledger_tax_dates'] = set(ledger_dates)
row['_voucher_vat_sensitive'] = voucher_sensitive
row['_voucher_tax_months'] = set(voucher_months)
row['_voucher_tax_dates'] = set(voucher_dates)
ledger_key = _group_voucher_context_key(row, 'ledger')
voucher_key = _group_voucher_context_key(row, 'voucher')
if ledger_sensitive and ledger_key[-1]:
bucket = ledger_group_context.setdefault(ledger_key, {'months': set(), 'dates': set(), 'sensitive': False})
bucket['months'].update(ledger_months)
bucket['dates'].update(ledger_dates)
bucket['sensitive'] = True
if voucher_sensitive and voucher_key[1]:
bucket = voucher_group_context.setdefault(voucher_key, {'months': set(), 'dates': set(), 'sensitive': False})
bucket['months'].update(voucher_months)
bucket['dates'].update(voucher_dates)
bucket['sensitive'] = True
for section in sections.values():
for row in section.get('rows', []):
ledger_key = _group_voucher_context_key(row, 'ledger')
voucher_key = _group_voucher_context_key(row, 'voucher')
ledger_ctx = ledger_group_context.get(ledger_key)
voucher_ctx = voucher_group_context.get(voucher_key)
if ledger_ctx:
row['_ledger_vat_sensitive'] = bool(row.get('_ledger_vat_sensitive')) or bool(ledger_ctx.get('sensitive'))
row['_ledger_tax_months'] = set(row.get('_ledger_tax_months') or set()) | set(ledger_ctx.get('months') or set())
row['_ledger_tax_dates'] = set(row.get('_ledger_tax_dates') or set()) | set(ledger_ctx.get('dates') or set())
if voucher_ctx:
row['_voucher_vat_sensitive'] = bool(row.get('_voucher_vat_sensitive')) or bool(voucher_ctx.get('sensitive'))
row['_voucher_tax_months'] = set(row.get('_voucher_tax_months') or set()) | set(voucher_ctx.get('months') or set())
row['_voucher_tax_dates'] = set(row.get('_voucher_tax_dates') or set()) | set(voucher_ctx.get('dates') or set())
row['ledger_tax_context_months'] = _format_int_set(set(row.get('_ledger_tax_months') or set()))
row['ledger_tax_context_dates'] = _format_text_set(set(row.get('_ledger_tax_dates') or set()))
row['ledger_vat_sensitive'] = 1 if row.get('_ledger_vat_sensitive') else 0
row['voucher_tax_context_months'] = _format_int_set(set(row.get('_voucher_tax_months') or set()))
row['voucher_tax_context_dates'] = _format_text_set(set(row.get('_voucher_tax_dates') or set()))
row['voucher_vat_sensitive'] = 1 if row.get('_voucher_vat_sensitive') else 0
for key in ['_ledger_tax_months','_ledger_tax_dates','_voucher_tax_months','_voucher_tax_dates','_ledger_vat_sensitive','_voucher_vat_sensitive']:
row.pop(key, None)
return sections
def _jaccard_similarity(left: Any, right: Any) -> float:
left_tokens = _tokenize_for_similarity(left)
right_tokens = _tokenize_for_similarity(right)
if not left_tokens or not right_tokens:
return 0.0
inter = len(left_tokens & right_tokens)
union = len(left_tokens | right_tokens)
return (inter / union) if union else 0.0
def _jaccard_similarity_tokens(left_tokens: set[str], right_tokens: set[str]) -> float:
if not left_tokens or not right_tokens:
return 0.0
inter = len(left_tokens & right_tokens)
union = len(left_tokens | right_tokens)
return (inter / union) if union else 0.0
def _extract_month_tokens(value: Any) -> set[int]:
text = str(value or "")
tokens: set[int] = set()
for raw in re.findall(r"(?<!\d)(1[0-2]|0?[1-9])\s*월", text):
try:
month_value = int(raw)
except ValueError:
continue
if 1 <= month_value <= 12:
tokens.add(month_value)
return tokens
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)
def _extract_desc_date_tokens(value: Any) -> set[str]:
text_value = clean(value)
if not text_value:
return set()
tokens: set[str] = set()
for year, month, day in re.findall(r"(?<!\d)((?:19|20)\d{2})[-./년\s]+(1[0-2]|0?[1-9])[-./월\s]+([12]\d|3[01]|0?[1-9])", text_value):
tokens.add(f"{int(year):04d}-{int(month):02d}-{int(day):02d}")
for month, day in re.findall(r"(?<!\d)(1[0-2]|0?[1-9])[-./월\s]+([12]\d|3[01]|0?[1-9])\s*일?", text_value):
tokens.add(f"{int(month):02d}-{int(day):02d}")
for year, month in re.findall(r"(?<!\d)((?:19|20)\d{2})[-./](1[0-2]|0?[1-9])(?!\d)", text_value):
tokens.add(f"{int(year):04d}-{int(month):02d}")
for year, month in re.findall(r"(?<!\d)((?:19|20)\d{2})\s*년\s*(1[0-2]|0?[1-9])\s*월", text_value):
tokens.add(f"{int(year):04d}-{int(month):02d}")
for month in re.findall(r"(?<!\d)(1[0-2]|0?[1-9])\s*월", text_value):
tokens.add(f"{int(month):02d}")
return tokens
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"(?<!\d)(?:19|20)\d{2}[-./년\s]+(?:1[0-2]|0?[1-9])[-./월\s]+(?:[12]\d|3[01]|0?[1-9])\s*일?", " ", text_value)
text_value = re.sub(r"(?<!\d)(?:1[0-2]|0?[1-9])[-./월\s]+(?:[12]\d|3[01]|0?[1-9])\s*일?", " ", text_value)
text_value = re.sub(r"(?<!\d)(?:19|20)\d{2}[-./](?:1[0-2]|0?[1-9])(?!\d)", " ", text_value)
text_value = re.sub(r"(?<!\d)(?:19|20)\d{2}\s*년\s*(?:1[0-2]|0?[1-9])\s*월", " ", text_value)
text_value = re.sub(r"(?<!\d)(?:1[0-2]|0?[1-9])\s*월", " ", text_value)
return text_value
def _date_tokens_compatible(left: Any, right: Any) -> bool:
left_dates = _extract_desc_date_tokens(left)
right_dates = _extract_desc_date_tokens(right)
if not left_dates and not right_dates:
return True
if left_dates == right_dates:
return True
left_months = _date_token_month_numbers(left_dates)
right_months = _date_token_month_numbers(right_dates)
return bool(left_months and right_months and left_months == right_months)
def _date_tokens_conflict(left: Any, right: Any) -> bool:
left_dates = _extract_desc_date_tokens(left)
right_dates = _extract_desc_date_tokens(right)
return bool(left_dates and right_dates and not _date_tokens_compatible(left, right))
def _core_token_overlap(left: Any, right: Any) -> bool:
left_tokens = _extract_core_keywords(left)
right_tokens = _extract_core_keywords(right)
if left_tokens & right_tokens:
return True
left_norm = _normalize_core_similarity_text(left)
right_norm = _normalize_core_similarity_text(right)
if not left_norm or not right_norm:
return False
if len(left_norm) >= 2 and left_norm in right_norm:
return True
if len(right_norm) >= 2 and right_norm in left_norm:
return True
return _jaccard_similarity(left_norm, right_norm) >= 0.55
def _core_token_strong_overlap(left: Any, right: Any) -> bool:
left_tokens = _extract_core_keywords(left)
right_tokens = _extract_core_keywords(right)
if left_tokens and right_tokens and left_tokens & right_tokens:
return True
left_norm = _normalize_core_similarity_text(left)
right_norm = _normalize_core_similarity_text(right)
if not left_norm or not right_norm:
return False
return _jaccard_similarity(left_norm, right_norm) >= 0.72
def _account_names_compatible(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -> bool:
ledger_code = clean(ledger_row.get("ledger_account_code"))
voucher_code = clean(voucher_row.get("voucher_account_code"))
ledger_name = clean(ledger_row.get("ledger_account_name"))
voucher_name = clean(voucher_row.get("voucher_account_name"))
if not _account_category_pair_allowed(ledger_code, ledger_name, voucher_code, voucher_name):
return False
if ledger_code and voucher_code and ledger_code == voucher_code:
return True
if normalize_text(ledger_name) and normalize_text(ledger_name) == normalize_text(voucher_name):
return True
if _build_account_substitution_hint(ledger_code, ledger_name, voucher_code, voucher_name):
return True
if _account_base_names_compatible(ledger_name, voucher_name):
return True
ledger_family = _classify_account_family(ledger_code, ledger_name)
voucher_family = _classify_account_family(voucher_code, voucher_name)
if ledger_family and ledger_family == voucher_family:
return True
return _jaccard_similarity(ledger_name, voucher_name) >= 0.55
def _account_base_name(value: Any) -> str:
text_value = clean(value)
text_value = re.sub(r"^\s*원가\)\s*", "", text_value)
text_value = text_value.split("(", 1)[0]
normalized = normalize_text(text_value)
if normalized.startswith("원가"):
normalized = normalized[2:]
return normalized
def _account_base_names_compatible(left: Any, right: Any) -> bool:
left_base = _account_base_name(left)
right_base = _account_base_name(right)
if not left_base or not right_base:
return False
if left_base == right_base:
return True
short, long = sorted((left_base, right_base), key=len)
return len(short) >= 2 and short in long
def _same_or_similar_vendor(row: dict[str, Any]) -> bool:
return _core_token_overlap(row.get("ledger_vendor"), row.get("voucher_vendor"))
def _same_or_similar_desc(row: dict[str, Any]) -> bool:
if not _date_tokens_compatible(row.get("ledger_desc"), row.get("voucher_desc")):
return False
ledger_desc = _strip_desc_date_tokens(row.get("ledger_desc"))
voucher_desc = _strip_desc_date_tokens(row.get("voucher_desc"))
return _core_token_strong_overlap(ledger_desc, voucher_desc)
def _contained_core_desc_match(left: Any, right: Any) -> bool:
if not _date_tokens_compatible(left, right):
return False
left_norm = _normalize_core_similarity_text(_strip_desc_date_tokens(left))
right_norm = _normalize_core_similarity_text(_strip_desc_date_tokens(right))
if not left_norm or not right_norm:
return False
short, long = sorted((left_norm, right_norm), key=len)
return len(short) >= 2 and short in long
def _short_core_desc_fuzzy_match(left: Any, right: Any) -> bool:
if not _date_tokens_compatible(left, right):
return False
left_norm = _normalize_core_similarity_text(_strip_desc_date_tokens(left))
right_norm = _normalize_core_similarity_text(_strip_desc_date_tokens(right))
if not left_norm or not right_norm:
return False
if max(len(left_norm), len(right_norm)) > 4 or min(len(left_norm), len(right_norm)) < 2:
return False
return SequenceMatcher(None, left_norm, right_norm).ratio() >= 0.75
def _bank_payable_desc_approved(row: dict[str, Any]) -> bool:
ledger_desc = row.get("ledger_desc")
voucher_desc = row.get("voucher_desc")
return (
_same_or_similar_desc(row)
or _contained_core_desc_match(ledger_desc, voucher_desc)
or _short_core_desc_fuzzy_match(ledger_desc, voucher_desc)
or _shared_strong_keyword(ledger_desc, voucher_desc, {"보증"})
)
def _shared_strong_keyword(left: Any, right: Any, keywords: set[str]) -> bool:
left_norm = normalize_text(left)
right_norm = normalize_text(right)
return any(keyword and keyword in left_norm and keyword in right_norm for keyword in keywords)
def _is_same_bank_guarantee_case(row: dict[str, Any]) -> bool:
ledger_family = _classify_account_family(row.get("ledger_account_code"), row.get("ledger_account_name"))
voucher_family = _classify_account_family(row.get("voucher_account_code"), row.get("voucher_account_name"))
return (
ledger_family == "bank"
and voucher_family == "bank"
and _shared_strong_keyword(row.get("ledger_desc"), row.get("voucher_desc"), {"보증"})
)
def _is_approved_guarantee_account_pair(row: dict[str, Any]) -> bool:
if not _shared_strong_keyword(row.get("ledger_desc"), row.get("voucher_desc"), {"보증"}):
return False
ledger_name = normalize_text(row.get("ledger_account_name"))
voucher_name = normalize_text(row.get("voucher_account_name"))
approved_pairs = (
("민사보전금", "임차보증금"),
("세금과공과금", "보험료"),
("선급금", "전도금"),
("보증수수료", "지급수수료"),
)
for left_marker, right_marker in approved_pairs:
left = normalize_text(left_marker)
right = normalize_text(right_marker)
if left in ledger_name and right in voucher_name:
return True
if right in ledger_name and left in voucher_name:
return True
return False
def _account_base_desc_approved(row: dict[str, Any]) -> bool:
if not _account_base_names_compatible(row.get("ledger_account_name"), row.get("voucher_account_name")):
return False
return (
_same_or_similar_desc(row)
or _contained_core_desc_match(row.get("ledger_desc"), row.get("voucher_desc"))
)
def _has_boundary_substitution_date(row: dict[str, Any]) -> bool:
try:
fiscal_year = int(row.get("fiscal_year") or 0)
except (TypeError, ValueError):
fiscal_year = 0
for field in ("ledger_date", "proof_date"):
parsed = _parse_iso_date(row.get(field))
if parsed is None:
continue
if fiscal_year and parsed.year != fiscal_year:
continue
if (parsed.month, parsed.day) in {(1, 1), (12, 31)}:
return True
return False
def _is_substitution_like_row(row: dict[str, Any]) -> bool:
review_text = normalize_text(
" ".join(
clean(row.get(field))
for field in ("review_reason", "substitution_hint", "ledger_desc", "voucher_desc")
)
)
if any(token in review_text for token in ("대체", "substitution")):
return True
ledger_family = _classify_account_family(row.get("ledger_account_code"), row.get("ledger_account_name"))
voucher_family = _classify_account_family(row.get("voucher_account_code"), row.get("voucher_account_name"))
return bool(_build_account_substitution_hint(
row.get("ledger_account_code"),
row.get("ledger_account_name"),
row.get("voucher_account_code"),
row.get("voucher_account_name"),
) and ledger_family != voucher_family)
def _is_bank_payable_case(row: dict[str, Any]) -> bool:
ledger_family = _classify_account_family(row.get("ledger_account_code"), row.get("ledger_account_name"))
voucher_family = _classify_account_family(row.get("voucher_account_code"), row.get("voucher_account_name"))
return {ledger_family, voucher_family} == {"bank", "payable"}
def _is_boundary_substitution_row(row: dict[str, Any]) -> bool:
return _has_boundary_substitution_date(row) and _is_substitution_like_row(row)
def _account_candidate_family_keys(account_code: Any, account_name: Any) -> set[str]:
account_code_text = clean(account_code)
account_name_norm = normalize_text(account_name)
family = _classify_account_family(account_code_text, account_name)
keys: set[str] = set()
if account_code_text:
keys.add(f"code:{account_code_text}")
if account_name_norm:
keys.add(f"name:{account_name_norm}")
if family:
keys.add(f"family:{family}")
related = {
"payable": {"bank"},
"bank": {"payable"},
"vat_input": {"vat_output"},
"vat_output": {"vat_input"},
}
for related_family in related.get(family, set()):
keys.add(f"family:{related_family}")
return keys
def _build_amount_account_index(
rows: list[dict[str, Any]],
*,
prefix: str,
) -> tuple[dict[float, list[dict[str, Any]]], dict[tuple[float, str], list[dict[str, Any]]]]:
amount_index: dict[float, list[dict[str, Any]]] = {}
account_index: dict[tuple[float, str], list[dict[str, Any]]] = {}
for row in rows:
amount = round(_get_row_match_amount(row, prefix), 2)
if amount <= 0:
continue
amount_index.setdefault(amount, []).append(row)
for key in _account_candidate_family_keys(
row.get(f"{prefix}_account_code"),
row.get(f"{prefix}_account_name"),
):
account_index.setdefault((amount, key), []).append(row)
return amount_index, account_index
def _candidate_rows_for_amount_account(
amount: float,
ledger_row: dict[str, Any],
amount_index: dict[float, list[dict[str, Any]]],
account_index: dict[tuple[float, str], list[dict[str, Any]]],
*,
wide_bucket_limit: int = 80,
filtered_bucket_limit: int = 160,
) -> list[dict[str, Any]]:
candidates = amount_index.get(amount, [])
if len(candidates) <= wide_bucket_limit:
return candidates
filtered: list[dict[str, Any]] = []
seen_keys: set[int] = set()
for key in _account_candidate_family_keys(
ledger_row.get("ledger_account_code"),
ledger_row.get("ledger_account_name"),
):
for candidate in account_index.get((amount, key), []):
object_key = id(candidate)
if object_key in seen_keys:
continue
seen_keys.add(object_key)
filtered.append(candidate)
if len(filtered) > filtered_bucket_limit:
return []
return filtered
def _is_recheck_row_clear_match(row: dict[str, Any]) -> bool:
score_result = _score_pair_match(row, row)
if score_result.get('month_conflict'):
return False
ledger_amount = _get_row_match_amount(row, "ledger")
voucher_amount = _get_row_match_amount(row, "voucher")
if ledger_amount <= 0 or abs(ledger_amount - voucher_amount) >= 0.5:
return False
if not _account_category_pair_allowed(
row.get("ledger_account_code"),
row.get("ledger_account_name"),
row.get("voucher_account_code"),
row.get("voucher_account_name"),
):
return False
ledger_side = _determine_primary_side(row)
voucher_side = "debit" if parse_amount(row.get("voucher_debit")) > 0 and parse_amount(row.get("voucher_credit")) <= 0 else "credit" if parse_amount(row.get("voucher_credit")) > 0 and parse_amount(row.get("voucher_debit")) <= 0 else "either"
if not _nature_compatible(
row.get("ledger_account_code"),
row.get("ledger_account_name"),
ledger_side,
row.get("voucher_account_code"),
row.get("voucher_account_name"),
voucher_side,
):
return False
if _is_boundary_substitution_row(row):
return False
if _is_clear_vat_match_row(row):
return True
vendor_match = _same_or_similar_vendor(row)
desc_match = _same_or_similar_desc(row)
if _account_base_desc_approved(row):
return True
if _is_approved_guarantee_account_pair(row):
return True
if not _account_names_compatible(row, row):
return False
if _is_same_bank_guarantee_case(row):
return True
if vendor_match and desc_match:
return True
if vendor_match and not _date_tokens_conflict(row.get("ledger_desc"), row.get("voucher_desc")):
return True
if desc_match:
return True
return False
def _month_gap(left: date, right: date) -> int:
return abs((left.year - right.year) * 12 + (left.month - right.month))
def _normalize_account_family_text(value: Any) -> str:
return normalize_text(value)
def _classify_account_family(account_code: Any, account_name: Any) -> str:
normalized_name = _normalize_account_family_text(account_name)
normalized_code = clean(account_code)
payable_markers = ("미지급금", "외상미지급금", "외상매입금", "매입채무")
bank_markers = ("보통예금",)
receivable_markers = ("미수금", "외상매출금", "매출채권", "공사미수금")
advance_markers = ("전도금", "가지급금", "선급금")
sales_markers = ("매출금", "매출", "용역수입", "수입")
vat_input_markers = ("부가세대급금", "매입세액", "부가세매입", "부가가치세대급금")
vat_output_markers = ("부가세예수금", "매출세액", "부가세매출", "부가가치세예수금")
education_markers = ("교육훈련비", "교육훈련")
welfare_markers = ("복리후생비", "복리후생", "회식대")
if any(marker in normalized_name for marker in payable_markers):
return "payable"
if any(marker in normalized_name for marker in bank_markers):
return "bank"
if any(marker in normalized_name for marker in receivable_markers):
return "receivable"
if any(marker in normalized_name for marker in advance_markers):
return "advance"
if any(marker in normalized_name for marker in sales_markers):
return "sales"
if any(marker in normalized_name for marker in vat_input_markers):
return "vat_input"
if any(marker in normalized_name for marker in vat_output_markers):
return "vat_output"
if any(marker in normalized_name for marker in education_markers):
return "education_training"
if any(marker in normalized_name for marker in welfare_markers):
return "welfare"
if normalized_code.startswith(("211", "213")):
return "payable"
if normalized_code.startswith(("111", "112")):
return "bank"
if normalized_code.startswith(("108", "120", "112")):
return "receivable"
if normalized_code.startswith(("113", "114", "117")):
return "advance"
if normalized_code.startswith(("401", "411", "412", "413", "414", "415")):
return "sales"
if normalized_code.startswith(("135", "136")):
return "vat_input"
if normalized_code.startswith(("255",)):
return "vat_output"
return ""
def _classify_account_category(account_code: Any, account_name: Any) -> str:
normalized_name = _normalize_account_family_text(account_name)
normalized_code = clean(account_code)
asset_markers = (
"보통예금", "예금", "현금", "미수금", "외상매출금", "매출채권", "공사미수금",
"선급", "대여금", "가수금환급", "부가세대급금", "매입세액",
)
liability_markers = (
"미지급금", "외상미지급금", "외상매입금", "매입채무", "예수금", "부가세예수금", "매출세액", "선수금", "차입금",
)
equity_markers = ("자본금", "이익잉여금", "자본잉여금")
revenue_markers = ("매출", "용역수입", "수입", "수익")
expense_markers = (
"원가", "비용", "차량유지비", "지급임차료", "임차료", "복리후생비", "접대비", "접대",
"교육훈련비", "급여", "외주비", "수수료", "소모품비", "여비교통비",
)
if any(marker in normalized_name for marker in asset_markers):
return "asset"
if any(marker in normalized_name for marker in liability_markers):
return "liability"
if any(marker in normalized_name for marker in equity_markers):
return "equity"
if any(marker in normalized_name for marker in revenue_markers):
return "revenue"
if any(marker in normalized_name for marker in expense_markers):
return "expense"
family = _classify_account_family(account_code, account_name)
family_category_map = {
"bank": "asset",
"receivable": "asset",
"advance": "asset",
"vat_input": "asset",
"payable": "liability",
"vat_output": "liability",
"sales": "revenue",
"education_training": "expense",
"welfare": "expense",
}
if family in family_category_map:
return family_category_map[family]
if normalized_code.startswith(("1",)):
return "asset"
if normalized_code.startswith(("2",)):
return "liability"
if normalized_code.startswith(("3",)):
return "equity"
if normalized_code.startswith(("4",)):
return "revenue"
if normalized_code.startswith(("5", "6", "7", "8", "9")):
return "expense"
return ""
def _account_nature_signature(account_code: Any, account_name: Any, side: str) -> str:
category = _classify_account_category(account_code, account_name)
if not category or side not in {"debit", "credit"}:
return ""
if category in {"asset", "expense"}:
direction = "increase" if side == "debit" else "decrease"
else:
direction = "increase" if side == "credit" else "decrease"
return f"{category}:{direction}"
def _nature_compatible(
ledger_code: Any,
ledger_name: Any,
ledger_side: str,
voucher_code: Any,
voucher_name: Any,
voucher_side: str,
) -> bool:
ledger_signature = _account_nature_signature(ledger_code, ledger_name, ledger_side)
voucher_signature = _account_nature_signature(voucher_code, voucher_name, voucher_side)
if not ledger_signature or not voucher_signature:
return True
return ledger_signature == voucher_signature
def _is_vat_family(family: str) -> bool:
return family in {"vat_input", "vat_output"}
def _is_clear_vat_match_row(row: dict[str, Any]) -> bool:
ledger_family = _classify_account_family(row.get("ledger_account_code"), row.get("ledger_account_name"))
voucher_family = _classify_account_family(row.get("voucher_account_code"), row.get("voucher_account_name"))
if not ledger_family or ledger_family != voucher_family or not _is_vat_family(ledger_family):
return False
ledger_amount = _get_row_match_amount(row, "ledger")
voucher_amount = _get_row_match_amount(row, "voucher")
if ledger_amount <= 0 or abs(ledger_amount - voucher_amount) >= 0.5:
return False
ledger_date = _parse_iso_date(row.get("ledger_date"))
voucher_date = _parse_iso_date(row.get("proof_date"))
if ledger_date and voucher_date:
return ledger_date == voucher_date
return True
def _is_income_expense_category(category: str) -> bool:
return category in {"revenue", "expense"}
def _is_asset_liability_category(category: str) -> bool:
return category in {"asset", "liability"}
def _account_category_pair_allowed(
ledger_code: Any,
ledger_name: Any,
voucher_code: Any,
voucher_name: Any,
) -> bool:
ledger_category = _classify_account_category(ledger_code, ledger_name)
voucher_category = _classify_account_category(voucher_code, voucher_name)
ledger_family = _classify_account_family(ledger_code, ledger_name)
voucher_family = _classify_account_family(voucher_code, voucher_name)
# VAT rows should be matched only against VAT/tax rows.
if _is_vat_family(ledger_family) or _is_vat_family(voucher_family):
return _is_vat_family(ledger_family) and _is_vat_family(voucher_family)
# Line-level bank/payable, receivable/payable, advance/payable pairs are not direct-match targets.
incompatible_families = {frozenset({"bank", "payable"}), frozenset({"receivable", "payable"}), frozenset({"advance", "payable"}), frozenset({"bank", "receivable"}), frozenset({"bank", "advance"})}
if frozenset({ledger_family, voucher_family}) in incompatible_families:
return False
strict_categories = {"asset", "liability", "revenue", "expense"}
if (
ledger_category in strict_categories
and voucher_category in strict_categories
and ledger_category != voucher_category
):
return False
# Asset/liability rows should not match revenue/expense rows and vice versa.
if (
_is_asset_liability_category(ledger_category)
and _is_income_expense_category(voucher_category)
) or (
_is_income_expense_category(ledger_category)
and _is_asset_liability_category(voucher_category)
):
return False
return True
def _matched_row_passes_account_pair_rule(row: dict[str, Any]) -> bool:
return _account_category_pair_allowed(
row.get("ledger_account_code"),
row.get("ledger_account_name"),
row.get("voucher_account_code"),
row.get("voucher_account_name"),
)
def _restore_ledger_only_row_from_match(row: dict[str, Any]) -> dict[str, Any]:
restored = dict(row)
for field in (
"proof_date",
"draft_no",
"voucher_account_code",
"voucher_account_name",
"voucher_vendor",
"voucher_debit",
"voucher_credit",
"voucher_desc",
"voucher_row_key",
"pair_match_key",
"match_identity_key",
"matched_case",
"substitution_hint",
):
restored.pop(field, None)
restored["review_reason"] = ""
restored["review_memo"] = ""
restored["ledger_row_key"] = clean(restored.get("ledger_row_key")) or build_ledger_row_key(restored)
restored["review_key"] = build_review_key(restored)
return restored
def _restore_voucher_only_row_from_match(row: dict[str, Any]) -> dict[str, Any]:
restored = dict(row)
for field in (
"ledger_row_key",
"ledger_date",
"voucher_no",
"ledger_account_code",
"ledger_account_name",
"ledger_vendor",
"ledger_debit",
"ledger_credit",
"ledger_desc",
"pair_match_key",
"match_identity_key",
"matched_case",
"substitution_hint",
):
restored.pop(field, None)
restored["review_reason"] = ""
restored["review_memo"] = ""
restored["voucher_row_key"] = clean(restored.get("voucher_row_key")) or build_voucher_row_key(restored)
restored["review_key"] = build_review_key(restored)
return restored
def _erp_group_key_for_row(row: dict[str, Any]) -> tuple[int, str]:
fiscal_year = int(row.get("fiscal_year") or 0)
normalized_draft = clean(row.get("draft_no"))
normalized_voucher = clean(row.get("voucher_no"))
normalized_date = clean(row.get("proof_date"))
base = normalized_draft or normalized_voucher
if base:
base = re.sub(r"-\d+$", "", base)
if not base and normalized_date and normalized_voucher:
base = f"{normalized_date}|{normalized_voucher}"
return (fiscal_year, base)
def _wehago_group_key_for_row(row: dict[str, Any]) -> tuple[int, str, str]:
return (
int(row.get("fiscal_year") or 0),
clean(row.get("voucher_no")),
clean(row.get("ledger_date")),
)
def _release_invalid_cross_category_matches(
sections: dict[str, dict[str, Any]],
) -> tuple[dict[str, dict[str, Any]], bool]:
matched_rows = list((sections or {}).get("matched", {}).get("rows", []))
if not matched_rows:
return sections, False
kept_rows: list[dict[str, Any]] = []
restored_ledger_rows: list[dict[str, Any]] = []
restored_voucher_rows: list[dict[str, Any]] = []
changed = False
for row in matched_rows:
if _matched_row_passes_account_pair_rule(row):
kept_rows.append(row)
continue
changed = True
restored_ledger_rows.append(_restore_ledger_only_row_from_match(row))
restored_voucher_rows.append(_restore_voucher_only_row_from_match(row))
if not changed:
return sections, False
ledger_rows = list((sections or {}).get("ledger_only", {}).get("rows", []))
voucher_rows = list((sections or {}).get("voucher_only", {}).get("rows", []))
ledger_seen = {
clean(item.get("ledger_row_key")) or build_ledger_row_key(item)
for item in ledger_rows
}
voucher_seen = {
clean(item.get("voucher_row_key")) or build_voucher_row_key(item)
for item in voucher_rows
}
for row in restored_ledger_rows:
row_key = clean(row.get("ledger_row_key")) or build_ledger_row_key(row)
if row_key and row_key not in ledger_seen:
ledger_rows.append(row)
ledger_seen.add(row_key)
for row in restored_voucher_rows:
row_key = clean(row.get("voucher_row_key")) or build_voucher_row_key(row)
if row_key and row_key not in voucher_seen:
voucher_rows.append(row)
voucher_seen.add(row_key)
sections["matched"]["rows"] = kept_rows
sections["matched"]["count"] = len(kept_rows)
sections["ledger_only"]["rows"] = ledger_rows
sections["ledger_only"]["count"] = len(ledger_rows)
sections["voucher_only"]["rows"] = voucher_rows
sections["voucher_only"]["count"] = len(voucher_rows)
return sections, True
def _release_invalid_cross_category_recheck_rows(
sections: dict[str, dict[str, Any]],
) -> tuple[dict[str, dict[str, Any]], bool]:
recheck_rows = list((sections or {}).get("amount_mismatch", {}).get("rows", []))
if not recheck_rows:
return sections, False
kept_rows: list[dict[str, Any]] = []
restored_ledger_rows: list[dict[str, Any]] = []
restored_voucher_rows: list[dict[str, Any]] = []
changed = False
for row in recheck_rows:
if _matched_row_passes_account_pair_rule(row):
kept_rows.append(row)
continue
changed = True
restored_ledger_rows.append(_restore_ledger_only_row_from_match(row))
restored_voucher_rows.append(_restore_voucher_only_row_from_match(row))
if not changed:
return sections, False
ledger_rows = list((sections or {}).get("ledger_only", {}).get("rows", []))
voucher_rows = list((sections or {}).get("voucher_only", {}).get("rows", []))
ledger_seen = {
clean(item.get("ledger_row_key")) or build_ledger_row_key(item)
for item in ledger_rows
}
voucher_seen = {
clean(item.get("voucher_row_key")) or build_voucher_row_key(item)
for item in voucher_rows
}
for row in restored_ledger_rows:
row_key = clean(row.get("ledger_row_key")) or build_ledger_row_key(row)
if row_key and row_key not in ledger_seen:
ledger_rows.append(row)
ledger_seen.add(row_key)
for row in restored_voucher_rows:
row_key = clean(row.get("voucher_row_key")) or build_voucher_row_key(row)
if row_key and row_key not in voucher_seen:
voucher_rows.append(row)
voucher_seen.add(row_key)
sections["amount_mismatch"]["rows"] = kept_rows
sections["amount_mismatch"]["count"] = len(kept_rows)
sections["ledger_only"]["rows"] = ledger_rows
sections["ledger_only"]["count"] = len(ledger_rows)
sections["voucher_only"]["rows"] = voucher_rows
sections["voucher_only"]["count"] = len(voucher_rows)
return sections, True
def _repair_invalid_cross_category_matches(
sections: dict[str, dict[str, Any]],
) -> dict[str, dict[str, Any]]:
sections, changed_matched = _release_invalid_cross_category_matches(sections)
sections, changed_recheck = _release_invalid_cross_category_recheck_rows(sections)
if not (changed_matched or changed_recheck):
return sections
return _promote_direct_auto_matches(sections)
def _build_account_substitution_hint(
ledger_code: Any,
ledger_name: Any,
voucher_code: Any,
voucher_name: Any,
) -> str:
ledger_family = _classify_account_family(ledger_code, ledger_name)
voucher_family = _classify_account_family(voucher_code, voucher_name)
if ledger_family and ledger_family == voucher_family and ledger_family in {"vat_input", "vat_output"}:
return "부가세 계정 유사"
return ""
def _get_row_match_amount(row: dict[str, Any], prefix: str) -> float:
debit = parse_amount(row.get(f"{prefix}_debit"))
credit = parse_amount(row.get(f"{prefix}_credit"))
if debit > 0 and credit <= 0:
return debit
if credit > 0 and debit <= 0:
return credit
return max(debit, credit)
def _get_side_amount(row: dict[str, Any], prefix: str, side: str) -> float:
if side == "debit":
return parse_amount(row.get(f"{prefix}_debit"))
if side == "credit":
return parse_amount(row.get(f"{prefix}_credit"))
return _get_row_match_amount(row, prefix)
def _build_match_row_features(
row: dict[str, Any],
*,
prefix: str,
row_key_field: str,
account_code_field: str,
account_name_field: str,
vendor_field: str,
desc_field: str,
date_field: str,
) -> MatchRowFeatures:
debit_amount = parse_amount(row.get(f"{prefix}_debit"))
credit_amount = parse_amount(row.get(f"{prefix}_credit"))
if debit_amount > 0 and credit_amount <= 0:
match_amount = debit_amount
primary_side = "debit"
elif credit_amount > 0 and debit_amount <= 0:
match_amount = credit_amount
primary_side = "credit"
else:
match_amount = max(debit_amount, credit_amount)
primary_side = "either"
positive_amounts = tuple(
amount
for amount in {
round(debit_amount, 2),
round(credit_amount, 2),
}
if amount > 0
)
desc_text = clean(row.get(desc_field))
return MatchRowFeatures(
row=row,
row_key=clean(row.get(row_key_field)),
account_code=clean(row.get(account_code_field)),
account_name=clean(row.get(account_name_field)),
vendor_name=clean(row.get(vendor_field)),
desc_text=desc_text,
account_tokens=_tokenize_for_similarity(row.get(account_name_field)),
vendor_tokens=_tokenize_for_similarity(row.get(vendor_field)),
desc_tokens=_tokenize_for_similarity(desc_text),
month_tokens=_extract_month_tokens(desc_text),
date_value=_parse_iso_date(row.get(date_field)),
debit_amount=debit_amount,
credit_amount=credit_amount,
match_amount=match_amount,
primary_side=primary_side,
positive_amounts=positive_amounts,
tax_context_months=_parse_int_set_text(row.get(f'{prefix}_tax_context_months')),
tax_context_dates=_parse_text_set_text(row.get(f'{prefix}_tax_context_dates')),
vat_sensitive=bool(int(row.get(f'{prefix}_vat_sensitive') or 0)),
)
def _get_feature_side_amount(features: MatchRowFeatures, side: str) -> float:
if side == "debit":
return features.debit_amount
if side == "credit":
return features.credit_amount
return features.match_amount
def _is_strong_substitution_candidate(
score_result: dict[str, Any],
desc_sim: float,
) -> bool:
return bool(
score_result.get("substitution_hint")
and float(score_result.get("amount_gap", 0)) < 0.5
and desc_sim >= 0.85
)
def _is_strong_text_match(
amount_gap: float,
vendor_sim: float,
desc_sim: float,
month_conflict: bool,
) -> bool:
return bool(
amount_gap < 0.5
and not month_conflict
and (
(vendor_sim >= 0.65 and desc_sim >= 0.82)
or (vendor_sim >= 0.82 and desc_sim >= 0.55)
or (vendor_sim >= 0.55 and desc_sim >= 0.72)
)
)
def _is_reviewable_text_match(
amount_gap: float,
vendor_sim: float,
desc_sim: float,
month_conflict: bool,
) -> bool:
return bool(
amount_gap < 0.5
and not month_conflict
and (vendor_sim >= 0.4 or desc_sim >= 0.6)
)
def _build_substitution_review_rows(
ledger_rows: list[dict[str, Any]],
voucher_rows: list[dict[str, Any]],
existing_pairs: set[tuple[str, str]],
) -> list[dict[str, Any]]:
if not ledger_rows or not voucher_rows:
return []
voucher_amount_index, voucher_account_index = _build_amount_account_index(voucher_rows, prefix="voucher")
built: list[dict[str, Any]] = []
seen_pairs = set(existing_pairs)
for ledger_row in ledger_rows:
amount = round(_get_row_match_amount(ledger_row, "ledger"), 2)
if amount <= 0:
continue
candidates = _candidate_rows_for_amount_account(
amount,
ledger_row,
voucher_amount_index,
voucher_account_index,
)
if not candidates:
continue
for voucher_row in candidates:
ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row)
voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row)
pair_key = (ledger_key, voucher_key)
if not ledger_key or not voucher_key or pair_key in seen_pairs:
continue
score_result = _score_pair_match(ledger_row, voucher_row)
desc_sim = _jaccard_similarity(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc"))
if not _is_strong_substitution_candidate(score_result, desc_sim):
continue
item = {
"fiscal_year": ledger_row.get("fiscal_year") or voucher_row.get("fiscal_year"),
"voucher_no": ledger_row.get("voucher_no", ""),
"ledger_date": ledger_row.get("ledger_date", ""),
"draft_no": voucher_row.get("draft_no", ""),
"ledger_account_code": ledger_row.get("ledger_account_code", ""),
"ledger_account_name": ledger_row.get("ledger_account_name", ""),
"voucher_account_code": voucher_row.get("voucher_account_code", ""),
"voucher_account_name": voucher_row.get("voucher_account_name", ""),
"ledger_vendor": ledger_row.get("ledger_vendor", ""),
"voucher_vendor": voucher_row.get("voucher_vendor", ""),
"ledger_debit": parse_amount(ledger_row.get("ledger_debit")),
"ledger_credit": parse_amount(ledger_row.get("ledger_credit")),
"voucher_debit": parse_amount(voucher_row.get("voucher_debit")),
"voucher_credit": parse_amount(voucher_row.get("voucher_credit")),
"ledger_desc": ledger_row.get("ledger_desc", ""),
"voucher_desc": voucher_row.get("voucher_desc", ""),
"voucher_tax_context_months": voucher_row.get("voucher_tax_context_months", ""),
"voucher_tax_context_dates": voucher_row.get("voucher_tax_context_dates", ""),
"voucher_vat_sensitive": voucher_row.get("voucher_vat_sensitive", 0),
"review_reason": "SUBSTITUTION_RECHECK",
"review_memo": "",
"substitution_hint": score_result.get("substitution_hint") or "미지급금/보통예금 대체 검토",
}
item["review_key"] = build_review_key(item)
item["match_identity_key"] = build_match_identity_key(item)
item["ledger_row_key"] = ledger_key
item["voucher_row_key"] = voucher_key
built.append(item)
seen_pairs.add(pair_key)
return built
def _build_quality_review_rows(
ledger_rows: list[dict[str, Any]],
voucher_rows: list[dict[str, Any]],
existing_pairs: set[tuple[str, str]],
) -> list[dict[str, Any]]:
if not ledger_rows or not voucher_rows:
return []
voucher_amount_index, voucher_account_index = _build_amount_account_index(voucher_rows, prefix="voucher")
built: list[dict[str, Any]] = []
seen_pairs = set(existing_pairs)
for ledger_row in ledger_rows:
amount = round(_get_row_match_amount(ledger_row, "ledger"), 2)
if amount <= 0:
continue
best_row: dict[str, Any] | None = None
best_score: dict[str, Any] | None = None
for voucher_row in _candidate_rows_for_amount_account(
amount,
ledger_row,
voucher_amount_index,
voucher_account_index,
):
ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row)
voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row)
pair_key = (ledger_key, voucher_key)
if not ledger_key or not voucher_key or pair_key in seen_pairs:
continue
score_result = _score_pair_match(ledger_row, voucher_row)
if score_result.get("auto_eligible"):
continue
if not _is_reviewable_text_match(
float(score_result.get("amount_gap", 0)),
float(score_result.get("vendor_similarity", 0)),
float(score_result.get("desc_similarity", 0)),
bool(score_result.get("month_conflict")),
):
continue
if best_score is None or float(score_result.get("score", 0)) > float(best_score.get("score", 0)):
best_row = voucher_row
best_score = score_result
if best_row is None or best_score is None:
continue
item = {
"fiscal_year": ledger_row.get("fiscal_year") or best_row.get("fiscal_year"),
"voucher_no": ledger_row.get("voucher_no", ""),
"ledger_date": ledger_row.get("ledger_date", ""),
"draft_no": best_row.get("draft_no", ""),
"ledger_account_code": ledger_row.get("ledger_account_code", ""),
"ledger_account_name": ledger_row.get("ledger_account_name", ""),
"voucher_account_code": best_row.get("voucher_account_code", ""),
"voucher_account_name": best_row.get("voucher_account_name", ""),
"ledger_vendor": ledger_row.get("ledger_vendor", ""),
"voucher_vendor": best_row.get("voucher_vendor", ""),
"ledger_debit": parse_amount(ledger_row.get("ledger_debit")),
"ledger_credit": parse_amount(ledger_row.get("ledger_credit")),
"voucher_debit": parse_amount(best_row.get("voucher_debit")),
"voucher_credit": parse_amount(best_row.get("voucher_credit")),
"ledger_desc": ledger_row.get("ledger_desc", ""),
"voucher_desc": best_row.get("voucher_desc", ""),
"review_reason": "QUALITY_RECHECK",
"review_memo": "",
"substitution_hint": best_score.get("substitution_hint", ""),
}
item["review_key"] = build_review_key(item)
item["match_identity_key"] = build_match_identity_key(item)
item["ledger_row_key"] = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row)
item["voucher_row_key"] = clean(best_row.get("voucher_row_key")) or build_voucher_row_key(best_row)
built.append(item)
seen_pairs.add((item["ledger_row_key"], item["voucher_row_key"]))
return built
def _rebalance_matched_rows_for_review(
sections: dict[str, dict[str, Any]],
) -> dict[str, dict[str, Any]]:
kept_rows: list[dict[str, Any]] = []
moved_rows: list[dict[str, Any]] = []
moved_to_ledger_only: list[dict[str, Any]] = []
for row in sections["matched"]["rows"]:
score_result = _score_pair_match(row, row)
if _is_boundary_substitution_row(row):
recheck_row = dict(row)
recheck_row["review_reason"] = "BOUNDARY_SUBSTITUTION_RECHECK"
recheck_row.setdefault("review_memo", "")
recheck_row["substitution_hint"] = (
score_result.get("substitution_hint")
or clean(recheck_row.get("substitution_hint"))
or "연초/연말 대체전표 매칭 제외"
)
moved_rows.append(recheck_row)
continue
if not _matched_row_has_voucher_payload(row):
ledger_only_row = dict(row)
ledger_only_row["review_reason"] = "MISSING_MATCH_TARGET"
ledger_only_row["review_memo"] = ""
moved_to_ledger_only.append(ledger_only_row)
continue
if score_result.get("month_conflict") or _date_tokens_conflict(row.get("ledger_desc"), row.get("voucher_desc")):
recheck_row = dict(row)
recheck_row["review_reason"] = "MONTH_CONFLICT_RECHECK"
recheck_row.setdefault("review_memo", "")
moved_rows.append(recheck_row)
continue
if score_result.get("review_conflict"):
recheck_row = dict(row)
recheck_row["review_reason"] = "PROOF_DATE_RECHECK"
recheck_row.setdefault("review_memo", "")
moved_rows.append(recheck_row)
continue
if not score_result.get("auto_eligible") and not _is_recheck_row_clear_match(row):
recheck_row = dict(row)
recheck_row["review_reason"] = "WEAK_MATCH_RECHECK"
recheck_row.setdefault("review_memo", "")
moved_rows.append(recheck_row)
continue
kept_rows.append(row)
if moved_rows or moved_to_ledger_only:
sections["matched"]["rows"] = kept_rows
sections["matched"]["count"] = len(kept_rows)
sections["amount_mismatch"]["rows"].extend(moved_rows)
sections["amount_mismatch"]["count"] = len(sections["amount_mismatch"]["rows"])
sections["ledger_only"]["rows"].extend(moved_to_ledger_only)
sections["ledger_only"]["count"] = len(sections["ledger_only"]["rows"])
return sections
def _promote_recheck_rows_to_matched(
sections: dict[str, dict[str, Any]],
) -> dict[str, dict[str, Any]]:
matched_rows = list(sections["matched"]["rows"])
recheck_rows = list(sections["amount_mismatch"]["rows"])
promoted_rows: list[dict[str, Any]] = []
remaining_rows: list[dict[str, Any]] = []
existing_identity_keys = {
clean(row.get("match_identity_key")) or build_match_identity_key(row)
for row in matched_rows
}
for row in recheck_rows:
score_result = _score_pair_match(row, row)
if score_result.get("auto_eligible") or _is_recheck_row_clear_match(row):
identity_key = clean(row.get("match_identity_key")) or build_match_identity_key(row)
if identity_key not in existing_identity_keys:
promoted = dict(row)
promoted["review_reason"] = "자동승격매칭" if score_result.get("auto_eligible") else "RECHECK_CLEAR_MATCH"
promoted_rows.append(promoted)
existing_identity_keys.add(identity_key)
else:
remaining_rows.append(row)
if promoted_rows:
sections["matched"]["rows"] = matched_rows + promoted_rows
sections["matched"]["count"] = len(sections["matched"]["rows"])
sections["amount_mismatch"]["rows"] = remaining_rows
sections["amount_mismatch"]["count"] = len(remaining_rows)
return sections
def _voucher_group_tax_context_compatible(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -> bool:
ledger_months = _parse_int_set_text(ledger_row.get('ledger_tax_context_months'))
voucher_months = _parse_int_set_text(voucher_row.get('voucher_tax_context_months'))
ledger_dates = _parse_text_set_text(ledger_row.get('ledger_tax_context_dates'))
voucher_dates = _parse_text_set_text(voucher_row.get('voucher_tax_context_dates'))
ledger_year = int(ledger_row.get("fiscal_year") or 0)
voucher_year = int(voucher_row.get("fiscal_year") or 0)
ledger_sensitive = bool(ledger_months or ledger_dates or ledger_row.get("_ledger_vat_sensitive"))
voucher_sensitive = bool(voucher_months or voucher_dates or voucher_row.get("_voucher_vat_sensitive"))
if not (ledger_sensitive or voucher_sensitive):
return True
if ledger_year and voucher_year and ledger_year != voucher_year:
return False
ledger_exact_date = parse_excel_date(ledger_row.get("ledger_date"), default_year=ledger_year or None) or ""
voucher_exact_date = parse_excel_date(voucher_row.get("proof_date"), default_year=voucher_year or None) or ""
if ledger_exact_date or voucher_exact_date:
return bool(ledger_exact_date and voucher_exact_date and ledger_exact_date == voucher_exact_date)
ledger_exact_dates = _extract_exact_tax_dates(ledger_dates)
voucher_exact_dates = _extract_exact_tax_dates(voucher_dates)
if ledger_exact_dates or voucher_exact_dates:
return bool(ledger_exact_dates and voucher_exact_dates and not ledger_exact_dates.isdisjoint(voucher_exact_dates))
if ledger_dates and voucher_dates and ledger_dates.isdisjoint(voucher_dates):
return False
if ledger_months and voucher_months and ledger_months.isdisjoint(voucher_months):
return False
return True
def _promote_direct_auto_matches(
sections: dict[str, dict[str, Any]],
) -> dict[str, dict[str, Any]]:
ledger_rows = list(sections["ledger_only"]["rows"])
voucher_rows = list(sections["voucher_only"]["rows"])
if not ledger_rows or not voucher_rows:
return sections
amount_index, account_index = _build_amount_account_index(voucher_rows, prefix="voucher")
edge_candidates: list[tuple[float, dict[str, Any], dict[str, Any], dict[str, Any]]] = []
for ledger_row in ledger_rows:
candidate_amounts = {
round(parse_amount(ledger_row.get("ledger_debit")), 2),
round(parse_amount(ledger_row.get("ledger_credit")), 2),
}
seen_voucher_keys: set[str] = set()
for amount in candidate_amounts:
if amount <= 0:
continue
for voucher_row in _candidate_rows_for_amount_account(
amount,
ledger_row,
amount_index,
account_index,
):
voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row)
if voucher_key in seen_voucher_keys:
continue
seen_voucher_keys.add(voucher_key)
score_result = _score_pair_match(ledger_row, voucher_row)
if not score_result.get("auto_eligible"):
continue
if not _voucher_group_tax_context_compatible(ledger_row, voucher_row):
continue
boundary_probe = dict(ledger_row)
boundary_probe.update(
{
"proof_date": voucher_row.get("proof_date", ""),
"voucher_account_code": voucher_row.get("voucher_account_code", ""),
"voucher_account_name": voucher_row.get("voucher_account_name", ""),
"voucher_vendor": voucher_row.get("voucher_vendor", ""),
"voucher_desc": voucher_row.get("voucher_desc", ""),
}
)
if _is_boundary_substitution_row(boundary_probe):
continue
edge_candidates.append((float(score_result.get("score", 0)), ledger_row, voucher_row, score_result))
if not edge_candidates:
return sections
edge_candidates.sort(key=lambda item: item[0], reverse=True)
matched_ledger_keys: set[str] = set()
matched_voucher_keys: set[str] = set()
promoted_rows: list[dict[str, Any]] = []
for _score, ledger_row, voucher_row, score_result in edge_candidates:
ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row)
voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row)
if ledger_key in matched_ledger_keys or voucher_key in matched_voucher_keys:
continue
merged = dict(ledger_row)
merged.update(
{
"proof_date": voucher_row.get("proof_date", ""),
"draft_no": voucher_row.get("draft_no", ""),
"voucher_account_code": voucher_row.get("voucher_account_code", ""),
"voucher_account_name": voucher_row.get("voucher_account_name", ""),
"voucher_vendor": voucher_row.get("voucher_vendor", ""),
"voucher_debit": voucher_row.get("voucher_debit", 0),
"voucher_credit": voucher_row.get("voucher_credit", 0),
"voucher_desc": voucher_row.get("voucher_desc", ""),
"voucher_row_key": voucher_row.get("voucher_row_key", ""),
"voucher_tax_context_months": voucher_row.get("voucher_tax_context_months", ""),
"voucher_tax_context_dates": voucher_row.get("voucher_tax_context_dates", ""),
"voucher_vat_sensitive": voucher_row.get("voucher_vat_sensitive", 0),
"review_reason": "자동승격매칭",
"review_memo": "",
"match_identity_key": build_manual_pair_key(ledger_key, voucher_key),
}
)
promoted_rows.append(merged)
matched_ledger_keys.add(ledger_key)
matched_voucher_keys.add(voucher_key)
if not promoted_rows:
return sections
sections["matched"]["rows"].extend(promoted_rows)
sections["matched"]["count"] = len(sections["matched"]["rows"])
sections["ledger_only"]["rows"] = [
row for row in ledger_rows
if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in matched_ledger_keys
]
sections["ledger_only"]["count"] = len(sections["ledger_only"]["rows"])
sections["voucher_only"]["rows"] = [
row for row in voucher_rows
if (clean(row.get("voucher_row_key")) or build_voucher_row_key(row)) not in matched_voucher_keys
]
sections["voucher_only"]["count"] = len(sections["voucher_only"]["rows"])
return sections
def _matched_row_has_voucher_payload(row: dict[str, Any]) -> bool:
return bool(
clean(row.get("draft_no"))
or clean(row.get("voucher_account_code"))
or clean(row.get("voucher_account_name"))
or clean(row.get("voucher_vendor"))
or clean(row.get("voucher_desc"))
or _get_row_match_amount(row, "voucher") > 0
)
def _promote_cross_year_auto_matches(
sections: dict[str, dict[str, Any]],
) -> dict[str, dict[str, Any]]:
ledger_rows = list(sections["ledger_only"]["rows"])
voucher_rows = list(sections["voucher_only"]["rows"])
if not ledger_rows or not voucher_rows:
return sections
amount_index, account_index = _build_amount_account_index(voucher_rows, prefix="voucher")
edge_candidates: list[tuple[float, dict[str, Any], dict[str, Any], dict[str, Any]]] = []
for ledger_row in ledger_rows:
ledger_year = int(ledger_row.get("fiscal_year") or 0)
if not ledger_year:
continue
candidate_amounts = {
round(parse_amount(ledger_row.get("ledger_debit")), 2),
round(parse_amount(ledger_row.get("ledger_credit")), 2),
}
seen_voucher_keys: set[str] = set()
for amount in candidate_amounts:
if amount <= 0:
continue
for voucher_row in _candidate_rows_for_amount_account(amount, ledger_row, amount_index, account_index):
voucher_year = int(voucher_row.get("fiscal_year") or 0)
if abs(ledger_year - voucher_year) != 1:
continue
voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row)
if voucher_key in seen_voucher_keys:
continue
seen_voucher_keys.add(voucher_key)
score_result = _score_pair_match(ledger_row, voucher_row)
if not _voucher_group_tax_context_compatible(ledger_row, voucher_row):
continue
probe = dict(ledger_row)
probe.update(
{
"proof_date": voucher_row.get("proof_date", ""),
"draft_no": voucher_row.get("draft_no", ""),
"voucher_account_code": voucher_row.get("voucher_account_code", ""),
"voucher_account_name": voucher_row.get("voucher_account_name", ""),
"voucher_vendor": voucher_row.get("voucher_vendor", ""),
"voucher_debit": voucher_row.get("voucher_debit", 0),
"voucher_credit": voucher_row.get("voucher_credit", 0),
"voucher_desc": voucher_row.get("voucher_desc", ""),
}
)
if not score_result.get("auto_eligible"):
continue
if not (_same_or_similar_desc(probe) or _same_or_similar_vendor(probe)):
continue
edge_candidates.append((float(score_result.get("score", 0)), ledger_row, voucher_row, score_result))
if not edge_candidates:
return sections
edge_candidates.sort(key=lambda item: item[0], reverse=True)
matched_ledger_keys: set[str] = set()
matched_voucher_keys: set[str] = set()
promoted_rows: list[dict[str, Any]] = []
for _score, ledger_row, voucher_row, _score_result in edge_candidates:
ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row)
voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row)
if ledger_key in matched_ledger_keys or voucher_key in matched_voucher_keys:
continue
merged = dict(ledger_row)
merged.update(
{
"proof_date": voucher_row.get("proof_date", ""),
"draft_no": voucher_row.get("draft_no", ""),
"voucher_account_code": voucher_row.get("voucher_account_code", ""),
"voucher_account_name": voucher_row.get("voucher_account_name", ""),
"voucher_vendor": voucher_row.get("voucher_vendor", ""),
"voucher_debit": voucher_row.get("voucher_debit", 0),
"voucher_credit": voucher_row.get("voucher_credit", 0),
"voucher_desc": voucher_row.get("voucher_desc", ""),
"voucher_row_key": voucher_row.get("voucher_row_key", ""),
"review_reason": "연도교차자동매칭",
"matched_case": "cross_year",
"review_memo": "",
"match_identity_key": build_manual_pair_key(ledger_key, voucher_key),
}
)
promoted_rows.append(merged)
matched_ledger_keys.add(ledger_key)
matched_voucher_keys.add(voucher_key)
if not promoted_rows:
return sections
sections["matched"]["rows"].extend(promoted_rows)
sections["matched"]["count"] = len(sections["matched"]["rows"])
sections["ledger_only"]["rows"] = [
row for row in ledger_rows
if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in matched_ledger_keys
]
sections["ledger_only"]["count"] = len(sections["ledger_only"]["rows"])
sections["voucher_only"]["rows"] = [
row for row in voucher_rows
if (clean(row.get("voucher_row_key")) or build_voucher_row_key(row)) not in matched_voucher_keys
]
sections["voucher_only"]["count"] = len(sections["voucher_only"]["rows"])
return sections
def _apply_previous_year_erp_candidates(
conn: Any,
year: int,
sections: dict[str, dict[str, Any]],
) -> dict[str, dict[str, Any]]:
if year <= 0:
return sections
ledger_rows = list(sections["ledger_only"]["rows"])
if not ledger_rows:
return sections
available_years = set(_discover_available_fiscal_years(conn))
prior_years = sorted((candidate_year for candidate_year in available_years if candidate_year < year), reverse=True)
next_years = sorted(candidate_year for candidate_year in available_years if candidate_year > year)
candidate_years = prior_years + next_years
if not candidate_years:
return sections
def jan1_row_is_excluded(row: dict[str, Any]) -> bool:
return _extract_boundary_month_day(row.get("ledger_date"), row.get("fiscal_year")) == "01-01" and _row_has_earlier_fiscal_year_source(conn, row)
adjacent_voucher_rows: list[dict[str, Any]] = []
for candidate_year in candidate_years:
payload = _fetch_status_detail_rows_from_db(
conn,
candidate_year,
candidate_year,
"voucher_only",
"",
"",
"",
"",
"",
"",
"",
"",
"",
False,
0,
1_000_000,
)
adjacent_voucher_rows.extend(list(payload.get("rows", [])))
if not adjacent_voucher_rows:
return sections
tax_seed_sections = _annotate_tax_context_to_sections({
"matched": {"rows": [], "count": 0, "columns": DETAIL_COLUMN_MAP["matched"]},
"ledger_only": {"rows": list(ledger_rows), "count": len(ledger_rows), "columns": DETAIL_COLUMN_MAP["ledger_only"]},
"amount_mismatch": {"rows": [], "count": 0, "columns": DETAIL_COLUMN_MAP["amount_mismatch"]},
"voucher_only": {"rows": list(adjacent_voucher_rows), "count": len(adjacent_voucher_rows), "columns": DETAIL_COLUMN_MAP["voucher_only"]},
})
ledger_rows = list(tax_seed_sections["ledger_only"]["rows"])
adjacent_voucher_rows = list(tax_seed_sections["voucher_only"]["rows"])
def remaining_matched_voucher_keys() -> set[str]:
return {
clean(row.get("voucher_row_key")) or build_voucher_row_key(row)
for row in sections["matched"]["rows"]
if clean(row.get("voucher_row_key")) or build_voucher_row_key(row)
}
def remaining_matched_ledger_keys() -> set[str]:
return {
clean(row.get("ledger_row_key")) or build_ledger_row_key(row)
for row in sections["matched"]["rows"]
if clean(row.get("ledger_row_key")) or build_ledger_row_key(row)
}
def apply_adjacent_candidates(eligible_ledger_rows: list[dict[str, Any]], *, boundary_phase: bool) -> list[dict[str, Any]]:
if not eligible_ledger_rows:
return []
matched_ledger_keys = remaining_matched_ledger_keys()
matched_voucher_keys = remaining_matched_voucher_keys()
usable_voucher_rows = [
row
for row in adjacent_voucher_rows
if (clean(row.get("voucher_row_key")) or build_voucher_row_key(row)) not in matched_voucher_keys
]
if not usable_voucher_rows:
return []
amount_index, account_index = _build_amount_account_index(usable_voucher_rows, prefix="voucher")
edge_candidates: list[tuple[float, dict[str, Any], dict[str, Any], dict[str, Any]]] = []
for ledger_row in eligible_ledger_rows:
ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row)
if not ledger_key or ledger_key in matched_ledger_keys:
continue
if jan1_row_is_excluded(ledger_row):
continue
if boundary_phase != _is_boundary_substitution_row(ledger_row):
continue
candidate_amounts = {
round(parse_amount(ledger_row.get("ledger_debit")), 2),
round(parse_amount(ledger_row.get("ledger_credit")), 2),
}
seen_voucher_keys: set[str] = set()
for amount in candidate_amounts:
if amount <= 0:
continue
for voucher_row in _candidate_rows_for_amount_account(
amount,
ledger_row,
amount_index,
account_index,
):
voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row)
if not voucher_key or voucher_key in matched_voucher_keys or voucher_key in seen_voucher_keys:
continue
seen_voucher_keys.add(voucher_key)
score_result = _score_pair_match(ledger_row, voucher_row)
probe = dict(ledger_row)
probe.update(
{
"proof_date": voucher_row.get("proof_date", ""),
"draft_no": voucher_row.get("draft_no", ""),
"voucher_account_code": voucher_row.get("voucher_account_code", ""),
"voucher_account_name": voucher_row.get("voucher_account_name", ""),
"voucher_vendor": voucher_row.get("voucher_vendor", ""),
"voucher_debit": voucher_row.get("voucher_debit", 0),
"voucher_credit": voucher_row.get("voucher_credit", 0),
"voucher_desc": voucher_row.get("voucher_desc", ""),
}
)
if boundary_phase != _is_boundary_substitution_row(probe):
continue
if not _voucher_group_tax_context_compatible(ledger_row, voucher_row):
continue
if not score_result.get("auto_eligible"):
continue
if not (_same_or_similar_desc(probe) or _same_or_similar_vendor(probe)):
continue
edge_candidates.append((float(score_result.get("score", 0)), ledger_row, voucher_row, score_result))
if not edge_candidates:
return []
edge_candidates.sort(key=lambda item: item[0], reverse=True)
phase_matched_ledger_keys: set[str] = set()
phase_matched_voucher_keys: set[str] = set()
promoted_rows: list[dict[str, Any]] = []
for _score, ledger_row, voucher_row, _score_result in edge_candidates:
ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row)
voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row)
if (
ledger_key in matched_ledger_keys
or voucher_key in matched_voucher_keys
or ledger_key in phase_matched_ledger_keys
or voucher_key in phase_matched_voucher_keys
):
continue
voucher_year = int(voucher_row.get("fiscal_year") or 0)
review_reason = "전기ERP자동매칭" if voucher_year < year else "차기ERP자동매칭"
matched_case = "previous_year_erp" if voucher_year < year else "next_year_erp"
merged = dict(ledger_row)
merged.update(
{
"proof_date": voucher_row.get("proof_date", ""),
"draft_no": voucher_row.get("draft_no", ""),
"voucher_account_code": voucher_row.get("voucher_account_code", ""),
"voucher_account_name": voucher_row.get("voucher_account_name", ""),
"voucher_vendor": voucher_row.get("voucher_vendor", ""),
"voucher_debit": voucher_row.get("voucher_debit", 0),
"voucher_credit": voucher_row.get("voucher_credit", 0),
"voucher_desc": voucher_row.get("voucher_desc", ""),
"voucher_row_key": voucher_row.get("voucher_row_key", ""),
"review_reason": review_reason,
"matched_case": matched_case,
"review_memo": "",
"match_identity_key": build_manual_pair_key(ledger_key, voucher_key),
}
)
promoted_rows.append(merged)
phase_matched_ledger_keys.add(ledger_key)
phase_matched_voucher_keys.add(voucher_key)
if promoted_rows:
sections["matched"]["rows"].extend(promoted_rows)
sections["matched"]["count"] = len(sections["matched"]["rows"])
remaining_keys = remaining_matched_ledger_keys()
ledger_rows[:] = [
row
for row in ledger_rows
if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in remaining_keys
]
sections["ledger_only"]["rows"] = ledger_rows
sections["ledger_only"]["count"] = len(ledger_rows)
return promoted_rows
def build_adjacent_review_rows(eligible_ledger_rows: list[dict[str, Any]], *, boundary_phase: bool) -> list[dict[str, Any]]:
matched_voucher_keys = remaining_matched_voucher_keys()
matched_ledger_keys = remaining_matched_ledger_keys()
review_voucher_rows = [
row
for row in adjacent_voucher_rows
if (clean(row.get("voucher_row_key")) or build_voucher_row_key(row)) not in matched_voucher_keys
]
review_ledger_rows = [
row
for row in eligible_ledger_rows
if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in matched_ledger_keys
and (_is_boundary_substitution_row(row) == boundary_phase)
and not jan1_row_is_excluded(row)
]
if not review_ledger_rows or not review_voucher_rows:
return []
existing_pairs = {
(
clean(row.get("ledger_row_key")) or build_ledger_row_key(row),
clean(row.get("voucher_row_key")) or build_voucher_row_key(row),
)
for row in sections["amount_mismatch"]["rows"]
}
review_rows = []
review_rows.extend(_build_substitution_review_rows(review_ledger_rows, review_voucher_rows, existing_pairs))
existing_pairs.update(
{
(
clean(row.get("ledger_row_key")) or build_ledger_row_key(row),
clean(row.get("voucher_row_key")) or build_voucher_row_key(row),
)
for row in review_rows
}
)
review_rows.extend(_build_quality_review_rows(review_ledger_rows, review_voucher_rows, existing_pairs))
return review_rows
# 1) 일반 전표를 먼저 인접 연도 ERP와 비교
apply_adjacent_candidates(ledger_rows, boundary_phase=False)
normal_review_rows = build_adjacent_review_rows(ledger_rows, boundary_phase=False)
if normal_review_rows:
review_ledger_keys = {
clean(row.get("ledger_row_key")) or build_ledger_row_key(row)
for row in normal_review_rows
}
sections["amount_mismatch"]["rows"].extend(normal_review_rows)
sections["amount_mismatch"]["count"] = len(sections["amount_mismatch"]["rows"])
sections["ledger_only"]["rows"] = [
row
for row in sections["ledger_only"]["rows"]
if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in review_ledger_keys
]
sections["ledger_only"]["count"] = len(sections["ledger_only"]["rows"])
ledger_rows = list(sections["ledger_only"]["rows"])
# 2) 연초/연말 대체성 전표는 최후 순서로만 인접 연도 ERP와 비교
apply_adjacent_candidates(ledger_rows, boundary_phase=True)
boundary_review_rows = build_adjacent_review_rows(ledger_rows, boundary_phase=True)
if boundary_review_rows:
review_ledger_keys = {
clean(row.get("ledger_row_key")) or build_ledger_row_key(row)
for row in boundary_review_rows
}
sections["amount_mismatch"]["rows"].extend(boundary_review_rows)
sections["amount_mismatch"]["count"] = len(sections["amount_mismatch"]["rows"])
sections["ledger_only"]["rows"] = [
row
for row in sections["ledger_only"]["rows"]
if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in review_ledger_keys
]
sections["ledger_only"]["count"] = len(sections["ledger_only"]["rows"])
sections = _demote_incomplete_adjacent_year_matches(sections)
return sections
def _demote_incomplete_adjacent_year_matches(
sections: dict[str, dict[str, Any]],
) -> dict[str, dict[str, Any]]:
matched_rows = list(sections["matched"]["rows"])
ledger_only_rows = list(sections["ledger_only"]["rows"])
if not matched_rows or not ledger_only_rows:
return sections
residual_by_group: dict[tuple[int, str, str], list[dict[str, Any]]] = {}
for row in ledger_only_rows:
key = (
int(row.get("fiscal_year") or 0),
clean(row.get("voucher_no")),
clean(row.get("ledger_date")),
)
residual_by_group.setdefault(key, []).append(row)
demote_groups: set[tuple[int, str, str]] = set()
important_families = {"vat_input", "vat_output", "payable", "receivable"}
for row in matched_rows:
if clean(row.get("matched_case")) not in {"previous_year_erp", "next_year_erp"}:
continue
key = (
int(row.get("fiscal_year") or 0),
clean(row.get("voucher_no")),
clean(row.get("ledger_date")),
)
residual_rows = residual_by_group.get(key, [])
if not residual_rows:
continue
residual_families = {
_classify_account_family(item.get("ledger_account_code"), item.get("ledger_account_name"))
for item in residual_rows
}
residual_families.discard("")
if residual_families & important_families:
demote_groups.add(key)
if not demote_groups:
return sections
kept_matched: list[dict[str, Any]] = []
demoted_rows: list[dict[str, Any]] = []
for row in matched_rows:
key = (
int(row.get("fiscal_year") or 0),
clean(row.get("voucher_no")),
clean(row.get("ledger_date")),
)
if key in demote_groups and clean(row.get("matched_case")) in {"previous_year_erp", "next_year_erp"}:
demoted = dict(row)
demoted["review_reason"] = "전표단위 재검토"
demoted["matched_case"] = "voucher_level_recheck"
demoted_rows.append(demoted)
else:
kept_matched.append(row)
if not demoted_rows:
return sections
sections["matched"]["rows"] = kept_matched
sections["matched"]["count"] = len(kept_matched)
sections["amount_mismatch"]["rows"].extend(demoted_rows)
sections["amount_mismatch"]["count"] = len(sections["amount_mismatch"]["rows"])
return sections
def _matched_signature_set(rows: list[dict[str, Any]]) -> set[str]:
signatures: set[str] = set()
for row in rows:
parts = [
clean(row.get("voucher_no")),
clean(row.get("draft_no")),
normalize_text(row.get("ledger_vendor")),
normalize_text(row.get("voucher_vendor")),
normalize_text(row.get("ledger_desc")),
normalize_text(row.get("voucher_desc")),
clean(row.get("ledger_account_name")),
clean(row.get("voucher_account_name")),
f"{_get_row_match_amount(row, 'ledger'):.2f}",
f"{_get_row_match_amount(row, 'voucher'):.2f}",
]
signatures.add("|".join(parts))
return signatures
def _drop_recheck_rows_covered_by_matched(
sections: dict[str, dict[str, Any]],
) -> dict[str, dict[str, Any]]:
matched_signatures = _matched_signature_set(sections["matched"]["rows"])
matched_ledger_keys = {
clean(row.get("ledger_row_key")) or build_ledger_row_key(row)
for row in sections["matched"]["rows"]
if clean(row.get("ledger_row_key")) or build_ledger_row_key(row)
}
matched_voucher_keys = {
clean(row.get("voucher_row_key")) or build_voucher_row_key(row)
for row in sections["matched"]["rows"]
if clean(row.get("voucher_row_key")) or build_voucher_row_key(row)
}
if not matched_signatures and not matched_ledger_keys and not matched_voucher_keys:
return sections
remaining_rows: list[dict[str, Any]] = []
for row in sections["amount_mismatch"]["rows"]:
ledger_key = clean(row.get("ledger_row_key")) or build_ledger_row_key(row)
voucher_key = clean(row.get("voucher_row_key")) or build_voucher_row_key(row)
if (ledger_key and ledger_key in matched_ledger_keys) or (voucher_key and voucher_key in matched_voucher_keys):
continue
signature = "|".join(
[
clean(row.get("voucher_no")),
clean(row.get("draft_no")),
normalize_text(row.get("ledger_vendor")),
normalize_text(row.get("voucher_vendor")),
normalize_text(row.get("ledger_desc")),
normalize_text(row.get("voucher_desc")),
clean(row.get("ledger_account_name")),
clean(row.get("voucher_account_name")),
f"{_get_row_match_amount(row, 'ledger'):.2f}",
f"{_get_row_match_amount(row, 'voucher'):.2f}",
]
)
if signature in matched_signatures:
continue
remaining_rows.append(row)
sections["amount_mismatch"]["rows"] = remaining_rows
sections["amount_mismatch"]["count"] = len(remaining_rows)
return sections
def _append_substitution_review_rows(
sections: dict[str, dict[str, Any]],
) -> dict[str, dict[str, Any]]:
sections = _rebalance_matched_rows_for_review(sections)
sections = _promote_recheck_rows_to_matched(sections)
sections = _promote_direct_auto_matches(sections)
if not ENABLE_GENERATED_RECHECK_CANDIDATES:
return _drop_recheck_rows_covered_by_matched(sections)
amount_rows = sections["amount_mismatch"]["rows"]
existing_pairs = {
(
clean(row.get("ledger_row_key")) or build_ledger_row_key(row),
clean(row.get("voucher_row_key")) or build_voucher_row_key(row),
)
for row in amount_rows
}
matched_rows = list(sections["matched"]["rows"])
ledger_only_rows = list(sections["ledger_only"]["rows"])
voucher_only_rows = list(sections["voucher_only"]["rows"])
synthetic_rows = []
synthetic_rows.extend(
_build_substitution_review_rows(matched_rows + ledger_only_rows, voucher_only_rows, existing_pairs)
)
existing_pairs.update(
{
(
clean(row.get("ledger_row_key")) or build_ledger_row_key(row),
clean(row.get("voucher_row_key")) or build_voucher_row_key(row),
)
for row in synthetic_rows
}
)
synthetic_rows.extend(
_build_substitution_review_rows(ledger_only_rows, matched_rows + voucher_only_rows, existing_pairs)
)
existing_pairs.update(
{
(
clean(row.get("ledger_row_key")) or build_ledger_row_key(row),
clean(row.get("voucher_row_key")) or build_voucher_row_key(row),
)
for row in synthetic_rows
}
)
synthetic_rows.extend(
_build_quality_review_rows(ledger_only_rows, voucher_only_rows, existing_pairs)
)
if not synthetic_rows:
sections = _promote_recheck_rows_to_matched(sections)
return _drop_recheck_rows_covered_by_matched(sections)
sections["amount_mismatch"]["rows"].extend(synthetic_rows)
sections["amount_mismatch"]["count"] = len(sections["amount_mismatch"]["rows"])
sections = _promote_recheck_rows_to_matched(sections)
return _drop_recheck_rows_covered_by_matched(sections)
def _numeric_amount_for_side(row: dict[str, Any], side: str) -> float:
if side == "debit":
return parse_amount(row.get("ledger_debit") if "ledger_debit" in row else row.get("voucher_debit"))
if side == "credit":
return parse_amount(row.get("ledger_credit") if "ledger_credit" in row else row.get("voucher_credit"))
return max(
parse_amount(row.get("ledger_debit") if "ledger_debit" in row else row.get("voucher_debit")),
parse_amount(row.get("ledger_credit") if "ledger_credit" in row else row.get("voucher_credit")),
)
def _determine_primary_side(ledger_row: dict[str, Any]) -> str:
debit = parse_amount(ledger_row.get("ledger_debit"))
credit = parse_amount(ledger_row.get("ledger_credit"))
if debit > 0 and credit <= 0:
return "debit"
if credit > 0 and debit <= 0:
return "credit"
return "either"
def _score_pair_match(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -> dict[str, Any]:
ledger_features = _build_match_row_features(
ledger_row,
prefix="ledger",
row_key_field="ledger_row_key",
account_code_field="ledger_account_code",
account_name_field="ledger_account_name",
vendor_field="ledger_vendor",
desc_field="ledger_desc",
date_field="ledger_date",
)
voucher_features = _build_match_row_features(
voucher_row,
prefix="voucher",
row_key_field="voucher_row_key",
account_code_field="voucher_account_code",
account_name_field="voucher_account_name",
vendor_field="voucher_vendor",
desc_field="voucher_desc",
date_field="proof_date",
)
return _score_pair_match_features(ledger_features, voucher_features)
def _score_pair_match_features(ledger: MatchRowFeatures, voucher: MatchRowFeatures) -> dict[str, Any]:
score = 0.0
reasons: list[str] = []
hard_pass = True
account_supported = False
side = ledger.primary_side
ledger_amount = _get_feature_side_amount(ledger, side)
voucher_same_side_amount = _get_feature_side_amount(voucher, side)
voucher_any_side_amount = voucher.match_amount
amount_gap_same_side = abs(ledger_amount - voucher_same_side_amount)
amount_gap_any_side = abs(ledger_amount - voucher_any_side_amount)
amount_gap = min(amount_gap_same_side, amount_gap_any_side)
side_flipped_match = amount_gap_any_side < 0.5 and amount_gap_same_side >= 0.5
nature_same_side_ok = _nature_compatible(
ledger.account_code,
ledger.account_name,
side,
voucher.account_code,
voucher.account_name,
side,
)
opposite_side = "credit" if side == "debit" else "debit" if side == "credit" else "either"
nature_opposite_side_ok = _nature_compatible(
ledger.account_code,
ledger.account_name,
side,
voucher.account_code,
voucher.account_name,
opposite_side,
) if opposite_side in {"debit", "credit"} else False
family_pair_allowed = _account_category_pair_allowed(
ledger.account_code,
ledger.account_name,
voucher.account_code,
voucher.account_name,
)
ledger_family = _classify_account_family(ledger.account_code, ledger.account_name)
voucher_family = _classify_account_family(voucher.account_code, voucher.account_name)
vat_family_pair = bool(
ledger_family
and ledger_family == voucher_family
and _is_vat_family(ledger_family)
)
if amount_gap < 0.5 and ledger_amount > 0:
score += 50
reasons.append("금액 일치")
if side_flipped_match:
reasons.append("차대 방향 보정")
elif amount_gap < 5 and ledger_amount > 0:
score += 35
reasons.append("금액 근접")
elif amount_gap < 100 and ledger_amount > 0:
score += 10
reasons.append("금액 유사")
else:
hard_pass = False
if not family_pair_allowed:
hard_pass = False
score -= 60
reasons.append("계정 성격 교차 불가")
if side in {"debit", "credit"}:
if amount_gap_same_side < 0.5 and not nature_same_side_ok:
hard_pass = False
score -= 40
reasons.append("차대 성격 불일치")
elif side_flipped_match:
# 같은 금액이어도 반대 방향이면 원칙적으로 다른 거래 성격으로 본다.
if not nature_opposite_side_ok:
hard_pass = False
score -= 45
reasons.append("차대 방향 성격 불일치")
substitution_hint = _build_account_substitution_hint(
ledger.account_code,
ledger.account_name,
voucher.account_code,
voucher.account_name,
)
account_sim = _jaccard_similarity_tokens(ledger.account_tokens, voucher.account_tokens)
bank_pair = ledger_family == "bank" and voucher_family == "bank"
if ledger.account_code and voucher.account_code and ledger.account_code == voucher.account_code:
score += 4 if bank_pair else 22
reasons.append("보통예금 코드 일치" if bank_pair else "계정코드 일치")
account_supported = True
elif ledger.account_code and voucher.account_code and ledger.account_code[:4] == voucher.account_code[:4]:
score += 2 if bank_pair else 10
reasons.append("보통예금 대분류 일치" if bank_pair else "계정코드 대분류 일치")
account_supported = True
elif substitution_hint:
score += 12
reasons.append(substitution_hint)
account_supported = True
else:
if account_sim >= 0.8:
score += 3 if bank_pair else 16
reasons.append("보통예금 계정명 유사" if bank_pair else "계정명 유사도 높음")
account_supported = True
elif account_sim >= 0.55:
score += 2 if bank_pair else 8
reasons.append("보통예금 계정명 유사" if bank_pair else "계정명 유사")
account_supported = True
elif _account_base_names_compatible(ledger.account_name, voucher.account_name):
score += 2 if bank_pair else 12
reasons.append("보통예금 핵심 일치" if bank_pair else "계정명 핵심 일치")
account_supported = True
else:
if ledger_family and ledger_family == voucher_family:
score += 2 if bank_pair else 10
reasons.append("보통예금 계정군 일치" if bank_pair else "계정군 일치")
account_supported = True
if vat_family_pair and amount_gap < 0.5:
score += 14
reasons.append("VAT 계정군 일치")
account_supported = True
vendor_sim = _jaccard_similarity_tokens(ledger.vendor_tokens, voucher.vendor_tokens)
row_for_text = {
"ledger_account_code": ledger.account_code,
"ledger_account_name": ledger.account_name,
"ledger_vendor": ledger.vendor_name,
"ledger_desc": ledger.desc_text,
"voucher_account_code": voucher.account_code,
"voucher_account_name": voucher.account_name,
"voucher_vendor": voucher.vendor_name,
"voucher_desc": voucher.desc_text,
}
account_strong = bool(
(ledger.account_code and voucher.account_code and ledger.account_code == voucher.account_code)
or substitution_hint
or account_sim >= 0.8
or _account_names_compatible(row_for_text, row_for_text)
)
vendor_core_match = _same_or_similar_vendor(row_for_text)
desc_core_match = _same_or_similar_desc(row_for_text)
if vendor_sim >= 0.9:
score += 16
reasons.append("거래처 일치")
elif vendor_sim >= 0.65:
score += 10
reasons.append("거래처 유사")
elif vendor_sim >= 0.4:
score += 4
reasons.append("거래처 일부 유사")
else:
score -= 8
desc_sim = _jaccard_similarity_tokens(ledger.desc_tokens, voucher.desc_tokens)
if desc_sim >= 0.85:
score += 10
reasons.append("적요 매우 유사")
elif desc_sim >= 0.6:
score += 6
reasons.append("적요 유사")
elif desc_sim >= 0.35:
score += 2
month_conflict = _has_conflicting_month_token_sets(
ledger.month_tokens,
voucher.month_tokens,
) or _date_tokens_conflict(ledger.desc_text, voucher.desc_text)
tax_sensitive = bool(
ledger.vat_sensitive
or voucher.vat_sensitive
or ledger.tax_context_months
or voucher.tax_context_months
or ledger.tax_context_dates
or voucher.tax_context_dates
)
ledger_tax_year = int(ledger.row.get("fiscal_year") or 0)
voucher_tax_year = int(voucher.row.get("fiscal_year") or 0)
ledger_exact_date = ledger.date_value.isoformat() if ledger.date_value else ""
voucher_exact_date = voucher.date_value.isoformat() if voucher.date_value else ""
tax_year_conflict = bool(
tax_sensitive
and ledger_tax_year
and voucher_tax_year
and ledger_tax_year != voucher_tax_year
)
tax_month_conflict = False
tax_date_missing = False
tax_date_conflict = False
tax_day_review_conflict = False
if tax_sensitive:
if ledger_exact_date or voucher_exact_date:
if not ledger_exact_date or not voucher_exact_date:
tax_date_missing = True
elif ledger_exact_date == voucher_exact_date:
pass
elif ledger_exact_date[:7] == voucher_exact_date[:7]:
tax_day_review_conflict = True
else:
tax_date_conflict = True
elif ledger.tax_context_months and voucher.tax_context_months and ledger.tax_context_months.isdisjoint(voucher.tax_context_months):
tax_month_conflict = True
if tax_year_conflict:
hard_pass = False
score -= 75
reasons.append('세금계산서 연도 불일치')
elif tax_date_missing:
hard_pass = False
score -= 60
reasons.append('세금계산서 일자 미확인')
elif tax_date_conflict:
hard_pass = False
score -= 65
reasons.append('세금계산서 일자 불일치')
elif tax_month_conflict:
hard_pass = False
score -= 55
reasons.append('세금계산서 월 불일치')
elif tax_day_review_conflict:
hard_pass = False
score -= 18
reasons.append('세금계산서 일자 재검토')
else:
if ledger_exact_date and voucher_exact_date:
score += 18
reasons.append('세금계산서 일자 일치')
elif ledger.tax_context_months and voucher.tax_context_months:
score += 10
reasons.append('세금계산서 월 일치')
month_conflict = month_conflict or tax_year_conflict or tax_month_conflict or tax_date_missing or tax_date_conflict
review_conflict = tax_day_review_conflict
if substitution_hint and amount_gap < 0.5:
if desc_sim >= 0.85:
score += 8
reasons.append("대체 적요 일치")
elif desc_sim >= 0.6:
score += 4
reasons.append("대체 적요 유사")
strong_text_match = _is_strong_text_match(amount_gap, vendor_sim, desc_sim, month_conflict)
if strong_text_match:
score += 10
reasons.append("적요/거래처 강한 일치")
elif not account_supported:
hard_pass = False
day_gap = None
month_gap = None
if ledger.date_value and voucher.date_value:
day_gap = abs((ledger.date_value - voucher.date_value).days)
month_gap = _month_gap(ledger.date_value, voucher.date_value)
if vat_family_pair and day_gap == 0 and amount_gap < 0.5:
score += 10
reasons.append("VAT 일자 일치")
if strong_text_match:
if day_gap <= 7:
score += 8
reasons.append("일자 근접")
elif day_gap <= 31:
score += 5
reasons.append("일자 차이 허용")
elif month_gap <= PAIR_RECOMMEND_DATE_WINDOW_MONTHS:
score += 2
reasons.append("전후 8개월 내 일자 차이")
else:
score -= 6
reasons.append("전후 8개월 초과")
else:
if day_gap <= 3:
score += 8
reasons.append("일자 근접")
elif day_gap <= 10:
score += 4
elif month_gap <= PAIR_RECOMMEND_DATE_WINDOW_MONTHS:
score += 1
reasons.append("전후 8개월 내 일자 차이")
else:
score -= 6
reasons.append("전후 8개월 초과")
# 계정군이 명백히 다른데 기간 차이까지 크면 자동매칭 대상이 아니다.
explicit_family_mismatch = bool(
ledger_family and voucher_family and ledger_family != voucher_family and not substitution_hint
)
if (
explicit_family_mismatch
and month_gap is not None and month_gap >= 2
and account_sim < 0.35
and not bank_pair
):
hard_pass = False
score -= 42
reasons.append("계정 상이/기간 차이")
confidence = "low"
if score >= 88:
confidence = "high"
elif score >= 72:
confidence = "medium"
auto_eligible = bool(
hard_pass
and amount_gap < 0.5
and not side_flipped_match
and not month_conflict
and not review_conflict
and (
(vat_family_pair and ledger.date_value and voucher.date_value and ledger.date_value == voucher.date_value)
or
(score >= 82 and strong_text_match and vendor_sim >= 0.65)
or (score >= 72 and account_strong and vendor_core_match)
or (score >= 68 and account_strong and desc_core_match)
)
)
return {
"score": round(score, 2),
"confidence_level": confidence,
"reason": ", ".join(reasons[:4]),
"auto_eligible": auto_eligible,
"hard_pass": hard_pass,
"vendor_similarity": round(vendor_sim, 4),
"account_similarity": round(account_sim, 4),
"desc_similarity": round(desc_sim, 4),
"amount_gap": round(amount_gap, 2),
"month_conflict": month_conflict,
"review_conflict": review_conflict,
"strong_text_match": strong_text_match,
"substitution_hint": substitution_hint,
}
def _collect_status_rows_for_workbench(
engine: Any,
start_year: int | None,
end_year: int | None,
ledger_voucher_no: str = "",
ledger_review_reason: str = "",
voucher_voucher_no: str = "",
voucher_review_reason: str = "",
) -> dict[str, list[dict[str, Any]]]:
if start_year is None or end_year is None:
return {"ledger_only": [], "voucher_only": []}
result: dict[str, list[dict[str, Any]]] = {"ledger_only": [], "voucher_only": []}
ledger_voucher_filter = normalize_text(ledger_voucher_no)
ledger_reason_filter = normalize_text(ledger_review_reason)
voucher_voucher_filter = normalize_text(voucher_voucher_no)
voucher_reason_filter = normalize_text(voucher_review_reason)
rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year)
for row in rows_by_status["ledger_only"]:
if _filter_status_row(
row,
ledger_voucher_filter,
"",
"",
"",
"",
"",
"",
"",
ledger_reason_filter,
):
result["ledger_only"].append(row)
for row in rows_by_status["voucher_only"]:
if _filter_status_row(
row,
voucher_voucher_filter,
"",
"",
"",
"",
"",
"",
"",
voucher_reason_filter,
):
result["voucher_only"].append(row)
return result
def _build_pair_recommend_cache_key(
start_year: int | None,
end_year: int | None,
ledger_voucher_no: str = "",
ledger_review_reason: str = "",
voucher_voucher_no: str = "",
voucher_review_reason: str = "",
limit: int = 300,
) -> str:
safe_limit = max(min(int(limit or 300), 1000), 1)
return "|".join(
[
PAIR_RECOMMEND_POLICY_VERSION,
str(start_year),
str(end_year),
normalize_text(ledger_voucher_no),
normalize_text(ledger_review_reason),
normalize_text(voucher_voucher_no),
normalize_text(voucher_review_reason),
str(safe_limit),
]
)
def _delete_stale_pair_recommend_cache(conn: Any) -> None:
conn.execute(
text(
"""
DELETE FROM wehago_pair_recommend_cache
WHERE strftime('%s', 'now') - strftime('%s', updated_at) > :ttl
"""
),
{"ttl": int(_PAIR_RECOMMEND_PERSIST_TTL_SEC)},
)
def _load_persisted_pair_recommend_cache(conn: Any, cache_key: str) -> dict[str, Any] | None:
row = conn.execute(
text(
"""
SELECT payload_json
FROM wehago_pair_recommend_cache
WHERE cache_key = :cache_key
AND strftime('%s', 'now') - strftime('%s', updated_at) <= :ttl
"""
),
{"cache_key": cache_key, "ttl": int(_PAIR_RECOMMEND_PERSIST_TTL_SEC)},
).mappings().first()
if not row:
return None
try:
payload = json.loads(row["payload_json"] or "{}")
except json.JSONDecodeError:
return None
conn.execute(
text(
"""
UPDATE wehago_pair_recommend_cache
SET last_accessed_at = CURRENT_TIMESTAMP
WHERE cache_key = :cache_key
"""
),
{"cache_key": cache_key},
)
return payload if isinstance(payload, dict) else None
def _store_persisted_pair_recommend_cache(
conn: Any,
cache_key: str,
payload: dict[str, Any],
*,
start_year: int | None,
end_year: int | None,
ledger_voucher_no: str,
ledger_review_reason: str,
voucher_voucher_no: str,
voucher_review_reason: str,
limit: int,
) -> None:
safe_limit = max(min(int(limit or 300), 1000), 1)
payload_json = json.dumps(payload, ensure_ascii=False)
pair_count = len(payload.get("pairs") or [])
conn.execute(
text(
"""
INSERT INTO wehago_pair_recommend_cache (
cache_key, start_year, end_year,
ledger_voucher_no, ledger_review_reason, voucher_voucher_no, voucher_review_reason,
row_limit, payload_json, pair_count, created_at, updated_at, last_accessed_at
) VALUES (
:cache_key, :start_year, :end_year,
:ledger_voucher_no, :ledger_review_reason, :voucher_voucher_no, :voucher_review_reason,
:row_limit, :payload_json, :pair_count, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
ON CONFLICT(cache_key) DO UPDATE SET
start_year = excluded.start_year,
end_year = excluded.end_year,
ledger_voucher_no = excluded.ledger_voucher_no,
ledger_review_reason = excluded.ledger_review_reason,
voucher_voucher_no = excluded.voucher_voucher_no,
voucher_review_reason = excluded.voucher_review_reason,
row_limit = excluded.row_limit,
payload_json = excluded.payload_json,
pair_count = excluded.pair_count,
updated_at = CURRENT_TIMESTAMP,
last_accessed_at = CURRENT_TIMESTAMP
"""
),
{
"cache_key": cache_key,
"start_year": start_year,
"end_year": end_year,
"ledger_voucher_no": clean(ledger_voucher_no),
"ledger_review_reason": clean(ledger_review_reason),
"voucher_voucher_no": clean(voucher_voucher_no),
"voucher_review_reason": clean(voucher_review_reason),
"row_limit": safe_limit,
"payload_json": payload_json,
"pair_count": pair_count,
},
)
def clear_persisted_pair_recommend_cache(engine: Any) -> None:
init_wehago_compare_db(engine)
with engine.begin() as conn:
conn.execute(text("DELETE FROM wehago_pair_recommend_cache"))
conn.execute(text("DELETE FROM wehago_background_jobs WHERE job_type = 'pair_recommend_precompute'"))
def _ensure_pair_recommend_worker(engine: Any) -> None:
global _PAIR_RECOMMEND_WORKER_STARTED
with _PAIR_RECOMMEND_WORKER_LOCK:
if _PAIR_RECOMMEND_WORKER_STARTED:
return
worker = threading.Thread(
target=_pair_recommend_worker_loop,
args=(engine,),
daemon=True,
name="wehago-pair-recommend-worker",
)
worker.start()
_PAIR_RECOMMEND_WORKER_STARTED = True
def enqueue_pair_recommend_precompute(
engine: Any,
start_year: int | None,
end_year: int | None,
ledger_voucher_no: str = "",
ledger_review_reason: str = "",
voucher_voucher_no: str = "",
voucher_review_reason: str = "",
limit: int = 300,
) -> None:
safe_limit = max(min(int(limit or 300), 1000), 1)
cache_key = _build_pair_recommend_cache_key(
start_year,
end_year,
ledger_voucher_no=ledger_voucher_no,
ledger_review_reason=ledger_review_reason,
voucher_voucher_no=voucher_voucher_no,
voucher_review_reason=voucher_review_reason,
limit=safe_limit,
)
payload = {
"cache_key": cache_key,
"start_year": start_year,
"end_year": end_year,
"ledger_voucher_no": clean(ledger_voucher_no),
"ledger_review_reason": clean(ledger_review_reason),
"voucher_voucher_no": clean(voucher_voucher_no),
"voucher_review_reason": clean(voucher_review_reason),
"limit": safe_limit,
}
init_wehago_compare_db(engine)
with engine.begin() as conn:
_delete_stale_pair_recommend_cache(conn)
existing_payload = _load_persisted_pair_recommend_cache(conn, cache_key)
if existing_payload is not None:
return
existing_job = conn.execute(
text(
"""
SELECT state
FROM wehago_background_jobs
WHERE job_key = :job_key
AND job_type = 'pair_recommend_precompute'
"""
),
{"job_key": cache_key},
).mappings().first()
if existing_job and clean(existing_job.get("state")) in {"queued", "running"}:
return
conn.execute(
text(
"""
INSERT INTO wehago_background_jobs (
job_key, job_type, payload_json, state, error_message, created_at, updated_at
) VALUES (
:job_key, 'pair_recommend_precompute', :payload_json, 'queued', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
ON CONFLICT(job_key) DO UPDATE SET
payload_json = excluded.payload_json,
state = CASE
WHEN wehago_background_jobs.state = 'running' THEN wehago_background_jobs.state
ELSE 'queued'
END,
error_message = '',
updated_at = CURRENT_TIMESTAMP
"""
),
{"job_key": cache_key, "payload_json": json.dumps(payload, ensure_ascii=False)},
)
_ensure_pair_recommend_worker(engine)
_PAIR_RECOMMEND_JOB_EVENT.set()
def _pair_recommend_worker_loop(engine: Any) -> None:
while True:
_PAIR_RECOMMEND_JOB_EVENT.wait(timeout=5.0)
_PAIR_RECOMMEND_JOB_EVENT.clear()
while True:
init_wehago_compare_db(engine)
with engine.begin() as conn:
job = conn.execute(
text(
"""
SELECT job_key, payload_json
FROM wehago_background_jobs
WHERE job_type = 'pair_recommend_precompute'
AND state = 'queued'
ORDER BY created_at ASC
LIMIT 1
"""
)
).mappings().first()
if not job:
break
conn.execute(
text(
"""
UPDATE wehago_background_jobs
SET state = 'running',
error_message = '',
started_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE job_key = :job_key
"""
),
{"job_key": job["job_key"]},
)
try:
payload = json.loads(job["payload_json"] or "{}")
if not isinstance(payload, dict):
payload = {}
result = _compute_pair_recommendations_payload(
engine,
start_year=payload.get("start_year"),
end_year=payload.get("end_year"),
ledger_voucher_no=payload.get("ledger_voucher_no", ""),
ledger_review_reason=payload.get("ledger_review_reason", ""),
voucher_voucher_no=payload.get("voucher_voucher_no", ""),
voucher_review_reason=payload.get("voucher_review_reason", ""),
limit=int(payload.get("limit") or 300),
)
with engine.begin() as conn:
_store_persisted_pair_recommend_cache(
conn,
payload.get("cache_key") or job["job_key"],
result,
start_year=payload.get("start_year"),
end_year=payload.get("end_year"),
ledger_voucher_no=payload.get("ledger_voucher_no", ""),
ledger_review_reason=payload.get("ledger_review_reason", ""),
voucher_voucher_no=payload.get("voucher_voucher_no", ""),
voucher_review_reason=payload.get("voucher_review_reason", ""),
limit=int(payload.get("limit") or 300),
)
conn.execute(
text(
"""
UPDATE wehago_background_jobs
SET state = 'done',
error_message = '',
finished_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE job_key = :job_key
"""
),
{"job_key": job["job_key"]},
)
except Exception as exc:
with engine.begin() as conn:
conn.execute(
text(
"""
UPDATE wehago_background_jobs
SET state = 'failed',
error_message = :error_message,
finished_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE job_key = :job_key
"""
),
{"job_key": job["job_key"], "error_message": clean(exc)},
)
def _compute_pair_recommendations_payload(
engine: Any,
start_year: int | None,
end_year: int | None,
ledger_voucher_no: str = "",
ledger_review_reason: str = "",
voucher_voucher_no: str = "",
voucher_review_reason: str = "",
limit: int = 300,
) -> dict[str, Any]:
safe_limit = max(min(int(limit or 300), 1000), 1)
dataset = _collect_status_rows_for_workbench(
engine,
start_year,
end_year,
ledger_voucher_no=ledger_voucher_no,
ledger_review_reason=ledger_review_reason,
voucher_voucher_no=voucher_voucher_no,
voucher_review_reason=voucher_review_reason,
)
ledger_rows = dataset["ledger_only"]
voucher_rows = dataset["voucher_only"]
if not ledger_rows or not voucher_rows:
return {"pairs": [], "stats": {"ledger_rows": len(ledger_rows), "voucher_rows": len(voucher_rows), "recommended": 0, "auto_eligible": 0}}
ledger_features = [
_build_match_row_features(
row,
prefix="ledger",
row_key_field="ledger_row_key",
account_code_field="ledger_account_code",
account_name_field="ledger_account_name",
vendor_field="ledger_vendor",
desc_field="ledger_desc",
date_field="ledger_date",
)
for row in ledger_rows
]
voucher_features = [
_build_match_row_features(
row,
prefix="voucher",
row_key_field="voucher_row_key",
account_code_field="voucher_account_code",
account_name_field="voucher_account_name",
vendor_field="voucher_vendor",
desc_field="voucher_desc",
date_field="proof_date",
)
for row in voucher_rows
]
amount_index: dict[float, list[MatchRowFeatures]] = {}
for voucher_feature in voucher_features:
for amount in voucher_feature.positive_amounts:
if amount <= 0:
continue
amount_index.setdefault(amount, []).append(voucher_feature)
edge_candidates: list[dict[str, Any]] = []
for ledger_feature in ledger_features:
voucher_candidates: list[MatchRowFeatures] = []
seen_keys: set[str] = set()
for amount in ledger_feature.positive_amounts:
for voucher_feature in amount_index.get(amount, []):
voucher_key = voucher_feature.row_key
if voucher_key and voucher_key not in seen_keys:
seen_keys.add(voucher_key)
voucher_candidates.append(voucher_feature)
if not voucher_candidates:
continue
top_feature: MatchRowFeatures | None = None
top_score: dict[str, Any] | None = None
second_best_score = -999.0
for voucher_feature in voucher_candidates:
score_result = _score_pair_match_features(ledger_feature, voucher_feature)
if not score_result["hard_pass"] or score_result["score"] < 72:
continue
if top_score is None or score_result["score"] > float(top_score["score"]):
second_best_score = float(top_score["score"]) if top_score else second_best_score
top_feature = voucher_feature
top_score = score_result
elif score_result["score"] > second_best_score:
second_best_score = score_result["score"]
if top_feature is None or top_score is None:
continue
if top_score["score"] - second_best_score < 6:
continue
edge_candidates.append(
{
"ledger_row": ledger_feature.row,
"voucher_row": top_feature.row,
"score": top_score["score"],
"confidence_level": top_score["confidence_level"],
"reason": top_score["reason"],
"auto_eligible": top_score["auto_eligible"],
"vendor_similarity": top_score.get("vendor_similarity", 0),
"account_similarity": top_score.get("account_similarity", 0),
"amount_gap": top_score.get("amount_gap", 0),
"substitution_hint": top_score.get("substitution_hint", ""),
}
)
edge_candidates.sort(key=lambda item: item["score"], reverse=True)
matched_ledger: set[str] = set()
matched_voucher: set[str] = set()
picked: list[dict[str, Any]] = []
for edge in edge_candidates:
ledger_key = clean(edge["ledger_row"].get("ledger_row_key"))
voucher_key = clean(edge["voucher_row"].get("voucher_row_key"))
if not ledger_key or not voucher_key:
continue
if ledger_key in matched_ledger or voucher_key in matched_voucher:
continue
matched_ledger.add(ledger_key)
matched_voucher.add(voucher_key)
pair_key = build_manual_pair_key(ledger_key, voucher_key)
picked.append(
{
"pair_key": pair_key,
"ledger_row_key": ledger_key,
"voucher_row_key": voucher_key,
"score": edge["score"],
"confidence_level": edge["confidence_level"],
"reason": edge["reason"],
"auto_eligible": edge["auto_eligible"],
"vendor_similarity": edge.get("vendor_similarity", 0),
"account_similarity": edge.get("account_similarity", 0),
"amount_gap": edge.get("amount_gap", 0),
"substitution_hint": edge.get("substitution_hint", ""),
"ledger_row": edge["ledger_row"],
"voucher_row": edge["voucher_row"],
}
)
if len(picked) >= safe_limit:
break
payload = {
"pairs": picked,
"stats": {
"ledger_rows": len(ledger_rows),
"voucher_rows": len(voucher_rows),
"recommended": len(picked),
"auto_eligible": sum(1 for row in picked if row["auto_eligible"]),
"high_confidence": sum(1 for row in picked if row["confidence_level"] == "high"),
},
}
return payload
def recommend_pair_matches(
engine: Any,
start_year: int | None,
end_year: int | None,
ledger_voucher_no: str = "",
ledger_review_reason: str = "",
voucher_voucher_no: str = "",
voucher_review_reason: str = "",
limit: int = 300,
) -> dict[str, Any]:
safe_limit = max(min(int(limit or 300), 1000), 1)
cache_key = _build_pair_recommend_cache_key(
start_year,
end_year,
ledger_voucher_no=ledger_voucher_no,
ledger_review_reason=ledger_review_reason,
voucher_voucher_no=voucher_voucher_no,
voucher_review_reason=voucher_review_reason,
limit=safe_limit,
)
now = time.time()
cached = _PAIR_RECOMMEND_CACHE.get(cache_key)
if cached and (now - float(cached.get("ts", 0))) <= _PAIR_RECOMMEND_CACHE_TTL_SEC:
return cached["payload"]
init_wehago_compare_db(engine)
with engine.begin() as conn:
persisted = _load_persisted_pair_recommend_cache(conn, cache_key)
if persisted is not None:
_PAIR_RECOMMEND_CACHE.clear()
_PAIR_RECOMMEND_CACHE[cache_key] = {"ts": now, "payload": persisted}
return persisted
payload = _compute_pair_recommendations_payload(
engine,
start_year=start_year,
end_year=end_year,
ledger_voucher_no=ledger_voucher_no,
ledger_review_reason=ledger_review_reason,
voucher_voucher_no=voucher_voucher_no,
voucher_review_reason=voucher_review_reason,
limit=safe_limit,
)
with engine.begin() as conn:
_store_persisted_pair_recommend_cache(
conn,
cache_key,
payload,
start_year=start_year,
end_year=end_year,
ledger_voucher_no=ledger_voucher_no,
ledger_review_reason=ledger_review_reason,
voucher_voucher_no=voucher_voucher_no,
voucher_review_reason=voucher_review_reason,
limit=safe_limit,
)
_PAIR_RECOMMEND_CACHE.clear()
_PAIR_RECOMMEND_CACHE[cache_key] = {"ts": now, "payload": payload}
return payload
def save_recommended_pair_matches(
engine: Any,
start_year: int | None,
end_year: int | None,
pair_keys: list[str] | None = None,
auto_only: bool = False,
ledger_voucher_no: str = "",
ledger_review_reason: str = "",
voucher_voucher_no: str = "",
voucher_review_reason: str = "",
) -> dict[str, Any]:
recommended = recommend_pair_matches(
engine,
start_year=start_year,
end_year=end_year,
ledger_voucher_no=ledger_voucher_no,
ledger_review_reason=ledger_review_reason,
voucher_voucher_no=voucher_voucher_no,
voucher_review_reason=voucher_review_reason,
limit=1000,
)
selected_keys = {clean(key) for key in (pair_keys or []) if clean(key)}
selected_pairs: list[dict[str, Any]] = []
for pair in recommended["pairs"]:
if auto_only and not pair.get("auto_eligible"):
continue
if selected_keys and clean(pair.get("pair_key")) not in selected_keys:
continue
selected_pairs.append(pair)
ledger_rows = [pair["ledger_row"] for pair in selected_pairs]
voucher_rows = [pair["voucher_row"] for pair in selected_pairs]
pair_meta_by_key = {
clean(pair["pair_key"]): {
"score": pair["score"],
"confidence_level": pair["confidence_level"],
"reason": pair["reason"],
"match_source": "auto_recommend" if auto_only else "recommend",
}
for pair in selected_pairs
}
saved_count = save_manual_pair_matches(
engine,
ledger_rows=ledger_rows,
voucher_rows=voucher_rows,
pair_meta_by_key=pair_meta_by_key,
match_source="auto_recommend" if auto_only else "recommend",
)
return {
"saved_count": saved_count,
"selected_count": len(selected_pairs),
"recommended_count": recommended["stats"]["recommended"],
}
def get_individual_pair_recommendations(
engine: Any,
start_year: int | None,
end_year: int | None,
source_status: str,
source_row_key: str,
offset: int = 0,
limit: int = 10,
) -> dict[str, Any]:
source_status = normalize_text(source_status).lower()
if source_status not in {"ledgeronly", "voucheronly", "ledger_only", "voucher_only"}:
raise ValueError("개별 추천의 상태 값이 올바르지 않습니다.")
normalized_source_status = "ledger_only" if source_status in {"ledgeronly", "ledger_only"} else "voucher_only"
normalized_row_key = clean(source_row_key)
if not normalized_row_key:
raise ValueError("추천할 기준 행 키가 필요합니다.")
dataset = _collect_status_rows_for_workbench(engine, start_year, end_year)
ledger_rows = dataset["ledger_only"]
voucher_rows = dataset["voucher_only"]
ledger_features = [
_build_match_row_features(
row,
prefix="ledger",
row_key_field="ledger_row_key",
account_code_field="ledger_account_code",
account_name_field="ledger_account_name",
vendor_field="ledger_vendor",
desc_field="ledger_desc",
date_field="ledger_date",
)
for row in ledger_rows
]
voucher_features = [
_build_match_row_features(
row,
prefix="voucher",
row_key_field="voucher_row_key",
account_code_field="voucher_account_code",
account_name_field="voucher_account_name",
vendor_field="voucher_vendor",
desc_field="voucher_desc",
date_field="proof_date",
)
for row in voucher_rows
]
source_feature = None
if normalized_source_status == "ledger_only":
for feature in ledger_features:
if feature.row_key == normalized_row_key:
source_feature = feature
break
else:
for feature in voucher_features:
if feature.row_key == normalized_row_key:
source_feature = feature
break
if source_feature is None:
return {
"source_status": normalized_source_status,
"source_row": None,
"rows": [],
"total_count": 0,
"shown_count": 0,
"offset": 0,
"limit": limit,
"has_more": False,
"next_offset": 0,
"bank_payable_case_count": 0,
}
candidates: list[dict[str, Any]] = []
if normalized_source_status == "ledger_only":
source_amounts = set(source_feature.positive_amounts)
for target_feature in voucher_features:
if not (source_amounts & set(target_feature.positive_amounts)):
continue
score = _score_pair_match_features(source_feature, target_feature)
if not score["hard_pass"] or score["score"] < 60:
continue
candidates.append(
{
"pair_key": build_manual_pair_key(source_feature.row_key, target_feature.row_key),
"score": score["score"],
"confidence_level": score["confidence_level"],
"amount_gap": score.get("amount_gap", 0),
"account_similarity": score.get("account_similarity", 0),
"vendor_similarity": score.get("vendor_similarity", 0),
"reason": score.get("reason", ""),
"target_row": target_feature.row,
}
)
else:
source_amounts = set(source_feature.positive_amounts)
for target_feature in ledger_features:
if not (source_amounts & set(target_feature.positive_amounts)):
continue
score = _score_pair_match_features(target_feature, source_feature)
if not score["hard_pass"] or score["score"] < 60:
continue
candidates.append(
{
"pair_key": build_manual_pair_key(target_feature.row_key, source_feature.row_key),
"score": score["score"],
"confidence_level": score["confidence_level"],
"amount_gap": score.get("amount_gap", 0),
"account_similarity": score.get("account_similarity", 0),
"vendor_similarity": score.get("vendor_similarity", 0),
"reason": score.get("reason", ""),
"target_row": target_feature.row,
}
)
candidates.sort(
key=lambda row: (
-float(row.get("score") or 0),
float(row.get("amount_gap") or 0),
-float(row.get("vendor_similarity") or 0),
)
)
safe_offset = max(int(offset or 0), 0)
safe_limit = max(min(int(limit or 10), 100), 1)
rows = candidates[safe_offset : safe_offset + safe_limit]
next_offset = safe_offset + len(rows)
return {
"source_status": normalized_source_status,
"source_row": source_feature.row,
"rows": rows,
"total_count": len(candidates),
"shown_count": len(rows),
"offset": safe_offset,
"limit": safe_limit,
"has_more": next_offset < len(candidates),
"next_offset": next_offset,
}
def _contains_filter(value: Any, keyword: str) -> bool:
if not keyword:
return True
return keyword in normalize_text(value)
def _filter_status_row(
row: dict[str, Any],
voucher_no: str,
draft_no: str,
wehago_account: str,
erp_account: str,
wehago_amount: str,
erp_amount: str,
wehago_vendor: str,
erp_vendor: str,
desc_keyword: str,
) -> bool:
if voucher_no and not _contains_filter(row.get("voucher_no"), voucher_no):
return False
if draft_no and not _contains_filter(row.get("draft_no"), draft_no):
return False
wehago_account_candidates = [
row.get("ledger_account_code"),
row.get("ledger_account_name"),
" ".join(part for part in (clean(row.get("ledger_account_code")), clean(row.get("ledger_account_name"))) if part),
]
if wehago_account and not any(_contains_filter(item, wehago_account) for item in wehago_account_candidates):
return False
erp_account_candidates = [
row.get("voucher_account_code"),
row.get("voucher_account_name"),
" ".join(part for part in (clean(row.get("voucher_account_code")), clean(row.get("voucher_account_name"))) if part),
]
if erp_account and not any(_contains_filter(item, erp_account) for item in erp_account_candidates):
return False
if wehago_vendor and not _contains_filter(row.get("ledger_vendor"), wehago_vendor):
return False
if erp_vendor and not _contains_filter(row.get("voucher_vendor"), erp_vendor):
return False
if wehago_amount:
target = parse_amount(wehago_amount)
if abs(parse_amount(row.get("ledger_debit")) - target) >= 0.5 and abs(parse_amount(row.get("ledger_credit")) - target) >= 0.5:
return False
if erp_amount:
target = parse_amount(erp_amount)
if abs(parse_amount(row.get("voucher_debit")) - target) >= 0.5 and abs(parse_amount(row.get("voucher_credit")) - target) >= 0.5:
return False
desc_candidates = [
row.get("ledger_desc"),
row.get("voucher_desc"),
row.get("review_reason"),
row.get("review_memo"),
]
if desc_keyword and not any(_contains_filter(item, desc_keyword) for item in desc_candidates):
return False
return True
def _is_bank_payable_matched_row(row: dict[str, Any]) -> bool:
return clean(row.get("matched_case")) == "bank_payable" or clean(row.get("review_reason")) == "BANK_PAYABLE_MATCH"
BOUNDARY_EXCLUSION_KEYWORDS = (
"대체",
"이월",
"전기",
"기초",
"기말",
"마감",
"결산",
"손익",
"잉여금",
)
STRONG_CARRYOVER_KEYWORDS = (
"전기이월",
"기초이월",
"전기잔액",
"기초잔액",
)
def _is_boundary_date_value(value: Any, fiscal_year: Any = None) -> bool:
date_text = clean(value)
if not date_text:
return False
match = re.search(r"(\d{4})[-./](\d{1,2})[-./](\d{1,2})", date_text)
if match:
year_value = int(match.group(1))
month_day = f"{int(match.group(2)):02d}-{int(match.group(3)):02d}"
target_year = int(fiscal_year or 0) if str(fiscal_year or "").isdigit() else None
return month_day in {"01-01", "12-31"} and (not target_year or year_value == target_year)
return bool(re.search(r"(^|[^0-9])(?:0?1[-./]0?1|12[-./]31)([^0-9]|$)", date_text))
def _extract_boundary_month_day(value: Any, fiscal_year: Any = None) -> str:
date_text = clean(value)
if not date_text:
return ""
match = re.search(r"(\d{4})[-./](\d{1,2})[-./](\d{1,2})", date_text)
if match:
year_value = int(match.group(1))
month_day = f"{int(match.group(2)):02d}-{int(match.group(3)):02d}"
target_year = int(fiscal_year or 0) if str(fiscal_year or "").isdigit() else None
if month_day in {"01-01", "12-31"} and (not target_year or year_value == target_year):
return month_day
return ""
loose = re.search(r"(^|[^0-9])(0?1[-./]0?1|12[-./]31)([^0-9]|$)", date_text)
if not loose:
return ""
token = loose.group(2)
if token.startswith(("1-1", "01-1", "1/1", "01/1", "1.1", "01.1", "01-01", "01/01", "01.01")):
return "01-01"
return "12-31"
def _row_has_earlier_fiscal_year_source(conn: Any, row: dict[str, Any]) -> bool:
try:
fiscal_year = int(row.get("fiscal_year") or 0)
except (TypeError, ValueError):
fiscal_year = 0
if fiscal_year <= 0:
return False
account_code = clean(row.get("ledger_account_code") or row.get("account_code"))
account_name = clean(row.get("ledger_account_name") or row.get("account_name"))
if not account_code and not account_name:
return False
exists = conn.execute(
text(
"""
SELECT 1
FROM wehago_ledger_rows
WHERE fiscal_year < :fiscal_year
AND (
(:account_code <> '' AND COALESCE(account_code, '') = :account_code)
OR (:account_name <> '' AND COALESCE(account_name, '') = :account_name)
)
LIMIT 1
"""
),
{
"fiscal_year": fiscal_year,
"account_code": account_code,
"account_name": account_name,
},
).first()
return bool(exists)
def _build_earlier_year_account_lookup(conn: Any) -> dict[int, set[str]]:
rows = conn.execute(
text(
"""
SELECT fiscal_year, COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name
FROM wehago_ledger_rows
WHERE fiscal_year IS NOT NULL
AND fiscal_year > 0
"""
)
).mappings()
accounts_by_year: dict[int, set[str]] = {}
for row in rows:
year = int(row["fiscal_year"] or 0)
if year <= 0:
continue
bucket = accounts_by_year.setdefault(year, set())
account_code = clean(row["account_code"])
account_name = clean(row["account_name"])
if account_code:
bucket.add(f"code:{account_code}")
if account_name:
bucket.add(f"name:{account_name}")
earlier_lookup: dict[int, set[str]] = {}
accumulated: set[str] = set()
for year in sorted(accounts_by_year):
earlier_lookup[year] = set(accumulated)
accumulated.update(accounts_by_year[year])
return earlier_lookup
def _row_has_earlier_fiscal_year_source_from_lookup(
row: dict[str, Any],
earlier_lookup: dict[int, set[str]],
) -> bool:
try:
fiscal_year = int(row.get("fiscal_year") or 0)
except (TypeError, ValueError):
fiscal_year = 0
if fiscal_year <= 0:
return False
earlier_accounts = earlier_lookup.get(fiscal_year, set())
if not earlier_accounts:
return False
account_code = clean(row.get("ledger_account_code") or row.get("account_code"))
account_name = clean(row.get("ledger_account_name") or row.get("account_name"))
return (
(bool(account_code) and f"code:{account_code}" in earlier_accounts)
or (bool(account_name) and f"name:{account_name}" in earlier_accounts)
)
def _has_boundary_exclusion_keyword(*values: Any) -> bool:
text_value = normalize_text(" ".join(clean(value) for value in values if clean(value)))
return any(keyword in text_value for keyword in BOUNDARY_EXCLUSION_KEYWORDS)
def _has_strong_carryover_keyword(*values: Any) -> bool:
text_value = normalize_text(" ".join(clean(value) for value in values if clean(value)))
compact_value = text_value.replace(" ", "")
return any(keyword in compact_value for keyword in STRONG_CARRYOVER_KEYWORDS)
def _is_boundary_excluded_unmatched_row(
row: dict[str, Any],
conn: Any | None = None,
earlier_year_account_lookup: dict[int, set[str]] | None = None,
) -> bool:
if clean(row.get("boundary_excluded")) == "1" or clean(row.get("matched_case")) == "boundary_excluded":
return True
month_day = _extract_boundary_month_day(row.get("ledger_date"), row.get("fiscal_year"))
text_values = (
row.get("ledger_desc"),
row.get("description"),
row.get("ledger_account_name"),
row.get("account_name"),
row.get("review_reason"),
row.get("notes"),
)
if month_day == "01-01":
if earlier_year_account_lookup is not None:
return _row_has_earlier_fiscal_year_source_from_lookup(row, earlier_year_account_lookup)
return bool(conn is not None and _row_has_earlier_fiscal_year_source(conn, row))
if _has_strong_carryover_keyword(*text_values):
return True
if not month_day:
return False
return _has_boundary_exclusion_keyword(*text_values)
def _annotate_boundary_excluded_row(
row: dict[str, Any],
conn: Any | None = None,
earlier_year_account_lookup: dict[int, set[str]] | None = None,
) -> dict[str, Any]:
if _is_boundary_excluded_unmatched_row(row, conn, earlier_year_account_lookup):
row["boundary_excluded"] = "1"
row["boundary_excluded_label"] = "연초/연말 대체·이월"
row["matched_case"] = "boundary_excluded"
return row
def _refresh_section_counts(sections: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
for section in sections.values():
rows = section.get("rows", [])
if isinstance(rows, list):
section["count"] = len(rows)
return sections
def _apply_boundary_exclusions_to_sections(conn: Any, sections: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
earlier_year_account_lookup = _build_earlier_year_account_lookup(conn)
prepared: dict[str, dict[str, Any]] = {}
for status_key, section in sections.items():
copied = dict(section)
copied["rows"] = [dict(row) for row in section.get("rows", [])]
prepared[status_key] = copied
ledger_only_rows = prepared.setdefault("ledger_only", {"rows": [], "count": 0, "columns": DETAIL_COLUMN_MAP["ledger_only"]})["rows"]
for status_key in ("matched", "amount_mismatch"):
kept_rows: list[dict[str, Any]] = []
for row in prepared.get(status_key, {}).get("rows", []):
if _is_boundary_excluded_unmatched_row(row, conn, earlier_year_account_lookup):
ledger_only_rows.append(_annotate_boundary_excluded_row(row, conn, earlier_year_account_lookup))
else:
kept_rows.append(row)
if status_key in prepared:
prepared[status_key]["rows"] = kept_rows
prepared["ledger_only"]["rows"] = [
_annotate_boundary_excluded_row(row, conn, earlier_year_account_lookup)
for row in prepared.get("ledger_only", {}).get("rows", [])
]
return _refresh_section_counts(prepared)
def _boundary_excluded_sql(date_sql: str, *text_sqls: str) -> str:
text_expr = " || ' ' || ".join(f"COALESCE({field}, '')" for field in text_sqls)
compact_text_expr = f"REPLACE(({text_expr}), ' ', '')"
keyword_sql = " OR ".join(f"{text_expr} LIKE '%{keyword}%'" for keyword in BOUNDARY_EXCLUSION_KEYWORDS)
strong_keyword_sql = " OR ".join(f"{compact_text_expr} LIKE '%{keyword}%'" for keyword in STRONG_CARRYOVER_KEYWORDS)
return f"(({strong_keyword_sql}) OR (substr(COALESCE({date_sql}, ''), 6, 5) IN ('01-01', '12-31') AND ({keyword_sql})))"
def _append_like_filter(conditions: list[str], params: dict[str, Any], field_sql: str, param_name: str, value: str) -> None:
cleaned = clean(value)
if not cleaned:
return
conditions.append(f"COALESCE({field_sql}, '') LIKE :{param_name}")
params[param_name] = f"%{cleaned}%"
def _append_amount_filter(conditions: list[str], params: dict[str, Any], fields: list[str], param_name: str, value: str) -> None:
cleaned = clean(value)
if not cleaned:
return
amount = parse_amount(cleaned)
if abs(amount) < 0.5 and not re.search(r"\d", cleaned):
return
parts: list[str] = []
for index, field_sql in enumerate(fields):
key = f"{param_name}_{index}"
parts.append(f"ABS(COALESCE({field_sql}, 0) - :{key}) < 0.5")
params[key] = amount
conditions.append("(" + " OR ".join(parts) + ")")
def _bridge_expense_candidates_sql() -> str:
compact_desc_sql = """
lower(
replace(replace(replace(replace(replace(
COALESCE(desc1, '') || COALESCE(desc2, ''),
' ', ''
), '(', ''), ')', ''), '/', ''), '-', '')
)
"""
ledger_desc_sql = """
lower(
replace(replace(replace(replace(replace(
COALESCE(description, ''),
' ', ''
), '(', ''), ')', ''), '/', ''), '-', '')
)
"""
bank_sql = "(account_name LIKE '%보통예금%' OR account_code LIKE '101105%' OR account_code IN ('103'))"
payable_sql = "(account_name LIKE '%미지급금%' OR account_name LIKE '%외상매입금%' OR account_code LIKE '201%')"
ledger_expense_sql = """
(
account_code LIKE '5%'
OR account_code LIKE '6%'
OR account_name LIKE '%급여%'
OR account_name LIKE '%접대%'
OR account_name LIKE '%수수료%'
OR account_name LIKE '%임차료%'
OR account_name LIKE '%복리후생%'
OR account_name LIKE '%교육훈련%'
OR account_name LIKE '%보험료%'
OR account_name LIKE '%도서인쇄%'
OR account_name LIKE '%소모품%'
OR account_name LIKE '%외주비%'
)
"""
expense_sql = """
(
account_code LIKE '5%'
OR account_code LIKE '6%'
OR account_name LIKE '%급여%'
OR account_name LIKE '%접대%'
OR account_name LIKE '%수수료%'
OR account_name LIKE '%임차료%'
OR account_name LIKE '%복리후생%'
OR account_name LIKE '%교육훈련%'
OR account_name LIKE '%보험료%'
OR account_name LIKE '%도서인쇄%'
OR account_name LIKE '%소모품%'
OR account_name LIKE '%외주비%'
)
"""
return f"""
WITH ledger_groups AS (
SELECT
fiscal_year,
voucher_no,
ledger_date,
SUM(CASE WHEN {bank_sql} THEN COALESCE(credit, 0) ELSE 0 END) AS bank_credit,
GROUP_CONCAT(DISTINCT CASE WHEN {ledger_expense_sql} THEN account_code || ' ' || account_name END) AS ledger_accounts,
GROUP_CONCAT(DISTINCT CASE WHEN {ledger_expense_sql} THEN vendor_name END) AS ledger_vendors,
GROUP_CONCAT(DISTINCT description) AS ledger_desc,
MIN({ledger_desc_sql}) AS ledger_desc_norm
FROM wehago_ledger_rows
WHERE fiscal_year >= :start_year
AND fiscal_year <= :end_year
AND COALESCE(voucher_no, '') <> ''
GROUP BY fiscal_year, voucher_no, ledger_date
HAVING bank_credit > 0
AND COALESCE(ledger_accounts, '') <> ''
),
payment_groups AS (
SELECT
fiscal_year,
rtrim(rtrim(COALESCE(draft_no, confirmed_no), '0123456789'), '-') AS payment_group_key,
MIN(CASE
WHEN length(COALESCE(confirmed_no, '')) >= 11
THEN substr(confirmed_no, 4, 4) || '-' || substr(confirmed_no, 8, 2) || '-' || substr(confirmed_no, 10, 2)
ELSE proof_date
END) AS payment_date,
SUM(CASE WHEN {bank_sql} THEN COALESCE(credit_supply, 0) ELSE 0 END) AS bank_credit,
SUM(CASE WHEN {payable_sql} THEN COALESCE(debit_supply, 0) ELSE 0 END) AS payable_debit,
GROUP_CONCAT(DISTINCT CASE WHEN {payable_sql} THEN vendor_name END) AS payable_vendors,
GROUP_CONCAT(DISTINCT account_code || ' ' || account_name) AS payment_accounts,
GROUP_CONCAT(DISTINCT confirmed_no) AS payment_vouchers,
GROUP_CONCAT(DISTINCT draft_no) AS payment_drafts,
GROUP_CONCAT(DISTINCT COALESCE(desc1, '') || COALESCE(desc2, '')) AS payment_desc,
MIN({compact_desc_sql}) AS payment_desc_norm
FROM wehago_voucher_rows
WHERE fiscal_year >= :start_year
AND fiscal_year <= :end_year
AND COALESCE(draft_no, confirmed_no, '') <> ''
GROUP BY fiscal_year, payment_group_key
HAVING bank_credit > 0
AND payable_debit > 0
),
expense_sources AS (
SELECT
fiscal_year,
MIN(CASE
WHEN length(COALESCE(confirmed_no, '')) >= 11
THEN substr(confirmed_no, 4, 4) || '-' || substr(confirmed_no, 8, 2) || '-' || substr(confirmed_no, 10, 2)
ELSE proof_date
END) AS source_date,
{compact_desc_sql} AS source_desc_norm,
GROUP_CONCAT(DISTINCT CASE WHEN {expense_sql} THEN account_code || ' ' || account_name END) AS expense_accounts,
GROUP_CONCAT(DISTINCT CASE WHEN {expense_sql} THEN vendor_name END) AS expense_vendors,
GROUP_CONCAT(DISTINCT CASE WHEN {expense_sql} THEN confirmed_no END) AS expense_vouchers,
GROUP_CONCAT(DISTINCT CASE WHEN {expense_sql} THEN draft_no END) AS expense_drafts,
GROUP_CONCAT(DISTINCT COALESCE(desc1, '') || COALESCE(desc2, '')) AS expense_desc,
SUM(CASE WHEN {expense_sql} THEN COALESCE(debit_supply, 0) ELSE 0 END) AS expense_debit,
SUM(CASE WHEN {payable_sql} THEN COALESCE(credit_supply, 0) ELSE 0 END) AS payable_credit
FROM wehago_voucher_rows
WHERE fiscal_year >= :start_year
AND fiscal_year <= :end_year
GROUP BY fiscal_year, source_desc_norm
HAVING expense_debit > 0
AND payable_credit > 0
AND COALESCE(expense_accounts, '') <> ''
)
SELECT
l.fiscal_year AS fiscal_year,
l.ledger_date AS ledger_date,
l.voucher_no AS voucher_no,
COALESCE(l.ledger_accounts, '') AS ledger_account_name,
COALESCE(l.ledger_vendors, '') AS ledger_vendor,
0 AS ledger_debit,
l.bank_credit AS ledger_credit,
COALESCE(l.ledger_desc, '') AS ledger_desc,
p.payment_date AS payment_date,
p.payment_group_key AS draft_no,
COALESCE(p.payment_accounts, '') AS voucher_account_name,
COALESCE(p.payable_vendors, '') AS voucher_vendor,
p.payable_debit AS voucher_debit,
p.bank_credit AS voucher_credit,
COALESCE(p.payment_desc, '') AS voucher_desc,
COALESCE(s.expense_accounts, '') AS inferred_expense_accounts,
COALESCE(s.expense_vendors, '') AS inferred_expense_vendors,
COALESCE(s.expense_vouchers, '') AS expense_source_vouchers,
COALESCE(s.expense_drafts, '') AS expense_source_drafts,
COALESCE(s.expense_desc, '') AS expense_source_desc,
COALESCE(s.expense_debit, 0) AS expense_source_debit,
COALESCE(s.payable_credit, 0) AS expense_source_payable_credit,
'보통예금 출금액=ERP 보통예금 지급액, ERP 지급전표는 미지급금 결제, 같은 핵심 적요의 원 비용전표 존재' AS bridge_reason,
'bridge_expense' AS matched_case
FROM ledger_groups l
JOIN payment_groups p
ON p.fiscal_year = l.fiscal_year
AND ABS(p.bank_credit - l.bank_credit) < 0.5
AND (
l.ledger_date = p.payment_date
OR ABS(julianday(l.ledger_date) - julianday(p.payment_date)) <= 30
)
AND (
instr(l.ledger_desc_norm, p.payment_desc_norm) > 0
OR instr(p.payment_desc_norm, l.ledger_desc_norm) > 0
)
JOIN expense_sources s
ON s.fiscal_year = p.fiscal_year
AND s.source_desc_norm = p.payment_desc_norm
AND (
s.source_date IS NULL
OR p.payment_date IS NULL
OR julianday(s.source_date) <= julianday(p.payment_date) + 1
)
"""
def _fetch_bridge_expense_review_rows_from_db(
conn: Any,
start_year: int,
end_year: int,
voucher_filter: str,
draft_filter: str,
wehago_account_filter: str,
erp_account_filter: str,
wehago_amount_filter: str,
erp_amount_filter: str,
wehago_vendor_filter: str,
erp_vendor_filter: str,
desc_filter: str,
offset: int,
limit: int,
) -> dict[str, Any]:
params: dict[str, Any] = {
"start_year": start_year,
"end_year": end_year,
"limit": limit,
"offset": offset,
}
conditions = ["1 = 1"]
_append_like_filter(conditions, params, "b.voucher_no", "voucher_no", voucher_filter)
_append_like_filter(conditions, params, "b.draft_no || ' ' || b.payment_vouchers || ' ' || b.expense_source_vouchers", "draft_no", draft_filter)
_append_like_filter(conditions, params, "b.ledger_account_name", "wehago_account", wehago_account_filter)
_append_like_filter(conditions, params, "b.voucher_account_name || ' ' || b.inferred_expense_accounts", "erp_account", erp_account_filter)
_append_like_filter(conditions, params, "b.ledger_vendor", "wehago_vendor", wehago_vendor_filter)
_append_like_filter(conditions, params, "b.voucher_vendor || ' ' || b.inferred_expense_vendors", "erp_vendor", erp_vendor_filter)
_append_like_filter(
conditions,
params,
"b.ledger_desc || ' ' || b.voucher_desc || ' ' || b.expense_source_desc || ' ' || b.bridge_reason",
"desc_keyword",
desc_filter,
)
_append_amount_filter(conditions, params, ["b.ledger_credit"], "wehago_amount", wehago_amount_filter)
_append_amount_filter(conditions, params, ["b.voucher_debit", "b.voucher_credit", "b.expense_source_debit"], "erp_amount", erp_amount_filter)
base_sql = _bridge_expense_candidates_sql()
where_sql = " AND ".join(conditions)
from_sql = f"FROM ({base_sql}) b WHERE {where_sql}"
total_count = int(conn.execute(text(f"SELECT COUNT(*) {from_sql}"), params).scalar_one() or 0)
rows = [
{key: clean(value) if isinstance(value, str) else value for key, value in dict(row).items()}
for row in conn.execute(
text(
f"""
SELECT *
{from_sql}
ORDER BY fiscal_year, ledger_date, voucher_no, draft_no
LIMIT :limit OFFSET :offset
"""
),
params,
).mappings()
]
next_offset = offset + len(rows)
return {
"columns": DETAIL_COLUMN_MAP["bridge_expense_review"],
"rows": rows,
"total_count": total_count,
"shown_count": len(rows),
"offset": offset,
"limit": limit,
"has_more": next_offset < total_count,
"next_offset": next_offset,
"notice": "",
"bank_payable_case_count": 0,
"boundary_excluded_count": 0,
}
def _fetch_bridge_expense_candidate_rows(
conn: Any,
start_year: int,
end_year: int,
) -> list[dict[str, Any]]:
base_sql = _bridge_expense_candidates_sql()
rows = conn.execute(
text(
f"""
SELECT *
FROM ({base_sql}) b
ORDER BY fiscal_year, ledger_date, voucher_no, draft_no
"""
),
{"start_year": start_year, "end_year": end_year},
).mappings().all()
return [
{key: clean(value) if isinstance(value, str) else value for key, value in dict(row).items()}
for row in rows
]
def _split_bridge_account_entries(value: Any) -> list[tuple[str, str]]:
entries: list[tuple[str, str]] = []
for chunk in [part.strip() for part in clean(value).split(",") if clean(part)]:
match = re.match(r"^(\S+)\s+(.+)$", chunk)
if match:
entries.append((clean(match.group(1)), clean(match.group(2))))
else:
entries.append(("", clean(chunk)))
return entries
def _bridge_candidate_group_key(row: dict[str, Any]) -> tuple[int, str, str]:
return (
int(row.get("fiscal_year") or 0),
clean(row.get("voucher_no")),
clean(row.get("ledger_date")),
)
def _bridge_candidate_has_expense_nature(row: dict[str, Any]) -> bool:
entries = _split_bridge_account_entries(row.get("inferred_expense_accounts"))
if not entries:
return False
valid_count = 0
for code, name in entries:
category = _classify_account_category(code, name)
if category == "expense":
valid_count += 1
elif category:
return False
return valid_count > 0
def _bridge_candidate_is_eligible_for_promotion(row: dict[str, Any], settings: dict[str, Any]) -> bool:
if not settings.get("enable_promotion"):
return False
amount_gap_1 = abs(parse_amount(row.get("ledger_credit")) - parse_amount(row.get("voucher_credit")))
amount_gap_2 = abs(parse_amount(row.get("ledger_credit")) - parse_amount(row.get("expense_source_debit")))
amount_gap_3 = abs(parse_amount(row.get("voucher_debit")) - parse_amount(row.get("expense_source_payable_credit")))
if settings.get("require_exact_amount") and max(amount_gap_1, amount_gap_2, amount_gap_3) >= 0.5:
return False
if settings.get("require_expense_nature") and not _bridge_candidate_has_expense_nature(row):
return False
if settings.get("require_strong_text"):
bridge_probe = {
"ledger_desc": clean(row.get("ledger_desc")),
"voucher_desc": clean(row.get("expense_source_desc")) or clean(row.get("voucher_desc")),
"ledger_vendor": clean(row.get("ledger_vendor")),
"voucher_vendor": clean(row.get("inferred_expense_vendors")) or clean(row.get("voucher_vendor")),
}
if not (_same_or_similar_desc(bridge_probe) or _same_or_similar_vendor(bridge_probe)):
return False
return True
def _bridge_candidate_to_voucher_matched_group(row: dict[str, Any]) -> dict[str, Any]:
draft_display = clean(row.get("expense_source_drafts")) or clean(row.get("draft_no"))
voucher_display = clean(row.get("expense_source_vouchers")) or clean(row.get("draft_no"))
proof_date = clean(row.get("payment_date"))
review_reason = "2단계자동매칭"
review_detail = clean(row.get("bridge_reason"))
if voucher_display:
review_detail = f"{review_reason} / 원전표 {voucher_display}"
if draft_display and draft_display not in review_detail:
review_detail = f"{review_detail} / 원가전표 {draft_display}"
row_payload = {
"fiscal_year": int(row.get("fiscal_year") or 0),
"ledger_date": clean(row.get("ledger_date")),
"proof_date": proof_date,
"voucher_no": clean(row.get("voucher_no")),
"draft_no": draft_display,
"ledger_account_code": "",
"ledger_account_name": clean(row.get("ledger_account_name")),
"voucher_account_code": "",
"voucher_account_name": clean(row.get("inferred_expense_accounts")),
"ledger_vendor": clean(row.get("ledger_vendor")),
"voucher_vendor": clean(row.get("inferred_expense_vendors")) or clean(row.get("voucher_vendor")),
"ledger_debit": parse_amount(row.get("ledger_debit")),
"ledger_credit": parse_amount(row.get("ledger_credit")),
"voucher_debit": parse_amount(row.get("expense_source_debit")),
"voucher_credit": 0,
"ledger_desc": clean(row.get("ledger_desc")),
"voucher_desc": clean(row.get("expense_source_desc")) or clean(row.get("voucher_desc")),
"status_label": "Matched",
"review_reason": review_reason,
"review_memo": review_detail,
"matched_case": "bridge_expense_promoted",
"ledger_row_key": f"bridge-ledger:{clean(row.get('fiscal_year'))}:{clean(row.get('ledger_date'))}:{clean(row.get('voucher_no'))}",
"voucher_row_key": f"bridge-voucher:{clean(row.get('fiscal_year'))}:{clean(voucher_display)}:{clean(draft_display)}",
"match_identity_key": f"bridge:{clean(row.get('fiscal_year'))}:{clean(row.get('voucher_no'))}:{clean(row.get('ledger_date'))}:{clean(voucher_display)}",
}
summary = {
"fiscal_year": row_payload["fiscal_year"],
"status_label": "Matched",
"ledger_date": row_payload["ledger_date"],
"proof_date": row_payload["proof_date"],
"voucher_no": row_payload["voucher_no"],
"draft_no": row_payload["draft_no"],
"ledger_row_count": 1,
"voucher_row_count": 1,
"ledger_debit": parse_amount(row_payload["ledger_debit"]),
"ledger_credit": parse_amount(row_payload["ledger_credit"]),
"voucher_debit": parse_amount(row_payload["voucher_debit"]),
"voucher_credit": parse_amount(row_payload["voucher_credit"]),
"ledger_accounts": clean(row_payload["ledger_account_name"]),
"voucher_accounts": clean(row_payload["voucher_account_name"]),
"ledger_vendors": clean(row_payload["ledger_vendor"]),
"voucher_vendors": clean(row_payload["voucher_vendor"]),
"review_reason": review_detail,
"matched_case": row_payload["matched_case"],
}
return {"summary": summary, "rows": [row_payload]}
def _bridge_candidates_to_voucher_recheck_group(group_key: tuple[int, str, str], candidates: list[dict[str, Any]]) -> dict[str, Any]:
primary = candidates[0] if candidates else {}
review_reason = "2단계복수후보"
voucher_labels = [clean(candidate.get("expense_source_vouchers")) or clean(candidate.get("draft_no")) for candidate in candidates]
voucher_labels = [label for label in voucher_labels if label]
review_detail = review_reason
if voucher_labels:
review_detail = f"{review_reason} / 후보 {', '.join(dict.fromkeys(voucher_labels))}"
rows: list[dict[str, Any]] = []
for index, candidate in enumerate(candidates, start=1):
voucher_display = clean(candidate.get("expense_source_vouchers")) or clean(candidate.get("draft_no"))
draft_display = clean(candidate.get("expense_source_drafts")) or clean(candidate.get("draft_no"))
rows.append(
{
"fiscal_year": int(candidate.get("fiscal_year") or 0),
"ledger_date": clean(candidate.get("ledger_date")),
"proof_date": clean(candidate.get("payment_date")),
"voucher_no": clean(candidate.get("voucher_no")),
"draft_no": draft_display,
"ledger_account_code": "",
"ledger_account_name": clean(candidate.get("ledger_account_name")),
"voucher_account_code": "",
"voucher_account_name": clean(candidate.get("inferred_expense_accounts")),
"ledger_vendor": clean(candidate.get("ledger_vendor")),
"voucher_vendor": clean(candidate.get("inferred_expense_vendors")) or clean(candidate.get("voucher_vendor")),
"ledger_debit": parse_amount(candidate.get("ledger_debit")),
"ledger_credit": parse_amount(candidate.get("ledger_credit")),
"voucher_debit": parse_amount(candidate.get("expense_source_debit")),
"voucher_credit": 0,
"ledger_desc": clean(candidate.get("ledger_desc")),
"voucher_desc": clean(candidate.get("expense_source_desc")) or clean(candidate.get("voucher_desc")),
"status_label": "Recheck",
"review_reason": review_reason,
"review_memo": f"{review_detail} / 후보 {index}",
"matched_case": "bridge_expense_recheck",
"ledger_row_key": f"bridge-recheck-ledger:{clean(candidate.get('fiscal_year'))}:{clean(candidate.get('ledger_date'))}:{clean(candidate.get('voucher_no'))}:{index}",
"voucher_row_key": f"bridge-recheck-voucher:{clean(candidate.get('fiscal_year'))}:{clean(voucher_display)}:{clean(draft_display)}:{index}",
"match_identity_key": f"bridge-recheck:{clean(candidate.get('fiscal_year'))}:{clean(candidate.get('voucher_no'))}:{clean(candidate.get('ledger_date'))}:{index}",
}
)
summary = {
"fiscal_year": int(primary.get("fiscal_year") or group_key[0] or 0),
"status_label": "Recheck",
"ledger_date": clean(primary.get("ledger_date")) or clean(group_key[2]),
"proof_date": clean(primary.get("payment_date")),
"voucher_no": clean(primary.get("voucher_no")) or clean(group_key[1]),
"draft_no": ", ".join(dict.fromkeys(clean(candidate.get("expense_source_drafts")) or clean(candidate.get("draft_no")) for candidate in candidates if clean(candidate.get("expense_source_drafts")) or clean(candidate.get("draft_no")))),
"ledger_row_count": 1 if primary else 0,
"voucher_row_count": len(rows),
"ledger_debit": parse_amount(primary.get("ledger_debit")),
"ledger_credit": parse_amount(primary.get("ledger_credit")),
"voucher_debit": sum(parse_amount(candidate.get("expense_source_debit")) for candidate in candidates),
"voucher_credit": 0,
"ledger_accounts": clean(primary.get("ledger_account_name")),
"voucher_accounts": ", ".join(dict.fromkeys(clean(candidate.get("inferred_expense_accounts")) for candidate in candidates if clean(candidate.get("inferred_expense_accounts")))),
"ledger_vendors": clean(primary.get("ledger_vendor")),
"voucher_vendors": ", ".join(dict.fromkeys((clean(candidate.get("inferred_expense_vendors")) or clean(candidate.get("voucher_vendor"))) for candidate in candidates if (clean(candidate.get("inferred_expense_vendors")) or clean(candidate.get("voucher_vendor"))))),
"review_reason": review_detail,
"matched_case": "bridge_expense_recheck",
}
return {"summary": summary, "rows": rows}
def _apply_bridge_expense_promotions_to_voucher_sections(
conn: Any,
start_year: int | None,
end_year: int | None,
voucher_sections: dict[str, list[dict[str, Any]]],
) -> dict[str, list[dict[str, Any]]]:
if start_year is None or end_year is None:
return voucher_sections
settings = _load_bridge_review_settings_from_conn(conn)
if not settings.get("enable_promotion"):
return voucher_sections
candidates = _fetch_bridge_expense_candidate_rows(conn, start_year, end_year)
if not candidates:
return voucher_sections
existing_wehago_keys = {
(
int(group.get("summary", {}).get("fiscal_year") or 0),
clean(group.get("summary", {}).get("voucher_no")),
clean(group.get("summary", {}).get("ledger_date")),
)
for group in voucher_sections.get("voucher_matched", [])
}
eligible_by_group: dict[tuple[int, str, str], list[dict[str, Any]]] = {}
for candidate in candidates:
group_key = _bridge_candidate_group_key(candidate)
if group_key in existing_wehago_keys:
continue
if not _bridge_candidate_is_eligible_for_promotion(candidate, settings):
continue
eligible_by_group.setdefault(group_key, []).append(candidate)
promoted_groups: list[dict[str, Any]] = []
recheck_groups: list[dict[str, Any]] = []
for group_key, group_candidates in eligible_by_group.items():
if len(group_candidates) != 1:
if settings.get("ambiguous_handling") == "recheck":
recheck_groups.append(_bridge_candidates_to_voucher_recheck_group(group_key, group_candidates))
continue
promoted_groups.append(_bridge_candidate_to_voucher_matched_group(group_candidates[0]))
if not promoted_groups and not recheck_groups:
return voucher_sections
if promoted_groups:
voucher_sections["voucher_matched"].extend(promoted_groups)
voucher_sections["voucher_matched"].sort(
key=lambda item: (
int(item.get("summary", {}).get("fiscal_year") or 0),
clean(item.get("summary", {}).get("proof_date")) or clean(item.get("summary", {}).get("ledger_date")),
clean(item.get("summary", {}).get("voucher_no")),
clean(item.get("summary", {}).get("draft_no")),
)
)
if recheck_groups:
voucher_sections["voucher_recheck"].extend(recheck_groups)
voucher_sections["voucher_recheck"].sort(
key=lambda item: (
int(item.get("summary", {}).get("fiscal_year") or 0),
clean(item.get("summary", {}).get("proof_date")) or clean(item.get("summary", {}).get("ledger_date")),
clean(item.get("summary", {}).get("voucher_no")),
clean(item.get("summary", {}).get("draft_no")),
)
)
return voucher_sections
def _fetch_status_detail_rows_from_db(
conn: Any,
start_year: int,
end_year: int,
status: str,
voucher_filter: str,
draft_filter: str,
wehago_account_filter: str,
erp_account_filter: str,
wehago_amount_filter: str,
erp_amount_filter: str,
wehago_vendor_filter: str,
erp_vendor_filter: str,
desc_filter: str,
boundary_excluded_filter: bool,
offset: int,
limit: int,
) -> dict[str, Any]:
if status == "bridge_expense_review":
return _fetch_bridge_expense_review_rows_from_db(
conn,
start_year,
end_year,
voucher_filter,
draft_filter,
wehago_account_filter,
erp_account_filter,
wehago_amount_filter,
erp_amount_filter,
wehago_vendor_filter,
erp_vendor_filter,
desc_filter,
offset,
limit,
)
params: dict[str, Any] = {
"start_year": start_year,
"end_year": end_year,
"status": status,
"limit": limit,
"offset": offset,
}
conditions = [
"c.status = :status",
"c.fiscal_year >= :start_year",
"c.fiscal_year <= :end_year",
]
voucher_rep = """
SELECT *
FROM (
SELECT fiscal_year, compare_voucher_no, proof_date, confirmed_no, draft_no,
account_code, account_name, vendor_name, desc1, desc2, debit_supply, credit_supply,
ROW_NUMBER() OVER (
PARTITION BY fiscal_year, compare_voucher_no
ORDER BY row_number
) AS rn
FROM wehago_voucher_rows
WHERE COALESCE(compare_voucher_no, '') <> ''
)
WHERE rn = 1
"""
if status == "ledger_only":
boundary_sql = _boundary_excluded_sql("l.ledger_date", "l.description", "l.account_name", "c.notes")
select_sql = """
c.fiscal_year AS fiscal_year,
COALESCE(l.voucher_no, c.voucher_no) AS voucher_no,
COALESCE(l.ledger_date, '') AS ledger_date,
COALESCE(l.account_code, '') AS ledger_account_code,
COALESCE(l.account_name, c.ledger_accounts, '') AS ledger_account_name,
COALESCE(l.vendor_name, c.ledger_vendors, '') AS ledger_vendor,
l.debit AS ledger_debit,
l.credit AS ledger_credit,
COALESCE(l.description, '') AS ledger_desc,
CASE WHEN {boundary_sql} THEN '1' ELSE '' END AS boundary_excluded,
CASE WHEN {boundary_sql} THEN '연초/연말 대체·이월' ELSE '' END AS boundary_excluded_label,
CASE WHEN {boundary_sql} THEN 'boundary_excluded' ELSE '' END AS matched_case
""".format(boundary_sql=boundary_sql)
joins = """
JOIN wehago_ledger_rows l
ON l.fiscal_year = c.fiscal_year AND l.compare_voucher_no = c.voucher_no
"""
_append_like_filter(conditions, params, "COALESCE(l.voucher_no, c.voucher_no)", "voucher_no", voucher_filter)
_append_like_filter(conditions, params, "COALESCE(l.account_name, c.ledger_accounts)", "wehago_account", wehago_account_filter)
_append_like_filter(conditions, params, "COALESCE(l.vendor_name, c.ledger_vendors)", "wehago_vendor", wehago_vendor_filter)
_append_like_filter(conditions, params, "l.description", "desc_keyword", desc_filter)
_append_amount_filter(conditions, params, ["l.debit", "l.credit"], "wehago_amount", wehago_amount_filter)
if boundary_excluded_filter:
conditions.append(boundary_sql)
elif status == "voucher_only":
select_sql = """
c.fiscal_year AS fiscal_year,
COALESCE(v.confirmed_no, v.draft_no, c.voucher_no) AS voucher_no,
COALESCE(v.proof_date, '') AS proof_date,
COALESCE(v.draft_no, '') AS draft_no,
COALESCE(v.account_code, '') AS voucher_account_code,
COALESCE(v.account_name, c.voucher_accounts, '') AS voucher_account_name,
COALESCE(v.vendor_name, c.voucher_vendors, '') AS voucher_vendor,
v.debit_supply AS voucher_debit,
v.credit_supply AS voucher_credit,
TRIM(COALESCE(v.desc1, '') || ' ' || COALESCE(v.desc2, '')) AS voucher_desc
"""
joins = """
JOIN wehago_voucher_rows v
ON v.fiscal_year = c.fiscal_year AND v.compare_voucher_no = c.voucher_no
"""
_append_like_filter(conditions, params, "COALESCE(v.confirmed_no, v.draft_no, c.voucher_no)", "voucher_no", voucher_filter)
_append_like_filter(conditions, params, "v.draft_no", "draft_no", draft_filter)
_append_like_filter(conditions, params, "COALESCE(v.account_name, c.voucher_accounts)", "erp_account", erp_account_filter)
_append_like_filter(conditions, params, "COALESCE(v.vendor_name, c.voucher_vendors)", "erp_vendor", erp_vendor_filter)
_append_like_filter(conditions, params, "TRIM(COALESCE(v.desc1, '') || ' ' || COALESCE(v.desc2, ''))", "desc_keyword", desc_filter)
_append_amount_filter(conditions, params, ["v.debit_supply", "v.credit_supply"], "erp_amount", erp_amount_filter)
else:
select_sql = """
c.fiscal_year AS fiscal_year,
COALESCE(l.ledger_date, v.proof_date, '') AS ledger_date,
COALESCE(l.voucher_no, v.confirmed_no, v.draft_no, c.voucher_no) AS voucher_no,
COALESCE(v.draft_no, '') AS draft_no,
COALESCE(l.account_code, '') AS ledger_account_code,
COALESCE(l.account_name, c.ledger_accounts, '') AS ledger_account_name,
COALESCE(v.account_code, '') AS voucher_account_code,
COALESCE(v.account_name, c.voucher_accounts, '') AS voucher_account_name,
COALESCE(l.vendor_name, c.ledger_vendors, '') AS ledger_vendor,
COALESCE(v.vendor_name, c.voucher_vendors, '') AS voucher_vendor,
l.debit AS ledger_debit,
l.credit AS ledger_credit,
COALESCE(v.debit_supply, 0) AS voucher_debit,
COALESCE(v.credit_supply, 0) AS voucher_credit,
COALESCE(l.description, '') AS ledger_desc,
TRIM(COALESCE(v.desc1, '') || ' ' || COALESCE(v.desc2, '')) AS voucher_desc,
c.notes AS review_reason
"""
joins = f"""
JOIN wehago_ledger_rows l
ON l.fiscal_year = c.fiscal_year AND l.compare_voucher_no = c.voucher_no
LEFT JOIN ({voucher_rep}) v
ON v.fiscal_year = c.fiscal_year AND v.compare_voucher_no = c.voucher_no
"""
_append_like_filter(conditions, params, "COALESCE(l.voucher_no, v.confirmed_no, v.draft_no, c.voucher_no)", "voucher_no", voucher_filter)
_append_like_filter(conditions, params, "v.draft_no", "draft_no", draft_filter)
_append_like_filter(conditions, params, "COALESCE(l.account_name, c.ledger_accounts)", "wehago_account", wehago_account_filter)
_append_like_filter(conditions, params, "COALESCE(v.account_name, c.voucher_accounts)", "erp_account", erp_account_filter)
_append_like_filter(conditions, params, "COALESCE(l.vendor_name, c.ledger_vendors)", "wehago_vendor", wehago_vendor_filter)
_append_like_filter(conditions, params, "COALESCE(v.vendor_name, c.voucher_vendors)", "erp_vendor", erp_vendor_filter)
_append_like_filter(
conditions,
params,
"COALESCE(l.description, '') || ' ' || COALESCE(v.desc1, '') || ' ' || COALESCE(v.desc2, '') || ' ' || COALESCE(c.notes, '')",
"desc_keyword",
desc_filter,
)
_append_amount_filter(conditions, params, ["l.debit", "l.credit"], "wehago_amount", wehago_amount_filter)
_append_amount_filter(conditions, params, ["v.debit_supply", "v.credit_supply"], "erp_amount", erp_amount_filter)
where_sql = " AND ".join(conditions)
from_sql = f"FROM wehago_comparison_results c {joins} WHERE {where_sql}"
total_count = int(conn.execute(text(f"SELECT COUNT(*) {from_sql}"), params).scalar_one() or 0)
rows = [
{key: clean(value) if isinstance(value, str) else value for key, value in dict(row).items()}
for row in conn.execute(
text(
f"""
SELECT {select_sql}
{from_sql}
ORDER BY c.fiscal_year, c.voucher_no
LIMIT :limit OFFSET :offset
"""
),
params,
).mappings()
]
next_offset = offset + len(rows)
boundary_excluded_count = 0
if status == "ledger_only":
boundary_sql = _boundary_excluded_sql("l.ledger_date", "l.description", "l.account_name", "c.notes")
boundary_count_conditions = [
item
for item in conditions
if item != boundary_sql and item != f"NOT {boundary_sql}"
]
boundary_excluded_count = int(
conn.execute(
text(
f"""
SELECT COUNT(*)
FROM wehago_comparison_results c {joins}
WHERE {' AND '.join(boundary_count_conditions + [boundary_sql])}
"""
),
params,
).scalar_one()
or 0
)
return {
"columns": DETAIL_COLUMN_MAP[status],
"rows": rows,
"total_count": total_count,
"shown_count": len(rows),
"offset": offset,
"limit": limit,
"has_more": next_offset < total_count,
"next_offset": next_offset,
"notice": "",
"bank_payable_case_count": 0,
"boundary_excluded_count": boundary_excluded_count,
}
def _build_status_detail_response_from_rows(
rows_by_status: dict[str, list[dict[str, Any]]],
status: str,
voucher_filter: str,
draft_filter: str,
wehago_account_filter: str,
erp_account_filter: str,
wehago_amount_filter: str,
erp_amount_filter: str,
wehago_vendor_filter: str,
erp_vendor_filter: str,
desc_filter: str,
boundary_excluded_filter: bool,
offset: int,
limit: int,
) -> dict[str, Any]:
source_rows = [
_annotate_boundary_excluded_row(dict(row)) if status == "ledger_only" else dict(row)
for row in rows_by_status.get(status, [])
]
filtered_rows = [
row
for row in source_rows
if _filter_status_row(
row,
voucher_filter,
draft_filter,
wehago_account_filter,
erp_account_filter,
wehago_amount_filter,
erp_amount_filter,
wehago_vendor_filter,
erp_vendor_filter,
desc_filter,
)
and (not boundary_excluded_filter or _is_boundary_excluded_unmatched_row(row))
]
next_offset = offset + min(limit, max(len(filtered_rows) - offset, 0))
shown_rows = filtered_rows[offset : offset + limit]
return {
"columns": DETAIL_COLUMN_MAP[status],
"rows": shown_rows,
"total_count": len(filtered_rows),
"shown_count": len(shown_rows),
"offset": offset,
"limit": limit,
"has_more": next_offset < len(filtered_rows),
"next_offset": next_offset,
"notice": "",
"bank_payable_case_count": sum(1 for row in filtered_rows if _is_bank_payable_matched_row(row)),
"boundary_excluded_count": sum(1 for row in source_rows if _is_boundary_excluded_unmatched_row(row)) if status == "ledger_only" else 0,
}
def _build_voucher_sections_from_rows_by_status(
rows_by_status: dict[str, list[dict[str, Any]]],
) -> dict[str, list[dict[str, Any]]]:
grouped: dict[tuple[str, int, str, str], dict[str, Any]] = {}
matched_ledger_keys: set[str] = set()
matched_voucher_keys: set[str] = set()
matched_wehago_group_keys: set[tuple[int, str, str]] = set()
matched_erp_group_keys: set[tuple[int, str]] = set()
def erp_set_key_from_values(fiscal_year: Any, draft_no: Any, voucher_no: Any, proof_date: Any) -> tuple[int, str]:
normalized_draft = clean(draft_no)
normalized_voucher = clean(voucher_no)
normalized_date = clean(proof_date)
base = normalized_draft or normalized_voucher
if base:
base = re.sub(r"-\d+$", "", base)
if not base and normalized_date and normalized_voucher:
base = f"{normalized_date}|{normalized_voucher}"
return (int(fiscal_year or 0), base)
def wehago_group_key(row: dict[str, Any]) -> tuple[int, str, str]:
return (
int(row.get("fiscal_year") or 0),
clean(row.get("voucher_no")),
clean(row.get("ledger_date")),
)
def erp_group_key(row: dict[str, Any]) -> tuple[int, str]:
return erp_set_key_from_values(
row.get("fiscal_year"),
row.get("draft_no"),
row.get("voucher_no"),
row.get("proof_date"),
)
def row_origin_rank(row: dict[str, Any]) -> int:
status_label = clean(row.get("status_label"))
if status_label == "Matched":
return 0
if status_label == "Unmatched":
return 1
if status_label == "ERP Unmatched":
return 2
if status_label == "Recheck":
return 3
return 4
def ensure_group(status_key: str, row: dict[str, Any]) -> dict[str, Any]:
fiscal_year = int(row.get("fiscal_year") or 0)
voucher_no = clean(row.get("voucher_no"))
draft_no = clean(row.get("draft_no"))
ledger_date = clean(row.get("ledger_date"))
proof_date = clean(row.get("proof_date"))
fallback_voucher_key = clean(
row.get("ledger_row_key")
or row.get("voucher_row_key")
or row.get("review_key")
or ledger_date
or proof_date
or row.get("ledger_desc")
or row.get("voucher_desc")
)
group_key_parts = [part for part in [voucher_no, draft_no, ledger_date, proof_date] if part]
if status_key in {"matched", "ledger_only", "amount_mismatch"}:
if voucher_no and ledger_date:
group_key = "|".join([str(fiscal_year), ledger_date, voucher_no])
elif voucher_no and proof_date:
group_key = "|".join([str(fiscal_year), proof_date, voucher_no])
elif group_key_parts:
group_key = "|".join([str(fiscal_year), *group_key_parts])
else:
group_key = fallback_voucher_key
elif draft_no and proof_date:
group_key = "|".join([str(fiscal_year), proof_date, draft_no])
elif voucher_no and proof_date:
group_key = "|".join([str(fiscal_year), proof_date, voucher_no])
elif group_key_parts:
group_key = "|".join([str(fiscal_year), *group_key_parts])
else:
group_key = fallback_voucher_key
key = (status_key, fiscal_year, group_key, "")
current = grouped.get(key)
if current is None:
status_label_map = {
"matched": "Matched",
"ledger_only": "Unmatched",
"voucher_only": "ERP Unmatched",
"amount_mismatch": "Recheck",
}
current = {
"fiscal_year": fiscal_year,
"status_label": status_label_map.get(status_key, status_key),
"ledger_date": ledger_date,
"proof_date": proof_date,
"voucher_no": voucher_no,
"draft_no": draft_no,
"ledger_row_count": 0,
"voucher_row_count": 0,
"ledger_debit": 0.0,
"ledger_credit": 0.0,
"voucher_debit": 0.0,
"voucher_credit": 0.0,
"ledger_accounts": [],
"voucher_accounts": [],
"ledger_vendors": [],
"voucher_vendors": [],
"review_reason": [],
"rows": [],
"wehago_group_key": wehago_group_key(row),
"erp_group_keys": set(),
"draft_nos": [],
}
grouped[key] = current
return current
def append_unique(target: list[str], value: Any) -> None:
text_value = clean(value)
if text_value and text_value not in target:
target.append(text_value)
for status_key in ("matched", "ledger_only", "voucher_only", "amount_mismatch"):
for row in rows_by_status.get(status_key, []):
current = ensure_group(status_key, row)
if not current.get("ledger_date"):
current["ledger_date"] = clean(row.get("ledger_date"))
if not current.get("proof_date"):
current["proof_date"] = clean(row.get("proof_date"))
if clean(row.get("ledger_account_name")) or clean(row.get("ledger_desc")):
current["ledger_row_count"] += 1
if clean(row.get("voucher_account_name")) or clean(row.get("voucher_desc")):
current["voucher_row_count"] += 1
current["ledger_debit"] += parse_amount(row.get("ledger_debit"))
current["ledger_credit"] += parse_amount(row.get("ledger_credit"))
current["voucher_debit"] += parse_amount(row.get("voucher_debit"))
current["voucher_credit"] += parse_amount(row.get("voucher_credit"))
append_unique(current["ledger_accounts"], row.get("ledger_account_name"))
append_unique(current["voucher_accounts"], row.get("voucher_account_name"))
append_unique(current["ledger_vendors"], row.get("ledger_vendor"))
append_unique(current["voucher_vendors"], row.get("voucher_vendor"))
append_unique(current["review_reason"], row.get("review_reason"))
append_unique(current["draft_nos"], row.get("draft_no"))
row_payload = dict(row)
row_payload.setdefault("status_label", current.get("status_label"))
current["rows"].append(row_payload)
if status_key in {"matched", "amount_mismatch"}:
current["erp_group_keys"].add(erp_group_key(row))
if status_key == "voucher_only":
current["erp_group_keys"].add(erp_group_key(row))
if status_key == "matched":
matched_erp_group_keys.add(erp_group_key(row))
ledger_key = clean(row.get("ledger_row_key"))
voucher_key = clean(row.get("voucher_row_key"))
if ledger_key:
matched_ledger_keys.add(ledger_key)
if voucher_key:
matched_voucher_keys.add(voucher_key)
current["erp_group_keys"].add(erp_group_key(row))
ledger_only_index: dict[tuple[int, str, str], list[dict[str, Any]]] = {}
for row in rows_by_status.get("ledger_only", []):
ledger_only_index.setdefault(wehago_group_key(row), []).append(dict(row))
voucher_only_index: dict[tuple[int, str], list[dict[str, Any]]] = {}
for row in rows_by_status.get("voucher_only", []):
voucher_only_index.setdefault(erp_group_key(row), []).append(dict(row))
recheck_by_wehago_index: dict[tuple[int, str, str], list[dict[str, Any]]] = {}
recheck_by_erp_index: dict[tuple[int, str], list[dict[str, Any]]] = {}
for row in rows_by_status.get("amount_mismatch", []):
row_copy = dict(row)
recheck_by_wehago_index.setdefault(wehago_group_key(row_copy), []).append(row_copy)
recheck_by_erp_index.setdefault(erp_group_key(row_copy), []).append(row_copy)
recheck_wehago_group_keys = set(recheck_by_wehago_index.keys())
recheck_erp_group_keys = set(recheck_by_erp_index.keys())
def recheck_identity(row: dict[str, Any]) -> str:
return (
clean(row.get("review_key"))
or "|".join(
[
clean(row.get("ledger_row_key")),
clean(row.get("voucher_row_key")),
clean(row.get("voucher_no")),
clean(row.get("draft_no")),
clean(row.get("ledger_account_name")),
clean(row.get("voucher_account_name")),
clean(row.get("ledger_desc")),
clean(row.get("voucher_desc")),
]
)
)
def append_recheck_row(target_rows: list[dict[str, Any]], extra_recheck: dict[str, Any], appended_recheck_ids: set[str]) -> None:
identity = recheck_identity(extra_recheck)
if identity and identity in appended_recheck_ids:
return
if identity:
appended_recheck_ids.add(identity)
recheck_payload = dict(extra_recheck)
recheck_payload["status_label"] = "Recheck"
target_rows.append(recheck_payload)
voucher_sections = {
"voucher_matched": [],
"erp_voucher_matched": [],
"voucher_unmatched": [],
"erp_voucher_unmatched": [],
"voucher_recheck": [],
}
for (status_key, _fiscal_year, _voucher_no, _draft_no), row in grouped.items():
if status_key == "matched":
matched_wehago_group_keys.add(row.get("wehago_group_key"))
for extra_ledger in ledger_only_index.get(row.get("wehago_group_key"), []):
ledger_key = clean(extra_ledger.get("ledger_row_key"))
if ledger_key and ledger_key in matched_ledger_keys:
continue
row["rows"].append(
{
**dict(extra_ledger),
"status_label": "Unmatched",
"voucher_account_code": "",
"voucher_account_name": "",
"voucher_vendor": "",
"voucher_debit": "",
"voucher_credit": "",
"voucher_desc": "",
"draft_no": "",
"proof_date": "",
}
)
appended_recheck_ids: set[str] = set()
for extra_recheck in recheck_by_wehago_index.get(row.get("wehago_group_key"), []):
append_recheck_row(row["rows"], extra_recheck, appended_recheck_ids)
appended_voucher_keys: set[str] = set()
for erp_key in row.get("erp_group_keys", set()):
for extra_voucher in voucher_only_index.get(erp_key, []):
voucher_key = clean(extra_voucher.get("voucher_row_key"))
if voucher_key and voucher_key in matched_voucher_keys:
continue
if voucher_key and voucher_key in appended_voucher_keys:
continue
if voucher_key:
appended_voucher_keys.add(voucher_key)
row["rows"].append(
{
**dict(extra_voucher),
"status_label": "ERP Unmatched",
"ledger_account_code": "",
"ledger_account_name": "",
"ledger_vendor": "",
"ledger_debit": "",
"ledger_credit": "",
"ledger_desc": "",
"ledger_date": row.get("ledger_date", ""),
"voucher_no": row.get("voucher_no", extra_voucher.get("voucher_no", "")),
}
)
for extra_recheck in recheck_by_erp_index.get(erp_key, []):
append_recheck_row(row["rows"], extra_recheck, appended_recheck_ids)
row["rows"].sort(
key=lambda item: (
clean(item.get("ledger_date")) or clean(item.get("proof_date")),
clean(item.get("voucher_no")),
row_origin_rank(item),
0 if (clean(item.get("ledger_account_name")) and clean(item.get("voucher_account_name"))) else 1 if clean(item.get("ledger_account_name")) else 2,
clean(item.get("ledger_account_name")) or clean(item.get("voucher_account_name")),
clean(item.get("draft_no")),
-parse_amount(item.get("ledger_debit") or item.get("voucher_debit")),
-parse_amount(item.get("ledger_credit") or item.get("voucher_credit")),
clean(item.get("ledger_desc")) or clean(item.get("voucher_desc")),
)
)
normalized_summary = dict(row)
normalized_summary["ledger_accounts"] = ", ".join(row["ledger_accounts"])
normalized_summary["voucher_accounts"] = ", ".join(row["voucher_accounts"])
normalized_summary["ledger_vendors"] = ", ".join(row["ledger_vendors"])
normalized_summary["voucher_vendors"] = ", ".join(row["voucher_vendors"])
normalized_summary["review_reason"] = " / ".join(row["review_reason"])
normalized_summary["draft_no"] = ", ".join(row["draft_nos"])
normalized_group = {
"summary": normalized_summary,
"rows": list(row["rows"]),
}
if status_key == "matched":
voucher_sections["voucher_matched"].append(normalized_group)
elif status_key == "ledger_only" and row.get("wehago_group_key") not in matched_wehago_group_keys and row.get("wehago_group_key") not in recheck_wehago_group_keys:
voucher_sections["voucher_unmatched"].append(normalized_group)
elif status_key == "amount_mismatch" and row.get("wehago_group_key") not in matched_wehago_group_keys:
for extra_ledger in ledger_only_index.get(row.get("wehago_group_key"), []):
ledger_key = clean(extra_ledger.get("ledger_row_key"))
if ledger_key and ledger_key in matched_ledger_keys:
continue
row["rows"].append(
{
**dict(extra_ledger),
"status_label": "Unmatched",
"voucher_account_code": "",
"voucher_account_name": "",
"voucher_vendor": "",
"voucher_debit": "",
"voucher_credit": "",
"voucher_desc": "",
"draft_no": "",
"proof_date": "",
}
)
appended_recheck_ids: set[str] = {recheck_identity(existing_row) for existing_row in row["rows"]}
appended_voucher_keys: set[str] = set()
for erp_key in row.get("erp_group_keys", set()):
for extra_voucher in voucher_only_index.get(erp_key, []):
voucher_key = clean(extra_voucher.get("voucher_row_key"))
if voucher_key and voucher_key in matched_voucher_keys:
continue
if voucher_key and voucher_key in appended_voucher_keys:
continue
if voucher_key:
appended_voucher_keys.add(voucher_key)
row["rows"].append(
{
**dict(extra_voucher),
"status_label": "ERP Unmatched",
"ledger_account_code": "",
"ledger_account_name": "",
"ledger_vendor": "",
"ledger_debit": "",
"ledger_credit": "",
"ledger_desc": "",
"ledger_date": row.get("ledger_date", ""),
"voucher_no": row.get("voucher_no", extra_voucher.get("voucher_no", "")),
}
)
for extra_recheck in recheck_by_erp_index.get(erp_key, []):
append_recheck_row(row["rows"], extra_recheck, appended_recheck_ids)
row["rows"].sort(
key=lambda item: (
clean(item.get("ledger_date")) or clean(item.get("proof_date")),
clean(item.get("voucher_no")),
row_origin_rank(item),
0 if (clean(item.get("ledger_account_name")) and clean(item.get("voucher_account_name"))) else 1 if clean(item.get("ledger_account_name")) else 2,
clean(item.get("ledger_account_name")) or clean(item.get("voucher_account_name")),
clean(item.get("draft_no")),
-parse_amount(item.get("ledger_debit") or item.get("voucher_debit")),
-parse_amount(item.get("ledger_credit") or item.get("voucher_credit")),
clean(item.get("ledger_desc")) or clean(item.get("voucher_desc")),
)
)
normalized_summary = dict(row)
normalized_summary["ledger_accounts"] = ", ".join(row["ledger_accounts"])
normalized_summary["voucher_accounts"] = ", ".join(row["voucher_accounts"])
normalized_summary["ledger_vendors"] = ", ".join(row["ledger_vendors"])
normalized_summary["voucher_vendors"] = ", ".join(row["voucher_vendors"])
normalized_summary["review_reason"] = " / ".join(row["review_reason"])
normalized_summary["draft_no"] = ", ".join(row["draft_nos"])
normalized_group = {
"summary": normalized_summary,
"rows": list(row["rows"]),
}
voucher_sections["voucher_recheck"].append(normalized_group)
erp_grouped: dict[tuple[int, str], dict[str, Any]] = {}
for row in rows_by_status.get("matched", []):
erp_key = erp_group_key(row)
fiscal_year = int(row.get("fiscal_year") or 0)
current = erp_grouped.get(erp_key)
if current is None:
current = {
"fiscal_year": fiscal_year,
"status_label": "Matched",
"ledger_date": clean(row.get("ledger_date")),
"proof_date": clean(row.get("proof_date")),
"voucher_no": clean(row.get("voucher_no")),
"draft_no": clean(row.get("draft_no")),
"ledger_row_count": 0,
"voucher_row_count": 0,
"ledger_debit": 0.0,
"ledger_credit": 0.0,
"voucher_debit": 0.0,
"voucher_credit": 0.0,
"ledger_accounts": [],
"voucher_accounts": [],
"ledger_vendors": [],
"voucher_vendors": [],
"review_reason": [],
"rows": [],
"wehago_group_keys": set(),
"erp_group_key": erp_key,
"voucher_nos": [],
"draft_nos": [],
}
erp_grouped[erp_key] = current
if not current.get("ledger_date"):
current["ledger_date"] = clean(row.get("ledger_date"))
if not current.get("proof_date"):
current["proof_date"] = clean(row.get("proof_date"))
if not current.get("voucher_no"):
current["voucher_no"] = clean(row.get("voucher_no"))
if not current.get("draft_no"):
current["draft_no"] = clean(row.get("draft_no"))
if clean(row.get("ledger_account_name")) or clean(row.get("ledger_desc")):
current["ledger_row_count"] += 1
if clean(row.get("voucher_account_name")) or clean(row.get("voucher_desc")):
current["voucher_row_count"] += 1
current["ledger_debit"] += parse_amount(row.get("ledger_debit"))
current["ledger_credit"] += parse_amount(row.get("ledger_credit"))
current["voucher_debit"] += parse_amount(row.get("voucher_debit"))
current["voucher_credit"] += parse_amount(row.get("voucher_credit"))
append_unique(current["ledger_accounts"], row.get("ledger_account_name"))
append_unique(current["voucher_accounts"], row.get("voucher_account_name"))
append_unique(current["ledger_vendors"], row.get("ledger_vendor"))
append_unique(current["voucher_vendors"], row.get("voucher_vendor"))
append_unique(current["review_reason"], row.get("review_reason"))
append_unique(current["voucher_nos"], row.get("voucher_no"))
append_unique(current["draft_nos"], row.get("draft_no"))
current["wehago_group_keys"].add(wehago_group_key(row))
row_payload = dict(row)
row_payload.setdefault("status_label", "Matched")
current["rows"].append(row_payload)
for row in erp_grouped.values():
appended_ledger_keys: set[str] = set()
appended_recheck_ids: set[str] = set()
for wehago_key in row.get("wehago_group_keys", set()):
for extra_ledger in ledger_only_index.get(wehago_key, []):
ledger_key = clean(extra_ledger.get("ledger_row_key"))
if ledger_key and ledger_key in matched_ledger_keys:
continue
if ledger_key and ledger_key in appended_ledger_keys:
continue
if ledger_key:
appended_ledger_keys.add(ledger_key)
row["rows"].append(
{
**dict(extra_ledger),
"status_label": "Unmatched",
"voucher_account_code": "",
"voucher_account_name": "",
"voucher_vendor": "",
"voucher_debit": "",
"voucher_credit": "",
"voucher_desc": "",
"draft_no": row.get("draft_no", ""),
"proof_date": row.get("proof_date", ""),
}
)
for extra_recheck in recheck_by_wehago_index.get(wehago_key, []):
append_recheck_row(row["rows"], extra_recheck, appended_recheck_ids)
appended_voucher_keys: set[str] = set()
for extra_voucher in voucher_only_index.get(row.get("erp_group_key"), []):
voucher_key = clean(extra_voucher.get("voucher_row_key"))
if voucher_key and voucher_key in matched_voucher_keys:
continue
if voucher_key and voucher_key in appended_voucher_keys:
continue
if voucher_key:
appended_voucher_keys.add(voucher_key)
row["rows"].append(
{
**dict(extra_voucher),
"status_label": "ERP Unmatched",
"ledger_account_code": "",
"ledger_account_name": "",
"ledger_vendor": "",
"ledger_debit": "",
"ledger_credit": "",
"ledger_desc": "",
"ledger_date": row.get("ledger_date", ""),
"voucher_no": row.get("voucher_no", extra_voucher.get("voucher_no", "")),
}
)
for extra_recheck in recheck_by_erp_index.get(row.get("erp_group_key"), []):
append_recheck_row(row["rows"], extra_recheck, appended_recheck_ids)
row["rows"].sort(
key=lambda item: (
clean(item.get("proof_date")) or clean(item.get("ledger_date")),
clean(item.get("draft_no")),
row_origin_rank(item),
0 if (clean(item.get("ledger_account_name")) and clean(item.get("voucher_account_name"))) else 1 if clean(item.get("voucher_account_name")) else 2,
clean(item.get("voucher_account_name")) or clean(item.get("ledger_account_name")),
clean(item.get("voucher_no")),
-parse_amount(item.get("voucher_debit") or item.get("ledger_debit")),
-parse_amount(item.get("voucher_credit") or item.get("ledger_credit")),
clean(item.get("voucher_desc")) or clean(item.get("ledger_desc")),
)
)
normalized_summary = dict(row)
normalized_summary["ledger_accounts"] = ", ".join(row["ledger_accounts"])
normalized_summary["voucher_accounts"] = ", ".join(row["voucher_accounts"])
normalized_summary["ledger_vendors"] = ", ".join(row["ledger_vendors"])
normalized_summary["voucher_vendors"] = ", ".join(row["voucher_vendors"])
normalized_summary["review_reason"] = " / ".join(row["review_reason"])
normalized_summary["voucher_no"] = ", ".join(row["voucher_nos"])
normalized_summary["draft_no"] = ", ".join(row["draft_nos"])
voucher_sections["erp_voucher_matched"].append(
{
"summary": normalized_summary,
"rows": list(row["rows"]),
}
)
erp_unmatched_grouped: dict[tuple[int, str], dict[str, Any]] = {}
for row in rows_by_status.get("voucher_only", []):
erp_key = erp_group_key(row)
if not erp_key[1] or erp_key in matched_erp_group_keys or erp_key in recheck_erp_group_keys:
continue
fiscal_year = int(row.get("fiscal_year") or 0)
current = erp_unmatched_grouped.get(erp_key)
if current is None:
current = {
"fiscal_year": fiscal_year,
"status_label": "ERP Unmatched",
"ledger_date": clean(row.get("ledger_date")),
"proof_date": clean(row.get("proof_date")),
"voucher_no": clean(row.get("voucher_no")),
"draft_no": clean(row.get("draft_no")),
"ledger_row_count": 0,
"voucher_row_count": 0,
"ledger_debit": 0.0,
"ledger_credit": 0.0,
"voucher_debit": 0.0,
"voucher_credit": 0.0,
"ledger_accounts": [],
"voucher_accounts": [],
"ledger_vendors": [],
"voucher_vendors": [],
"review_reason": [],
"rows": [],
"voucher_nos": [],
"draft_nos": [],
}
erp_unmatched_grouped[erp_key] = current
if not current.get("ledger_date"):
current["ledger_date"] = clean(row.get("ledger_date"))
if not current.get("proof_date"):
current["proof_date"] = clean(row.get("proof_date"))
if not current.get("voucher_no"):
current["voucher_no"] = clean(row.get("voucher_no"))
if not current.get("draft_no"):
current["draft_no"] = clean(row.get("draft_no"))
if clean(row.get("ledger_account_name")) or clean(row.get("ledger_desc")):
current["ledger_row_count"] += 1
if clean(row.get("voucher_account_name")) or clean(row.get("voucher_desc")):
current["voucher_row_count"] += 1
current["ledger_debit"] += parse_amount(row.get("ledger_debit"))
current["ledger_credit"] += parse_amount(row.get("ledger_credit"))
current["voucher_debit"] += parse_amount(row.get("voucher_debit"))
current["voucher_credit"] += parse_amount(row.get("voucher_credit"))
append_unique(current["ledger_accounts"], row.get("ledger_account_name"))
append_unique(current["voucher_accounts"], row.get("voucher_account_name"))
append_unique(current["ledger_vendors"], row.get("ledger_vendor"))
append_unique(current["voucher_vendors"], row.get("voucher_vendor"))
append_unique(current["review_reason"], row.get("review_reason"))
append_unique(current["voucher_nos"], row.get("voucher_no"))
append_unique(current["draft_nos"], row.get("draft_no"))
row_payload = dict(row)
row_payload.setdefault("status_label", "ERP Unmatched")
current["rows"].append(row_payload)
for row in erp_unmatched_grouped.values():
row["rows"].sort(
key=lambda item: (
clean(item.get("proof_date")) or clean(item.get("ledger_date")),
clean(item.get("draft_no")),
clean(item.get("voucher_no")),
clean(item.get("voucher_account_name")) or clean(item.get("ledger_account_name")),
-parse_amount(item.get("voucher_debit") or item.get("ledger_debit")),
-parse_amount(item.get("voucher_credit") or item.get("ledger_credit")),
clean(item.get("voucher_desc")) or clean(item.get("ledger_desc")),
)
)
normalized_summary = dict(row)
normalized_summary["ledger_accounts"] = ", ".join(row["ledger_accounts"])
normalized_summary["voucher_accounts"] = ", ".join(row["voucher_accounts"])
normalized_summary["ledger_vendors"] = ", ".join(row["ledger_vendors"])
normalized_summary["voucher_vendors"] = ", ".join(row["voucher_vendors"])
normalized_summary["review_reason"] = " / ".join(row["review_reason"])
normalized_summary["voucher_no"] = ", ".join(row["voucher_nos"])
normalized_summary["draft_no"] = ", ".join(row["draft_nos"])
voucher_sections["erp_voucher_unmatched"].append(
{
"summary": normalized_summary,
"rows": list(row["rows"]),
}
)
for status_key in voucher_sections:
voucher_sections[status_key].sort(
key=lambda item: (
int(item.get("summary", {}).get("fiscal_year") or 0),
clean(item.get("summary", {}).get("proof_date")) or clean(item.get("summary", {}).get("ledger_date")),
clean(item.get("summary", {}).get("voucher_no")),
clean(item.get("summary", {}).get("draft_no")),
)
)
return voucher_sections
def _filter_voucher_status_row(
row: dict[str, Any],
voucher_filter: str,
draft_filter: str,
wehago_account_filter: str,
erp_account_filter: str,
wehago_amount_filter: str,
erp_amount_filter: str,
wehago_vendor_filter: str,
erp_vendor_filter: str,
desc_filter: str,
) -> bool:
summary = row.get("summary", row)
if voucher_filter and voucher_filter not in normalize_text(row.get("voucher_no")):
if voucher_filter not in normalize_text(summary.get("voucher_no")):
return False
if draft_filter and draft_filter not in normalize_text(summary.get("draft_no")):
return False
if wehago_account_filter and wehago_account_filter not in normalize_text(summary.get("ledger_accounts")):
return False
if erp_account_filter and erp_account_filter not in normalize_text(summary.get("voucher_accounts")):
return False
if wehago_vendor_filter and wehago_vendor_filter not in normalize_text(summary.get("ledger_vendors")):
return False
if erp_vendor_filter and erp_vendor_filter not in normalize_text(summary.get("voucher_vendors")):
return False
if desc_filter:
haystack = " ".join(
[
clean(summary.get("review_reason")),
clean(summary.get("ledger_accounts")),
clean(summary.get("voucher_accounts")),
clean(summary.get("ledger_vendors")),
clean(summary.get("voucher_vendors")),
" ".join(clean(item.get("ledger_desc")) for item in row.get("rows", [])),
" ".join(clean(item.get("voucher_desc")) for item in row.get("rows", [])),
]
)
if desc_filter not in normalize_text(haystack):
return False
if wehago_amount_filter:
amount_text = " ".join(
[
str(int(round(parse_amount(summary.get("ledger_debit"))))),
str(int(round(parse_amount(summary.get("ledger_credit"))))),
]
)
if clean(wehago_amount_filter) not in amount_text:
return False
if erp_amount_filter:
amount_text = " ".join(
[
str(int(round(parse_amount(summary.get("voucher_debit"))))),
str(int(round(parse_amount(summary.get("voucher_credit"))))),
]
)
if clean(erp_amount_filter) not in amount_text:
return False
return True
def _build_voucher_status_detail_response_from_rows(
rows_by_status: dict[str, list[dict[str, Any]]],
status: str,
voucher_filter: str,
draft_filter: str,
wehago_account_filter: str,
erp_account_filter: str,
wehago_amount_filter: str,
erp_amount_filter: str,
wehago_vendor_filter: str,
erp_vendor_filter: str,
desc_filter: str,
offset: int,
limit: int,
) -> dict[str, Any]:
voucher_sections = _build_voucher_sections_from_rows_by_status(rows_by_status)
source_rows = voucher_sections.get(status, [])
filtered_rows = [
group
for group in source_rows
if _filter_voucher_status_row(
group,
voucher_filter,
draft_filter,
wehago_account_filter,
erp_account_filter,
wehago_amount_filter,
erp_amount_filter,
wehago_vendor_filter,
erp_vendor_filter,
desc_filter,
)
]
next_offset = offset + min(limit, max(len(filtered_rows) - offset, 0))
shown_groups = filtered_rows[offset : offset + limit]
return {
"columns": DETAIL_COLUMN_MAP[status],
"rows": [dict(group.get("summary", {})) for group in shown_groups],
"groups": shown_groups,
"total_count": len(filtered_rows),
"shown_count": len(shown_groups),
"offset": offset,
"limit": limit,
"has_more": next_offset < len(filtered_rows),
"next_offset": next_offset,
"notice": "",
"bank_payable_case_count": 0,
"boundary_excluded_count": 0,
}
def _fetch_voucher_status_detail_rows_from_db(
conn: Any,
start_year: int,
end_year: int,
status: str,
voucher_filter: str,
draft_filter: str,
wehago_account_filter: str,
erp_account_filter: str,
wehago_amount_filter: str,
erp_amount_filter: str,
wehago_vendor_filter: str,
erp_vendor_filter: str,
desc_filter: str,
offset: int,
limit: int,
) -> dict[str, Any]:
if status in {"voucher_matched", "erp_voucher_matched"}:
status_condition = "c.status = 'matched'"
elif status == "voucher_unmatched":
status_condition = "c.status = 'ledger_only'"
elif status == "erp_voucher_unmatched":
status_condition = "c.status = 'voucher_only'"
else:
status_condition = "c.status = 'amount_mismatch'"
account_filter = normalize_text(wehago_account_filter or erp_account_filter)
vendor_filter = normalize_text(wehago_vendor_filter or erp_vendor_filter)
amount_filter = clean(wehago_amount_filter or erp_amount_filter)
params: dict[str, Any] = {
"start_year": start_year,
"end_year": end_year,
"voucher_no": voucher_filter,
"voucher_like": f"%{voucher_filter}%",
"draft_no": draft_filter,
"draft_like": f"%{draft_filter}%",
"account_keyword": account_filter,
"account_like": f"%{account_filter}%",
"vendor_keyword": vendor_filter,
"vendor_like": f"%{vendor_filter}%",
"desc_keyword": desc_filter,
"desc_like": f"%{desc_filter}%",
"amount_keyword": amount_filter,
"limit": limit,
"offset": offset,
}
conditions = [
build_year_filter_sql("c.fiscal_year"),
status_condition,
"(:voucher_no = '' OR COALESCE(l.voucher_no, v.confirmed_no, v.draft_no, c.voucher_no) LIKE :voucher_like)",
"(:draft_no = '' OR COALESCE(v.draft_no, '') LIKE :draft_like)",
"(:account_keyword = '' OR COALESCE(c.ledger_accounts, '') LIKE :account_like OR COALESCE(c.voucher_accounts, '') LIKE :account_like)",
"(:vendor_keyword = '' OR COALESCE(c.ledger_vendors, '') LIKE :vendor_like OR COALESCE(c.voucher_vendors, '') LIKE :vendor_like)",
"""(
:desc_keyword = ''
OR COALESCE(c.notes, '') LIKE :desc_like
OR EXISTS (
SELECT 1
FROM wehago_ledger_rows l2
WHERE l2.fiscal_year = c.fiscal_year
AND l2.compare_voucher_no = c.voucher_no
AND COALESCE(l2.description, '') LIKE :desc_like
)
OR EXISTS (
SELECT 1
FROM wehago_voucher_rows v2
WHERE v2.fiscal_year = c.fiscal_year
AND v2.compare_voucher_no = c.voucher_no
AND TRIM(COALESCE(v2.desc1, '') || ' ' || COALESCE(v2.desc2, '')) LIKE :desc_like
)
)""",
"""(
:amount_keyword = ''
OR CAST(ABS(COALESCE(c.ledger_debit, 0)) AS TEXT) LIKE '%' || :amount_keyword || '%'
OR CAST(ABS(COALESCE(c.ledger_credit, 0)) AS TEXT) LIKE '%' || :amount_keyword || '%'
OR CAST(ABS(COALESCE(c.voucher_debit, 0)) AS TEXT) LIKE '%' || :amount_keyword || '%'
OR CAST(ABS(COALESCE(c.voucher_credit, 0)) AS TEXT) LIKE '%' || :amount_keyword || '%'
)""",
]
voucher_rep = """
SELECT *
FROM (
SELECT fiscal_year, compare_voucher_no, proof_date, confirmed_no, draft_no,
ROW_NUMBER() OVER (
PARTITION BY fiscal_year, compare_voucher_no
ORDER BY row_number
) AS rn
FROM wehago_voucher_rows
WHERE COALESCE(compare_voucher_no, '') <> ''
)
WHERE rn = 1
"""
ledger_rep = """
SELECT *
FROM (
SELECT fiscal_year, compare_voucher_no, ledger_date, voucher_no,
ROW_NUMBER() OVER (
PARTITION BY fiscal_year, compare_voucher_no
ORDER BY row_number
) AS rn
FROM wehago_ledger_rows
WHERE COALESCE(compare_voucher_no, '') <> ''
)
WHERE rn = 1
"""
from_sql = f"""
FROM wehago_comparison_results c
LEFT JOIN ({ledger_rep}) l
ON l.fiscal_year = c.fiscal_year AND l.compare_voucher_no = c.voucher_no
LEFT JOIN ({voucher_rep}) v
ON v.fiscal_year = c.fiscal_year AND v.compare_voucher_no = c.voucher_no
WHERE {' AND '.join(conditions)}
"""
select_sql = """
c.fiscal_year AS fiscal_year,
CASE c.status
WHEN 'matched' THEN 'Matched'
WHEN 'ledger_only' THEN 'Unmatched'
WHEN 'voucher_only' THEN 'ERP Unmatched'
WHEN 'amount_mismatch' THEN 'Recheck'
ELSE COALESCE(c.status, '')
END AS status_label,
COALESCE(l.ledger_date, '') AS ledger_date,
COALESCE(v.proof_date, '') AS proof_date,
COALESCE(l.voucher_no, v.confirmed_no, v.draft_no, c.voucher_no) AS voucher_no,
COALESCE(v.draft_no, '') AS draft_no,
COALESCE(c.ledger_row_count, 0) AS ledger_row_count,
COALESCE(c.voucher_row_count, 0) AS voucher_row_count,
COALESCE(c.ledger_debit, 0) AS ledger_debit,
COALESCE(c.ledger_credit, 0) AS ledger_credit,
COALESCE(c.voucher_debit, 0) AS voucher_debit,
COALESCE(c.voucher_credit, 0) AS voucher_credit,
COALESCE(c.ledger_accounts, '') AS ledger_accounts,
COALESCE(c.voucher_accounts, '') AS voucher_accounts,
COALESCE(c.ledger_vendors, '') AS ledger_vendors,
COALESCE(c.voucher_vendors, '') AS voucher_vendors,
COALESCE(c.notes, '') AS review_reason
"""
total_count = int(conn.execute(text(f"SELECT COUNT(*) {from_sql}"), params).scalar_one() or 0)
rows = [
{key: clean(value) if isinstance(value, str) else value for key, value in dict(row).items()}
for row in conn.execute(
text(
f"""
SELECT {select_sql}
{from_sql}
ORDER BY c.fiscal_year, c.voucher_no
LIMIT :limit OFFSET :offset
"""
),
params,
).mappings().all()
]
next_offset = offset + len(rows)
return {
"columns": DETAIL_COLUMN_MAP[status],
"rows": rows,
"total_count": total_count,
"shown_count": len(rows),
"offset": offset,
"limit": limit,
"has_more": next_offset < total_count,
"next_offset": next_offset,
"notice": "",
"bank_payable_case_count": 0,
"boundary_excluded_count": 0,
}
def _apply_broad_query_guard(
rows: list[dict[str, Any]],
voucher_no: str,
account_code: str,
vendor_name: str,
review_reason: str,
preview_limit: int = 500,
) -> tuple[list[dict[str, Any]], str]:
if voucher_no or account_code or vendor_name or review_reason:
return rows, ""
if len(rows) <= preview_limit:
return rows, ""
return rows[:preview_limit], f"조건 없이 조회된 항목이 많아 상위 {preview_limit:,}건만 먼저 표시합니다. 전표번호, 계정코드, 거래처, 검증근거로 좁혀서 전체 항목에 접근할 수 있습니다."
def get_status_detail_rows(
engine: Any,
start_year: int | None,
end_year: int | None,
status: str,
voucher_no: str = "",
draft_no: str = "",
wehago_account: str = "",
erp_account: str = "",
wehago_amount: str = "",
erp_amount: str = "",
wehago_vendor: str = "",
erp_vendor: str = "",
desc_keyword: str = "",
review_reason: str = "",
boundary_excluded: str = "",
offset: int = 0,
limit: int = 200,
) -> dict[str, Any]:
if engine is not None:
init_wehago_compare_db(engine)
if start_year is None or end_year is None:
return {
"columns": DETAIL_COLUMN_MAP.get(status, []),
"rows": [],
"total_count": 0,
"shown_count": 0,
"offset": 0,
"limit": limit,
"has_more": False,
"next_offset": 0,
}
normalized_status = normalize_text(status).lower()
allowed = {
"matched": "matched",
"bridgeexpensereview": "bridge_expense_review",
"ledgeronly": "ledger_only",
"amountmismatch": "amount_mismatch",
"voucheronly": "voucher_only",
"vouchermatched": "voucher_matched",
"erpvouchermatched": "erp_voucher_matched",
"voucherunmatched": "voucher_unmatched",
"erpvoucherunmatched": "erp_voucher_unmatched",
"voucherrecheck": "voucher_recheck",
}
if normalized_status not in {"matched", "bridge_expense_review", "ledger_only", "amount_mismatch", "voucher_only", "voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched", "voucher_recheck"}:
normalized_status = allowed.get(normalized_status, normalized_status)
if normalized_status not in {"matched", "bridge_expense_review", "ledger_only", "amount_mismatch", "voucher_only", "voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched", "voucher_recheck"}:
raise ValueError("상태 값이 올바르지 않습니다.")
voucher_filter = normalize_text(voucher_no)
draft_filter = normalize_text(draft_no)
wehago_account_filter = normalize_text(wehago_account)
erp_account_filter = normalize_text(erp_account)
wehago_vendor_filter = normalize_text(wehago_vendor)
erp_vendor_filter = normalize_text(erp_vendor)
desc_filter = normalize_text(desc_keyword or review_reason)
boundary_excluded_filter = clean(boundary_excluded).lower() in {"1", "true", "yes", "y", "on"}
safe_offset = max(int(offset or 0), 0)
safe_limit = max(min(int(limit or 200), 500), 1)
if normalized_status == "bridge_expense_review":
with engine.begin() as conn:
return _fetch_bridge_expense_review_rows_from_db(
conn,
start_year,
end_year,
voucher_filter,
draft_filter,
wehago_account_filter,
erp_account_filter,
clean(wehago_amount),
clean(erp_amount),
wehago_vendor_filter,
erp_vendor_filter,
desc_filter,
safe_offset,
safe_limit,
)
with engine.begin() as conn:
snapshot_state = _get_compare_snapshot_state(conn, start_year, end_year)
pending_years = snapshot_state["missing"] + snapshot_state["stale"]
if pending_years:
try:
enqueue_year_snapshot_rebuild(engine, pending_years)
except Exception:
pass
notice = ""
if pending_years:
year_text = ", ".join(str(year) for year in pending_years[:5])
suffix = "..." if len(pending_years) > 5 else ""
notice = f"최신 전표 스냅샷을 갱신 중입니다. ({year_text}{suffix})"
if normalized_status == "voucher_recheck":
with engine.begin() as conn:
shown_groups, total_count = _fetch_cached_export_group_page(
conn,
start_year,
end_year,
normalized_status,
voucher_filter,
draft_filter,
wehago_account_filter,
erp_account_filter,
clean(wehago_amount),
clean(erp_amount),
wehago_vendor_filter,
erp_vendor_filter,
desc_filter,
safe_offset,
safe_limit,
)
next_offset = safe_offset + len(shown_groups)
return {
"columns": DETAIL_COLUMN_MAP[normalized_status],
"rows": [dict(group.get("summary", {})) for group in shown_groups],
"groups": shown_groups,
"total_count": total_count,
"shown_count": len(shown_groups),
"offset": safe_offset,
"limit": safe_limit,
"has_more": next_offset < total_count,
"next_offset": next_offset,
"notice": notice,
"bank_payable_case_count": 0,
"boundary_excluded_count": 0,
}
if normalized_status in {"voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched"}:
with engine.begin() as conn:
shown_groups, total_count = _fetch_cached_export_group_page(
conn,
start_year,
end_year,
normalized_status,
voucher_filter,
draft_filter,
wehago_account_filter,
erp_account_filter,
clean(wehago_amount),
clean(erp_amount),
wehago_vendor_filter,
erp_vendor_filter,
desc_filter,
safe_offset,
safe_limit,
)
next_offset = safe_offset + len(shown_groups)
return {
"columns": DETAIL_COLUMN_MAP[normalized_status],
"rows": [dict(group.get("summary", {})) for group in shown_groups],
"groups": shown_groups,
"total_count": total_count,
"shown_count": len(shown_groups),
"offset": safe_offset,
"limit": safe_limit,
"has_more": next_offset < total_count,
"next_offset": next_offset,
"notice": notice,
"bank_payable_case_count": 0,
"boundary_excluded_count": 0,
}
rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year)
payload = _build_status_detail_response_from_rows(
rows_by_status,
normalized_status,
voucher_filter,
draft_filter,
wehago_account_filter,
erp_account_filter,
clean(wehago_amount),
clean(erp_amount),
wehago_vendor_filter,
erp_vendor_filter,
desc_filter,
boundary_excluded_filter,
safe_offset,
safe_limit,
)
payload["notice"] = notice or clean(payload.get("notice"))
return payload
def _normalize_voucher_export_status_key(status: str) -> str:
normalized_status = normalize_text(status).lower()
status_map = {
"vouchermatched": "voucher_matched",
"erpvouchermatched": "erp_voucher_matched",
"voucherunmatched": "voucher_unmatched",
"erpvoucherunmatched": "erp_voucher_unmatched",
"voucher_matched": "voucher_matched",
"erp_voucher_matched": "erp_voucher_matched",
"voucher_unmatched": "voucher_unmatched",
"erp_voucher_unmatched": "erp_voucher_unmatched",
}
normalized_status = status_map.get(normalized_status, normalized_status)
if normalized_status not in VOUCHER_EXPORT_STATUS_KEYS:
raise ValueError("엑셀 다운로드는 WEHAGO, Hanmac ERP, WEHAGO Unmatched 표에서만 지원됩니다.")
return normalized_status
def _get_filtered_voucher_export_groups(
engine: Any,
start_year: int | None,
end_year: int | None,
status: str,
voucher_no: str = "",
draft_no: str = "",
wehago_account: str = "",
erp_account: str = "",
wehago_amount: str = "",
erp_amount: str = "",
wehago_vendor: str = "",
erp_vendor: str = "",
desc_keyword: str = "",
) -> tuple[str, list[dict[str, Any]]]:
normalized_status = _normalize_voucher_export_status_key(status)
rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year)
voucher_sections = _get_cached_voucher_sections_by_range(engine, start_year, end_year, rows_by_status)
voucher_filter = normalize_text(voucher_no)
draft_filter = normalize_text(draft_no)
wehago_account_filter = normalize_text(wehago_account)
erp_account_filter = normalize_text(erp_account)
wehago_vendor_filter = normalize_text(wehago_vendor)
erp_vendor_filter = normalize_text(erp_vendor)
desc_filter = normalize_text(desc_keyword)
wehago_amount_filter = clean(wehago_amount)
erp_amount_filter = clean(erp_amount)
source_rows = voucher_sections.get(normalized_status, [])
filtered_rows = [
group
for group in source_rows
if _filter_voucher_status_row(
group,
voucher_filter,
draft_filter,
wehago_account_filter,
erp_account_filter,
wehago_amount_filter,
erp_amount_filter,
wehago_vendor_filter,
erp_vendor_filter,
desc_filter,
)
]
return normalized_status, filtered_rows
def _safe_compare_export_segment(value: Any) -> str:
raw = re.sub(r"[^0-9A-Za-z가-힣._-]+", "-", clean(value))
return raw.strip("-")[:80] or "all"
def _build_status_export_job_key(
normalized_status: str,
start_year: int | None,
end_year: int | None,
voucher_no: str,
draft_no: str,
wehago_account: str,
erp_account: str,
wehago_amount: str,
erp_amount: str,
wehago_vendor: str,
erp_vendor: str,
desc_keyword: str,
) -> tuple[str, str]:
export_key = _build_voucher_export_cache_key(
start_year,
end_year,
normalized_status,
voucher_no,
draft_no,
wehago_account,
erp_account,
wehago_amount,
erp_amount,
wehago_vendor,
erp_vendor,
desc_keyword,
)
digest = hashlib.sha1(export_key.encode('utf-8')).hexdigest()
return export_key, f"status_export_xlsx:{digest}"
def _compare_range_cache_key(start_year: int | None, end_year: int | None) -> str:
return f"{int(start_year or 0)}:{int(end_year or 0)}"
def _get_ttl_cached_payload(
cache: dict[str, dict[str, Any]],
key: str,
ttl_seconds: float,
) -> dict[str, Any] | None:
entry = cache.get(key)
if not entry:
return None
expires_at = float(entry.get("expires_at") or 0.0)
if time.monotonic() >= expires_at:
cache.pop(key, None)
return None
payload = entry.get("payload")
return copy.deepcopy(payload) if isinstance(payload, dict) else None
def _store_ttl_cached_payload(
cache: dict[str, dict[str, Any]],
key: str,
payload: dict[str, Any],
ttl_seconds: float,
) -> dict[str, Any]:
cache[key] = {
"expires_at": time.monotonic() + max(float(ttl_seconds or 0.0), 1.0),
"payload": copy.deepcopy(payload),
}
return copy.deepcopy(payload)
def _aggregate_snapshot_state_counts(snapshot_state: dict[str, Any] | None) -> dict[str, int]:
state = snapshot_state if isinstance(snapshot_state, dict) else {}
return {
"ready": len(state.get("ready") or []),
"queued": len(state.get("queued") or []),
"running": len(state.get("running") or []),
"stale": len(state.get("stale") or []),
"failed": len(state.get("failed") or []),
"missing": len(state.get("missing") or []),
}
def _build_snapshot_status_payload(
conn: Any,
start_year: int | None,
end_year: int | None,
) -> dict[str, Any]:
available_years = _discover_available_fiscal_years(conn)
selected_years = [
int(year)
for year in available_years
if (start_year is None or int(year) >= start_year) and (end_year is None or int(year) <= end_year)
]
status_map = _load_snapshot_status_map(conn, selected_years)
years = [
_compute_year_snapshot_runtime_state(conn, year, status_map.get(year))
for year in selected_years
]
exact_summary = _load_summary_range_cache(conn, start_year, end_year)
latest_summary = _load_latest_summary_range_cache_any_signature(conn, start_year, end_year)
recent_jobs = _load_recent_compare_jobs(conn, start_year, end_year, limit=20)
snapshot_state = _get_compare_snapshot_state(conn, start_year, end_year)
return {
"range_start_year": start_year,
"range_end_year": end_year,
"snapshot_state": snapshot_state,
"snapshot_policy": _build_compare_snapshot_policy(start_year, end_year, available_years),
"aggregate": _aggregate_snapshot_state_counts(snapshot_state),
"years": years,
"summary_cache": {
"exact": bool(exact_summary),
"latest_available": bool(latest_summary),
"counts": (exact_summary or latest_summary or {}).get("counts", {}),
"snapshot_state": (exact_summary or latest_summary or {}).get("snapshot_state", {}),
},
"recent_jobs": recent_jobs,
}
def _get_cached_snapshot_status_payload(
engine: Any,
start_year: int | None,
end_year: int | None,
*,
force: bool = False,
) -> dict[str, Any]:
key = _compare_range_cache_key(start_year, end_year)
if not force:
cached = _get_ttl_cached_payload(_SNAPSHOT_STATUS_CACHE, key, _SNAPSHOT_STATUS_CACHE_TTL_SEC)
if cached is not None:
return cached
init_wehago_compare_db(engine)
with engine.begin() as conn:
payload = _build_snapshot_status_payload(conn, start_year, end_year)
return _store_ttl_cached_payload(_SNAPSHOT_STATUS_CACHE, key, payload, _SNAPSHOT_STATUS_CACHE_TTL_SEC)
def _cleanup_old_compare_export_files(keep_paths: set[str] | None = None, older_than_sec: int = 24 * 60 * 60) -> None:
keep = {str(Path(item)) for item in (keep_paths or set())}
root = WEHAGO_COMPARE_EXPORT_ROOT
if not root.exists():
return
threshold = time.time() - older_than_sec
for path in root.glob('*.xlsx'):
try:
if str(path) in keep:
continue
if path.stat().st_mtime >= threshold:
continue
path.unlink(missing_ok=True)
except Exception:
continue
def _load_status_export_job(conn: Any, job_key: str) -> dict[str, Any] | None:
row = conn.execute(
text(
"""
SELECT job_key, export_key, status_key, start_year, end_year, payload_json,
file_name, file_path, row_count, state, error_message,
created_at, updated_at, started_at, finished_at
FROM wehago_compare_export_jobs
WHERE job_key = :job_key
LIMIT 1
"""
),
{"job_key": job_key},
).mappings().first()
if not row:
return None
payload = {}
try:
payload = json.loads(clean(row.get('payload_json')) or '{}')
except Exception:
payload = {}
return {
'job_key': clean(row.get('job_key')),
'export_key': clean(row.get('export_key')),
'status_key': clean(row.get('status_key')),
'start_year': row.get('start_year'),
'end_year': row.get('end_year'),
'payload': payload if isinstance(payload, dict) else {},
'file_name': clean(row.get('file_name')),
'file_path': clean(row.get('file_path')),
'row_count': int(row.get('row_count') or 0),
'state': clean(row.get('state')),
'error_message': clean(row.get('error_message')),
'created_at': clean(row.get('created_at')),
'updated_at': clean(row.get('updated_at')),
'started_at': clean(row.get('started_at')),
'finished_at': clean(row.get('finished_at')),
}
def _store_status_export_job_file(
conn: Any,
job_key: str,
file_name: str,
file_path: Path,
row_count: int,
) -> None:
conn.execute(
text(
"""
UPDATE wehago_compare_export_jobs
SET file_name = :file_name,
file_path = :file_path,
row_count = :row_count,
state = 'ready',
error_message = '',
finished_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE job_key = :job_key
"""
),
{
'job_key': job_key,
'file_name': file_name,
'file_path': str(file_path),
'row_count': int(row_count),
},
)
def _mark_status_export_job_failed(conn: Any, job_key: str, error_message: str) -> None:
conn.execute(
text(
"""
UPDATE wehago_compare_export_jobs
SET state = 'failed',
error_message = :error_message,
finished_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE job_key = :job_key
"""
),
{'job_key': job_key, 'error_message': clean(error_message)[:500]},
)
def request_status_export_xlsx(
engine: Any,
start_year: int | None,
end_year: int | None,
status: str,
voucher_no: str = '',
draft_no: str = '',
wehago_account: str = '',
erp_account: str = '',
wehago_amount: str = '',
erp_amount: str = '',
wehago_vendor: str = '',
erp_vendor: str = '',
desc_keyword: str = '',
) -> dict[str, Any]:
normalized_status = _normalize_voucher_export_status_key(status)
export_key, job_key = _build_status_export_job_key(
normalized_status,
start_year,
end_year,
voucher_no,
draft_no,
wehago_account,
erp_account,
wehago_amount,
erp_amount,
wehago_vendor,
erp_vendor,
desc_keyword,
)
init_wehago_compare_db(engine)
payload = {
'status': normalized_status,
'start_year': start_year,
'end_year': end_year,
'voucher_no': voucher_no,
'draft_no': draft_no,
'wehago_account': wehago_account,
'erp_account': erp_account,
'wehago_amount': wehago_amount,
'erp_amount': erp_amount,
'wehago_vendor': wehago_vendor,
'erp_vendor': erp_vendor,
'desc_keyword': desc_keyword,
}
def _read_existing_job(conn: Any) -> dict[str, Any] | None:
existing = conn.execute(
text(
"""
SELECT job_key, state, file_name, file_path, row_count, error_message,
created_at, updated_at, started_at, finished_at
FROM wehago_compare_export_jobs
WHERE export_key = :export_key
ORDER BY updated_at DESC, created_at DESC
LIMIT 1
"""
),
{'export_key': export_key},
).mappings().first()
if not existing:
return None
existing_state = clean(existing.get('state'))
existing_job_key = clean(existing.get('job_key'))
existing_file_path = clean(existing.get('file_path'))
if existing_state == 'ready' and existing_file_path and Path(existing_file_path).exists():
return {
'job_key': existing_job_key,
'state': 'ready',
'download_url': f"/wehago-compare/api/status-export-download/{existing_job_key}",
'row_count': int(existing.get('row_count') or 0),
'file_name': clean(existing.get('file_name')),
}
if existing_state in {'queued', 'running'}:
started_at = _parse_db_timestamp(existing.get('started_at')) or _parse_db_timestamp(existing.get('updated_at')) or _parse_db_timestamp(existing.get('created_at'))
if existing_state == 'running' and started_at and (datetime.now() - started_at).total_seconds() >= COMPARE_JOB_STALE_RUNNING_SEC:
try:
conn.execute(
text(
"""
UPDATE wehago_background_jobs
SET state = 'queued',
error_message = '',
updated_at = CURRENT_TIMESTAMP
WHERE job_key = :job_key
AND job_type = 'status_export_xlsx'
"""
),
{'job_key': existing_job_key},
)
conn.execute(
text(
"""
UPDATE wehago_compare_export_jobs
SET state = 'queued',
error_message = '',
updated_at = CURRENT_TIMESTAMP,
started_at = '',
finished_at = ''
WHERE job_key = :job_key
"""
),
{'job_key': existing_job_key},
)
existing_state = 'queued'
except OperationalError:
existing_state = 'running'
return {
'job_key': existing_job_key,
'state': existing_state,
'download_url': '',
'row_count': int(existing.get('row_count') or 0),
'file_name': clean(existing.get('file_name')),
}
return None
last_error: Exception | None = None
for attempt in range(4):
try:
with engine.begin() as conn:
_try_normalize_compare_background_jobs(conn, force=True)
existing_payload = _read_existing_job(conn)
if existing_payload:
return existing_payload
conn.execute(
text(
"""
INSERT INTO wehago_background_jobs (
job_key, job_type, payload_json, priority, state, error_message, created_at, updated_at
) VALUES (
:job_key, 'status_export_xlsx', :payload_json, :priority, 'queued', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
ON CONFLICT(job_key) DO UPDATE SET
state = CASE
WHEN wehago_background_jobs.state = 'running' THEN wehago_background_jobs.state
ELSE 'queued'
END,
payload_json = excluded.payload_json,
priority = excluded.priority,
error_message = '',
updated_at = CURRENT_TIMESTAMP
"""
),
{
'job_key': job_key,
'payload_json': json.dumps(payload, ensure_ascii=False, sort_keys=True),
'priority': max(5, _compare_range_job_priority(start_year, end_year) - 25),
},
)
conn.execute(
text(
"""
INSERT INTO wehago_compare_export_jobs (
job_key, export_key, status_key, start_year, end_year, payload_json,
file_name, file_path, row_count, state, error_message, created_at, updated_at, started_at, finished_at
) VALUES (
:job_key, :export_key, :status_key, :start_year, :end_year, :payload_json,
'', '', 0, 'queued', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, '', ''
)
ON CONFLICT(job_key) DO UPDATE SET
export_key = excluded.export_key,
status_key = excluded.status_key,
start_year = excluded.start_year,
end_year = excluded.end_year,
payload_json = excluded.payload_json,
state = CASE
WHEN wehago_compare_export_jobs.state = 'running' THEN wehago_compare_export_jobs.state
ELSE 'queued'
END,
error_message = '',
updated_at = CURRENT_TIMESTAMP
"""
),
{
'job_key': job_key,
'export_key': export_key,
'status_key': normalized_status,
'start_year': start_year,
'end_year': end_year,
'payload_json': json.dumps(payload, ensure_ascii=False, sort_keys=True),
},
)
_ensure_compare_snapshot_worker(engine)
_COMPARE_SNAPSHOT_JOB_EVENT.set()
return {'job_key': job_key, 'state': 'queued', 'download_url': '', 'row_count': 0, 'file_name': ''}
except OperationalError as exc:
last_error = exc
time.sleep(0.15 * (attempt + 1))
with engine.begin() as conn:
existing_payload = _read_existing_job(conn)
if existing_payload:
return existing_payload
if last_error is not None:
raise last_error
return {'job_key': job_key, 'state': 'queued', 'download_url': '', 'row_count': 0, 'file_name': ''}
def get_status_export_job(engine: Any, job_key: str) -> dict[str, Any]:
init_wehago_compare_db(engine)
with engine.begin() as conn:
_try_normalize_compare_background_jobs(conn, force=True)
job = _load_status_export_job(conn, job_key)
if not job:
raise ValueError('엑셀 준비 작업을 찾을 수 없습니다.')
file_path = clean(job.get('file_path'))
ready = job.get('state') == 'ready' and file_path and Path(file_path).exists()
return {
**job,
'download_url': f"/wehago-compare/api/status-export-download/{job_key}" if ready else '',
}
def _store_year_export_row_cache(
conn: Any,
year: int,
signature: str,
voucher_sections: dict[str, list[dict[str, Any]]],
) -> None:
conn.execute(
text(
"""
DELETE FROM wehago_compare_export_row_cache
WHERE fiscal_year = :fiscal_year
"""
),
{"fiscal_year": year},
)
insert_sql = text(
"""
INSERT INTO wehago_compare_export_row_cache (
fiscal_year, status_key, snapshot_signature, group_sort, row_sort,
group_voucher_no, group_draft_no, group_ledger_accounts, group_voucher_accounts,
group_ledger_vendors, group_voucher_vendors, group_ledger_debit, group_ledger_credit,
group_voucher_debit, group_voucher_credit, ledger_date, voucher_no, draft_no,
ledger_account_name, voucher_account_name, ledger_vendor, voucher_vendor,
ledger_debit, ledger_credit, voucher_debit, voucher_credit, ledger_desc, voucher_desc
) VALUES (
:fiscal_year, :status_key, :snapshot_signature, :group_sort, :row_sort,
:group_voucher_no, :group_draft_no, :group_ledger_accounts, :group_voucher_accounts,
:group_ledger_vendors, :group_voucher_vendors, :group_ledger_debit, :group_ledger_credit,
:group_voucher_debit, :group_voucher_credit, :ledger_date, :voucher_no, :draft_no,
:ledger_account_name, :voucher_account_name, :ledger_vendor, :voucher_vendor,
:ledger_debit, :ledger_credit, :voucher_debit, :voucher_credit, :ledger_desc, :voucher_desc
)
"""
)
payload_rows: list[dict[str, Any]] = []
for status_key in EXPORT_ROW_CACHE_STATUS_KEYS:
groups = voucher_sections.get(status_key, [])
for group_index, group in enumerate(groups):
summary = group.get("summary", {})
group_payload = {
"fiscal_year": year,
"status_key": status_key,
"snapshot_signature": signature,
"group_sort": group_index,
"group_voucher_no": clean(summary.get("voucher_no")),
"group_draft_no": clean(summary.get("draft_no")),
"group_ledger_accounts": clean(summary.get("ledger_accounts")),
"group_voucher_accounts": clean(summary.get("voucher_accounts")),
"group_ledger_vendors": clean(summary.get("ledger_vendors")),
"group_voucher_vendors": clean(summary.get("voucher_vendors")),
"group_ledger_debit": parse_amount(summary.get("ledger_debit")),
"group_ledger_credit": parse_amount(summary.get("ledger_credit")),
"group_voucher_debit": parse_amount(summary.get("voucher_debit")),
"group_voucher_credit": parse_amount(summary.get("voucher_credit")),
}
for row_index, row in enumerate(group.get("rows", [])):
payload_rows.append(
{
**group_payload,
"row_sort": row_index,
"ledger_date": clean(row.get("ledger_date")),
"voucher_no": clean(row.get("voucher_no")),
"draft_no": clean(row.get("draft_no")),
"ledger_account_name": clean(row.get("ledger_account_name")),
"voucher_account_name": clean(row.get("voucher_account_name")),
"ledger_vendor": clean(row.get("ledger_vendor")),
"voucher_vendor": clean(row.get("voucher_vendor")),
"ledger_debit": parse_amount(row.get("ledger_debit")),
"ledger_credit": parse_amount(row.get("ledger_credit")),
"voucher_debit": parse_amount(row.get("voucher_debit")),
"voucher_credit": parse_amount(row.get("voucher_credit")),
"ledger_desc": clean(row.get("ledger_desc")),
"voucher_desc": clean(row.get("voucher_desc")),
}
)
if payload_rows:
conn.execute(insert_sql, payload_rows)
def _ensure_year_export_row_cache(conn: Any, year: int) -> str:
signature = _build_db_state_signature(conn, year, year)
exists = conn.execute(
text(
"""
SELECT 1
FROM wehago_compare_export_row_cache
WHERE fiscal_year = :fiscal_year
AND snapshot_signature = :signature
LIMIT 1
"""
),
{"fiscal_year": year, "signature": signature},
).first()
if exists:
return signature
latest_cached_signature = clean(
conn.execute(
text(
"""
SELECT snapshot_signature
FROM wehago_compare_export_row_cache
WHERE fiscal_year = :fiscal_year
ORDER BY rowid DESC
LIMIT 1
"""
),
{"fiscal_year": year},
).scalar()
)
if latest_cached_signature:
return latest_cached_signature
stale_signature = clean(
conn.execute(
text(
"""
SELECT snapshot_signature
FROM wehago_snapshot_status
WHERE fiscal_year = :fiscal_year
ORDER BY updated_at DESC
LIMIT 1
"""
),
{"fiscal_year": year},
).scalar()
)
if stale_signature:
stale_exists = conn.execute(
text(
"""
SELECT 1
FROM wehago_compare_export_row_cache
WHERE fiscal_year = :fiscal_year
AND snapshot_signature = :signature
LIMIT 1
"""
),
{"fiscal_year": year, "signature": stale_signature},
).first()
if stale_exists:
return stale_signature
sections = _load_year_resolved_sections_cache(conn, year, signature)
if sections is None and stale_signature:
stale_sections = _load_year_resolved_sections_cache(conn, year, stale_signature)
if stale_sections is not None:
sections = stale_sections
signature = stale_signature
if sections is None:
sections = _refresh_year_resolved_sections(conn, year)
signature = _build_db_state_signature(conn, year, year)
rows_by_status = {
"matched": list((sections or {}).get("matched", {}).get("rows", [])),
"ledger_only": list((sections or {}).get("ledger_only", {}).get("rows", [])),
"amount_mismatch": list((sections or {}).get("amount_mismatch", {}).get("rows", [])),
"voucher_only": list((sections or {}).get("voucher_only", {}).get("rows", [])),
}
voucher_sections = _build_voucher_sections_with_bridge_promotions(conn, year, year, rows_by_status)
_store_year_export_row_cache(conn, year, signature, voucher_sections)
return signature
def _iter_cached_export_rows(
conn: Any,
start_year: int | None,
end_year: int | None,
normalized_status: str,
voucher_no: str,
draft_no: str,
wehago_account: str,
erp_account: str,
wehago_amount: str,
erp_amount: str,
wehago_vendor: str,
erp_vendor: str,
desc_keyword: str,
) -> tuple[list[dict[str, Any]], int]:
if start_year is None or end_year is None:
return [], 0
voucher_filter = normalize_text(voucher_no)
draft_filter = normalize_text(draft_no)
wehago_account_filter = normalize_text(wehago_account)
erp_account_filter = normalize_text(erp_account)
wehago_vendor_filter = normalize_text(wehago_vendor)
erp_vendor_filter = normalize_text(erp_vendor)
desc_filter = normalize_text(desc_keyword)
wehago_amount_filter = clean(wehago_amount)
erp_amount_filter = clean(erp_amount)
selected_years = list(range(int(start_year), int(end_year) + 1))
signatures = {year: _ensure_year_export_row_cache(conn, year) for year in selected_years}
all_rows: list[dict[str, Any]] = []
for year in selected_years:
signature = signatures.get(year) or ''
if not signature:
continue
rows = conn.execute(
text(
"""
SELECT fiscal_year, ledger_date, voucher_no, draft_no,
ledger_account_name, voucher_account_name, ledger_vendor, voucher_vendor,
ledger_debit, ledger_credit, voucher_debit, voucher_credit, ledger_desc, voucher_desc,
group_voucher_no, group_draft_no, group_ledger_accounts, group_voucher_accounts,
group_ledger_vendors, group_voucher_vendors, group_ledger_debit, group_ledger_credit,
group_voucher_debit, group_voucher_credit
FROM wehago_compare_export_row_cache
WHERE fiscal_year = :fiscal_year
AND status_key = :status_key
AND snapshot_signature = :signature
ORDER BY group_sort ASC, row_sort ASC
"""
),
{"fiscal_year": year, "status_key": normalized_status, "signature": signature},
).mappings().all()
for row in rows:
voucher_match_target = normalize_text(clean(row.get("voucher_no")) or clean(row.get("group_voucher_no")))
if voucher_filter and voucher_filter not in voucher_match_target:
continue
if draft_filter and draft_filter not in normalize_text(row.get("group_draft_no")):
continue
if wehago_account_filter and wehago_account_filter not in normalize_text(row.get("group_ledger_accounts")):
continue
if erp_account_filter and erp_account_filter not in normalize_text(row.get("group_voucher_accounts")):
continue
if wehago_vendor_filter and wehago_vendor_filter not in normalize_text(row.get("group_ledger_vendors")):
continue
if erp_vendor_filter and erp_vendor_filter not in normalize_text(row.get("group_voucher_vendors")):
continue
if desc_filter:
haystack = " ".join(
[
clean(row.get("group_ledger_accounts")),
clean(row.get("group_voucher_accounts")),
clean(row.get("group_ledger_vendors")),
clean(row.get("group_voucher_vendors")),
clean(row.get("ledger_desc")),
clean(row.get("voucher_desc")),
]
)
if desc_filter not in normalize_text(haystack):
continue
if wehago_amount_filter:
amount_text = " ".join(
[
str(int(round(parse_amount(row.get("group_ledger_debit"))))),
str(int(round(parse_amount(row.get("group_ledger_credit"))))),
]
)
if wehago_amount_filter not in amount_text:
continue
if erp_amount_filter:
amount_text = " ".join(
[
str(int(round(parse_amount(row.get("group_voucher_debit"))))),
str(int(round(parse_amount(row.get("group_voucher_credit"))))),
]
)
if erp_amount_filter not in amount_text:
continue
all_rows.append(dict(row))
return all_rows, len(all_rows)
def _build_voucher_groups_from_cached_export_rows(
rows: list[dict[str, Any]],
normalized_status: str,
) -> list[dict[str, Any]]:
status_label_map = {
"voucher_matched": "Matched",
"erp_voucher_matched": "Matched",
"voucher_unmatched": "Unmatched",
"erp_voucher_unmatched": "ERP Unmatched",
"voucher_recheck": "Recheck",
}
row_status_label = status_label_map.get(normalized_status, "Recheck")
groups: list[dict[str, Any]] = []
current_group: dict[str, Any] | None = None
current_key: tuple[int, str, str, str] | None = None
for row in rows:
fiscal_year = int(row.get("fiscal_year") or 0)
ledger_date = clean(row.get("ledger_date"))
group_voucher_no = clean(row.get("group_voucher_no") or row.get("voucher_no"))
group_draft_no = clean(row.get("group_draft_no") or row.get("draft_no"))
group_key = (fiscal_year, group_voucher_no, group_draft_no, ledger_date)
if current_key != group_key:
summary = {
"fiscal_year": fiscal_year,
"status_label": row_status_label,
"ledger_date": ledger_date,
"proof_date": "",
"voucher_no": group_voucher_no,
"draft_no": group_draft_no,
"ledger_row_count": 0,
"voucher_row_count": 0,
"ledger_debit": parse_amount(row.get("group_ledger_debit")),
"ledger_credit": parse_amount(row.get("group_ledger_credit")),
"voucher_debit": parse_amount(row.get("group_voucher_debit")),
"voucher_credit": parse_amount(row.get("group_voucher_credit")),
"ledger_accounts": clean(row.get("group_ledger_accounts")),
"voucher_accounts": clean(row.get("group_voucher_accounts")),
"ledger_vendors": clean(row.get("group_ledger_vendors")),
"voucher_vendors": clean(row.get("group_voucher_vendors")),
"review_reason": "",
}
current_group = {"summary": summary, "rows": []}
groups.append(current_group)
current_key = group_key
if current_group is None:
continue
row_payload = {
"fiscal_year": fiscal_year,
"status_label": row_status_label,
"ledger_date": ledger_date,
"proof_date": "",
"voucher_no": clean(row.get("voucher_no") or group_voucher_no),
"draft_no": clean(row.get("draft_no") or group_draft_no),
"ledger_account_name": clean(row.get("ledger_account_name")),
"voucher_account_name": clean(row.get("voucher_account_name")),
"ledger_vendor": clean(row.get("ledger_vendor")),
"voucher_vendor": clean(row.get("voucher_vendor")),
"ledger_debit": parse_amount(row.get("ledger_debit")),
"ledger_credit": parse_amount(row.get("ledger_credit")),
"voucher_debit": parse_amount(row.get("voucher_debit")),
"voucher_credit": parse_amount(row.get("voucher_credit")),
"ledger_desc": clean(row.get("ledger_desc")),
"voucher_desc": clean(row.get("voucher_desc")),
}
if row_payload["ledger_account_name"] or row_payload["ledger_desc"]:
current_group["summary"]["ledger_row_count"] += 1
if row_payload["voucher_account_name"] or row_payload["voucher_desc"]:
current_group["summary"]["voucher_row_count"] += 1
current_group["rows"].append(row_payload)
return groups
def _fetch_cached_export_group_page(
conn: Any,
start_year: int | None,
end_year: int | None,
normalized_status: str,
voucher_no: str,
draft_no: str,
wehago_account: str,
erp_account: str,
wehago_amount: str,
erp_amount: str,
wehago_vendor: str,
erp_vendor: str,
desc_keyword: str,
offset: int,
limit: int,
) -> tuple[list[dict[str, Any]], int]:
if start_year is None or end_year is None:
return [], 0
voucher_filter = normalize_text(voucher_no)
draft_filter = normalize_text(draft_no)
wehago_account_filter = normalize_text(wehago_account)
erp_account_filter = normalize_text(erp_account)
wehago_vendor_filter = normalize_text(wehago_vendor)
erp_vendor_filter = normalize_text(erp_vendor)
desc_filter = normalize_text(desc_keyword)
wehago_amount_filter = clean(wehago_amount)
erp_amount_filter = clean(erp_amount)
selected_years = list(range(int(start_year), int(end_year) + 1))
signatures = {year: _ensure_year_export_row_cache(conn, year) for year in selected_years}
where_sql = """
status_key = :status_key
AND fiscal_year = :fiscal_year
AND snapshot_signature = :signature
AND (:voucher_keyword = '' OR COALESCE(voucher_no, group_voucher_no, '') LIKE :voucher_like OR COALESCE(group_voucher_no, '') LIKE :voucher_like)
AND (:draft_keyword = '' OR COALESCE(group_draft_no, '') LIKE :draft_like)
AND (:wehago_account_keyword = '' OR COALESCE(group_ledger_accounts, '') LIKE :wehago_account_like)
AND (:erp_account_keyword = '' OR COALESCE(group_voucher_accounts, '') LIKE :erp_account_like)
AND (:wehago_vendor_keyword = '' OR COALESCE(group_ledger_vendors, '') LIKE :wehago_vendor_like)
AND (:erp_vendor_keyword = '' OR COALESCE(group_voucher_vendors, '') LIKE :erp_vendor_like)
AND (
:desc_keyword = ''
OR (
COALESCE(group_ledger_accounts, '') || ' '
|| COALESCE(group_voucher_accounts, '') || ' '
|| COALESCE(group_ledger_vendors, '') || ' '
|| COALESCE(group_voucher_vendors, '') || ' '
|| COALESCE(ledger_desc, '') || ' '
|| COALESCE(voucher_desc, '')
) LIKE :desc_like
)
AND (
:wehago_amount_keyword = ''
OR CAST(ABS(COALESCE(group_ledger_debit, 0)) AS TEXT) LIKE '%' || :wehago_amount_keyword || '%'
OR CAST(ABS(COALESCE(group_ledger_credit, 0)) AS TEXT) LIKE '%' || :wehago_amount_keyword || '%'
)
AND (
:erp_amount_keyword = ''
OR CAST(ABS(COALESCE(group_voucher_debit, 0)) AS TEXT) LIKE '%' || :erp_amount_keyword || '%'
OR CAST(ABS(COALESCE(group_voucher_credit, 0)) AS TEXT) LIKE '%' || :erp_amount_keyword || '%'
)
"""
header_params_base = {
"status_key": normalized_status,
"voucher_keyword": voucher_filter,
"voucher_like": f"%{voucher_filter}%",
"draft_keyword": draft_filter,
"draft_like": f"%{draft_filter}%",
"wehago_account_keyword": wehago_account_filter,
"wehago_account_like": f"%{wehago_account_filter}%",
"erp_account_keyword": erp_account_filter,
"erp_account_like": f"%{erp_account_filter}%",
"wehago_vendor_keyword": wehago_vendor_filter,
"wehago_vendor_like": f"%{wehago_vendor_filter}%",
"erp_vendor_keyword": erp_vendor_filter,
"erp_vendor_like": f"%{erp_vendor_filter}%",
"desc_keyword": desc_filter,
"desc_like": f"%{desc_filter}%",
"wehago_amount_keyword": wehago_amount_filter,
"erp_amount_keyword": erp_amount_filter,
}
header_rows: list[dict[str, Any]] = []
for year in selected_years:
signature = signatures.get(year) or ""
if not signature:
continue
params = {
**header_params_base,
"fiscal_year": year,
"signature": signature,
}
year_headers = conn.execute(
text(
f"""
SELECT DISTINCT
fiscal_year,
group_sort,
COALESCE(ledger_date, '') AS ledger_date,
COALESCE(group_voucher_no, '') AS group_voucher_no,
COALESCE(group_draft_no, '') AS group_draft_no
FROM wehago_compare_export_row_cache
WHERE {where_sql}
ORDER BY fiscal_year ASC, group_sort ASC
"""
),
params,
).mappings().all()
header_rows.extend(dict(row) for row in year_headers)
total_count = len(header_rows)
if total_count == 0:
return [], 0
selected_headers = header_rows[offset : offset + limit]
if not selected_headers:
return [], total_count
group_sorts_by_year: dict[int, set[int]] = {}
for header in selected_headers:
fiscal_year = int(header.get("fiscal_year") or 0)
group_sort = int(header.get("group_sort") or 0)
if fiscal_year <= 0:
continue
group_sorts_by_year.setdefault(fiscal_year, set()).add(group_sort)
detail_rows: list[dict[str, Any]] = []
for year in selected_years:
selected_group_sorts = sorted(group_sorts_by_year.get(year) or [])
if not selected_group_sorts:
continue
signature = signatures.get(year) or ""
if not signature:
continue
placeholders = ", ".join(f":group_sort_{index}" for index, _ in enumerate(selected_group_sorts))
params = {
**header_params_base,
"fiscal_year": year,
"signature": signature,
}
for index, group_sort in enumerate(selected_group_sorts):
params[f"group_sort_{index}"] = int(group_sort)
rows = conn.execute(
text(
f"""
SELECT fiscal_year, ledger_date, voucher_no, draft_no,
ledger_account_name, voucher_account_name, ledger_vendor, voucher_vendor,
ledger_debit, ledger_credit, voucher_debit, voucher_credit, ledger_desc, voucher_desc,
group_voucher_no, group_draft_no, group_ledger_accounts, group_voucher_accounts,
group_ledger_vendors, group_voucher_vendors, group_ledger_debit, group_ledger_credit,
group_voucher_debit, group_voucher_credit, group_sort, row_sort
FROM wehago_compare_export_row_cache
WHERE {where_sql}
AND group_sort IN ({placeholders})
ORDER BY fiscal_year ASC, group_sort ASC, row_sort ASC
"""
),
params,
).mappings().all()
detail_rows.extend(dict(row) for row in rows)
groups = _build_voucher_groups_from_cached_export_rows(detail_rows, normalized_status)
return groups, total_count
def _build_voucher_export_cache_key(
start_year: int | None,
end_year: int | None,
normalized_status: str,
voucher_no: str,
draft_no: str,
wehago_account: str,
erp_account: str,
wehago_amount: str,
erp_amount: str,
wehago_vendor: str,
erp_vendor: str,
desc_keyword: str,
) -> str:
return "|".join(
[
normalized_status,
str(start_year or "all"),
str(end_year or "all"),
_build_bundle_signature(start_year, end_year) if start_year is not None and end_year is not None else "no-range",
normalize_text(voucher_no),
normalize_text(draft_no),
normalize_text(wehago_account),
normalize_text(erp_account),
clean(wehago_amount),
clean(erp_amount),
normalize_text(wehago_vendor),
normalize_text(erp_vendor),
normalize_text(desc_keyword),
_current_logic_signature(),
]
)
def _coerce_voucher_export_cell(field: str, raw: Any) -> Any:
if field == "fiscal_year":
return clean(raw)
if field in {"ledger_debit", "ledger_credit", "voucher_debit", "voucher_credit"}:
amount = parse_amount(raw)
return "" if abs(amount) < 0.000001 else amount
return clean(raw)
def export_wehago_status_rows_xlsx(
engine: Any,
start_year: int | None,
end_year: int | None,
status: str,
voucher_no: str = "",
draft_no: str = "",
wehago_account: str = "",
erp_account: str = "",
wehago_amount: str = "",
erp_amount: str = "",
wehago_vendor: str = "",
erp_vendor: str = "",
desc_keyword: str = "",
) -> tuple[str, bytes, int]:
normalized_status = _normalize_voucher_export_status_key(status)
cache_key = _build_voucher_export_cache_key(
start_year,
end_year,
normalized_status,
voucher_no,
draft_no,
wehago_account,
erp_account,
wehago_amount,
erp_amount,
wehago_vendor,
erp_vendor,
desc_keyword,
)
cached = _STATUS_EXPORT_CACHE.get(cache_key)
now = time.time()
if cached and (now - float(cached.get("ts", 0))) <= _STATUS_EXPORT_CACHE_TTL_SEC:
return cached["file_name"], cached["content"], int(cached.get("row_count") or 0)
with engine.begin() as conn:
export_rows, row_count = _iter_cached_export_rows(
conn,
start_year,
end_year,
normalized_status,
voucher_no,
draft_no,
wehago_account,
erp_account,
wehago_amount,
erp_amount,
wehago_vendor,
erp_vendor,
desc_keyword,
)
workbook = Workbook(write_only=True)
status_labels = {key: label for key, label, _ in STATUS_META}
worksheet = workbook.create_sheet(title=status_labels.get(normalized_status, normalized_status)[:31])
headers = [label for _, label in VOUCHER_EXPORT_LINE_COLUMNS]
worksheet.append(headers)
for row in export_rows:
worksheet.append([_coerce_voucher_export_cell(field, row.get(field)) for field, _ in VOUCHER_EXPORT_LINE_COLUMNS])
buffer = BytesIO()
workbook.save(buffer)
content = buffer.getvalue()
file_name = f"wehago_compare_{normalized_status}_{start_year or 'all'}_{end_year or 'all'}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
_STATUS_EXPORT_CACHE.clear()
_STATUS_EXPORT_CACHE[cache_key] = {"ts": now, "file_name": file_name, "content": content, "row_count": row_count}
return file_name, content, row_count
def get_status_field_suggestions(
engine: Any,
start_year: int | None,
end_year: int | None,
status: str,
field: str = "voucher_no",
keyword: str = "",
offset: int = 0,
limit: int = 10,
) -> dict[str, Any]:
if start_year is None or end_year is None:
return {"rows": [], "total_count": 0, "shown_count": 0, "offset": 0, "limit": limit, "has_more": False, "next_offset": 0}
normalized_status = normalize_text(status).lower()
status_map = {
"matched": "matched",
"bridgeexpensereview": "bridge_expense_review",
"amountmismatch": "amount_mismatch",
"ledgeronly": "ledger_only",
"voucheronly": "voucher_only",
"vouchermatched": "voucher_matched",
"erpvouchermatched": "erp_voucher_matched",
"voucherunmatched": "voucher_unmatched",
"erpvoucherunmatched": "erp_voucher_unmatched",
"voucherrecheck": "voucher_recheck",
"ledger_only": "ledger_only",
"bridge_expense_review": "bridge_expense_review",
"voucher_only": "voucher_only",
"amount_mismatch": "amount_mismatch",
"voucher_matched": "voucher_matched",
"erp_voucher_matched": "erp_voucher_matched",
"voucher_unmatched": "voucher_unmatched",
"erp_voucher_unmatched": "erp_voucher_unmatched",
"voucher_recheck": "voucher_recheck",
}
normalized_status = status_map.get(normalized_status, normalized_status)
if normalized_status not in {"matched", "bridge_expense_review", "amount_mismatch", "ledger_only", "voucher_only", "voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched", "voucher_recheck"}:
raise ValueError("자동완성 상태 값이 올바르지 않습니다.")
normalized_field = normalize_text(field).lower()
if normalized_field not in {
"voucherno",
"draftno",
"account",
"vendor",
"wehagoaccount",
"erpaccount",
"wehagovendor",
"erpvendor",
}:
raise ValueError("자동완성 필드 값이 올바르지 않습니다.")
cache_key = "|".join(
[
normalized_status,
normalized_field,
str(start_year),
str(end_year),
_current_logic_signature(),
_build_bundle_signature(start_year, end_year),
normalize_text(keyword),
]
)
now = time.time()
cached = _SUGGEST_CACHE.get(cache_key)
if cached and (now - float(cached.get("ts", 0))) <= _SUGGEST_CACHE_TTL_SEC:
all_rows = cached["rows"]
elif normalized_status in {"voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched", "voucher_recheck"}:
rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year)
with engine.begin() as conn:
snapshot_state = _get_compare_snapshot_state(conn, start_year, end_year)
pending_years = snapshot_state["missing"] + snapshot_state["stale"]
if pending_years:
try:
enqueue_year_snapshot_rebuild(engine, pending_years)
except Exception:
pass
source_rows = _get_cached_voucher_sections_by_range(engine, start_year, end_year, rows_by_status).get(normalized_status, [])
keyword_norm = normalize_text(keyword)
seen: set[str] = set()
all_rows = []
for group in source_rows:
row = group.get("summary", group)
voucher_no = clean(row.get("voucher_no"))
draft_no = clean(row.get("draft_no"))
if normalized_field == "account":
labels = [clean(row.get("ledger_accounts")), clean(row.get("voucher_accounts"))]
elif normalized_field == "vendor":
labels = [clean(row.get("ledger_vendors")), clean(row.get("voucher_vendors"))]
elif normalized_field == "draftno":
labels = [draft_no]
else:
labels = [voucher_no]
for label in labels:
key = clean(label)
if not key or key in seen:
continue
if keyword_norm and keyword_norm not in normalize_text(key):
continue
seen.add(key)
all_rows.append(
{
"voucher_no": voucher_no,
"code": "",
"name": "",
"label": key,
}
)
_SUGGEST_CACHE.clear()
_PAIR_RECOMMEND_CACHE.clear()
_SUGGEST_CACHE[cache_key] = {"ts": now, "rows": all_rows}
else:
rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year)
keyword_norm = normalize_text(keyword)
seen: set[str] = set()
all_rows: list[dict[str, Any]] = []
for row in rows_by_status.get(normalized_status, []):
code = clean(row.get("voucher_account_code"))
name = clean(row.get("voucher_account_name"))
voucher_no = clean(row.get("voucher_no"))
if normalized_field == "account":
if normalized_status == "voucher_only":
code = clean(row.get("voucher_account_code"))
name = clean(row.get("voucher_account_name"))
else:
code = clean(row.get("ledger_account_code"))
name = clean(row.get("ledger_account_name"))
key = "|".join([code, name])
if not code and not name:
continue
label = " ".join(part for part in [code, name] if part)
search_text = normalize_text(label)
elif normalized_field == "wehagoaccount":
code = clean(row.get("ledger_account_code"))
name = clean(row.get("ledger_account_name"))
key = "|".join([code, name])
if not code and not name:
continue
label = " ".join(part for part in [code, name] if part)
search_text = normalize_text(label)
elif normalized_field == "erpaccount":
code = clean(row.get("voucher_account_code"))
name = clean(row.get("voucher_account_name"))
key = "|".join([code, name])
if not code and not name:
continue
label = " ".join(part for part in [code, name] if part)
search_text = normalize_text(label)
elif normalized_field == "vendor":
vendor_name = clean(row.get("voucher_vendor")) if normalized_status == "voucher_only" else clean(row.get("ledger_vendor"))
key = vendor_name
if not vendor_name:
continue
label = vendor_name
search_text = normalize_text(vendor_name)
elif normalized_field == "wehagovendor":
vendor_name = clean(row.get("ledger_vendor"))
key = vendor_name
if not vendor_name:
continue
label = vendor_name
search_text = normalize_text(vendor_name)
elif normalized_field == "erpvendor":
vendor_name = clean(row.get("voucher_vendor"))
key = vendor_name
if not vendor_name:
continue
label = vendor_name
search_text = normalize_text(vendor_name)
elif normalized_field == "draftno":
draft_no = clean(row.get("draft_no"))
key = draft_no
if not draft_no:
continue
label = draft_no
search_text = normalize_text(draft_no)
else:
key = voucher_no
if not voucher_no:
continue
label = voucher_no
search_text = normalize_text(voucher_no)
if key in seen:
continue
if keyword_norm and keyword_norm not in search_text:
continue
seen.add(key)
all_rows.append(
{
"voucher_no": voucher_no,
"code": code,
"name": name,
"label": label,
}
)
_SUGGEST_CACHE.clear()
_PAIR_RECOMMEND_CACHE.clear()
_SUGGEST_CACHE[cache_key] = {"ts": now, "rows": all_rows}
safe_offset = max(int(offset or 0), 0)
safe_limit = max(min(int(limit or 10), 50), 1)
rows = all_rows[safe_offset : safe_offset + safe_limit]
next_offset = safe_offset + len(rows)
return {
"rows": rows,
"total_count": len(all_rows),
"shown_count": len(rows),
"offset": safe_offset,
"limit": safe_limit,
"has_more": next_offset < len(all_rows),
"next_offset": next_offset,
}
def _fetch_filtered_table_rows(
conn: Any,
table_name: str,
columns: list[tuple[str, str]],
start_year: int | None,
end_year: int | None,
voucher_no: str = "",
account_code: str = "",
vendor_name: str = "",
review_reason: str = "",
) -> dict[str, Any]:
del review_reason
voucher_filter = f"%{clean(voucher_no)}%"
account_filter = f"%{clean(account_code)}%"
vendor_filter = f"%{clean(vendor_name)}%"
if table_name == "wehago_ledger_rows":
filters = [
build_year_filter_sql(),
"(:voucher_no = '' OR COALESCE(voucher_no, '') LIKE :voucher_like)",
"(:account_code = '' OR COALESCE(account_code, '') LIKE :account_like OR COALESCE(account_name, '') LIKE :account_like)",
"(:vendor_name = '' OR COALESCE(vendor_name, '') LIKE :vendor_like OR COALESCE(description, '') LIKE :vendor_like)",
]
order_sql = "ORDER BY COALESCE(ledger_date, '') DESC, voucher_no DESC, row_number DESC"
else:
filters = [
build_year_filter_sql(),
"(:voucher_no = '' OR COALESCE(confirmed_no, '') LIKE :voucher_like OR COALESCE(draft_no, '') LIKE :voucher_like)",
"(:account_code = '' OR COALESCE(account_code, '') LIKE :account_like OR COALESCE(account_name, '') LIKE :account_like)",
"(:vendor_name = '' OR COALESCE(vendor_name, '') LIKE :vendor_like OR COALESCE(desc1, '') LIKE :vendor_like OR COALESCE(desc2, '') LIKE :vendor_like)",
]
order_sql = "ORDER BY COALESCE(proof_date, '') DESC, confirmed_no DESC, draft_no DESC, row_number DESC"
params = {
"start_year": start_year,
"end_year": end_year,
"voucher_no": clean(voucher_no),
"voucher_like": voucher_filter,
"account_code": clean(account_code),
"account_like": account_filter,
"vendor_name": clean(vendor_name),
"vendor_like": vendor_filter,
}
selected_columns = ", ".join(name for name, _ in columns)
query = text(
f"""
SELECT {selected_columns}
FROM {table_name}
WHERE {' AND '.join(filters)}
{order_sql}
"""
)
rows = [dict(row._mapping) for row in conn.execute(query, params).fetchall()]
shown_rows, notice = _apply_broad_query_guard(
rows,
clean(voucher_no),
clean(account_code),
clean(vendor_name),
"",
)
return {
"columns": columns,
"rows": shown_rows,
"total_count": len(rows),
"shown_count": len(shown_rows),
"notice": notice,
}
def get_wehago_filtered_rows(
engine: Any,
start_year: int | None,
end_year: int | None,
voucher_no: str = "",
account_code: str = "",
vendor_name: str = "",
) -> dict[str, Any]:
init_wehago_compare_db(engine)
with engine.begin() as conn:
return _fetch_filtered_table_rows(
conn,
"wehago_ledger_rows",
WEHAGO_COLUMNS,
start_year,
end_year,
voucher_no=voucher_no,
account_code=account_code,
vendor_name=vendor_name,
)
def get_erp_filtered_rows(
engine: Any,
start_year: int | None,
end_year: int | None,
voucher_no: str = "",
account_code: str = "",
vendor_name: str = "",
) -> dict[str, Any]:
init_wehago_compare_db(engine)
with engine.begin() as conn:
return _fetch_filtered_table_rows(
conn,
"wehago_voucher_rows",
ERP_COLUMNS,
start_year,
end_year,
voucher_no=voucher_no,
account_code=account_code,
vendor_name=vendor_name,
)
def _warm_status_cache_worker(engine: Any, start_year: int, end_year: int, warm_key: str) -> None:
try:
try:
enqueue_year_snapshot_rebuild(engine, range(start_year, end_year + 1))
enqueue_metric_count_rebuild(engine, start_year, end_year)
except Exception:
pass
finally:
with _STATUS_CACHE_WARMING_LOCK:
_STATUS_CACHE_WARMING.discard(warm_key)
def warm_status_cache_async(engine: Any, start_year: int | None, end_year: int | None) -> None:
if start_year is None or end_year is None:
return
if start_year > end_year:
start_year, end_year = end_year, start_year
warm_key = f"{start_year}:{end_year}:{_build_bundle_signature(start_year, end_year)}"
with _STATUS_CACHE_WARMING_LOCK:
if warm_key in _STATUS_CACHE_WARMING:
return
_STATUS_CACHE_WARMING.add(warm_key)
worker = threading.Thread(
target=_warm_status_cache_worker,
args=(engine, start_year, end_year, warm_key),
daemon=True,
)
worker.start()
def get_wehago_compare_dashboard(
engine: Any,
start_year: int | None = None,
end_year: int | None = None,
*,
include_metric_counts: bool = True,
warm_caches: bool = True,
) -> dict[str, Any]:
init_wehago_compare_db(engine)
with engine.begin() as conn:
years = sorted(_discover_available_fiscal_years(conn) or discover_available_years(), reverse=True)
status_map = _load_snapshot_status_map(conn, years)
ready_years = [
int(year)
for year in years
if clean((status_map.get(int(year)) or {}).get("state")) == "ready"
]
current_year = date.today().year
completed_ready_years = [int(year) for year in ready_years if int(year) < current_year]
default_year = (
completed_ready_years[0]
if completed_ready_years
else ready_years[0] if ready_years else get_default_year(years)
)
if start_year is None and end_year is None:
start_year = default_year
end_year = default_year
elif start_year is None:
start_year = end_year
elif end_year is None:
end_year = start_year
valid_years = set(years)
if start_year not in valid_years:
start_year = default_year
if end_year not in valid_years:
end_year = start_year or default_year
if start_year and end_year and start_year > end_year:
start_year, end_year = end_year, start_year
metric_counts = (
get_dashboard_metric_counts(conn, start_year, end_year)
if include_metric_counts
else {status_key: 0 for status_key, _, _ in STATUS_META}
)
account_options = build_account_options_from_db(conn, start_year, end_year)
latest_upload = fetch_latest_upload_meta(conn)
last_action = get_last_action_summary(conn=conn)
metric_sections = [
{
"key": status_key,
"label": label,
"description": description,
"count": int(metric_counts.get(status_key, 0) or 0),
"columns": DETAIL_COLUMN_MAP[status_key],
"rows": [],
}
for status_key, label, description in STATUS_META
]
if warm_caches:
warm_metric_counts_async(engine, start_year, end_year)
if start_year == end_year:
warm_status_cache_async(engine, start_year, end_year)
return {
"page_title": "전표비교",
"source_root": str(WEHAGO_SOURCE_ROOT),
"db_file": "data.db",
"selected_start_year": start_year,
"selected_end_year": end_year,
"available_years": years,
"metric_sections": metric_sections,
"wehago_account_options": account_options["wehago"],
"erp_account_options": account_options["erp"],
"latest_upload": latest_upload,
"last_action": last_action,
}
def get_wehago_compare_summary(
engine: Any,
start_year: int | None = None,
end_year: int | None = None,
) -> dict[str, Any]:
dashboard = get_wehago_compare_dashboard(
engine,
start_year=start_year,
end_year=end_year,
include_metric_counts=False,
warm_caches=False,
)
metric_counts, pending = get_dashboard_metric_counts_nonblocking(
engine,
dashboard.get("selected_start_year"),
dashboard.get("selected_end_year"),
)
cache_key = _compare_range_cache_key(
dashboard.get("selected_start_year"),
dashboard.get("selected_end_year"),
)
cached_summary = _get_ttl_cached_payload(_COMPARE_SUMMARY_CACHE, cache_key, _COMPARE_SUMMARY_CACHE_TTL_SEC)
if cached_summary is None:
with engine.begin() as conn:
available_years = _discover_available_fiscal_years(conn)
latest_summary = _load_latest_summary_range_cache_any_signature(
conn,
dashboard.get("selected_start_year"),
dashboard.get("selected_end_year"),
)
snapshot_state = (latest_summary or {}).get("snapshot_state") or {"ready": [], "stale": [], "missing": []}
snapshot_policy = _build_compare_snapshot_policy(
dashboard.get("selected_start_year"),
dashboard.get("selected_end_year"),
available_years,
)
cached_summary = _store_ttl_cached_payload(
_COMPARE_SUMMARY_CACHE,
cache_key,
{
"snapshot_state": snapshot_state,
"snapshot_policy": snapshot_policy,
"snapshot_aggregate": _aggregate_snapshot_state_counts(snapshot_state),
},
_COMPARE_SUMMARY_CACHE_TTL_SEC,
)
metric_sections = [
{
**section,
"count": int(metric_counts.get(section.get("key"), 0) or 0),
}
for section in dashboard.get("metric_sections", [])
]
return {
"selected_start_year": dashboard.get("selected_start_year"),
"selected_end_year": dashboard.get("selected_end_year"),
"metric_sections": metric_sections,
"last_action": dashboard.get("last_action"),
"pending": pending,
"snapshot_state": cached_summary.get("snapshot_state", {"ready": [], "stale": [], "missing": []}),
"snapshot_policy": cached_summary.get("snapshot_policy", {}),
"snapshot_aggregate": cached_summary.get("snapshot_aggregate", {}),
"snapshot_status_payload": None,
}
def enqueue_default_pair_recommend_precompute(
engine: Any,
start_year: int | None = None,
end_year: int | None = None,
limit: int = 300,
) -> None:
with engine.begin() as conn:
years = sorted(_discover_available_fiscal_years(conn) or discover_available_years(), reverse=True)
status_map = _load_snapshot_status_map(conn, years)
ready_years = [
int(year)
for year in years
if clean((status_map.get(int(year)) or {}).get("state")) == "ready"
]
current_year = date.today().year
completed_ready_years = [int(year) for year in ready_years if int(year) < current_year]
default_year = (
completed_ready_years[0]
if completed_ready_years
else ready_years[0] if ready_years else get_default_year(years)
)
resolved_start = start_year
resolved_end = end_year
if resolved_start is None and resolved_end is None:
resolved_start = default_year
resolved_end = default_year
elif resolved_start is None:
resolved_start = resolved_end
elif resolved_end is None:
resolved_end = resolved_start
if resolved_start is None or resolved_end is None:
return
enqueue_pair_recommend_precompute(
engine,
start_year=resolved_start,
end_year=resolved_end,
limit=limit,
)