8890 lines
360 KiB
Python
8890 lines
360 KiB
Python
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,
|
||
)
|