3476 lines
141 KiB
Python
3476 lines
141 KiB
Python
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import re
|
||
import threading
|
||
import time
|
||
from dataclasses import dataclass
|
||
from datetime import date, datetime
|
||
from 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,
|
||
}
|