Files
HM_project_Viewer_Board/wehago_compare.py
T

3476 lines
141 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import hashlib
import json
import re
import threading
import time
from dataclasses import dataclass
from datetime import date, datetime
from 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 = [
"일자",
"적요",
"거래처",
"차변",
"대변",
"잔액",
"전표번호",
"계정코드",
"계정명",
]
STATUS_META = [
("matched", "Matched", "WEHAGO 기준으로 정상 매칭된 항목"),
("ledger_only", "Unmatched", "WEHAGO에는 있으나 ERP와 연결되지 않은 항목"),
("voucher_only", "ERP Unmatched", "ERP 기준으로 매치되지 않은 항목"),
("amount_mismatch", "Recheck", "전표번호는 같지만 금액이 달라 다시 확인이 필요한 항목"),
]
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 대변"),
("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", "적요"),
],
}
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 = 20
_STATUS_CACHE_WARMING: set[str] = set()
_STATUS_CACHE_WARMING_LOCK = threading.Lock()
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
@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
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 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
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_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
)
)
"""
)
)
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 ''")
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)"))
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)
fiscal_year = year_from_date_text(proof_date) or year_hint or ""
parts = [
clean(values[0] if len(values) > 0 else ""),
clean(values[1] if len(values) > 1 else ""),
clean(values[10] if len(values) > 10 else ""),
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
proof_date = parse_excel_date(values[20] if len(values) > 20 else None)
fiscal_year = year_from_date_text(proof_date) or year_hint
draft_no = clean(values[1] if len(values) > 1 else "")
confirmed_no = clean(values[10] if len(values) > 10 else "")
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_voucher_no(confirmed_no or draft_no),
"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 not any(item is not None and clean(item) for item in 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_voucher_no(values[6] if len(values) > 6 else ""),
"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
"""
)
)
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()
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)
proof_date = parse_excel_date(values[20] if len(values) > 20 else None)
fiscal_year = year_from_date_text(proof_date) or year_hint
draft_no = clean(values[1] if len(values) > 1 else "")
confirmed_no = clean(values[10] if len(values) > 10 else "")
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_voucher_no(confirmed_no or draft_no),
"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())
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]] = []
for status_key, label, description in STATUS_META:
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 "",
}
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 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[status_key]
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[status_key]
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}:none")
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 _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
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
LIMIT 1
"""
),
{
"fiscal_year": year,
"ledger_result_path": ledger_path,
"ledger_result_mtime": ledger_mtime,
"voucher_result_path": voucher_path,
"voucher_result_mtime": voucher_mtime,
},
).first()
if cached and cached[0]:
try:
payload = json.loads(str(cached[0]))
if isinstance(payload, dict):
return payload
except Exception:
pass
parsed = parse_compare_result_bundle(
ledger_path,
ledger_mtime,
voucher_path,
voucher_mtime,
year,
)
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,
created_at
) VALUES (
:fiscal_year,
:ledger_result_path,
:ledger_result_mtime,
:voucher_result_path,
:voucher_result_mtime,
: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,
"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
)
"""
),
{
"fiscal_year": year,
"ledger_result_path": ledger_path,
"ledger_result_mtime": ledger_mtime,
"voucher_result_path": voucher_path,
"voucher_result_mtime": voucher_mtime,
},
)
return parsed
def _build_db_state_signature(conn: Any, start_year: int | None, end_year: int | None) -> str:
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"r:{recheck[0]}:{recheck[1]}|p:{pair[0]}:{pair[1]}"
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 {"matched": [], "ledger_only": [], "amount_mismatch": [], "voucher_only": []}
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)
if cached and (now - float(cached.get("ts", 0))) <= _STATUS_ROWS_CACHE_TTL_SEC:
return cached["rows_by_status"]
reviewed_keys = get_saved_recheck_review_keys(conn, start_year, end_year)
manual_pair_matches = get_saved_manual_pair_matches(conn, 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):
parsed = _get_or_create_year_cached_sections(conn, year)
if not parsed:
continue
parsed = apply_saved_recheck_reviews(parsed, reviewed_keys)
parsed = apply_saved_manual_pair_matches(parsed, manual_pair_matches)
for status_key in rows_by_status:
rows_by_status[status_key].extend(parsed[status_key]["rows"])
_STATUS_ROWS_CACHE.clear()
_STATUS_ROWS_CACHE[cache_key] = {"ts": now, "rows_by_status": rows_by_status}
return rows_by_status
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()
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()
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()
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()
if len(normalized) <= 2:
return {normalized}
return {normalized[i : i + 2] for i in range(len(normalized) - 1)}
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 _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]:
score = 0.0
reasons: list[str] = []
hard_pass = True
side = _determine_primary_side(ledger_row)
if side == "debit":
ledger_amount = parse_amount(ledger_row.get("ledger_debit"))
voucher_amount = parse_amount(voucher_row.get("voucher_debit"))
elif side == "credit":
ledger_amount = parse_amount(ledger_row.get("ledger_credit"))
voucher_amount = parse_amount(voucher_row.get("voucher_credit"))
else:
ledger_amount = max(parse_amount(ledger_row.get("ledger_debit")), parse_amount(ledger_row.get("ledger_credit")))
voucher_amount = max(parse_amount(voucher_row.get("voucher_debit")), parse_amount(voucher_row.get("voucher_credit")))
amount_gap = abs(ledger_amount - voucher_amount)
if amount_gap < 0.5 and ledger_amount > 0:
score += 50
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
ledger_code = clean(ledger_row.get("ledger_account_code"))
voucher_code = clean(voucher_row.get("voucher_account_code"))
account_sim = _jaccard_similarity(ledger_row.get("ledger_account_name"), voucher_row.get("voucher_account_name"))
if ledger_code and voucher_code and ledger_code == voucher_code:
score += 22
reasons.append("계정코드 일치")
elif ledger_code and voucher_code and ledger_code[:4] == voucher_code[:4]:
score += 10
reasons.append("계정코드 대분류 일치")
else:
if account_sim >= 0.8:
score += 16
reasons.append("계정명 유사도 높음")
elif account_sim >= 0.55:
score += 8
reasons.append("계정명 유사")
else:
hard_pass = False
vendor_sim = _jaccard_similarity(ledger_row.get("ledger_vendor"), voucher_row.get("voucher_vendor"))
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(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc"))
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
ledger_date = _parse_iso_date(ledger_row.get("ledger_date"))
proof_date = _parse_iso_date(voucher_row.get("proof_date"))
if ledger_date and proof_date:
day_gap = abs((ledger_date - proof_date).days)
if day_gap <= 3:
score += 8
reasons.append("일자 근접")
elif day_gap <= 10:
score += 4
elif day_gap <= 45:
score += 1
else:
score -= 6
confidence = "low"
if score >= 88:
confidence = "high"
elif score >= 72:
confidence = "medium"
auto_eligible = bool(
hard_pass
and score >= 88
and amount_gap < 0.5
and (
(ledger_code and voucher_code and ledger_code == voucher_code)
or _jaccard_similarity(ledger_row.get("ledger_account_name"), voucher_row.get("voucher_account_name")) >= 0.8
)
and vendor_sim >= 0.65
)
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),
"amount_gap": round(amount_gap, 2),
}
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": []}
if engine is not None:
init_wehago_compare_db(engine)
reviewed_keys: set[str] = set()
manual_pair_matches: list[dict[str, Any]] = []
if engine is not None:
with engine.begin() as conn:
reviewed_keys = get_saved_recheck_review_keys(conn, start_year, end_year)
manual_pair_matches = get_saved_manual_pair_matches(conn, start_year, end_year)
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)
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, reviewed_keys)
parsed = apply_saved_manual_pair_matches(parsed, manual_pair_matches)
for row in parsed["ledger_only"]["rows"]:
if _filter_status_row(
row,
ledger_voucher_filter,
"",
"",
"",
"",
"",
"",
"",
ledger_reason_filter,
):
result["ledger_only"].append(row)
for row in parsed["voucher_only"]["rows"]:
if _filter_status_row(
row,
voucher_voucher_filter,
"",
"",
"",
"",
"",
"",
"",
voucher_reason_filter,
):
result["voucher_only"].append(row)
return result
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]:
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}}
amount_index: dict[float, list[dict[str, Any]]] = {}
for voucher_row in voucher_rows:
amounts = {
round(parse_amount(voucher_row.get("voucher_debit")), 2),
round(parse_amount(voucher_row.get("voucher_credit")), 2),
}
for amount in amounts:
if amount <= 0:
continue
amount_index.setdefault(amount, []).append(voucher_row)
edge_candidates: list[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),
}
voucher_candidates: list[dict[str, Any]] = []
seen_keys: set[str] = set()
for amount in candidate_amounts:
if amount <= 0:
continue
for voucher_row in amount_index.get(amount, []):
voucher_key = clean(voucher_row.get("voucher_row_key"))
if voucher_key and voucher_key not in seen_keys:
seen_keys.add(voucher_key)
voucher_candidates.append(voucher_row)
if not voucher_candidates:
continue
scored_candidates: list[tuple[dict[str, Any], dict[str, Any]]] = []
for voucher_row in voucher_candidates:
score_result = _score_pair_match(ledger_row, voucher_row)
if not score_result["hard_pass"] or score_result["score"] < 72:
continue
scored_candidates.append((voucher_row, score_result))
if not scored_candidates:
continue
scored_candidates.sort(key=lambda item: item[1]["score"], reverse=True)
top_candidate, top_score = scored_candidates[0]
second_score = scored_candidates[1][1]["score"] if len(scored_candidates) > 1 else -999
if top_score["score"] - second_score < 6:
continue
edge_candidates.append(
{
"ledger_row": ledger_row,
"voucher_row": top_candidate,
"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),
}
)
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),
"ledger_row": edge["ledger_row"],
"voucher_row": edge["voucher_row"],
}
)
if len(picked) >= max(min(int(limit or 300), 1000), 1):
break
return {
"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"),
},
}
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"]
source_row = None
if normalized_source_status == "ledger_only":
for row in ledger_rows:
if clean(row.get("ledger_row_key")) == normalized_row_key:
source_row = row
break
else:
for row in voucher_rows:
if clean(row.get("voucher_row_key")) == normalized_row_key:
source_row = row
break
if source_row 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,
}
candidates: list[dict[str, Any]] = []
if normalized_source_status == "ledger_only":
source_amounts = {
round(parse_amount(source_row.get("ledger_debit")), 2),
round(parse_amount(source_row.get("ledger_credit")), 2),
}
for target in voucher_rows:
target_amounts = {
round(parse_amount(target.get("voucher_debit")), 2),
round(parse_amount(target.get("voucher_credit")), 2),
}
if not (source_amounts & target_amounts):
continue
score = _score_pair_match(source_row, target)
if not score["hard_pass"] or score["score"] < 60:
continue
candidates.append(
{
"pair_key": build_manual_pair_key(clean(source_row.get("ledger_row_key")), clean(target.get("voucher_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,
}
)
else:
source_amounts = {
round(parse_amount(source_row.get("voucher_debit")), 2),
round(parse_amount(source_row.get("voucher_credit")), 2),
}
for target in ledger_rows:
target_amounts = {
round(parse_amount(target.get("ledger_debit")), 2),
round(parse_amount(target.get("ledger_credit")), 2),
}
if not (source_amounts & target_amounts):
continue
score = _score_pair_match(target, source_row)
if not score["hard_pass"] or score["score"] < 60:
continue
candidates.append(
{
"pair_key": build_manual_pair_key(clean(target.get("ledger_row_key")), clean(source_row.get("voucher_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,
}
)
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_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 _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 = "",
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",
}
if normalized_status not in {"matched", "ledger_only", "amount_mismatch", "voucher_only"}:
normalized_status = allowed.get(normalized_status, normalized_status)
if normalized_status not in {"matched", "ledger_only", "amount_mismatch", "voucher_only"}:
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)
safe_offset = max(int(offset or 0), 0)
safe_limit = max(min(int(limit or 200), 500), 1)
has_filters = any(
[
voucher_filter,
draft_filter,
wehago_account_filter,
erp_account_filter,
clean(wehago_amount),
clean(erp_amount),
wehago_vendor_filter,
erp_vendor_filter,
desc_filter,
]
)
if not has_filters:
columns = DETAIL_COLUMN_MAP[normalized_status]
rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year)
total_count = 0
rows_out: list[dict[str, Any]] = []
skipped = 0
needed = safe_limit
section_rows = rows_by_status.get(normalized_status, [])
total_count = len(section_rows)
if section_rows:
rows_out = section_rows[safe_offset : safe_offset + safe_limit]
next_offset = safe_offset + len(rows_out)
return {
"columns": columns,
"rows": rows_out,
"total_count": total_count,
"shown_count": len(rows_out),
"offset": safe_offset,
"limit": safe_limit,
"has_more": next_offset < total_count,
"next_offset": next_offset,
"notice": "",
}
rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year)
collected: list[dict[str, Any]] = []
columns = DETAIL_COLUMN_MAP[normalized_status]
for row in rows_by_status.get(normalized_status, []):
if _filter_status_row(
row,
voucher_filter,
draft_filter,
wehago_account_filter,
erp_account_filter,
clean(wehago_amount),
clean(erp_amount),
wehago_vendor_filter,
erp_vendor_filter,
desc_filter,
):
collected.append(row)
shown_rows = collected[safe_offset : safe_offset + safe_limit]
next_offset = safe_offset + len(shown_rows)
return {
"columns": columns,
"rows": shown_rows,
"total_count": len(collected),
"shown_count": len(shown_rows),
"offset": safe_offset,
"limit": safe_limit,
"has_more": next_offset < len(collected),
"next_offset": next_offset,
"notice": "",
}
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",
"ledger_only": "ledger_only",
"voucher_only": "voucher_only",
"amount_mismatch": "amount_mismatch",
}
normalized_status = status_map.get(normalized_status, normalized_status)
if normalized_status not in {"matched", "amount_mismatch", "ledger_only", "voucher_only"}:
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),
_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"]
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()
_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:
_get_cached_status_rows_by_range(engine, start_year, end_year)
except Exception:
pass
finally:
with _STATUS_CACHE_WARMING_LOCK:
_STATUS_CACHE_WARMING.discard(warm_key)
def warm_status_cache_async(engine: Any, start_year: int | None, end_year: int | None) -> None:
if start_year is None or end_year is None:
return
if start_year > end_year:
start_year, end_year = end_year, start_year
warm_key = f"{start_year}:{end_year}:{_build_bundle_signature(start_year, end_year)}"
with _STATUS_CACHE_WARMING_LOCK:
if warm_key in _STATUS_CACHE_WARMING:
return
_STATUS_CACHE_WARMING.add(warm_key)
worker = threading.Thread(
target=_warm_status_cache_worker,
args=(engine, start_year, end_year, warm_key),
daemon=True,
)
worker.start()
def get_wehago_compare_dashboard(
engine: Any,
start_year: int | None = None,
end_year: int | None = None,
) -> 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
rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year)
metric_sections = [
{
"key": status_key,
"label": label,
"description": description,
"count": len(rows_by_status.get(status_key, [])),
"columns": DETAIL_COLUMN_MAP[status_key],
"rows": [],
}
for status_key, label, description in STATUS_META
]
with engine.begin() as conn:
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)
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,
}