Files
HM_project_Viewer_Board/wehago_compare.py
T

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