diff --git a/data.db b/data.db index a63f63f..e138198 100644 Binary files a/data.db and b/data.db differ diff --git a/main.py b/main.py index 3fa9cc6..6c9fffb 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,4 @@ +import copy import os import logging import json @@ -8,6 +9,7 @@ import time import tempfile import zipfile from datetime import date, datetime +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from functools import lru_cache from pathlib import Path from typing import Any @@ -20,19 +22,21 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from openpyxl import load_workbook -from sqlalchemy import create_engine, event, text +from sqlalchemy import bindparam, create_engine, event, text +from datasette.app import Datasette from wehago_compare import ( + enqueue_default_pair_recommend_precompute, get_erp_filtered_rows, get_individual_pair_recommendations, get_last_action_summary, get_status_field_suggestions, get_status_detail_rows, get_wehago_compare_dashboard, + get_wehago_compare_summary, get_wehago_filtered_rows, import_uploaded_erp_voucher_file, init_wehago_compare_db, recommend_pair_matches, - refresh_wehago_compare_data, save_recommended_pair_matches, save_manual_pair_matches, save_recheck_review_rows, @@ -52,6 +56,10 @@ _DB_BACKUP_STATE: dict[str, Any] = { _DB_BACKUP_LOCK = threading.Lock() DB_BACKUP_MIN_INTERVAL_SECONDS = 900.0 DB_BACKUP_KEEP_COUNT = 24 +PROCESS_COST_CACHE_TTL_SECONDS = 120.0 +_PROCESS_COST_RUNTIME_CACHE_LOCK = threading.Lock() +_PROCESS_COST_PROJECT_OPTIONS_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {} +_PROCESS_COST_PROJECT_DETAIL_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {} app = FastAPI() @@ -72,6 +80,142 @@ engine = create_engine( ) +def _get_runtime_cache_entry( + cache: dict[tuple[Any, ...], dict[str, Any]], + key: tuple[Any, ...], + ttl_seconds: float = PROCESS_COST_CACHE_TTL_SECONDS, +) -> Any | None: + now = time.time() + with _PROCESS_COST_RUNTIME_CACHE_LOCK: + cached = cache.get(key) + if not cached: + return None + if now - float(cached.get("stored_at") or 0.0) > ttl_seconds: + cache.pop(key, None) + return None + return copy.deepcopy(cached.get("value")) + + +def _set_runtime_cache_entry( + cache: dict[tuple[Any, ...], dict[str, Any]], + key: tuple[Any, ...], + value: Any, +) -> Any: + with _PROCESS_COST_RUNTIME_CACHE_LOCK: + cache[key] = { + "stored_at": time.time(), + "value": copy.deepcopy(value), + } + return copy.deepcopy(value) + + +def build_datasette_metadata() -> dict[str, Any]: + return { + "title": "한맥 인트라넷 DB 조회", + "databases": { + "data": { + "title": "운영 DB", + "queries": { + "transaction_source_files": { + "title": "거래 원본 파일 현황", + "sql": """ + SELECT source_file, COUNT(*) AS row_count, MAX(updated_at) AS last_updated_at + FROM transactions + WHERE COALESCE(source_file, '') <> '' + GROUP BY source_file + ORDER BY row_count DESC, source_file + """, + }, + "project_related_links_overview": { + "title": "연계 프로젝트 링크 현황", + "sql": """ + SELECT base_support_dept_code, + related_support_dept_code, + COALESCE(link_source, 'manual') AS link_source, + updated_at + FROM project_related_links + ORDER BY base_support_dept_code, related_support_dept_code + """, + }, + "project_collection_summary": { + "title": "프로젝트 수금 요약", + "sql": """ + SELECT support_dept_code, + MAX(support_dept_name) AS support_dept_name, + COUNT(*) AS row_count, + SUM(COALESCE(amount, 0)) AS collected_amount, + MIN(date) AS first_collection_date, + MAX(date) AS last_collection_date + FROM project_collection_entries + GROUP BY support_dept_code + ORDER BY collected_amount DESC, support_dept_code + """, + }, + "billing_vs_collection_gap": { + "title": "청구/수금 차이 점검", + "sql": """ + WITH billing AS ( + SELECT support_dept_code, + SUM(COALESCE(billed_amount, 0)) AS billed_amount, + SUM(COALESCE(collected_amount, 0)) AS billing_collected_amount + FROM project_billing_entries + GROUP BY support_dept_code + ), + collection AS ( + SELECT support_dept_code, + SUM(COALESCE(amount, 0)) AS collection_amount + FROM project_collection_entries + GROUP BY support_dept_code + ), + codes AS ( + SELECT support_dept_code FROM billing + UNION + SELECT support_dept_code FROM collection + ) + SELECT codes.support_dept_code, + COALESCE(billing.billed_amount, 0) AS billed_amount, + COALESCE(collection.collection_amount, 0) AS collection_amount, + COALESCE(billing.billed_amount, 0) - COALESCE(collection.collection_amount, 0) AS gap_amount + FROM codes + LEFT JOIN billing + ON billing.support_dept_code = codes.support_dept_code + LEFT JOIN collection + ON collection.support_dept_code = codes.support_dept_code + ORDER BY ABS(COALESCE(billing.billed_amount, 0) - COALESCE(collection.collection_amount, 0)) DESC, + codes.support_dept_code + """, + }, + }, + "tables": { + "transactions": {"title": "거래전표"}, + "project_status": {"title": "프로젝트 상태"}, + "project_related_links": {"title": "연계 프로젝트"}, + "project_billing_entries": {"title": "청구 내역"}, + "project_collection_entries": {"title": "수금 내역"}, + "project_contract_info": {"title": "계약 현황"}, + "project_analysis_settings": {"title": "분석 설정"}, + }, + } + }, + } + + +datasette_app = Datasette( + files=[str(DB_PATH)], + metadata=build_datasette_metadata(), + settings={ + "base_url": "/db/", + "default_page_size": 50, + "max_returned_rows": 2000, + "sql_time_limit_ms": 20000, + "allow_facet": True, + "default_allow_sql": True, + "allow_download": True, + }, +).app() +app.mount("/db", datasette_app, name="datasette") + + @event.listens_for(engine, "connect") def configure_sqlite_connection(dbapi_connection: Any, _: Any) -> None: cursor = dbapi_connection.cursor() @@ -88,6 +232,8 @@ SUPPORT_DEPARTMENT_NAMES = ("경영지원부", "임원실", "총괄기획실", " VOUCHER_PATTERN = re.compile(r"^11-(\d{4})(\d{2})(\d{2})-[^-]+-[^-]+-[^-]+$") REVENUE_SQL = "(account_code LIKE '401101%' OR account_code LIKE '401102%')" +EXEC_COST_PLAN_OTHER_CODE = "501999" +EXEC_COST_PLAN_OTHER_NAME = "기타" SUPPORT_COST_DEPT_SQL = ( "cost_dept_name IN ('경영지원부', '임원실', '총괄기획실', '기술개발센터', '기술개발부', '공통')" ) @@ -1242,12 +1388,55 @@ def get_labor_grade_options() -> list[str]: return [item["label"] for item in get_option_items("labor_grades")] +def round_percentage_rate(value: Any) -> int: + text_value = normalize_text(value) + if not text_value: + return 0 + try: + return int(Decimal(text_value).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + except (InvalidOperation, ValueError): + return 0 + + +def format_rounded_percentage_options(group_key: str) -> list[dict[str, Any]]: + default_values_by_group = { + "expected_as_rates": {"0", "2", "5", "10"}, + "expected_sga_rates": {"13", "15", "20", "25"}, + } + default_values = default_values_by_group.get(group_key, set()) + default_options: list[dict[str, Any]] = [] + existing_options: list[dict[str, Any]] = [] + seen_values: set[str] = set() + for option in get_option_items(group_key): + rounded_rate = round_percentage_rate(option.get("value")) + value = str(rounded_rate) + if value in seen_values: + continue + is_default = value in default_values or int(option.get("sort_order") or 0) < 999 + if not is_default and rounded_rate < 0: + continue + option_payload = { + **option, + "label": f"{rounded_rate}%", + "value": value, + "rounded_rate": rounded_rate, + "is_existing_value": not is_default, + } + seen_values.add(value) + if is_default: + default_options.append(option_payload) + else: + existing_options.append(option_payload) + existing_options.sort(key=lambda item: (item["rounded_rate"], item.get("label", ""))) + return default_options + existing_options + + def get_expected_as_rate_options() -> list[dict[str, Any]]: - return get_option_items("expected_as_rates") + return format_rounded_percentage_options("expected_as_rates") def get_expected_sga_rate_options() -> list[dict[str, Any]]: - return get_option_items("expected_sga_rates") + return format_rounded_percentage_options("expected_sga_rates") def get_collection_progress_type_options() -> list[str]: @@ -1781,10 +1970,18 @@ def import_billing_status_workbook(workbook: Any, source_file: str) -> int: round_code_text = normalize_text(current.get("round_code")) round_prefix = next((character.upper() for character in round_code_text if character.isalpha()), "Y") - support_dept_code = normalize_project_code( - current.get("raw_project_code") or current.get("round_code"), + normalized_round_code = normalize_project_code( + current.get("round_code"), default_prefix=round_prefix, ) + normalized_raw_project_code = normalize_project_code( + current.get("raw_project_code"), + default_prefix=round_prefix, + ) + # Billing workbook stores the parent contract code in raw_project_code + # and the actual charge/collection project code in round_code. + # Prefer round_code when present so each sub-project keeps its own billing history. + support_dept_code = normalized_round_code or normalized_raw_project_code if not support_dept_code: continue @@ -2021,10 +2218,14 @@ def get_project_contract_change_maps() -> tuple[dict[str, dict[str, Any]], dict[ } summary_by_title: dict[str, list[dict[str, Any]]] = {} + summary_codes_by_title: dict[str, set[str]] = {} for row in summary_rows: title_key = normalize_text(row["normalized_title"]) if title_key: summary_by_title.setdefault(title_key, []).append(dict(row)) + summary_code = normalize_project_code(row.get("raw_summary_code")) + if summary_code: + summary_codes_by_title.setdefault(title_key, set()).add(summary_code) latest_summary_by_title = { title_key: select_latest_contract_change_entry(rows) for title_key, rows in summary_by_title.items() @@ -2047,8 +2248,10 @@ def get_project_contract_change_maps() -> tuple[dict[str, dict[str, Any]], dict[ } representative_by_title: dict[str, str] = {} - for title_key, codes in round_codes_by_title.items(): - sorted_codes = sorted(codes) + all_title_keys = set(summary_codes_by_title) | set(round_codes_by_title) + for title_key in all_title_keys: + codes = set(round_codes_by_title.get(title_key, set())) | set(summary_codes_by_title.get(title_key, set())) + sorted_codes = sorted(code for code in codes if code) if not sorted_codes: continue @@ -2065,11 +2268,15 @@ def get_project_contract_change_maps() -> tuple[dict[str, dict[str, Any]], dict[ representative_by_title[title_key] = min(sorted_codes, key=representative_rank) - title_by_code = { - code: title_key - for title_key, codes in round_codes_by_title.items() - for code in codes - } + title_by_code: dict[str, str] = {} + for title_key, codes in round_codes_by_title.items(): + for code in codes: + if code: + title_by_code[code] = title_key + for title_key, codes in summary_codes_by_title.items(): + for code in codes: + if code: + title_by_code[code] = title_key return latest_summary_by_title, latest_round_by_code, representative_by_title, title_by_code @@ -2229,18 +2436,25 @@ def sync_auto_project_related_links() -> None: if normalize_text(row[0]) } + round_cluster_map: dict[str, set[str]] = {} cluster_map: dict[str, set[str]] = {} for row in billing_rows: base_code = normalize_text(row["support_dept_code"]) - raw_project_code = normalize_text(row["raw_project_code"]) + raw_project_code = normalize_project_code( + row["raw_project_code"], + default_prefix=base_code[:1] or "Y", + ) or normalize_text(row["raw_project_code"]) round_code = normalize_project_code(row["round_code"], default_prefix=base_code[:1] or "Y") if not base_code: continue cluster_key = raw_project_code or base_code + round_cluster = round_cluster_map.setdefault(cluster_key, set()) cluster = cluster_map.setdefault(cluster_key, set()) if base_code in existing_codes: + round_cluster.add(base_code) cluster.add(base_code) if round_code and round_code in existing_codes: + round_cluster.add(round_code) cluster.add(round_code) change_round_title_groups: dict[str, set[str]] = {} @@ -2473,20 +2687,103 @@ def sync_auto_project_related_links() -> None: }, ) + for cluster_codes in round_cluster_map.values(): + normalized_cluster = sorted(code for code in cluster_codes if code in existing_codes) + if len(normalized_cluster) < 2: + continue + for base_code in normalized_cluster: + for related_code in normalized_cluster: + if base_code == related_code: + continue + conn.execute( + text( + """ + INSERT INTO project_related_links ( + base_support_dept_code, + related_support_dept_code, + link_source, + updated_at + ) VALUES ( + :base_support_dept_code, + :related_support_dept_code, + 'auto_round', + CURRENT_TIMESTAMP + ) + ON CONFLICT(base_support_dept_code, related_support_dept_code) DO UPDATE SET + link_source = excluded.link_source, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "base_support_dept_code": base_code, + "related_support_dept_code": related_code, + }, + ) + @app.on_event("startup") def on_startup() -> None: init_db() + init_wehago_compare_db(engine) with engine.begin() as conn: corrected = sanitize_project_labor_amount_rows(conn) if corrected: logger.info("Sanitized project labor amounts for %s project(s)", corrected) - auto_import_project_excels() - sync_auto_project_related_links() - normalize_all_collection_entry_storage() + run_heavy_startup = normalize_text(os.getenv("HM_RUN_HEAVY_STARTUP", "0")) in {"1", "true", "yes", "on"} + if run_heavy_startup: + auto_import_project_excels() + sync_auto_project_related_links() + normalize_all_collection_entry_storage() + else: + logger.info("Skipping heavy startup refresh tasks; existing DB state will be used as-is") logger.info("DB ready at %s", DB_PATH) +@app.get("/db", include_in_schema=False) +async def database_browser_redirect() -> RedirectResponse: + return RedirectResponse(url="/db/") + + +@app.get("/db-browser") +async def db_browser(request: Request, target: str | None = None): + allowed_targets = { + "home": "/db/data", + "transactions": "/db/data/transactions", + "project_status": "/db/data/project_status", + "project_related_links": "/db/data/project_related_links", + "project_billing_entries": "/db/data/project_billing_entries", + "project_collection_entries": "/db/data/project_collection_entries", + "project_contract_info": "/db/data/project_contract_info", + "project_analysis_settings": "/db/data/project_analysis_settings", + "transaction_source_files": "/db/data/transaction_source_files", + "project_related_links_overview": "/db/data/project_related_links_overview", + "project_collection_summary": "/db/data/project_collection_summary", + "billing_vs_collection_gap": "/db/data/billing_vs_collection_gap", + } + normalized_target = normalize_text(target) or "home" + current_target = allowed_targets.get(normalized_target, allowed_targets["home"]) + context = { + **base_context(request), + "db_browser_target_key": normalized_target if normalized_target in allowed_targets else "home", + "db_browser_target_url": current_target, + "db_browser_links": [ + {"key": "home", "label": "DB 홈", "url": allowed_targets["home"]}, + {"key": "transactions", "label": "거래전표", "url": allowed_targets["transactions"]}, + {"key": "project_status", "label": "프로젝트 상태", "url": allowed_targets["project_status"]}, + {"key": "project_related_links", "label": "연계 프로젝트", "url": allowed_targets["project_related_links"]}, + {"key": "project_billing_entries", "label": "청구 내역", "url": allowed_targets["project_billing_entries"]}, + {"key": "project_collection_entries", "label": "수금 내역", "url": allowed_targets["project_collection_entries"]}, + {"key": "project_contract_info", "label": "계약 현황", "url": allowed_targets["project_contract_info"]}, + {"key": "project_analysis_settings", "label": "분석 설정", "url": allowed_targets["project_analysis_settings"]}, + {"key": "transaction_source_files", "label": "거래 원본 파일 현황", "url": allowed_targets["transaction_source_files"]}, + {"key": "project_related_links_overview", "label": "연계 링크 현황", "url": allowed_targets["project_related_links_overview"]}, + {"key": "project_collection_summary", "label": "프로젝트 수금 요약", "url": allowed_targets["project_collection_summary"]}, + {"key": "billing_vs_collection_gap", "label": "청구/수금 차이 점검", "url": allowed_targets["billing_vs_collection_gap"]}, + ], + } + return templates.TemplateResponse(request, "db_browser.html", context) + + def normalize_text(value: Any) -> str: if value is None: return "" @@ -3657,6 +3954,10 @@ def get_cost_account_options() -> list[dict[str, str]]: "account_code": normalized_code, "account_name": normalized_name, } + deduped[(EXEC_COST_PLAN_OTHER_CODE, EXEC_COST_PLAN_OTHER_NAME)] = { + "account_code": EXEC_COST_PLAN_OTHER_CODE, + "account_name": EXEC_COST_PLAN_OTHER_NAME, + } return sorted(deduped.values(), key=lambda item: (item["account_code"], item["account_name"])) @@ -3770,20 +4071,23 @@ def merge_project_external_fields( contract_amount = normalize_amount(item.get("contract_amount")) latest_summary_change = latest_summary_change or {} latest_round_change = latest_round_change or {} + direct_contract_amount = ( + normalize_amount(billing_summary.get("contract_amount")) + or normalize_amount(contract_info.get("hanmac_contract_amount")) + or contract_amount + ) + latest_round_contract_amount = normalize_amount(latest_round_change.get("changed_contract_amount")) latest_changed_contract_amount = ( normalize_amount(latest_summary_change.get("changed_contract_amount")) - or normalize_amount(latest_round_change.get("changed_contract_amount")) + or latest_round_contract_amount ) current_code = normalize_text(item.get("support_dept_code")) - if change_representative_code and current_code == change_representative_code and latest_changed_contract_amount: + if direct_contract_amount: + contract_amount = direct_contract_amount + elif latest_round_contract_amount: + contract_amount = latest_round_contract_amount + elif latest_changed_contract_amount and (not change_representative_code or current_code == change_representative_code): contract_amount = latest_changed_contract_amount - elif not contract_amount: - contract_amount = ( - normalize_amount(contract_info.get("hanmac_contract_amount")) - or normalize_amount(billing_summary.get("contract_amount")) - ) - if not contract_amount and latest_changed_contract_amount and (not change_representative_code or current_code == change_representative_code): - contract_amount = latest_changed_contract_amount collection_amount = normalize_amount(item.get("collection_amount")) if not collection_amount: collection_amount = normalize_amount(billing_summary.get("collected_amount")) @@ -3836,11 +4140,11 @@ def merge_project_external_fields( item["hanmac_contract_amount"] = normalize_amount(contract_info.get("hanmac_contract_amount")) item["billing_contract_amount"] = normalize_amount(billing_summary.get("contract_amount")) item["billed_amount"] = normalize_amount(billing_summary.get("billed_amount")) - # 수금잔액은 원천 balance 합계 대신 계약금액 대비 수금액 차이로 통일 계산한다. - computed_collection_balance = contract_amount - collection_amount - if abs(computed_collection_balance) < 1: - computed_collection_balance = 0.0 - item["collection_balance_amount"] = computed_collection_balance + source_collection_balance = normalize_amount(billing_summary.get("balance_amount")) + if billing_summary: + item["collection_balance_amount"] = source_collection_balance + else: + item["collection_balance_amount"] = contract_amount - collection_amount item["latest_billing_date"] = normalize_text(billing_summary.get("latest_billing_date")) item["changed_contract_amount"] = latest_changed_contract_amount item["changed_contract_date"] = normalize_text(latest_summary_change.get("change_date")) or normalize_text(latest_round_change.get("change_date")) @@ -4356,6 +4660,7 @@ def get_project_status_rows() -> list[dict[str, Any]]: ).mappings().all() result = [] seen_codes: set[str] = set() + shared_input_map = _get_shared_cluster_input_map() for row in rows: item = dict(row) support_dept_code = normalize_text(item.get("support_dept_code")) @@ -4377,6 +4682,9 @@ def get_project_status_rows() -> list[dict[str, Any]]: representative_by_title.get(title_by_code.get(normalize_text(item.get("support_dept_code"))) or normalize_project_title_for_linking(item.get("support_dept_name")), ""), title_by_code.get(normalize_text(item.get("support_dept_code"))) or normalize_project_title_for_linking(item.get("support_dept_name")), ) + shared_meta = shared_input_map.get(support_dept_code, {}) + item["shared_input_owner_code"] = normalize_text(shared_meta.get("owner_code")) + item["shared_input_cluster_codes"] = list(shared_meta.get("cluster_codes") or []) seen_codes.add(normalize_text(item.get("support_dept_code"))) result.append(item) for support_dept_code in sorted((set(contract_info_map) | set(billing_summary_map)) - seen_codes): @@ -4423,6 +4731,8 @@ def get_project_status_rows() -> list[dict[str, Any]]: "latest_year": 0, "latest_month": 0, "project_type": "", + "shared_input_owner_code": normalize_text((shared_input_map.get(support_dept_code) or {}).get("owner_code")), + "shared_input_cluster_codes": list((shared_input_map.get(support_dept_code) or {}).get("cluster_codes") or []), }, contract_info_map.get(support_dept_code), billing_summary_map.get(support_dept_code), @@ -4769,7 +5079,7 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any] result["exec_labor_rates"] = shared_labor_rates else: result["exec_labor_rates"] = project_labor_rates if isinstance(project_labor_rates, dict) else {} - return merge_project_external_fields( + result = merge_project_external_fields( result, contract_info_map.get(normalize_text(result.get("support_dept_code"))), billing_summary_map.get(normalize_text(result.get("support_dept_code"))), @@ -4778,6 +5088,12 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any] representative_by_title.get(title_by_code.get(normalize_text(result.get("support_dept_code"))) or normalize_project_title_for_linking(result.get("support_dept_name")), ""), title_by_code.get(normalize_text(result.get("support_dept_code"))) or normalize_project_title_for_linking(result.get("support_dept_name")), ) + shared_meta = _get_shared_cluster_input_map().get(support_dept_code, {}) + result["shared_input_owner_code"] = normalize_text(shared_meta.get("owner_code")) + result["shared_input_cluster_codes"] = list(shared_meta.get("cluster_codes") or []) + result["expected_as_rate"] = round_percentage_rate(result.get("expected_as_rate")) if normalize_text(result.get("expected_as_rate")) else "" + result["expected_sga_rate"] = round_percentage_rate(result.get("expected_sga_rate")) if normalize_text(result.get("expected_sga_rate")) else "" + return result def get_project_page_state(session_id: str | None = None) -> dict[str, Any]: @@ -4914,8 +5230,6 @@ def save_project_page_state(payload: dict[str, Any]) -> None: "related_project_selections_json": json.dumps(related_project_selections, ensure_ascii=False), }, ) - for base_code, related_codes in related_project_selections.items(): - save_project_related_links(base_code, related_codes) log_save_event( "project_page_state_save", "project_page_state", @@ -4941,14 +5255,18 @@ def get_project_related_links_map() -> dict[str, list[str]]: """ ) ).mappings().all() - related_map: dict[str, list[str]] = {} + related_map: dict[str, set[str]] = {} for row in rows: base_code = normalize_text(row["base_support_dept_code"]) related_code = normalize_text(row["related_support_dept_code"]) if not base_code or not related_code: continue - related_map.setdefault(base_code, []).append(related_code) - return related_map + related_map.setdefault(base_code, set()).add(related_code) + related_map.setdefault(related_code, set()).add(base_code) + return { + base_code: sorted(related_codes) + for base_code, related_codes in sorted(related_map.items()) + } def get_project_quick_links(session_id: str | None = None) -> list[str]: @@ -5121,7 +5439,7 @@ def save_project_related_links(base_support_dept_code: str, related_codes: list[ base_code = normalize_text(base_support_dept_code) if not base_code: return - normalized_codes = sorted( + requested_codes = sorted( { normalize_text(code) for code in related_codes @@ -5129,46 +5447,93 @@ def save_project_related_links(base_support_dept_code: str, related_codes: list[ } ) with engine.begin() as conn: + manual_rows = conn.execute( + text( + """ + SELECT base_support_dept_code, related_support_dept_code + FROM project_related_links + WHERE COALESCE(link_source, 'manual') = 'manual' + """ + ) + ).fetchall() + manual_adjacency: dict[str, set[str]] = {} + for row_base, row_related in manual_rows: + normalized_base = normalize_text(row_base) + normalized_related = normalize_text(row_related) + if not normalized_base or not normalized_related: + continue + manual_adjacency.setdefault(normalized_base, set()).add(normalized_related) + manual_adjacency.setdefault(normalized_related, set()).add(normalized_base) + previous_cluster: set[str] = set() + stack = [base_code] + while stack: + current = stack.pop() + if current in previous_cluster: + continue + previous_cluster.add(current) + stack.extend(sorted(manual_adjacency.get(current, set()) - previous_cluster)) + + next_cluster = {base_code, *requested_codes} + impacted_codes = previous_cluster | next_cluster + auto_pairs = { + (normalize_text(row[0]), normalize_text(row[1])) + for row in conn.execute( + text( + """ + SELECT base_support_dept_code, related_support_dept_code + FROM project_related_links + WHERE COALESCE(link_source, 'manual') LIKE 'auto%' + """ + ) + ).fetchall() + if normalize_text(row[0]) and normalize_text(row[1]) + } conn.execute( text( """ DELETE FROM project_related_links - WHERE base_support_dept_code = :base_support_dept_code - AND COALESCE(link_source, 'manual') = 'manual' + WHERE COALESCE(link_source, 'manual') = 'manual' + AND ( + base_support_dept_code IN :impacted_codes + OR related_support_dept_code IN :impacted_codes + ) """ - ), - {"base_support_dept_code": base_code}, + ).bindparams(bindparam("impacted_codes", expanding=True)), + {"impacted_codes": sorted(impacted_codes) or [""]}, ) - for related_code in normalized_codes: - conn.execute( - text( - """ - INSERT INTO project_related_links ( - base_support_dept_code, - related_support_dept_code, - link_source, - updated_at - ) VALUES ( - :base_support_dept_code, - :related_support_dept_code, - 'manual', - CURRENT_TIMESTAMP - ) - ON CONFLICT(base_support_dept_code, related_support_dept_code) DO UPDATE SET - link_source = excluded.link_source, - updated_at = CURRENT_TIMESTAMP - """ - ), - { - "base_support_dept_code": base_code, - "related_support_dept_code": related_code, - }, - ) + for cluster_base in sorted(next_cluster): + for related_code in sorted(next_cluster): + if cluster_base == related_code or (cluster_base, related_code) in auto_pairs: + continue + conn.execute( + text( + """ + INSERT INTO project_related_links ( + base_support_dept_code, + related_support_dept_code, + link_source, + updated_at + ) VALUES ( + :base_support_dept_code, + :related_support_dept_code, + 'manual', + CURRENT_TIMESTAMP + ) + ON CONFLICT(base_support_dept_code, related_support_dept_code) DO UPDATE SET + link_source = excluded.link_source, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "base_support_dept_code": cluster_base, + "related_support_dept_code": related_code, + }, + ) log_save_event( "project_related_links_save", "project_related_links", base_code, - payload={"related_codes": normalized_codes}, + payload={"related_codes": requested_codes, "cluster_codes": sorted(next_cluster)}, ) @@ -6068,6 +6433,52 @@ def _get_project_contract_meta() -> dict[str, dict[str, Any]]: return result +def _get_direct_project_contract_amount( + code: str, + contract_meta_map: dict[str, dict[str, Any]], + billing_summary_map: dict[str, dict[str, Any]], + latest_summary_by_title: dict[str, dict[str, Any]] | None = None, + latest_round_by_code: dict[str, dict[str, Any]] | None = None, + representative_by_title: dict[str, str] | None = None, + title_by_code: dict[str, str] | None = None, +) -> float: + normalized_code = normalize_text(code) + contract_meta = contract_meta_map.get(normalized_code, {}) + billing_summary = billing_summary_map.get(normalized_code, {}) + direct_amount = ( + normalize_amount(billing_summary.get("contract_amount")) + or normalize_amount(contract_meta.get("hanmac_contract_amount")) + ) + if direct_amount: + return direct_amount + latest_round_change = (latest_round_by_code or {}).get(normalized_code) or {} + round_amount = normalize_amount(latest_round_change.get("changed_contract_amount")) + if round_amount: + return round_amount + return 0.0 + + +def _get_aggregated_project_contract_amount( + codes: list[str], + contract_meta_map: dict[str, dict[str, Any]], + billing_summary_map: dict[str, dict[str, Any]], +) -> float: + latest_summary_by_title, latest_round_by_code, representative_by_title, title_by_code = get_project_contract_change_maps() + return sum( + _get_direct_project_contract_amount( + code, + contract_meta_map, + billing_summary_map, + latest_summary_by_title, + latest_round_by_code, + representative_by_title, + title_by_code, + ) + for code in codes + if normalize_text(code) + ) + + def _get_project_actual_input_group_summary( codes: list[str], group_names: list[str], @@ -6146,6 +6557,71 @@ def _get_project_actual_as_summary(codes: list[str]) -> dict[str, dict[str, Any] return _get_project_actual_input_group_summary(codes, ["as"]) +def _get_shared_cluster_input_map() -> dict[str, dict[str, Any]]: + related_clusters = _build_process_cost_related_clusters() + if not related_clusters: + return {} + billing_summary_map = get_project_billing_summary_map() + contract_meta_map = _get_project_contract_meta() + with engine.begin() as conn: + input_codes = { + normalize_text(row[0]) + for row in conn.execute( + text( + """ + SELECT support_dept_code + FROM project_status + WHERE COALESCE(task_plan_department_budget, 0) <> 0 + OR COALESCE(task_plan_outsource_budget, 0) <> 0 + OR COALESCE(task_plan_joint_operating_cost, 0) <> 0 + OR COALESCE(exec_budget_labor_by_grade, 0) <> 0 + OR COALESCE(exec_budget_outsource, 0) <> 0 + OR COALESCE(exec_budget_cost_plan, 0) <> 0 + OR COALESCE(expected_as_cost, 0) <> 0 + OR COALESCE(expected_sga_budget, 0) <> 0 + OR COALESCE(item_investment, 0) <> 0 + UNION + SELECT support_dept_code FROM project_task_plan_entries + UNION + SELECT support_dept_code FROM project_exec_budget_entries + UNION + SELECT support_dept_code FROM project_actual_input_entries + """ + ) + ).fetchall() + if normalize_text(row[0]) + } + + result: dict[str, dict[str, Any]] = {} + visited_clusters: set[tuple[str, ...]] = set() + for code, cluster_codes in related_clusters.items(): + cluster_key = tuple(cluster_codes) + if cluster_key in visited_clusters: + continue + visited_clusters.add(cluster_key) + normalized_cluster = [normalize_text(item) for item in cluster_codes if normalize_text(item)] + input_members = [item for item in normalized_cluster if item in input_codes] + material_members = [ + item + for item in normalized_cluster + if ( + normalize_amount((billing_summary_map.get(item) or {}).get("contract_amount")) > 0 + or normalize_amount((billing_summary_map.get(item) or {}).get("collected_amount")) > 0 + or normalize_amount((contract_meta_map.get(item) or {}).get("hanmac_contract_amount")) > 0 + ) + ] + if len(normalized_cluster) < 2 or len(input_members) != 1 or len(material_members) < 2: + continue + owner_code = input_members[0] + meta = { + "owner_code": owner_code, + "cluster_codes": normalized_cluster, + } + for member in normalized_cluster: + result[member] = meta + return result + + def _get_project_exec_budget_summary(codes: list[str]) -> dict[str, dict[str, float]]: normalized_codes = [normalize_text(code) for code in codes if normalize_text(code)] if not normalized_codes: @@ -6243,6 +6719,10 @@ def get_process_cost_project_options( include_related: bool = False, ) -> list[dict[str, Any]]: normalized_source = normalize_text(source).lower() + cache_key = ("project_options", normalized_source, start_year, end_year, bool(include_related)) + cached = _get_runtime_cache_entry(_PROCESS_COST_PROJECT_OPTIONS_CACHE, cache_key) + if cached is not None: + return cached contract_meta = _get_project_contract_meta() billing_summary_map = get_project_billing_summary_map() if normalized_source != "wehago" else {} @@ -6320,7 +6800,7 @@ def get_process_cost_project_options( continue if (start_year or end_year) and not project_start_year: continue - contract_amount = normalize_amount(contract_row.get("hanmac_contract_amount")) + contract_amount = _get_direct_project_contract_amount(code, contract_meta, billing_summary_map) revenue_amount = normalize_amount(row.get("revenue_amount")) expense_amount = normalize_amount(row.get("expense_amount")) profit_amount = revenue_amount - expense_amount @@ -6341,7 +6821,11 @@ def get_process_cost_project_options( "project_kind_label": _get_process_cost_project_kind(code)[1], } ) - return _sort_process_cost_project_options(result) + return _set_runtime_cache_entry( + _PROCESS_COST_PROJECT_OPTIONS_CACHE, + cache_key, + _sort_process_cost_project_options(result), + ) def get_process_cost_project_detail( @@ -6362,12 +6846,32 @@ def get_process_cost_project_detail( } normalized_source = normalize_text(source).lower() + normalized_active_related_codes = tuple( + sorted( + normalize_text(item) + for item in (active_related_codes or []) + if normalize_text(item) + ) + ) + cache_key = ( + "project_detail", + normalized_source, + selected_year, + code, + bool(include_related), + normalized_active_related_codes, + ) + cached = _get_runtime_cache_entry(_PROCESS_COST_PROJECT_DETAIL_CACHE, cache_key) + if cached is not None: + return cached contract_meta_map = _get_project_contract_meta() + billing_summary_map = get_project_billing_summary_map() if normalized_source != "wehago" else {} + shared_input_map = _get_shared_cluster_input_map() contract_meta = contract_meta_map.get(code, {}) saved_related_codes = get_process_cost_related_codes(code) active_related_codes = [ normalize_text(item) - for item in (active_related_codes or []) + for item in normalized_active_related_codes if normalize_text(item) ] active_related_codes = [ @@ -6375,6 +6879,13 @@ def get_process_cost_project_detail( if item != code and item in saved_related_codes ] cluster_codes = [code, *active_related_codes] if include_related else [code] + cluster_code_set = {normalize_text(item) for item in cluster_codes if normalize_text(item)} + suppressed_shared_owner_codes = { + normalize_text(meta.get("owner_code")) + for member_code in cluster_code_set + for meta in [shared_input_map.get(member_code) or {}] + if meta and not set(meta.get("cluster_codes") or []).issubset(cluster_code_set) + } actual_sga_summary_map = _get_project_actual_sga_summary(cluster_codes) actual_labor_summary_map = _get_project_actual_labor_summary(cluster_codes) actual_as_summary_map = _get_project_actual_as_summary(cluster_codes) @@ -6509,19 +7020,19 @@ def get_process_cost_project_detail( summary_row = dict(summary) if summary else {} if include_related: - contract_amount = sum(normalize_amount(contract_meta_map.get(member, {}).get("hanmac_contract_amount")) for member in cluster_codes) - as_cost_amount = sum(normalize_amount(contract_meta_map.get(member, {}).get("expected_as_cost")) for member in cluster_codes) - expected_sga_budget = sum(normalize_amount(contract_meta_map.get(member, {}).get("expected_sga_budget")) for member in cluster_codes) - planned_labor_amount = sum(normalize_amount((exec_budget_summary_map.get(member) or {}).get("labor")) for member in cluster_codes) - planned_outsource_amount = sum(normalize_amount((exec_budget_summary_map.get(member) or {}).get("outsource")) for member in cluster_codes) - planned_cost_plan_amount = sum(normalize_amount((exec_budget_summary_map.get(member) or {}).get("cost_plan")) for member in cluster_codes) + contract_amount = _get_aggregated_project_contract_amount(cluster_codes, contract_meta_map, billing_summary_map) + as_cost_amount = sum(normalize_amount(contract_meta_map.get(member, {}).get("expected_as_cost")) for member in cluster_codes if member not in suppressed_shared_owner_codes) + expected_sga_budget = sum(normalize_amount(contract_meta_map.get(member, {}).get("expected_sga_budget")) for member in cluster_codes if member not in suppressed_shared_owner_codes) + planned_labor_amount = sum(normalize_amount((exec_budget_summary_map.get(member) or {}).get("labor")) for member in cluster_codes if member not in suppressed_shared_owner_codes) + planned_outsource_amount = sum(normalize_amount((exec_budget_summary_map.get(member) or {}).get("outsource")) for member in cluster_codes if member not in suppressed_shared_owner_codes) + planned_cost_plan_amount = sum(normalize_amount((exec_budget_summary_map.get(member) or {}).get("cost_plan")) for member in cluster_codes if member not in suppressed_shared_owner_codes) else: - contract_amount = normalize_amount(contract_meta.get("hanmac_contract_amount")) - as_cost_amount = normalize_amount(contract_meta.get("expected_as_cost")) - expected_sga_budget = normalize_amount(contract_meta.get("expected_sga_budget")) - planned_labor_amount = normalize_amount((exec_budget_summary_map.get(code) or {}).get("labor")) - planned_outsource_amount = normalize_amount((exec_budget_summary_map.get(code) or {}).get("outsource")) - planned_cost_plan_amount = normalize_amount((exec_budget_summary_map.get(code) or {}).get("cost_plan")) + contract_amount = _get_direct_project_contract_amount(code, contract_meta_map, billing_summary_map) + as_cost_amount = 0.0 if code in suppressed_shared_owner_codes else normalize_amount(contract_meta.get("expected_as_cost")) + expected_sga_budget = 0.0 if code in suppressed_shared_owner_codes else normalize_amount(contract_meta.get("expected_sga_budget")) + planned_labor_amount = 0.0 if code in suppressed_shared_owner_codes else normalize_amount((exec_budget_summary_map.get(code) or {}).get("labor")) + planned_outsource_amount = 0.0 if code in suppressed_shared_owner_codes else normalize_amount((exec_budget_summary_map.get(code) or {}).get("outsource")) + planned_cost_plan_amount = 0.0 if code in suppressed_shared_owner_codes else normalize_amount((exec_budget_summary_map.get(code) or {}).get("cost_plan")) revenue_amount = normalize_amount(summary_row.get("revenue_amount")) ledger_expense_amount = normalize_amount(summary_row.get("expense_amount")) ledger_labor_amount = normalize_amount(summary_row.get("labor_amount")) @@ -6530,6 +7041,7 @@ def get_process_cost_project_detail( actual_labor_amount = sum( normalize_amount((actual_labor_summary_map.get(member) or {}).get("amount")) for member in cluster_codes + if member not in suppressed_shared_owner_codes ) real_project_actual_sga_amount = 0.0 actual_input_last_updated_at = "" @@ -6540,6 +7052,9 @@ def get_process_cost_project_detail( for member in cluster_codes: member_expected_sga_budget = normalize_amount(contract_meta_map.get(member, {}).get("expected_sga_budget")) actual_summary = actual_sga_summary_map.get(member) + if member in suppressed_shared_owner_codes: + actual_summary = None + member_expected_sga_budget = 0.0 if _is_real_project_actual_sga(actual_summary, member_expected_sga_budget): real_project_actual_sga_amount += normalize_amount((actual_summary or {}).get("amount")) updated_at = normalize_text((actual_summary or {}).get("last_updated_at")) @@ -6548,10 +7063,12 @@ def get_process_cost_project_detail( actual_as_amount = sum( normalize_amount((actual_as_summary_map.get(member) or {}).get("amount")) for member in cluster_codes + if member not in suppressed_shared_owner_codes ) actual_sga_amount = sum( normalize_amount((actual_sga_summary_map.get(member) or {}).get("amount")) for member in cluster_codes + if member not in suppressed_shared_owner_codes ) labor_amount = actual_labor_amount if actual_labor_amount > 0 else ledger_labor_amount as_amount = actual_as_amount if actual_as_amount > 0 else as_cost_amount @@ -6626,7 +7143,10 @@ def get_process_cost_project_detail( ) normalized_monthly.reverse() - return { + return _set_runtime_cache_entry( + _PROCESS_COST_PROJECT_DETAIL_CACHE, + cache_key, + { "overview": { "support_dept_code": code, "support_dept_name": normalize_text(contract_meta.get("support_dept_name")) or normalize_text(summary_row.get("support_dept_name")) or code, @@ -6657,7 +7177,8 @@ def get_process_cost_project_detail( "sga_ratio": _safe_ratio(sga_amount, expense_amount), "cost_to_revenue_ratio": _safe_ratio(expense_amount, revenue_amount), }, - } + }, + ) def render_process_cost_page( @@ -6934,7 +7455,9 @@ def parse_excel_upload(upload_file: UploadFile) -> int: with engine.begin() as conn: conn.execute( - text("DELETE FROM transactions WHERE COALESCE(source_file, '') <> ''") + text("DELETE FROM transactions WHERE COALESCE(source_file, '') = :source_file") + , + {"source_file": upload_file.filename or ""}, ) if rows_to_insert: conn.execute(insert_sql, rows_to_insert) @@ -7519,13 +8042,15 @@ def build_project_status_payload(payload: dict[str, Any]) -> dict[str, Any]: ) actual_input_rows = filter_amount_rows(actual_input_rows, amount_key="amount") - expected_as_rate = normalize_amount(payload.get("expected_as_rate")) - expected_sga_rate = normalize_amount(payload.get("expected_sga_rate")) + has_expected_as_rate = normalize_text(payload.get("expected_as_rate")) != "" + has_expected_sga_rate = normalize_text(payload.get("expected_sga_rate")) != "" + expected_as_rate = round_percentage_rate(payload.get("expected_as_rate")) if has_expected_as_rate else 0 + expected_sga_rate = round_percentage_rate(payload.get("expected_sga_rate")) if has_expected_sga_rate else 0 expected_as_cost = normalize_amount(payload.get("expected_as_cost")) expected_sga_budget = normalize_amount(payload.get("expected_sga_budget")) - if not expected_as_cost and contract_amount and expected_as_rate: + if has_expected_as_rate and contract_amount: expected_as_cost = contract_amount * expected_as_rate / 100 - if not expected_sga_budget and contract_amount and expected_sga_rate: + if has_expected_sga_rate and contract_amount: expected_sga_budget = contract_amount * expected_sga_rate / 100 exec_labor_rates = normalize_text(payload.get("exec_labor_rates_json")) or "{}" @@ -7923,7 +8448,13 @@ def render_wehago_compare_page( init_db() context = { **base_context(request, message), - "wehago_compare": get_wehago_compare_dashboard(engine, start_year=start_year, end_year=end_year), + "wehago_compare": get_wehago_compare_dashboard( + engine, + start_year=start_year, + end_year=end_year, + include_metric_counts=False, + warm_caches=False, + ), } return templates.TemplateResponse(request, "wehago_compare.html", context) @@ -8003,7 +8534,7 @@ async def project_related_links_save(request: Request): if not isinstance(related_codes, list): raise ValueError("연관 프로젝트 목록 형식이 올바르지 않습니다.") save_project_related_links(base_code, related_codes) - return JSONResponse(content={"status": "ok"}) + return JSONResponse(content={"status": "ok", "related_project_links": get_project_related_links_map()}) except Exception as exc: logger.exception("연관 프로젝트 저장 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @@ -8476,31 +9007,6 @@ async def wehago_compare(request: Request, start_year: str | None = None, end_ye return HTMLResponse("

서버 오류

로그를 확인해주세요.

", status_code=500) -@app.post("/wehago-compare/refresh") -async def refresh_wehago_compare( - request: Request, - start_year: str | None = None, - end_year: str | None = None, -): - try: - summary = refresh_wehago_compare_data(engine) - message = "WEHAGO DB 갱신 완료: " + ", ".join(f"{key}={value}" for key, value in summary.items()) - return render_wehago_compare_page( - request, - start_year=parse_optional_year(start_year), - end_year=parse_optional_year(end_year), - message=message, - ) - except Exception as exc: - logger.exception("전표비교 DB 갱신 에러: %s", exc) - return render_wehago_compare_page( - request, - start_year=parse_optional_year(start_year), - end_year=parse_optional_year(end_year), - message=f"WEHAGO DB 갱신 중 오류가 발생했습니다: {exc}", - ) - - @app.post("/wehago-compare/upload-erp") async def upload_wehago_erp_file( request: Request, @@ -8561,6 +9067,7 @@ async def wehago_compare_status_rows( erp_vendor: str = "", desc_keyword: str = "", review_reason: str = "", + boundary_excluded: str = "", offset: int = 0, limit: int = 200, ): @@ -8580,6 +9087,7 @@ async def wehago_compare_status_rows( erp_vendor=erp_vendor, desc_keyword=desc_keyword, review_reason=review_reason, + boundary_excluded=boundary_excluded, offset=offset, limit=limit, ) @@ -8589,6 +9097,23 @@ async def wehago_compare_status_rows( return JSONResponse(content={"error": str(exc)}, status_code=500) +@app.get("/wehago-compare/api/summary") +async def wehago_compare_summary( + start_year: int | None = None, + end_year: int | None = None, +): + try: + payload = get_wehago_compare_summary( + engine, + start_year=start_year, + end_year=end_year, + ) + return JSONResponse(content=jsonable_encoder(payload)) + except Exception as exc: + logger.exception("전표비교 현황 조회 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + @app.get("/wehago-compare/api/status-suggestions") async def wehago_compare_status_suggestions( start_year: int | None = None, @@ -8624,6 +9149,7 @@ async def wehago_compare_recheck_review_save(request: Request): if not isinstance(rows, list): raise ValueError("저장할 검토 항목 형식이 올바르지 않습니다.") saved = save_recheck_review_rows(engine, rows) + enqueue_default_pair_recommend_precompute(engine) return JSONResponse(content={"saved_count": saved}) except Exception as exc: logger.exception("전표비교 검토 저장 에러: %s", exc) @@ -8639,6 +9165,7 @@ async def wehago_compare_pair_match_save(request: Request): if not isinstance(ledger_rows, list) or not isinstance(voucher_rows, list): raise ValueError("저장할 쌍비교 항목 형식이 올바르지 않습니다.") saved = save_manual_pair_matches(engine, ledger_rows, voucher_rows) + enqueue_default_pair_recommend_precompute(engine) return JSONResponse(content={"saved_count": saved}) except Exception as exc: logger.exception("전표비교 쌍매칭 저장 에러: %s", exc) @@ -8709,6 +9236,7 @@ async def wehago_compare_last_action(): async def wehago_compare_undo_last_action(): try: payload = undo_last_action(engine) + enqueue_default_pair_recommend_precompute(engine) return JSONResponse(content=jsonable_encoder(payload)) except Exception as exc: logger.exception("전표비교 되돌리기 에러: %s", exc) @@ -8922,6 +9450,6 @@ async def save_project_json(request: Request): if __name__ == "__main__": - auto_reload = os.getenv("INTRANET_AUTO_RELOAD", "1").lower() not in {"0", "false", "no"} + auto_reload = os.getenv("INTRANET_AUTO_RELOAD", "0").lower() not in {"0", "false", "no"} port = int(os.getenv("INTRANET_PORT", "8010")) uvicorn.run("main:app", host="0.0.0.0", port=port, reload=auto_reload, reload_dirs=[str(BASE_DIR)]) diff --git a/scripts/run_server.sh b/scripts/run_server.sh index 1955bca..0949b1f 100755 --- a/scripts/run_server.sh +++ b/scripts/run_server.sh @@ -4,6 +4,7 @@ set -euo pipefail cd "$(dirname "$0")/.." PORT="${1:-${INTRANET_PORT:-8010}}" +HEALTH_URL="http://127.0.0.1:${PORT}/health" if [ ! -x ".venv/bin/python" ]; then echo "가상환경이 없습니다. 먼저 아래를 실행하세요." @@ -13,7 +14,19 @@ if [ ! -x ".venv/bin/python" ]; then exit 1 fi -export INTRANET_AUTO_RELOAD="${INTRANET_AUTO_RELOAD:-1}" +if curl -fsS --max-time 2 "$HEALTH_URL" >/dev/null 2>&1; then + echo "이미 실행 중입니다: $HEALTH_URL" + exit 0 +fi + +if ss -ltn "( sport = :${PORT} )" | tail -n +2 | grep -q .; then + echo "포트 ${PORT} 는 이미 다른 프로세스가 사용 중입니다." + echo "기존 서버가 멈춘 상태라면 먼저 종료한 뒤 다시 실행하세요." + ss -ltnp "( sport = :${PORT} )" || true + exit 1 +fi + +export INTRANET_AUTO_RELOAD="${INTRANET_AUTO_RELOAD:-0}" export INTRANET_PORT="$PORT" exec ./.venv/bin/python main.py diff --git a/templates/wehago_compare.html b/templates/wehago_compare.html index 89b92a5..ec3ef79 100644 --- a/templates/wehago_compare.html +++ b/templates/wehago_compare.html @@ -211,21 +211,27 @@ } .status-grid { - display: flex; - gap: 10px; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(8, minmax(0, 1fr)); + gap: 4px; + align-items: stretch; } .status-card { - min-width: 130px; - width: auto; - border: 1px solid rgba(217, 221, 227, 0.9); - border-radius: 999px; - background: rgba(255, 255, 255, 0.76); - padding: 10px 12px; + min-width: 0; + width: 100%; + border: 1px solid rgba(217, 221, 227, 0.92); + border-radius: 12px; + background: #fbfcfd; + padding: 7px 6px 8px; text-align: left; cursor: pointer; transition: background-color 0.16s ease, color 0.16s ease, border-color 0.16s ease; + display: grid; + gap: 4px; + align-content: start; + justify-items: start; + box-shadow: none !important; } .status-card:hover, @@ -233,14 +239,23 @@ background: #111827; border-color: #111827; color: #fff; + box-shadow: none !important; } .status-label { display: block; - font-size: 12px; + width: 100%; + font-size: 9px; font-weight: 700; - color: var(--muted); - margin-bottom: 4px; + color: #475569; + margin-bottom: 0; + text-transform: none; + letter-spacing: 0; + text-align: left; + line-height: 1.1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .status-card:hover .status-label, @@ -250,10 +265,53 @@ .status-value { display: block; - font-size: 26px; + width: 100%; + font-size: 18px; font-weight: 800; - line-height: 1; + line-height: 1.05; color: #111; + text-align: left; + } + + .status-card[data-status="matched"] { + background: #f7faf8; + border-color: #d2ddd6; + } + + .status-card[data-status="ledger_only"], + .status-card[data-status="voucher_only"], + .status-card[data-status="amount_mismatch"] { + background: #f7faf8; + border-color: #d2ddd6; + } + + .status-card[data-status="voucher_matched"], + .status-card[data-status="erp_voucher_matched"], + .status-card[data-status="voucher_unmatched"], + .status-card[data-status="voucher_recheck"] { + background: #f5f8fb; + border-color: #c8d7e6; + } + + .status-card[data-status="matched"]:hover, + .status-card[data-status="matched"][aria-expanded="true"], + .status-card[data-status="ledger_only"]:hover, + .status-card[data-status="ledger_only"][aria-expanded="true"], + .status-card[data-status="voucher_only"]:hover, + .status-card[data-status="voucher_only"][aria-expanded="true"], + .status-card[data-status="amount_mismatch"]:hover, + .status-card[data-status="amount_mismatch"][aria-expanded="true"], + .status-card[data-status="voucher_matched"]:hover, + .status-card[data-status="voucher_matched"][aria-expanded="true"], + .status-card[data-status="erp_voucher_matched"]:hover, + .status-card[data-status="erp_voucher_matched"][aria-expanded="true"], + .status-card[data-status="voucher_unmatched"]:hover, + .status-card[data-status="voucher_unmatched"][aria-expanded="true"], + .status-card[data-status="voucher_recheck"]:hover, + .status-card[data-status="voucher_recheck"][aria-expanded="true"] { + background: #111827; + border-color: #111827; + color: #fff; } .status-card:hover .status-value, @@ -342,7 +400,8 @@ .table-wrap { width: 100%; max-width: 100%; - overflow: auto; + overflow-x: hidden; + overflow-y: auto; border-top: 1px solid rgba(217, 221, 227, 0.9); background: transparent; max-height: 460px; @@ -351,7 +410,7 @@ .detail-panel .table-wrap { max-height: 360px; - overflow-x: auto; + overflow-x: hidden; overflow-y: auto; } @@ -368,8 +427,8 @@ } .table-wrap table { - width: max-content; - min-width: max(100%, 1040px); + width: 100%; + min-width: 0; border-collapse: collapse; table-layout: fixed; } @@ -384,6 +443,10 @@ line-height: 1.35; } + .table-wrap td { + background: #fff; + } + .table-wrap th { position: sticky; top: 0; @@ -391,10 +454,162 @@ background: #f8fafc; font-size: 13px; letter-spacing: 0.01em; + white-space: nowrap; + } + + .table-wrap tr.bank-payable-case td, + .table-wrap tr.bank-payable-case:hover td, + .table-wrap tr.boundary-excluded-row td, + .table-wrap tr.boundary-excluded-row:hover td { + background: #fff; + color: #111827; + } + + .voucher-group-list { + display: grid; + gap: 12px; + } + + .voucher-group { + border: 1px solid rgba(217, 221, 227, 0.92); + border-radius: 12px; + overflow: hidden; + background: rgba(255, 255, 255, 0.86); + } + + .voucher-group-head { + display: grid; + gap: 10px; + padding: 14px 16px; + border-bottom: 1px solid rgba(217, 221, 227, 0.92); + background: #f8fafc; + } + + .voucher-group-title { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + } + + .voucher-group-title strong { + font-size: 18px; + color: #111827; + } + + .voucher-group-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + + .voucher-group-meta span { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid #d7e0ea; + background: #fff; + font-size: 12px; + color: #334155; + } + + .voucher-group-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 8px; + font-size: 12px; + color: #475569; + } + + .voucher-group-summary strong { + display: block; + margin-bottom: 4px; + color: #111827; + font-size: 12px; + } + + .voucher-group-lines { + overflow: visible; + max-height: none; + } + + .voucher-group-lines table { + width: 100%; + min-width: 0; + border-collapse: collapse; + table-layout: fixed; + } + + .voucher-group-lines th, + .voucher-group-lines td { + padding: 7px 8px; + border-bottom: 1px solid rgba(148, 163, 184, 0.18); + font-size: 13px; + vertical-align: middle; + text-align: left; + line-height: 1.35; + } + + /* Voucher body rows stay white; voucher grouping is expressed only by borders. */ + .voucher-group-lines td { + background: #fff !important; + } + + .voucher-group-lines th { + position: sticky; + top: 0; + z-index: 2; + background: #f8fafc; + color: inherit; + font-size: 13px; + letter-spacing: 0.01em; + white-space: nowrap; + } + + .voucher-group-lines tr:last-child td { + border-bottom: 0; + } + + .voucher-group-lines tr.bank-payable-case td, + .voucher-group-lines tr.bank-payable-case:hover td, + .voucher-group-lines tr.boundary-excluded-row td, + .voucher-group-lines tr.boundary-excluded-row:hover td { + background: #fff !important; + } + + .voucher-table-group-start td { + border-top: 2px solid rgba(148, 163, 184, 0.62); + } + + .case-stat { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 2px 8px; + border: 1px solid #bfd7c8; + border-radius: 999px; + background: #edf6f0; + color: #173522; + font-size: 12px; + font-weight: 800; + white-space: nowrap; + cursor: pointer; + box-shadow: none; + transform: none; + } + + .case-stat:hover { + background: #dfeee4; + border-color: #9fc4ad; } .cell-ellipsis { - max-width: 180px; + width: 100%; + min-width: 0; + max-width: 100%; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; @@ -402,19 +617,67 @@ } .cell-tight { - max-width: 86px; + max-width: clamp(56px, 5vw, 86px); } .cell-code { - max-width: 108px; + max-width: clamp(72px, 7vw, 108px); } .cell-equal { - max-width: 128px; + max-width: clamp(88px, 9vw, 148px); } .cell-desc { - max-width: 220px; + max-width: clamp(116px, 14vw, 220px); + } + + .col-fiscal_year { + width: 4.5%; + } + + .col-ledger_date, + .col-proof_date { + width: 6%; + } + + .col-voucher_no, + .col-confirmed_no { + width: 5.5%; + } + + .col-draft_no, + .col-account_code { + width: 7%; + } + + .col-ledger_account_name, + .col-voucher_account_name { + width: 8%; + } + + .col-ledger_vendor, + .col-voucher_vendor { + width: 10%; + } + + .col-ledger_debit, + .col-ledger_credit, + .col-voucher_debit, + .col-voucher_credit, + .col-amount { + width: 7%; + } + + .col-ledger_desc, + .col-voucher_desc, + .col-desc1, + .col-desc2, + .col-description, + .col-notes, + .col-review_memo, + .col-review_reason { + width: 12%; } .mono { @@ -560,10 +823,8 @@ min-width: 106px; } - .detail-panel[data-status-panel="matched"] .filter-form .suggest-field:has(input[name="wehago_vendor"]), - .detail-panel[data-status-panel="matched"] .filter-form .suggest-field:has(input[name="erp_vendor"]), - .detail-panel[data-status-panel="amount_mismatch"] .filter-form .suggest-field:has(input[name="wehago_vendor"]), - .detail-panel[data-status-panel="amount_mismatch"] .filter-form .suggest-field:has(input[name="erp_vendor"]) { + .detail-panel[data-status-panel="matched"] .filter-form .suggest-field-wide, + .detail-panel[data-status-panel="amount_mismatch"] .filter-form .suggest-field-wide { flex: 1 1 220px; width: auto; min-width: 220px; @@ -660,22 +921,22 @@ font-size: 13px; } - body[data-view-mode="dual"] .voucher-page:has(#pairRecommendPanel.active) { + body[data-view-mode="dual"] .voucher-page.has-recommend-panel-open { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; column-gap: 12px; } - body[data-view-mode="dual"] .voucher-page:has(#pairRecommendPanel.active) > .voucher-topbar { + body[data-view-mode="dual"] .voucher-page.has-recommend-panel-open > .voucher-topbar { grid-column: 1 / -1; } - body[data-view-mode="dual"] .voucher-page:has(#pairRecommendPanel.active) > .panel-shell:not(#pairRecommendPanel) { + body[data-view-mode="dual"] .voucher-page.has-recommend-panel-open > .panel-shell:not(#pairRecommendPanel) { grid-column: 1; min-width: 0; } - body[data-view-mode="dual"] .voucher-page:has(#pairRecommendPanel.active) > #pairRecommendPanel.active { + body[data-view-mode="dual"] .voucher-page.has-recommend-panel-open > #pairRecommendPanel.active { grid-column: 2; grid-row: 2; display: grid; @@ -694,6 +955,34 @@ max-height: clamp(300px, 44vh, 520px); } + body[data-view-mode="dual"] .cell-code { + max-width: clamp(90px, 8vw, 132px); + } + + body[data-view-mode="dual"] .cell-equal { + max-width: clamp(110px, 11vw, 180px); + } + + body[data-view-mode="dual"] .cell-desc { + max-width: clamp(150px, 15vw, 280px); + } + + body[data-view-mode="dual"] .col-ledger_vendor, + body[data-view-mode="dual"] .col-voucher_vendor { + width: 11%; + } + + body[data-view-mode="dual"] .col-ledger_desc, + body[data-view-mode="dual"] .col-voucher_desc, + body[data-view-mode="dual"] .col-desc1, + body[data-view-mode="dual"] .col-desc2, + body[data-view-mode="dual"] .col-description, + body[data-view-mode="dual"] .col-notes, + body[data-view-mode="dual"] .col-review_memo, + body[data-view-mode="dual"] .col-review_reason { + width: 13%; + } + @media (max-width: 720px) { .voucher-topbar, .panel-header, @@ -701,13 +990,9 @@ align-items: flex-start; } - .status-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .status-card { - min-width: 0; + .status-card[data-status="voucher_matched"], + .status-card[data-status="erp_voucher_matched"] { + margin-left: 0; } .cell-ellipsis { @@ -763,9 +1048,6 @@ -
- -
{% if wehago_compare.last_action %} @@ -825,11 +1107,11 @@ -
+
-
+
@@ -960,10 +1242,88 @@ return classes.join(' '); }; + const getColumnClass = (field) => `col-${String(field || '').replace(/[^a-zA-Z0-9_]/g, '_')}`; + + const renderVoucherGroups = (container, payload, append = false, statusKey = '') => { + if (!container) return; + const groups = payload.groups || []; + if (!groups.length && !append) { + container.innerHTML = '
조건에 맞는 항목이 없습니다.
'; + return; + } + const lineColumns = [ + ['fiscal_year', '연도'], + ['ledger_date', 'WEHAGO 일자'], + ['voucher_no', '전표번호'], + ['draft_no', '가전표번호'], + ['ledger_account_name', 'WEHAGO 계정'], + ['voucher_account_name', 'ERP 계정'], + ['ledger_vendor', 'WEHAGO 거래처'], + ['voucher_vendor', 'ERP 거래처'], + ['ledger_debit', 'WEHAGO 차변'], + ['ledger_credit', 'WEHAGO 대변'], + ['voucher_debit', 'ERP 차변'], + ['voucher_credit', 'ERP 대변'], + ['ledger_desc', 'WEHAGO 적요'], + ['voucher_desc', 'ERP 적요'], + ]; + const formatValue = (field, raw) => { + if (field === 'fiscal_year') return String(raw ?? ''); + if (typeof raw === 'number') { + return Number.isInteger(raw) + ? raw.toLocaleString() + : raw.toLocaleString(undefined, { maximumFractionDigits: 2 }); + } + return String(raw ?? ''); + }; + const lineHead = lineColumns.map(([field, label]) => `${escapeHtml(label)}`).join(''); + const lineBody = groups.map((group, groupIndex) => { + const rows = Array.isArray(group.rows) ? group.rows : []; + return rows.map((row, rowIndex) => { + const rowClasses = []; + if (rowIndex === 0) { + rowClasses.push('voucher-table-group-start'); + } + if (row.matched_case === 'boundary_excluded' || row.boundary_excluded === '1') { + rowClasses.push('boundary-excluded-row'); + } else if (row.matched_case === 'bank_payable' || row.review_reason === 'BANK_PAYABLE_MATCH') { + rowClasses.push('bank-payable-case'); + } + const cells = lineColumns.map(([field]) => { + const display = formatValue(field, row[field]); + return `${escapeHtml(display)}`; + }).join(''); + return `${cells}`; + }).join(''); + }).join(''); + const tableHtml = ` + + ${lineHead} + ${lineBody} +
+ `; + if (!append || !container.querySelector('.voucher-group-lines')) { + container.innerHTML = `
${tableHtml}
`; + } else { + const tbody = container.querySelector('.voucher-group-lines tbody'); + const temp = document.createElement('tbody'); + temp.innerHTML = lineBody; + if (tbody) { + tbody.insertAdjacentHTML('beforeend', temp.innerHTML); + } else { + container.innerHTML = `
${tableHtml}
`; + } + } + }; + const renderTable = (container, payload, monoFields = [], append = false, statusKey = '') => { if (!container) { return; } + if (statusKey === 'voucher_matched' || statusKey === 'erp_voucher_matched' || statusKey === 'voucher_unmatched' || statusKey === 'voucher_recheck') { + renderVoucherGroups(container, payload, append, statusKey); + return; + } const columns = payload.columns || []; const rows = payload.rows || []; if (!rows.length && !append) { @@ -974,7 +1334,7 @@ const includePairCheckbox = statusKey === 'ledger_only' || statusKey === 'voucher_only'; const head = [ (includeReviewCheckbox || includePairCheckbox) ? '' : '', - ...columns.map(([, label]) => `${escapeHtml(label)}`), + ...columns.map(([field, label]) => `${escapeHtml(label)}`), ].join(''); const body = rows.map((row) => { const reviewKey = row.review_key || ''; @@ -1006,15 +1366,23 @@ display = String(raw); } const className = getCellClass(field, monoFields); - return `${escapeHtml(display)}`; + return `${escapeHtml(display)}`; + }).join(''); + const checkboxCell = includeReviewCheckbox + ? `` + : includePairCheckbox + ? `` + : ''; + const rowClasses = []; + if (row.matched_case === 'bank_payable' || row.review_reason === 'BANK_PAYABLE_MATCH') { + rowClasses.push('bank-payable-case'); + } + if (row.matched_case === 'boundary_excluded' || row.boundary_excluded === '1') { + rowClasses.push('boundary-excluded-row'); + } + const rowClass = rowClasses.length ? ` class="${rowClasses.join(' ')}"` : ''; + return `${checkboxCell}${cells}`; }).join(''); - const checkboxCell = includeReviewCheckbox - ? `` - : includePairCheckbox - ? `` - : ''; - return `${checkboxCell}${cells}`; - }).join(''); if (!append || !container.querySelector('table')) { container.innerHTML = `${head}${body}
`; } else { @@ -1043,7 +1411,7 @@ } }; - const setMeta = (key, totalCount, shownCount, notice = '') => { + const setMeta = (key, totalCount, shownCount, notice = '', stats = {}) => { const target = document.querySelector(`[data-result-meta="${key}"]`); if (target) { const countText = `조회 결과 ${Number(totalCount || 0).toLocaleString()}건`; @@ -1051,7 +1419,15 @@ ? ` / 현재 ${Number(shownCount || 0).toLocaleString()}건` : ''; const noticeText = notice ? ` / ${notice}` : ''; - target.textContent = `${countText}${shownText}${noticeText}`; + const bankPayableCount = Number(stats.bank_payable_case_count || 0); + const bankPayableText = key === 'matched' && bankPayableCount + ? ` ` + : ''; + const boundaryCount = Number(stats.boundary_excluded_count || 0); + const boundaryText = key === 'ledger_only' && boundaryCount + ? ` ` + : ''; + target.innerHTML = `${escapeHtml(`${countText}${shownText}${noticeText}`)}${bankPayableText}${boundaryText}`; } }; @@ -1064,6 +1440,43 @@ return payload; }; + const applySummaryPayload = (payload) => { + const sections = Array.isArray(payload?.metric_sections) ? payload.metric_sections : []; + sections.forEach((section) => { + const card = document.querySelector(`.status-card[data-status="${escapeSelectorValue(section.key || '')}"]`); + const valueNode = card?.querySelector('.status-value'); + if (valueNode) { + valueNode.textContent = Number(section.count || 0).toLocaleString(); + } + }); + const lastAction = payload?.last_action; + if (lastActionMeta) { + if (lastAction && lastAction.id) { + lastActionMeta.textContent = `최근 작업: ${lastAction.action_type} / ${Number(lastAction.count || 0).toLocaleString()}건`; + } else { + lastActionMeta.textContent = '최근 작업: 없음'; + } + } + if (undoLastActionBtn) { + undoLastActionBtn.disabled = !(lastAction && lastAction.id); + } + }; + + const loadDashboardSummary = async (attempt = 0) => { + const params = new URLSearchParams(); + if (startYearValue) params.set('start_year', startYearValue); + if (endYearValue) params.set('end_year', endYearValue); + try { + const payload = await fetchJson(`/wehago-compare/api/summary?${params.toString()}`); + applySummaryPayload(payload); + if (payload?.pending && attempt < 6) { + window.setTimeout(() => loadDashboardSummary(attempt + 1), 1200); + } + } catch (_error) { + // Keep the page usable even if the summary loads later or fails once. + } + }; + const buildQuery = (form, extra = {}) => { const params = new URLSearchParams(); if (startYearValue) params.set('start_year', startYearValue); @@ -1113,6 +1526,11 @@ selectedPairKey: '', }; + const syncRecommendationPanelState = () => { + if (!page || !recommendPanel) return; + page.classList.toggle('has-recommend-panel-open', recommendPanel.classList.contains('active')); + }; + const refreshLastActionMeta = async () => { if (!lastActionMeta || !undoLastActionBtn) return; try { @@ -1162,7 +1580,7 @@ const initStatusSuggest = (form, type) => { if (!form) return; const status = form.dataset.status || ''; - if (!['matched', 'amount_mismatch', 'ledger_only', 'voucher_only'].includes(status)) return; + if (!['matched', 'amount_mismatch', 'ledger_only', 'voucher_only', 'voucher_matched', 'erp_voucher_matched', 'voucher_unmatched', 'voucher_recheck'].includes(status)) return; const inputName = type === 'account' ? 'account_keyword' : type === 'wehago_account' @@ -1359,6 +1777,8 @@ vendor_keyword: '거래처', amount_keyword: '금액', desc_keyword: '적요', + review_reason: '검증근거', + boundary_excluded: '연초/연말 대체·이월', }; const getFormFieldValues = (form) => { @@ -1373,6 +1793,14 @@ return values; }; + const syncFormWithActiveFilters = (statusKey, form) => { + if (!form) return; + const active = cumulativeFiltersByStatus.get(statusKey) || {}; + form.querySelectorAll('input[name]').forEach((input) => { + input.value = active[input.name] || ''; + }); + }; + const updateCumulativeFilter = (statusKey, fieldName, value, checked = true) => { const current = { ...(cumulativeFiltersByStatus.get(statusKey) || {}) }; if (checked && String(value || '').trim()) { @@ -1457,6 +1885,7 @@ 계정유사도 거래처유사도 신뢰도 + 검토 연도 전표번호 일자 @@ -1489,6 +1918,7 @@ ${escapeHtml((Number(pair.account_similarity || 0) * 100).toFixed(1) + '%')} ${escapeHtml((Number(pair.vendor_similarity || 0) * 100).toFixed(1) + '%')} ${escapeHtml(pair.confidence_level)} + ${escapeHtml(pair.substitution_hint || pair.reason || '')} ${escapeHtml(ledger.fiscal_year || voucher.fiscal_year || '')} ${escapeHtml(ledger.voucher_no || '')} ${escapeHtml(ledger.ledger_date || '')} @@ -1566,12 +1996,14 @@ if (!recommendPanel) return; setRecommendMode(mode); recommendPanel.classList.add('active'); + syncRecommendationPanelState(); recommendPanel.scrollIntoView({ behavior: 'smooth', block: 'start' }); }; const closeRecommendationPanel = () => { if (!recommendPanel) return; recommendPanel.classList.remove('active'); + syncRecommendationPanelState(); }; const getSelectedSourceForIndividual = (sourceStatus) => { @@ -1699,7 +2131,7 @@ }; const buildStatusQuery = (form, statusKey, extra = {}) => { - const selected = form ? (cumulativeFiltersByStatus.get(statusKey) || {}) : {}; + const selected = cumulativeFiltersByStatus.get(statusKey) || {}; const mapped = new URLSearchParams(); if (startYearValue) mapped.set('start_year', startYearValue); if (endYearValue) mapped.set('end_year', endYearValue); @@ -1712,9 +2144,11 @@ const account = String(selected.account_keyword || '').trim(); const vendor = String(selected.vendor_keyword || '').trim(); const amount = String(selected.amount_keyword || '').trim(); + const boundaryExcluded = String(selected.boundary_excluded || '').trim(); if (account) mapped.set('wehago_account', account); if (vendor) mapped.set('wehago_vendor', vendor); if (amount) mapped.set('wehago_amount', amount); + if (boundaryExcluded) mapped.set('boundary_excluded', boundaryExcluded); } else if (statusKey === 'voucher_only') { const account = String(selected.account_keyword || '').trim(); const vendor = String(selected.vendor_keyword || '').trim(); @@ -1722,6 +2156,22 @@ if (account) mapped.set('erp_account', account); if (vendor) mapped.set('erp_vendor', vendor); if (amount) mapped.set('erp_amount', amount); + } else if (statusKey === 'voucher_matched' || statusKey === 'erp_voucher_matched' || statusKey === 'voucher_unmatched' || statusKey === 'voucher_recheck') { + const account = String(selected.account_keyword || '').trim(); + const vendor = String(selected.vendor_keyword || '').trim(); + const amount = String(selected.amount_keyword || '').trim(); + if (account) { + mapped.set('wehago_account', account); + mapped.set('erp_account', account); + } + if (vendor) { + mapped.set('wehago_vendor', vendor); + mapped.set('erp_vendor', vendor); + } + if (amount) { + mapped.set('wehago_amount', amount); + mapped.set('erp_amount', amount); + } } else { for (const [key, value] of Object.entries(selected)) { if (String(value).trim()) { @@ -1744,8 +2194,7 @@ if (current.loading) return; if (!append && form) { const selected = getFormFieldValues(form); - const merged = { ...(cumulativeFiltersByStatus.get(statusKey) || {}), ...selected }; - cumulativeFiltersByStatus.set(statusKey, merged); + cumulativeFiltersByStatus.set(statusKey, selected); renderActiveFilterCards(statusKey); } const nextOffset = append ? (current.nextOffset || 0) : 0; @@ -1767,7 +2216,7 @@ form, shownCount, }); - setMeta(statusKey, payload.total_count, shownCount, payload.notice); + setMeta(statusKey, payload.total_count, shownCount, payload.notice, payload); setLoadMoreState(statusKey, payload.has_more, false); } catch (error) { if (!append) { @@ -1796,6 +2245,7 @@ panel.classList.add('active'); card.setAttribute('aria-expanded', 'true'); const form = panel.querySelector('form[data-filter-kind="status"]'); + syncFormWithActiveFilters(statusKey, form); loadStatusRows(statusKey, form, false); } }); @@ -1818,6 +2268,23 @@ }); document.addEventListener('click', (event) => { + const caseButton = event.target.closest('[data-case-filter="bank_payable"], [data-case-filter="boundary_excluded"]'); + if (caseButton) { + const caseFilter = caseButton.dataset.caseFilter || ''; + const statusKey = caseFilter === 'boundary_excluded' ? 'ledger_only' : 'matched'; + const current = { ...(cumulativeFiltersByStatus.get(statusKey) || {}) }; + if (caseFilter === 'boundary_excluded') { + current.boundary_excluded = '1'; + } else { + current.review_reason = 'BANK_PAYABLE_MATCH'; + } + cumulativeFiltersByStatus.set(statusKey, current); + renderActiveFilterCards(statusKey); + const form = document.querySelector(`form[data-filter-kind="status"][data-status="${statusKey}"]`); + syncFormWithActiveFilters(statusKey, form); + loadStatusRows(statusKey, null, false); + return; + } const removeButton = event.target.closest('[data-remove-active-filter]'); if (!removeButton) return; const statusKey = removeButton.dataset.removeActiveFilter || ''; @@ -2105,6 +2572,7 @@ const startYear = document.getElementById('start_year'); const endYear = document.getElementById('end_year'); + syncRecommendationPanelState(); const syncYearOptions = () => { if (!startYear || !endYear) { return; @@ -2126,6 +2594,9 @@ endYear.addEventListener('change', syncYearOptions); syncYearOptions(); } + window.setTimeout(() => { + loadDashboardSummary(); + }, 400); refreshLastActionMeta(); })(); diff --git a/wehago_compare.py b/wehago_compare.py index 4c5fb4b..76780d3 100644 --- a/wehago_compare.py +++ b/wehago_compare.py @@ -7,6 +7,7 @@ import threading import time from dataclasses import dataclass from datetime import date, datetime +from difflib import SequenceMatcher from functools import lru_cache from pathlib import Path from typing import Any, Iterable @@ -54,11 +55,17 @@ LEDGER_HEADERS = [ "계정명", ] +CONSOLIDATED_LEDGER_PREFIX_HEADERS = ["계정코드", "계정명", "원본파일", "원본시트", "원본행번호"] + STATUS_META = [ ("matched", "Matched", "WEHAGO 기준으로 정상 매칭된 항목"), ("ledger_only", "Unmatched", "WEHAGO에는 있으나 ERP와 연결되지 않은 항목"), ("voucher_only", "ERP Unmatched", "ERP 기준으로 매치되지 않은 항목"), ("amount_mismatch", "Recheck", "전표번호는 같지만 금액이 달라 다시 확인이 필요한 항목"), + ("voucher_matched", "WEHAGO", "WEHAGO 전표 기준으로 매칭된 결과"), + ("erp_voucher_matched", "Voucher", "Hanmac ERP 전표 기준으로 매칭된 결과"), + ("voucher_unmatched", "WEHAGO Unmatched", "WEHAGO 전표 기준으로 매칭되지 않은 결과"), + ("voucher_recheck", "WEHAGO Recheck", "WEHAGO 전표 기준으로 재검토가 필요한 결과"), ] DETAIL_COLUMN_MAP = { @@ -101,6 +108,7 @@ DETAIL_COLUMN_MAP = { ("ledger_credit", "대변"), ("voucher_debit", "ERP 차변"), ("voucher_credit", "ERP 대변"), + ("substitution_hint", "대체 검토"), ("ledger_desc", "WEHAGO 적요"), ("voucher_desc", "ERP 적요"), ], @@ -114,6 +122,80 @@ DETAIL_COLUMN_MAP = { ("voucher_credit", "대변"), ("voucher_desc", "적요"), ], + "voucher_matched": [ + ("fiscal_year", "연도"), + ("ledger_date", "WEHAGO 일자"), + ("proof_date", "ERP 증빙일자"), + ("voucher_no", "전표번호"), + ("draft_no", "가전표번호"), + ("ledger_row_count", "WEHAGO 행수"), + ("voucher_row_count", "ERP 행수"), + ("ledger_debit", "WEHAGO 차변"), + ("ledger_credit", "WEHAGO 대변"), + ("voucher_debit", "ERP 차변"), + ("voucher_credit", "ERP 대변"), + ("ledger_accounts", "WEHAGO 계정"), + ("voucher_accounts", "ERP 계정"), + ("ledger_vendors", "WEHAGO 거래처"), + ("voucher_vendors", "ERP 거래처"), + ("review_reason", "검증근거"), + ], + "voucher_unmatched": [ + ("fiscal_year", "연도"), + ("status_label", "구분"), + ("ledger_date", "WEHAGO 일자"), + ("proof_date", "ERP 증빙일자"), + ("voucher_no", "전표번호"), + ("draft_no", "가전표번호"), + ("ledger_row_count", "WEHAGO 행수"), + ("voucher_row_count", "ERP 행수"), + ("ledger_debit", "WEHAGO 차변"), + ("ledger_credit", "WEHAGO 대변"), + ("voucher_debit", "ERP 차변"), + ("voucher_credit", "ERP 대변"), + ("ledger_accounts", "WEHAGO 계정"), + ("voucher_accounts", "ERP 계정"), + ("ledger_vendors", "WEHAGO 거래처"), + ("voucher_vendors", "ERP 거래처"), + ("review_reason", "검토사유"), + ], + "voucher_recheck": [ + ("fiscal_year", "연도"), + ("status_label", "구분"), + ("ledger_date", "WEHAGO 일자"), + ("proof_date", "ERP 증빙일자"), + ("voucher_no", "전표번호"), + ("draft_no", "가전표번호"), + ("ledger_row_count", "WEHAGO 행수"), + ("voucher_row_count", "ERP 행수"), + ("ledger_debit", "WEHAGO 차변"), + ("ledger_credit", "WEHAGO 대변"), + ("voucher_debit", "ERP 차변"), + ("voucher_credit", "ERP 대변"), + ("ledger_accounts", "WEHAGO 계정"), + ("voucher_accounts", "ERP 계정"), + ("ledger_vendors", "WEHAGO 거래처"), + ("voucher_vendors", "ERP 거래처"), + ("review_reason", "검토사유"), + ], + "erp_voucher_matched": [ + ("fiscal_year", "연도"), + ("ledger_date", "WEHAGO 일자"), + ("proof_date", "ERP 증빙일자"), + ("voucher_no", "전표번호"), + ("draft_no", "가전표번호"), + ("ledger_row_count", "WEHAGO 행수"), + ("voucher_row_count", "ERP 행수"), + ("ledger_debit", "WEHAGO 차변"), + ("ledger_credit", "WEHAGO 대변"), + ("voucher_debit", "ERP 차변"), + ("voucher_credit", "ERP 대변"), + ("ledger_accounts", "WEHAGO 계정"), + ("voucher_accounts", "ERP 계정"), + ("ledger_vendors", "WEHAGO 거래처"), + ("voucher_vendors", "ERP 거래처"), + ("review_reason", "검증근거"), + ], } WEHAGO_COLUMNS = [ @@ -146,10 +228,47 @@ _SUGGEST_CACHE: dict[str, dict[str, Any]] = {} _SUGGEST_CACHE_TTL_SEC = 20 _STATUS_ROWS_CACHE: dict[str, dict[str, Any]] = {} _STATUS_ROWS_CACHE_TTL_SEC = 300 +_VOUCHER_SECTION_CACHE: dict[str, dict[str, Any]] = {} +_VOUCHER_SECTION_CACHE_TTL_SEC = 300 _PAIR_RECOMMEND_CACHE: dict[str, dict[str, Any]] = {} _PAIR_RECOMMEND_CACHE_TTL_SEC = 120 _STATUS_CACHE_WARMING: set[str] = set() _STATUS_CACHE_WARMING_LOCK = threading.Lock() +_METRIC_COUNTS_WARMING: set[str] = set() +_METRIC_COUNTS_WARMING_LOCK = threading.Lock() +ENABLE_GENERATED_RECHECK_CANDIDATES = False +RECHECK_RESOLUTION_POLICY_VERSION = "recheck-flex-strong-conditions-v13-cross-year-and-match-validation" +METRIC_COUNT_CACHE_VERSION = "voucher-summary-v5" +_PAIR_RECOMMEND_PERSIST_TTL_SEC = 1800 +_PAIR_RECOMMEND_JOB_EVENT = threading.Event() +_PAIR_RECOMMEND_WORKER_LOCK = threading.Lock() +_PAIR_RECOMMEND_WORKER_STARTED = False +PAIR_RECOMMEND_POLICY_VERSION = "date-window-8m-no-month-token-gate-v1" +PAIR_RECOMMEND_DATE_WINDOW_MONTHS = 8 + + +@lru_cache(maxsize=8) +def _file_content_signature(path_text: str, mtime_ns: int, size: int) -> str: + path = Path(path_text) + try: + return hashlib.sha1(path.read_bytes()).hexdigest() + except Exception: + return f"{mtime_ns}:{size}" + + +def _current_logic_signature() -> str: + module_path = Path(__file__).resolve() + stat = module_path.stat() + file_sig = _file_content_signature(str(module_path), int(stat.st_mtime_ns), int(stat.st_size)) + return "|".join( + [ + METRIC_COUNT_CACHE_VERSION, + RECHECK_RESOLUTION_POLICY_VERSION, + PAIR_RECOMMEND_POLICY_VERSION, + "generated-recheck-on" if ENABLE_GENERATED_RECHECK_CANDIDATES else "generated-recheck-off", + file_sig, + ] + ) def _fast_metric_counts_from_result_files(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int]: @@ -225,6 +344,362 @@ def _fast_metric_counts_from_result_files(conn: Any, start_year: int | None, end return counts +def _empty_metric_counts() -> dict[str, int]: + return {status_key: 0 for status_key, _, _ in STATUS_META} + + +def _metric_counts_signature(conn: Any, start_year: int | None, end_year: int | None) -> str: + return "|".join( + [ + _current_logic_signature(), + _build_bundle_signature(start_year, end_year), + _build_db_state_signature(conn, start_year, end_year), + ] + ) + + +def _load_metric_counts_cache(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int] | None: + if start_year is None or end_year is None: + return _empty_metric_counts() + signature = _metric_counts_signature(conn, start_year, end_year) + cached = conn.execute( + text( + """ + SELECT counts_json + FROM wehago_metric_count_cache + WHERE start_year = :start_year + AND end_year = :end_year + AND signature = :signature + LIMIT 1 + """ + ), + {"start_year": start_year, "end_year": end_year, "signature": signature}, + ).first() + if not cached or not cached[0]: + return None + try: + payload = json.loads(str(cached[0])) + if isinstance(payload, dict): + return {status_key: int(payload.get(status_key, 0) or 0) for status_key, _, _ in STATUS_META} + except Exception: + return None + return None + + +def _load_latest_metric_counts_cache_any_signature( + conn: Any, + start_year: int | None, + end_year: int | None, +) -> dict[str, int] | None: + if start_year is None or end_year is None: + return _empty_metric_counts() + cached = conn.execute( + text( + """ + SELECT counts_json + FROM wehago_metric_count_cache + WHERE start_year = :start_year + AND end_year = :end_year + ORDER BY updated_at DESC, created_at DESC + LIMIT 1 + """ + ), + {"start_year": start_year, "end_year": end_year}, + ).first() + if not cached or not cached[0]: + return None + try: + payload = json.loads(str(cached[0])) + if isinstance(payload, dict): + return {status_key: int(payload.get(status_key, 0) or 0) for status_key, _, _ in STATUS_META} + except Exception: + return None + return None + + +def _store_metric_counts_cache( + conn: Any, + start_year: int | None, + end_year: int | None, + counts: dict[str, int], +) -> None: + if start_year is None or end_year is None: + return + signature = _metric_counts_signature(conn, start_year, end_year) + conn.execute( + text( + """ + INSERT INTO wehago_metric_count_cache ( + start_year, end_year, signature, counts_json, created_at, updated_at + ) VALUES ( + :start_year, :end_year, :signature, :counts_json, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + ON CONFLICT(start_year, end_year, signature) DO UPDATE SET + counts_json = excluded.counts_json, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "start_year": start_year, + "end_year": end_year, + "signature": signature, + "counts_json": json.dumps(counts, ensure_ascii=False), + }, + ) + + +def _fallback_metric_counts_from_db(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int]: + year_sql = build_year_filter_sql("c.fiscal_year") + return { + "matched": int( + conn.execute( + text( + """ + SELECT COUNT(*) + FROM wehago_ledger_rows l + JOIN wehago_comparison_results c + ON c.fiscal_year = l.fiscal_year AND c.voucher_no = l.compare_voucher_no + WHERE c.status = 'matched' + AND """ + + year_sql + ), + {"start_year": start_year, "end_year": end_year}, + ).scalar_one() + or 0 + ), + "ledger_only": int( + conn.execute( + text( + """ + SELECT COUNT(*) + FROM wehago_ledger_rows l + JOIN wehago_comparison_results c + ON c.fiscal_year = l.fiscal_year AND c.voucher_no = l.compare_voucher_no + WHERE c.status = 'ledger_only' + AND """ + + year_sql + ), + {"start_year": start_year, "end_year": end_year}, + ).scalar_one() + or 0 + ), + "amount_mismatch": int( + conn.execute( + text( + """ + SELECT COUNT(*) + FROM wehago_ledger_rows l + JOIN wehago_comparison_results c + ON c.fiscal_year = l.fiscal_year AND c.voucher_no = l.compare_voucher_no + WHERE c.status = 'amount_mismatch' + AND """ + + year_sql + ), + {"start_year": start_year, "end_year": end_year}, + ).scalar_one() + or 0 + ), + "voucher_only": int( + conn.execute( + text( + """ + SELECT COUNT(*) + FROM wehago_voucher_rows v + JOIN wehago_comparison_results c + ON c.fiscal_year = v.fiscal_year AND c.voucher_no = v.compare_voucher_no + WHERE c.status = 'voucher_only' + AND """ + + year_sql + ), + {"start_year": start_year, "end_year": end_year}, + ).scalar_one() + or 0 + ), + } + + +def _voucher_metric_counts_from_db(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int]: + year_sql = build_year_filter_sql("fiscal_year") + matched_count = int( + conn.execute( + text( + """ + SELECT COUNT(*) + FROM wehago_comparison_results + WHERE status = 'matched' + AND """ + + year_sql + ), + {"start_year": start_year, "end_year": end_year}, + ).scalar_one() + or 0 + ) + unmatched_count = int( + conn.execute( + text( + """ + SELECT COUNT(*) + FROM wehago_comparison_results + WHERE status = 'ledger_only' + AND """ + + year_sql + ), + {"start_year": start_year, "end_year": end_year}, + ).scalar_one() + or 0 + ) + recheck_count = int( + conn.execute( + text( + """ + SELECT COUNT(*) + FROM wehago_comparison_results + WHERE status = 'amount_mismatch' + AND """ + + year_sql + ), + {"start_year": start_year, "end_year": end_year}, + ).scalar_one() + or 0 + ) + return { + "voucher_matched": matched_count, + "erp_voucher_matched": matched_count, + "voucher_unmatched": unmatched_count, + "voucher_recheck": recheck_count, + } + + +def _resolved_metric_counts(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int]: + counts = _empty_metric_counts() + if start_year is None or end_year is None: + return counts + if start_year != end_year: + rows_by_status: dict[str, list[dict[str, Any]]] = { + "matched": [], + "ledger_only": [], + "amount_mismatch": [], + "voucher_only": [], + } + for year in range(start_year, end_year + 1): + sections = _get_or_create_year_resolved_sections(conn, year) + if not sections: + db_counts = _fallback_metric_counts_from_db(conn, year, year) + for status_key, value in db_counts.items(): + counts[status_key] += value + continue + for status_key in rows_by_status: + rows_by_status[status_key].extend(sections.get(status_key, {}).get("rows", [])) + section_payload = { + status_key: { + "rows": list(rows), + "count": len(rows), + "columns": DETAIL_COLUMN_MAP[status_key], + } + for status_key, rows in rows_by_status.items() + } + section_payload = _promote_cross_year_auto_matches(section_payload) + for status_key in counts: + counts[status_key] = len(section_payload.get(status_key, {}).get("rows", [])) + voucher_sections = _build_voucher_sections_from_rows_by_status( + { + "matched": list(section_payload.get("matched", {}).get("rows", [])), + "ledger_only": list(section_payload.get("ledger_only", {}).get("rows", [])), + "amount_mismatch": list(section_payload.get("amount_mismatch", {}).get("rows", [])), + "voucher_only": list(section_payload.get("voucher_only", {}).get("rows", [])), + } + ) + counts["voucher_matched"] = len(voucher_sections.get("voucher_matched", [])) + counts["erp_voucher_matched"] = len(voucher_sections.get("erp_voucher_matched", [])) + counts["voucher_unmatched"] = len(voucher_sections.get("voucher_unmatched", [])) + counts["voucher_recheck"] = len(voucher_sections.get("voucher_recheck", [])) + if not counts["voucher_matched"] and not counts["erp_voucher_matched"] and not counts["voucher_unmatched"] and not counts["voucher_recheck"] and any(counts.get(key) for key in ("matched", "ledger_only", "voucher_only", "amount_mismatch")): + counts.update(_voucher_metric_counts_from_db(conn, start_year, end_year)) + return counts + rows_by_status: dict[str, list[dict[str, Any]]] = { + "matched": [], + "ledger_only": [], + "amount_mismatch": [], + "voucher_only": [], + } + for year in range(start_year, end_year + 1): + sections = _get_or_create_year_resolved_sections(conn, year) + if not sections: + db_counts = _fallback_metric_counts_from_db(conn, year, year) + for status_key, value in db_counts.items(): + counts[status_key] += value + continue + for status_key in ("matched", "ledger_only", "amount_mismatch", "voucher_only"): + counts[status_key] += len(sections.get(status_key, {}).get("rows", [])) + rows_by_status[status_key].extend(sections.get(status_key, {}).get("rows", [])) + voucher_sections = _build_voucher_sections_from_rows_by_status(rows_by_status) + counts["voucher_matched"] = len(voucher_sections.get("voucher_matched", [])) + counts["erp_voucher_matched"] = len(voucher_sections.get("erp_voucher_matched", [])) + counts["voucher_unmatched"] = len(voucher_sections.get("voucher_unmatched", [])) + counts["voucher_recheck"] = len(voucher_sections.get("voucher_recheck", [])) + if not counts["voucher_matched"] and not counts["erp_voucher_matched"] and not counts["voucher_unmatched"] and not counts["voucher_recheck"] and any(counts.get(key) for key in ("matched", "ledger_only", "voucher_only", "amount_mismatch")): + counts.update(_voucher_metric_counts_from_db(conn, start_year, end_year)) + return counts + + +def get_dashboard_metric_counts(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int]: + cached = _load_metric_counts_cache(conn, start_year, end_year) + if cached is not None: + return cached + counts = _resolved_metric_counts(conn, start_year, end_year) + _store_metric_counts_cache(conn, start_year, end_year, counts) + return counts + + +def get_dashboard_metric_counts_nonblocking( + engine: Any, + start_year: int | None, + end_year: int | None, +) -> tuple[dict[str, int], bool]: + init_wehago_compare_db(engine) + with engine.begin() as conn: + cached = _load_metric_counts_cache(conn, start_year, end_year) + if cached is not None: + return cached, False + fallback = _load_latest_metric_counts_cache_any_signature(conn, start_year, end_year) + return fallback or _empty_metric_counts(), fallback is None + + +def _warm_metric_counts_worker(engine: Any, start_year: int, end_year: int, warm_key: str) -> None: + try: + init_wehago_compare_db(engine) + with engine.begin() as conn: + counts = _resolved_metric_counts(conn, start_year, end_year) + _store_metric_counts_cache(conn, start_year, end_year, counts) + finally: + with _METRIC_COUNTS_WARMING_LOCK: + _METRIC_COUNTS_WARMING.discard(warm_key) + + +def warm_metric_counts_async(engine: Any, start_year: int | None, end_year: int | None) -> None: + if start_year is None or end_year is None: + return + if start_year > end_year: + start_year, end_year = end_year, start_year + init_wehago_compare_db(engine) + with engine.begin() as conn: + signature = _metric_counts_signature(conn, start_year, end_year) + if _load_metric_counts_cache(conn, start_year, end_year) is not None: + return + warm_key = f"{start_year}:{end_year}:{signature}" + with _METRIC_COUNTS_WARMING_LOCK: + if warm_key in _METRIC_COUNTS_WARMING: + return + _METRIC_COUNTS_WARMING.add(warm_key) + worker = threading.Thread( + target=_warm_metric_counts_worker, + args=(engine, start_year, end_year, warm_key), + daemon=True, + name=f"wehago-metric-counts-{start_year}-{end_year}", + ) + worker.start() + + @dataclass class WehagoImportSummary: scanned_files: int = 0 @@ -235,6 +710,26 @@ class WehagoImportSummary: comparison_rows: int = 0 +@dataclass(slots=True) +class MatchRowFeatures: + row: dict[str, Any] + row_key: str + account_code: str + account_name: str + vendor_name: str + desc_text: str + account_tokens: set[str] + vendor_tokens: set[str] + desc_tokens: set[str] + month_tokens: set[int] + date_value: date | None + debit_amount: float + credit_amount: float + match_amount: float + primary_side: str + positive_amounts: tuple[float, ...] + + def clean(value: Any) -> str: return "" if value is None else str(value).strip() @@ -249,6 +744,61 @@ def normalize_voucher_no(value: Any) -> str: return clean(value).replace(" ", "") +def _format_compare_date_key(value: Any) -> str: + parsed = parse_excel_date(value) + if not parsed: + return "" + return parsed.replace("-", "") + + +def normalize_compare_voucher_no(value: Any, date_value: Any = None) -> str: + voucher_no = normalize_voucher_no(value) + if not voucher_no: + return "" + full_match = re.match(r"^11-(\d{8})-(\d+)-\d+$", voucher_no) + if full_match: + return f"{full_match.group(1)}-{int(full_match.group(2)):05d}" + draft_match = re.match(r"^11-(\d{8})-[^-]+-(\d+)-\d+$", voucher_no) + if draft_match: + return f"{draft_match.group(1)}-{int(draft_match.group(2)):05d}" + if re.fullmatch(r"\d+", voucher_no): + date_key = _format_compare_date_key(date_value) + if date_key: + return f"{date_key}-{int(voucher_no):05d}" + return voucher_no + + +def _voucher_no_year(value: Any) -> int | None: + voucher_no = normalize_voucher_no(value) + match = re.match(r"^11-((?:19|20)\d{2})\d{4}-", voucher_no) + if match: + return int(match.group(1)) + return None + + +def choose_effective_erp_voucher_no(confirmed_no: Any, draft_no: Any, year_hint: int | None = None) -> str: + confirmed = normalize_voucher_no(confirmed_no) + draft = normalize_voucher_no(draft_no) + if year_hint: + confirmed_year = _voucher_no_year(confirmed) + draft_year = _voucher_no_year(draft) + if confirmed and confirmed_year == year_hint: + return confirmed + if draft and draft_year == year_hint: + return draft + return confirmed or draft + + +def infer_voucher_fiscal_year( + confirmed_no: Any, + draft_no: Any, + proof_date: Any, + year_hint: int | None, +) -> int | None: + effective_no = choose_effective_erp_voucher_no(confirmed_no, draft_no, year_hint) + return _voucher_no_year(effective_no) or year_from_date_text(proof_date) or year_hint + + def parse_amount(value: Any) -> float: text_value = clean(value).replace(",", "") if not text_value: @@ -307,6 +857,12 @@ def detect_file_kind(path: Path) -> tuple[str | None, list[str], str]: return "voucher", header, sheet.title if header[: len(LEDGER_HEADERS)] == LEDGER_HEADERS: return "ledger", header, sheet.title + if ( + header[: len(CONSOLIDATED_LEDGER_PREFIX_HEADERS)] == CONSOLIDATED_LEDGER_PREFIX_HEADERS + and header[len(CONSOLIDATED_LEDGER_PREFIX_HEADERS) : len(CONSOLIDATED_LEDGER_PREFIX_HEADERS) + len(LEDGER_HEADERS)] + == LEDGER_HEADERS + ): + return "ledger", header, sheet.title return None, header, sheet.title @@ -358,7 +914,7 @@ def infer_year_hint(path: Path, file_kind: str, sample_rows: list[tuple[Any, ... 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 "") + voucher_no = normalize_compare_voucher_no(row[10] if len(row) > 10 else row[1] if len(row) > 1 else "") match = re.search(r"-(\d{4})\d{4}-", voucher_no) if match: return int(match.group(1)) @@ -581,6 +1137,63 @@ def init_wehago_compare_db(engine: Any) -> None: """ ) ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS wehago_pair_recommend_cache ( + cache_key TEXT PRIMARY KEY, + start_year INTEGER, + end_year INTEGER, + ledger_voucher_no TEXT NOT NULL DEFAULT '', + ledger_review_reason TEXT NOT NULL DEFAULT '', + voucher_voucher_no TEXT NOT NULL DEFAULT '', + voucher_review_reason TEXT NOT NULL DEFAULT '', + row_limit INTEGER NOT NULL DEFAULT 300, + payload_json TEXT NOT NULL DEFAULT '', + pair_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_accessed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS wehago_metric_count_cache ( + start_year INTEGER NOT NULL, + end_year INTEGER NOT NULL, + signature TEXT NOT NULL, + counts_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (start_year, end_year, signature) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS wehago_background_jobs ( + job_key TEXT PRIMARY KEY, + job_type TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + state TEXT NOT NULL DEFAULT 'queued', + error_message TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + started_at TEXT NOT NULL DEFAULT '', + finished_at TEXT NOT NULL DEFAULT '' + ) + """ + ) + ) + ensure_column(conn, "wehago_result_row_cache", "resolved_state_signature", "TEXT NOT NULL DEFAULT ''") + ensure_column(conn, "wehago_result_row_cache", "parse_state_signature", "TEXT NOT NULL DEFAULT ''") + ensure_column(conn, "wehago_result_row_cache", "resolved_payload_json", "TEXT NOT NULL DEFAULT ''") + ensure_column(conn, "wehago_result_row_cache", "resolved_created_at", "TEXT NOT NULL DEFAULT ''") ensure_column(conn, "wehago_source_files", "source_origin", "TEXT NOT NULL DEFAULT 'filesystem'") ensure_column(conn, "wehago_manual_pair_matches", "match_source", "TEXT NOT NULL DEFAULT 'manual'") ensure_column(conn, "wehago_manual_pair_matches", "confidence_score", "REAL NOT NULL DEFAULT 0") @@ -595,6 +1208,9 @@ def init_wehago_compare_db(engine: Any) -> None: conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_recheck_reviews_year ON wehago_recheck_reviews(fiscal_year, reviewed_at)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_manual_pairs_year ON wehago_manual_pair_matches(fiscal_year, created_at)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_action_history_created ON wehago_action_history(created_at)")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_pair_recommend_cache_updated ON wehago_pair_recommend_cache(updated_at)")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_metric_count_cache_range ON wehago_metric_count_cache(start_year, end_year, updated_at)")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_background_jobs_state_created ON wehago_background_jobs(state, created_at)")) def upsert_source_file( @@ -673,11 +1289,13 @@ def upsert_source_file( 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 "" + draft_no = clean(values[1] if len(values) > 1 else "") + confirmed_no = clean(values[10] if len(values) > 10 else "") + fiscal_year = infer_voucher_fiscal_year(confirmed_no, draft_no, proof_date, year_hint) or "" parts = [ clean(values[0] if len(values) > 0 else ""), - clean(values[1] if len(values) > 1 else ""), - clean(values[10] if len(values) > 10 else ""), + draft_no, + confirmed_no, clean(values[2] if len(values) > 2 else ""), clean(values[3] if len(values) > 3 else ""), f"{parse_amount(values[4] if len(values) > 4 else 0):.2f}", @@ -748,10 +1366,11 @@ def import_voucher_rows(conn: Any, source_id: int, sheet_name: str, rows: Iterab 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 "") + proof_date = parse_excel_date(values[20] if len(values) > 20 else None) + fiscal_year = infer_voucher_fiscal_year(confirmed_no, draft_no, proof_date, year_hint) + effective_no = choose_effective_erp_voucher_no(confirmed_no, draft_no, year_hint) debit_supply = parse_amount(values[4] if len(values) > 4 else 0) credit_supply = parse_amount(values[6] if len(values) > 6 else 0) conn.execute( @@ -783,7 +1402,7 @@ def import_voucher_rows(conn: Any, source_id: int, sheet_name: str, rows: Iterab "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_voucher_no": normalize_compare_voucher_no(effective_no, proof_date), "compare_amount": debit_supply if debit_supply else credit_supply, "compare_side": "debit" if debit_supply else ("credit" if credit_supply else ""), "compare_vendor": normalize_text(values[18] if len(values) > 18 else ""), @@ -812,8 +1431,13 @@ def import_ledger_rows(conn: Any, source_id: int, sheet_name: str, rows: Iterabl ) for row_number, row in enumerate(rows, start=2): values = list(row) + if len(values) >= len(CONSOLIDATED_LEDGER_PREFIX_HEADERS) + len(LEDGER_HEADERS): + values = values[len(CONSOLIDATED_LEDGER_PREFIX_HEADERS) : len(CONSOLIDATED_LEDGER_PREFIX_HEADERS) + len(LEDGER_HEADERS)] if not any(item is not None and clean(item) for item in values): continue + normalized_values = {clean(item).replace(" ", "") for item in values} + if normalized_values & {"[월계]", "[누계]", "월계", "누계"}: + continue ledger_date = parse_excel_date(values[0] if len(values) > 0 else None, default_year=year_hint) fiscal_year = year_from_date_text(ledger_date) or year_hint debit = parse_amount(values[3] if len(values) > 3 else 0) @@ -833,7 +1457,7 @@ def import_ledger_rows(conn: Any, source_id: int, sheet_name: str, rows: Iterabl "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_voucher_no": normalize_compare_voucher_no(values[6] if len(values) > 6 else "", ledger_date), "compare_amount": debit if debit else credit, "compare_side": "debit" if debit else ("credit" if credit else ""), "compare_vendor": normalize_text(values[2] if len(values) > 2 else ""), @@ -956,6 +1580,26 @@ def rebuild_comparison_results(conn: Any) -> None: """ ) ) + boundary_sql = _boundary_excluded_sql("l.ledger_date", "l.description", "l.account_name") + conn.execute( + text( + f""" + UPDATE wehago_comparison_results + SET status = 'ledger_only', + notes = CASE + WHEN COALESCE(notes, '') = '' THEN '연초/연말 대체·이월 전표는 매칭 대상에서 제외했습니다.' + ELSE notes || ' / 연초/연말 대체·이월 전표는 매칭 대상에서 제외했습니다.' + END + WHERE EXISTS ( + SELECT 1 + FROM wehago_ledger_rows l + WHERE l.fiscal_year = wehago_comparison_results.fiscal_year + AND l.compare_voucher_no = wehago_comparison_results.voucher_no + AND {boundary_sql} + ) + """ + ) + ) def refresh_wehago_compare_data(engine: Any, source_root: Path | None = None) -> dict[str, int]: @@ -1003,6 +1647,7 @@ def refresh_wehago_compare_data(engine: Any, source_root: Path | None = None) -> _SUGGEST_CACHE.clear() _STATUS_ROWS_CACHE.clear() _PAIR_RECOMMEND_CACHE.clear() + clear_persisted_pair_recommend_cache(engine) return { "scanned_files": summary.scanned_files, "imported_files": summary.imported_files, @@ -1129,10 +1774,11 @@ def import_uploaded_erp_voucher_file(engine: Any, upload_path: Path, original_fi 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 "") + proof_date = parse_excel_date(values[20] if len(values) > 20 else None) + fiscal_year = infer_voucher_fiscal_year(confirmed_no, draft_no, proof_date, year_hint) + effective_no = choose_effective_erp_voucher_no(confirmed_no, draft_no, year_hint) debit_supply = parse_amount(values[4] if len(values) > 4 else 0) credit_supply = parse_amount(values[6] if len(values) > 6 else 0) conn.execute( @@ -1164,7 +1810,7 @@ def import_uploaded_erp_voucher_file(engine: Any, upload_path: Path, original_fi "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_voucher_no": normalize_compare_voucher_no(effective_no, proof_date), "compare_amount": debit_supply if debit_supply else credit_supply, "compare_side": "debit" if debit_supply else ("credit" if credit_supply else ""), "compare_vendor": normalize_text(values[18] if len(values) > 18 else ""), @@ -1189,7 +1835,11 @@ def import_uploaded_erp_voucher_file(engine: Any, upload_path: Path, original_fi ) rebuild_comparison_results(conn) comparison_rows = int(conn.execute(text("SELECT COUNT(*) FROM wehago_comparison_results")).scalar_one()) - + _DASHBOARD_CACHE.clear() + _SUGGEST_CACHE.clear() + _STATUS_ROWS_CACHE.clear() + _PAIR_RECOMMEND_CACHE.clear() + clear_persisted_pair_recommend_cache(engine) return { "source_id": source_id, "inserted_rows": inserted, @@ -1208,22 +1858,26 @@ def build_year_filter_sql(column_name: str = "fiscal_year") -> str: def fetch_metric_sections(conn: Any, start_year: int | None, end_year: int | None) -> list[dict[str, Any]]: sections: list[dict[str, Any]] = [] + voucher_counts = _voucher_metric_counts_from_db(conn, start_year, end_year) for status_key, label, description in STATUS_META: - 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() - ) + if status_key in {"voucher_matched", "erp_voucher_matched", "voucher_unmatched"}: + count = int(voucher_counts.get(status_key, 0) or 0) + else: + count = int( + conn.execute( + text( + """ + SELECT COUNT(*) + FROM wehago_comparison_results + WHERE status = :status + AND """ + + build_year_filter_sql() + + """ + """ + ), + {"status": status_key, "start_year": start_year, "end_year": end_year}, + ).scalar_one() + ) sections.append( { "key": status_key, @@ -1577,6 +2231,12 @@ def parse_compare_result_bundle( "ledger_credit": matched_ledger.get("ledger_credit", 0), "review_reason": review_reason, "review_memo": clean(row[voucher_idx["review_memo"]]) if "review_memo" in voucher_idx else "", + "substitution_hint": _build_account_substitution_hint( + matched_ledger.get("ledger_account_code", ""), + matched_ledger.get("ledger_account_name", ""), + clean(row[voucher_idx["계정코드"]]) if "계정코드" in voucher_idx else "", + clean(row[voucher_idx["계정명칭"]]) if "계정명칭" in voucher_idx else "", + ), } item["review_key"] = build_review_key(item) item["match_identity_key"] = build_match_identity_key(item) @@ -1629,7 +2289,7 @@ def parse_compare_result_bundle( metric_map["ledger_only"]["count"] += 1 metric_map["ledger_only"]["rows"].append(item) - return metric_map + return _append_substitution_review_rows(metric_map) def build_metric_sections_from_results(start_year: int | None, end_year: int | None) -> list[dict[str, Any]]: @@ -1658,7 +2318,7 @@ def build_metric_sections_from_results(start_year: int | None, end_year: int | N built_sections: list[dict[str, Any]] = [] for status_key, label, description in STATUS_META: - section = aggregate[status_key] + section = aggregate.get(status_key, {"count": 0, "columns": None, "rows": []}) built_sections.append( { "key": status_key, @@ -1700,7 +2360,7 @@ def build_metric_sections_with_review_state(conn: Any, start_year: int | None, e built_sections: list[dict[str, Any]] = [] for status_key, label, description in STATUS_META: - section = aggregate[status_key] + section = aggregate.get(status_key, {"count": 0, "columns": None, "rows": []}) built_sections.append( { "key": status_key, @@ -1862,7 +2522,7 @@ def _build_bundle_signature(start_year: int | None, end_year: int | None) -> str for year in range(start_year, end_year + 1): bundle = discover_compare_result_bundle(year) if not bundle: - parts.append(f"{year}:none") + parts.append(f"{year}:db") continue ledger_mtime = f"{bundle['ledger_result'].stat().st_mtime:.3f}" voucher_mtime = f"{bundle['voucher_result'].stat().st_mtime:.3f}" @@ -1870,10 +2530,62 @@ def _build_bundle_signature(start_year: int | None, end_year: int | None) -> str return "|".join(parts) +def _empty_status_sections() -> dict[str, dict[str, Any]]: + return { + status_key: { + "count": 0, + "columns": DETAIL_COLUMN_MAP[status_key], + "rows": [], + } + for status_key, _label, _description in STATUS_META + } + + +def _build_db_status_sections(conn: Any, year: int) -> dict[str, dict[str, Any]]: + sections = _empty_status_sections() + for status_key in sections: + payload = _fetch_status_detail_rows_from_db( + conn, + year, + year, + status_key, + "", + "", + "", + "", + "", + "", + "", + "", + "", + False, + 0, + 1_000_000, + ) + rows = payload["rows"] + for row in rows: + if status_key in {"matched", "ledger_only", "amount_mismatch"}: + row["ledger_row_key"] = build_ledger_row_key(row) + if status_key in {"matched", "voucher_only", "amount_mismatch"}: + row["voucher_row_key"] = build_voucher_row_key(row) + row["review_key"] = build_review_key(row) + row["match_identity_key"] = build_match_identity_key(row) + sections[status_key]["rows"] = rows + sections[status_key]["count"] = len(rows) + + sections = _append_substitution_review_rows(sections) + sections = _promote_direct_auto_matches(sections) + sections = _apply_previous_year_erp_candidates(conn, year, sections) + sections = apply_saved_recheck_reviews(sections, get_saved_recheck_review_keys(conn, year, year)) + sections = apply_saved_manual_pair_matches(sections, get_saved_manual_pair_matches(conn, year, year)) + return sections + + def _get_or_create_year_cached_sections(conn: Any, year: int) -> dict[str, dict[str, Any]] | None: bundle = discover_compare_result_bundle(year) if not bundle: return None + parse_signature = _current_logic_signature() ledger_path = str(bundle["ledger_result"]) voucher_path = str(bundle["voucher_result"]) ledger_mtime = float(bundle["ledger_result"].stat().st_mtime) @@ -1889,6 +2601,7 @@ def _get_or_create_year_cached_sections(conn: Any, year: int) -> dict[str, dict[ AND ledger_result_mtime = :ledger_result_mtime AND voucher_result_path = :voucher_result_path AND voucher_result_mtime = :voucher_result_mtime + AND parse_state_signature = :parse_signature LIMIT 1 """ ), @@ -1898,13 +2611,14 @@ def _get_or_create_year_cached_sections(conn: Any, year: int) -> dict[str, dict[ "ledger_result_mtime": ledger_mtime, "voucher_result_path": voucher_path, "voucher_result_mtime": voucher_mtime, + "parse_signature": parse_signature, }, ).first() if cached and cached[0]: try: payload = json.loads(str(cached[0])) if isinstance(payload, dict): - return payload + return _append_substitution_review_rows(payload) except Exception: pass @@ -1924,6 +2638,7 @@ def _get_or_create_year_cached_sections(conn: Any, year: int) -> dict[str, dict[ ledger_result_mtime, voucher_result_path, voucher_result_mtime, + parse_state_signature, payload_json, created_at ) VALUES ( @@ -1932,6 +2647,7 @@ def _get_or_create_year_cached_sections(conn: Any, year: int) -> dict[str, dict[ :ledger_result_mtime, :voucher_result_path, :voucher_result_mtime, + :parse_signature, :payload_json, CURRENT_TIMESTAMP ) @@ -1943,6 +2659,7 @@ def _get_or_create_year_cached_sections(conn: Any, year: int) -> dict[str, dict[ "ledger_result_mtime": ledger_mtime, "voucher_result_path": voucher_path, "voucher_result_mtime": voucher_mtime, + "parse_signature": parse_signature, "payload_json": json.dumps(parsed, ensure_ascii=False), }, ) @@ -1956,6 +2673,7 @@ def _get_or_create_year_cached_sections(conn: Any, year: int) -> dict[str, dict[ AND ledger_result_mtime = :ledger_result_mtime AND voucher_result_path = :voucher_result_path AND voucher_result_mtime = :voucher_result_mtime + AND parse_state_signature = :parse_signature ) """ ), @@ -1965,12 +2683,137 @@ def _get_or_create_year_cached_sections(conn: Any, year: int) -> dict[str, dict[ "ledger_result_mtime": ledger_mtime, "voucher_result_path": voucher_path, "voucher_result_mtime": voucher_mtime, + "parse_signature": parse_signature, }, ) return parsed +def _get_or_create_year_resolved_sections(conn: Any, year: int) -> dict[str, dict[str, Any]] | None: + signature = _build_db_state_signature(conn, year, year) + cached_payload = _load_year_resolved_sections_cache(conn, year, signature) + if cached_payload is not None: + return cached_payload + + parsed_sections = _get_or_create_year_cached_sections(conn, year) + if parsed_sections is not None: + sections = _apply_boundary_exclusions_to_sections(conn, parsed_sections) + sections = apply_saved_recheck_reviews(sections, get_saved_recheck_review_keys(conn, year, year)) + sections = apply_saved_manual_pair_matches(sections, get_saved_manual_pair_matches(conn, year, year)) + sections = _apply_previous_year_erp_candidates(conn, year, sections) + else: + sections = _build_db_status_sections(conn, year) + _store_year_resolved_sections_cache(conn, year, signature, sections) + return sections + + +def _load_year_resolved_sections_cache( + conn: Any, + year: int, + signature: str | None = None, +) -> dict[str, dict[str, Any]] | None: + signature = signature or _build_db_state_signature(conn, year, year) + cached = conn.execute( + text( + """ + SELECT resolved_payload_json + FROM wehago_result_row_cache + WHERE fiscal_year = :fiscal_year + AND ledger_result_path = 'db' + AND voucher_result_path = 'db' + AND resolved_state_signature = :signature + LIMIT 1 + """ + ), + {"fiscal_year": year, "signature": signature}, + ).first() + if cached and cached[0]: + try: + payload = json.loads(str(cached[0])) + if isinstance(payload, dict): + return payload + except Exception: + pass + return None + + +def _store_year_resolved_sections_cache( + conn: Any, + year: int, + signature: str, + sections: dict[str, dict[str, Any]], +) -> None: + conn.execute( + text( + """ + INSERT OR REPLACE INTO wehago_result_row_cache ( + fiscal_year, + ledger_result_path, + ledger_result_mtime, + voucher_result_path, + voucher_result_mtime, + payload_json, + resolved_state_signature, + resolved_payload_json, + resolved_created_at, + created_at + ) VALUES ( + :fiscal_year, + 'db', + 0, + 'db', + 0, + '{}', + :signature, + :payload_json, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + """ + ), + { + "fiscal_year": year, + "signature": signature, + "payload_json": json.dumps(sections, ensure_ascii=False), + }, + ) + conn.execute( + text( + """ + DELETE FROM wehago_result_row_cache + WHERE fiscal_year = :fiscal_year + AND ledger_result_path = 'db' + AND voucher_result_path = 'db' + AND resolved_state_signature <> :signature + """ + ), + {"fiscal_year": year, "signature": signature}, + ) + + def _build_db_state_signature(conn: Any, start_year: int | None, end_year: int | None) -> str: + source = conn.execute( + text( + """ + SELECT COALESCE(MAX(imported_at), ''), COUNT(*) + FROM wehago_source_files + WHERE (:start_year IS NULL OR year_hint >= :start_year) + AND (:end_year IS NULL OR year_hint <= :end_year) + """ + ), + {"start_year": start_year, "end_year": end_year}, + ).first() + comparison = conn.execute( + text( + """ + SELECT COUNT(*) + FROM wehago_comparison_results + WHERE (:start_year IS NULL OR fiscal_year >= :start_year) + AND (:end_year IS NULL OR fiscal_year <= :end_year) + """ + ), + {"start_year": start_year, "end_year": end_year}, + ).first() recheck = conn.execute( text( """ @@ -1993,7 +2836,32 @@ def _build_db_state_signature(conn: Any, start_year: int | None, end_year: int | ), {"start_year": start_year, "end_year": end_year}, ).first() - return f"r:{recheck[0]}:{recheck[1]}|p:{pair[0]}:{pair[1]}" + return ( + f"{_current_logic_signature()}|" + f"s:{source[0]}:{source[1]}|c:{comparison[0]}|" + f"r:{recheck[0]}:{recheck[1]}|p:{pair[0]}:{pair[1]}" + ) + + +def _discover_available_fiscal_years(conn: Any) -> list[int]: + rows = conn.execute( + text( + """ + SELECT DISTINCT fiscal_year + FROM ( + SELECT fiscal_year FROM wehago_ledger_rows + UNION + SELECT fiscal_year FROM wehago_voucher_rows + UNION + SELECT fiscal_year FROM wehago_comparison_results + ) + WHERE fiscal_year IS NOT NULL + AND fiscal_year > 0 + ORDER BY fiscal_year + """ + ) + ).fetchall() + return [int(row[0]) for row in rows if str(row[0] or "").isdigit()] def _get_cached_status_rows_by_range(engine: Any, start_year: int | None, end_year: int | None) -> dict[str, list[dict[str, Any]]]: @@ -2014,8 +2882,6 @@ def _get_cached_status_rows_by_range(engine: Any, start_year: int | None, end_ye 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": [], @@ -2023,18 +2889,69 @@ def _get_cached_status_rows_by_range(engine: Any, start_year: int | None, end_ye "voucher_only": [], } for year in range(start_year, end_year + 1): - parsed = _get_or_create_year_cached_sections(conn, year) + parsed = _get_or_create_year_resolved_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"]) + if start_year != end_year: + section_payload = { + status_key: { + "rows": list(rows), + "count": len(rows), + "columns": DETAIL_COLUMN_MAP[status_key], + } + for status_key, rows in rows_by_status.items() + } + section_payload = _promote_cross_year_auto_matches(section_payload) + rows_by_status = { + status_key: list(section_payload[status_key]["rows"]) + for status_key in rows_by_status + } _STATUS_ROWS_CACHE.clear() _STATUS_ROWS_CACHE[cache_key] = {"ts": now, "rows_by_status": rows_by_status} return rows_by_status +def _build_voucher_section_cache_key(conn: Any, start_year: int, end_year: int) -> str: + return "|".join( + [ + str(start_year), + str(end_year), + _build_bundle_signature(start_year, end_year), + _build_db_state_signature(conn, start_year, end_year), + ] + ) + + +def _get_cached_voucher_sections_by_range( + engine: Any, + start_year: int | None, + end_year: int | None, + rows_by_status: dict[str, list[dict[str, Any]]] | None = None, +) -> dict[str, list[dict[str, Any]]]: + if start_year is None or end_year is None: + return { + "voucher_matched": [], + "erp_voucher_matched": [], + "voucher_unmatched": [], + "voucher_recheck": [], + } + init_wehago_compare_db(engine) + with engine.begin() as conn: + cache_key = _build_voucher_section_cache_key(conn, start_year, end_year) + now = time.time() + cached = _VOUCHER_SECTION_CACHE.get(cache_key) + if cached and (now - float(cached.get("ts", 0))) <= _VOUCHER_SECTION_CACHE_TTL_SEC: + return cached["sections"] + if rows_by_status is None: + rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year) + sections = _build_voucher_sections_from_rows_by_status(rows_by_status) + _VOUCHER_SECTION_CACHE.clear() + _VOUCHER_SECTION_CACHE[cache_key] = {"ts": now, "sections": sections} + return sections + + def _push_action_history(conn: Any, action_type: str, payload: dict[str, Any]) -> int: result = conn.execute( text( @@ -2193,6 +3110,7 @@ def save_recheck_review_rows(engine: Any, rows: list[dict[str, Any]]) -> int: _SUGGEST_CACHE.clear() _STATUS_ROWS_CACHE.clear() _PAIR_RECOMMEND_CACHE.clear() + clear_persisted_pair_recommend_cache(engine) return len(normalized_rows) @@ -2313,6 +3231,7 @@ def save_manual_pair_matches( _SUGGEST_CACHE.clear() _STATUS_ROWS_CACHE.clear() _PAIR_RECOMMEND_CACHE.clear() + clear_persisted_pair_recommend_cache(engine) return len(rows_to_save) @@ -2390,6 +3309,7 @@ def undo_last_action(engine: Any) -> dict[str, Any]: _SUGGEST_CACHE.clear() _STATUS_ROWS_CACHE.clear() _PAIR_RECOMMEND_CACHE.clear() + clear_persisted_pair_recommend_cache(engine) return {"undone": True, "action_type": action_type, "affected": affected} @@ -2405,11 +3325,87 @@ def _parse_iso_date(value: Any) -> date | None: def _tokenize_for_similarity(value: Any) -> set[str]: normalized = normalize_text(value) + if not normalized: + return set() + normalized = _normalize_core_similarity_text(normalized) if not normalized: return set() if len(normalized) <= 2: return {normalized} - return {normalized[i : i + 2] for i in range(len(normalized) - 1)} + tokens = {normalized[i : i + 2] for i in range(len(normalized) - 1)} + tokens.add(normalized) + for keyword in _extract_core_keywords(normalized): + tokens.add(keyword) + return tokens + + +def _normalize_core_similarity_text(value: Any) -> str: + text_value = normalize_text(value) + replacements = [ + "주식회사", + "유한회사", + "합자회사", + "합명회사", + "재단법인", + "사단법인", + "농업회사법인", + "회사", + "법인", + "부가가치세", + "전자세금계산서", + "세금계산서", + ] + for token in replacements: + text_value = text_value.replace(token, "") + return text_value + + +def _extract_core_keywords(value: Any) -> set[str]: + text_value = _normalize_core_similarity_text(value) + if not text_value: + return set() + keywords: set[str] = set() + candidates = [ + "미수금", + "매출금", + "외상매출금", + "매입세액", + "부가세대급금", + "부가세예수금", + "대급금", + "예수금", + "보통예금", + "미지급금", + "급여", + "퇴직급여", + "복리후생", + "여비교통", + "접대", + "통신", + "전력", + "수도광열", + "차량유지", + "보험", + "교육훈련", + "도서인쇄", + "사무용품", + "소모품", + "지급수수료", + "수수료", + "감가상각", + "이자", + "배당", + "잡이익", + "잡손실", + ] + for keyword in candidates: + normalized_keyword = normalize_text(keyword) + if normalized_keyword and normalized_keyword in text_value: + keywords.add(normalized_keyword) + for size in (4, 3): + if len(text_value) >= size: + keywords.update(text_value[i : i + size] for i in range(len(text_value) - size + 1)) + return keywords def _jaccard_similarity(left: Any, right: Any) -> float: @@ -2430,6 +3426,1540 @@ def _jaccard_similarity_tokens(left_tokens: set[str], right_tokens: set[str]) -> return (inter / union) if union else 0.0 +def _extract_month_tokens(value: Any) -> set[int]: + text = str(value or "") + tokens: set[int] = set() + for raw in re.findall(r"(? bool: + left_months = _extract_month_tokens(left) + right_months = _extract_month_tokens(right) + if not left_months or not right_months: + return False + return left_months.isdisjoint(right_months) + + +def _has_conflicting_month_token_sets(left_months: set[int], right_months: set[int]) -> bool: + if not left_months or not right_months: + return False + return left_months.isdisjoint(right_months) + + +def _extract_desc_date_tokens(value: Any) -> set[str]: + text_value = clean(value) + if not text_value: + return set() + tokens: set[str] = set() + for year, month, day in re.findall(r"(? set[int]: + months: set[int] = set() + for token in tokens: + parts = token.split("-") + if len(parts) == 3: + month_text = parts[1] + elif len(parts) == 2 and len(parts[0]) == 4: + month_text = parts[1] + else: + month_text = parts[0] + try: + month = int(month_text) + except ValueError: + continue + if 1 <= month <= 12: + months.add(month) + return months + + +def _strip_desc_date_tokens(value: Any) -> str: + text_value = clean(value) + text_value = re.sub(r"(? bool: + left_dates = _extract_desc_date_tokens(left) + right_dates = _extract_desc_date_tokens(right) + if not left_dates and not right_dates: + return True + if left_dates == right_dates: + return True + left_months = _date_token_month_numbers(left_dates) + right_months = _date_token_month_numbers(right_dates) + return bool(left_months and right_months and left_months == right_months) + + +def _date_tokens_conflict(left: Any, right: Any) -> bool: + left_dates = _extract_desc_date_tokens(left) + right_dates = _extract_desc_date_tokens(right) + return bool(left_dates and right_dates and not _date_tokens_compatible(left, right)) + + +def _core_token_overlap(left: Any, right: Any) -> bool: + left_tokens = _extract_core_keywords(left) + right_tokens = _extract_core_keywords(right) + if left_tokens & right_tokens: + return True + left_norm = _normalize_core_similarity_text(left) + right_norm = _normalize_core_similarity_text(right) + if not left_norm or not right_norm: + return False + if len(left_norm) >= 2 and left_norm in right_norm: + return True + if len(right_norm) >= 2 and right_norm in left_norm: + return True + return _jaccard_similarity(left_norm, right_norm) >= 0.55 + + +def _core_token_strong_overlap(left: Any, right: Any) -> bool: + left_tokens = _extract_core_keywords(left) + right_tokens = _extract_core_keywords(right) + if left_tokens and right_tokens and left_tokens & right_tokens: + return True + left_norm = _normalize_core_similarity_text(left) + right_norm = _normalize_core_similarity_text(right) + if not left_norm or not right_norm: + return False + return _jaccard_similarity(left_norm, right_norm) >= 0.72 + + +def _account_names_compatible(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -> bool: + ledger_code = clean(ledger_row.get("ledger_account_code")) + voucher_code = clean(voucher_row.get("voucher_account_code")) + ledger_name = clean(ledger_row.get("ledger_account_name")) + voucher_name = clean(voucher_row.get("voucher_account_name")) + if ledger_code and voucher_code and ledger_code == voucher_code: + return True + if normalize_text(ledger_name) and normalize_text(ledger_name) == normalize_text(voucher_name): + return True + if _build_account_substitution_hint(ledger_code, ledger_name, voucher_code, voucher_name): + return True + if _account_base_names_compatible(ledger_name, voucher_name): + return True + ledger_family = _classify_account_family(ledger_code, ledger_name) + voucher_family = _classify_account_family(voucher_code, voucher_name) + if ledger_family and ledger_family == voucher_family: + return True + return _jaccard_similarity(ledger_name, voucher_name) >= 0.55 + + +def _account_base_name(value: Any) -> str: + text_value = clean(value) + text_value = re.sub(r"^\s*원가\)\s*", "", text_value) + text_value = text_value.split("(", 1)[0] + normalized = normalize_text(text_value) + if normalized.startswith("원가"): + normalized = normalized[2:] + return normalized + + +def _account_base_names_compatible(left: Any, right: Any) -> bool: + left_base = _account_base_name(left) + right_base = _account_base_name(right) + if not left_base or not right_base: + return False + if left_base == right_base: + return True + short, long = sorted((left_base, right_base), key=len) + return len(short) >= 2 and short in long + + +def _same_or_similar_vendor(row: dict[str, Any]) -> bool: + return _core_token_overlap(row.get("ledger_vendor"), row.get("voucher_vendor")) + + +def _same_or_similar_desc(row: dict[str, Any]) -> bool: + if not _date_tokens_compatible(row.get("ledger_desc"), row.get("voucher_desc")): + return False + ledger_desc = _strip_desc_date_tokens(row.get("ledger_desc")) + voucher_desc = _strip_desc_date_tokens(row.get("voucher_desc")) + return _core_token_strong_overlap(ledger_desc, voucher_desc) + + +def _contained_core_desc_match(left: Any, right: Any) -> bool: + if not _date_tokens_compatible(left, right): + return False + left_norm = _normalize_core_similarity_text(_strip_desc_date_tokens(left)) + right_norm = _normalize_core_similarity_text(_strip_desc_date_tokens(right)) + if not left_norm or not right_norm: + return False + short, long = sorted((left_norm, right_norm), key=len) + return len(short) >= 2 and short in long + + +def _short_core_desc_fuzzy_match(left: Any, right: Any) -> bool: + if not _date_tokens_compatible(left, right): + return False + left_norm = _normalize_core_similarity_text(_strip_desc_date_tokens(left)) + right_norm = _normalize_core_similarity_text(_strip_desc_date_tokens(right)) + if not left_norm or not right_norm: + return False + if max(len(left_norm), len(right_norm)) > 4 or min(len(left_norm), len(right_norm)) < 2: + return False + return SequenceMatcher(None, left_norm, right_norm).ratio() >= 0.75 + + +def _bank_payable_desc_approved(row: dict[str, Any]) -> bool: + ledger_desc = row.get("ledger_desc") + voucher_desc = row.get("voucher_desc") + return ( + _same_or_similar_desc(row) + or _contained_core_desc_match(ledger_desc, voucher_desc) + or _short_core_desc_fuzzy_match(ledger_desc, voucher_desc) + or _shared_strong_keyword(ledger_desc, voucher_desc, {"보증"}) + ) + + +def _shared_strong_keyword(left: Any, right: Any, keywords: set[str]) -> bool: + left_norm = normalize_text(left) + right_norm = normalize_text(right) + return any(keyword and keyword in left_norm and keyword in right_norm for keyword in keywords) + + +def _is_same_bank_guarantee_case(row: dict[str, Any]) -> bool: + ledger_family = _classify_account_family(row.get("ledger_account_code"), row.get("ledger_account_name")) + voucher_family = _classify_account_family(row.get("voucher_account_code"), row.get("voucher_account_name")) + return ( + ledger_family == "bank" + and voucher_family == "bank" + and _shared_strong_keyword(row.get("ledger_desc"), row.get("voucher_desc"), {"보증"}) + ) + + +def _is_approved_guarantee_account_pair(row: dict[str, Any]) -> bool: + if not _shared_strong_keyword(row.get("ledger_desc"), row.get("voucher_desc"), {"보증"}): + return False + ledger_name = normalize_text(row.get("ledger_account_name")) + voucher_name = normalize_text(row.get("voucher_account_name")) + approved_pairs = ( + ("민사보전금", "임차보증금"), + ("세금과공과금", "보험료"), + ("선급금", "전도금"), + ("보증수수료", "지급수수료"), + ) + for left_marker, right_marker in approved_pairs: + left = normalize_text(left_marker) + right = normalize_text(right_marker) + if left in ledger_name and right in voucher_name: + return True + if right in ledger_name and left in voucher_name: + return True + return False + + +def _account_base_desc_approved(row: dict[str, Any]) -> bool: + if not _account_base_names_compatible(row.get("ledger_account_name"), row.get("voucher_account_name")): + return False + return ( + _same_or_similar_desc(row) + or _contained_core_desc_match(row.get("ledger_desc"), row.get("voucher_desc")) + ) + + +def _has_boundary_substitution_date(row: dict[str, Any]) -> bool: + try: + fiscal_year = int(row.get("fiscal_year") or 0) + except (TypeError, ValueError): + fiscal_year = 0 + for field in ("ledger_date", "proof_date"): + parsed = _parse_iso_date(row.get(field)) + if parsed is None: + continue + if fiscal_year and parsed.year != fiscal_year: + continue + if (parsed.month, parsed.day) in {(1, 1), (12, 31)}: + return True + return False + + +def _is_substitution_like_row(row: dict[str, Any]) -> bool: + review_text = normalize_text( + " ".join( + clean(row.get(field)) + for field in ("review_reason", "substitution_hint", "ledger_desc", "voucher_desc") + ) + ) + if any(token in review_text for token in ("대체", "substitution")): + return True + ledger_family = _classify_account_family(row.get("ledger_account_code"), row.get("ledger_account_name")) + voucher_family = _classify_account_family(row.get("voucher_account_code"), row.get("voucher_account_name")) + return bool(_build_account_substitution_hint( + row.get("ledger_account_code"), + row.get("ledger_account_name"), + row.get("voucher_account_code"), + row.get("voucher_account_name"), + ) and ledger_family != voucher_family) + + +def _is_bank_payable_case(row: dict[str, Any]) -> bool: + ledger_family = _classify_account_family(row.get("ledger_account_code"), row.get("ledger_account_name")) + voucher_family = _classify_account_family(row.get("voucher_account_code"), row.get("voucher_account_name")) + return {ledger_family, voucher_family} == {"bank", "payable"} + + +def _is_boundary_substitution_row(row: dict[str, Any]) -> bool: + return _has_boundary_substitution_date(row) and _is_substitution_like_row(row) + + +def _account_candidate_family_keys(account_code: Any, account_name: Any) -> set[str]: + account_code_text = clean(account_code) + account_name_norm = normalize_text(account_name) + family = _classify_account_family(account_code_text, account_name) + keys: set[str] = set() + if account_code_text: + keys.add(f"code:{account_code_text}") + if account_name_norm: + keys.add(f"name:{account_name_norm}") + if family: + keys.add(f"family:{family}") + related = { + "payable": {"bank"}, + "bank": {"payable"}, + "receivable": {"sales"}, + "sales": {"receivable"}, + "vat_input": {"vat_output"}, + "vat_output": {"vat_input"}, + } + for related_family in related.get(family, set()): + keys.add(f"family:{related_family}") + return keys + + +def _build_amount_account_index( + rows: list[dict[str, Any]], + *, + prefix: str, +) -> tuple[dict[float, list[dict[str, Any]]], dict[tuple[float, str], list[dict[str, Any]]]]: + amount_index: dict[float, list[dict[str, Any]]] = {} + account_index: dict[tuple[float, str], list[dict[str, Any]]] = {} + for row in rows: + amount = round(_get_row_match_amount(row, prefix), 2) + if amount <= 0: + continue + amount_index.setdefault(amount, []).append(row) + for key in _account_candidate_family_keys( + row.get(f"{prefix}_account_code"), + row.get(f"{prefix}_account_name"), + ): + account_index.setdefault((amount, key), []).append(row) + return amount_index, account_index + + +def _candidate_rows_for_amount_account( + amount: float, + ledger_row: dict[str, Any], + amount_index: dict[float, list[dict[str, Any]]], + account_index: dict[tuple[float, str], list[dict[str, Any]]], + *, + wide_bucket_limit: int = 80, + filtered_bucket_limit: int = 160, +) -> list[dict[str, Any]]: + candidates = amount_index.get(amount, []) + if len(candidates) <= wide_bucket_limit: + return candidates + filtered: list[dict[str, Any]] = [] + seen_keys: set[int] = set() + for key in _account_candidate_family_keys( + ledger_row.get("ledger_account_code"), + ledger_row.get("ledger_account_name"), + ): + for candidate in account_index.get((amount, key), []): + object_key = id(candidate) + if object_key in seen_keys: + continue + seen_keys.add(object_key) + filtered.append(candidate) + if len(filtered) > filtered_bucket_limit: + return [] + return filtered + + +def _is_recheck_row_clear_match(row: dict[str, Any]) -> bool: + ledger_amount = _get_row_match_amount(row, "ledger") + voucher_amount = _get_row_match_amount(row, "voucher") + if ledger_amount <= 0 or abs(ledger_amount - voucher_amount) >= 0.5: + return False + ledger_side = _determine_primary_side(row) + voucher_side = "debit" if parse_amount(row.get("voucher_debit")) > 0 and parse_amount(row.get("voucher_credit")) <= 0 else "credit" if parse_amount(row.get("voucher_credit")) > 0 and parse_amount(row.get("voucher_debit")) <= 0 else "either" + if not _nature_compatible( + row.get("ledger_account_code"), + row.get("ledger_account_name"), + ledger_side, + row.get("voucher_account_code"), + row.get("voucher_account_name"), + voucher_side, + ): + return False + if _is_boundary_substitution_row(row): + return False + vendor_match = _same_or_similar_vendor(row) + desc_match = _same_or_similar_desc(row) + if _account_base_desc_approved(row): + return True + if _is_approved_guarantee_account_pair(row): + return True + if not _account_names_compatible(row, row): + return False + if _is_same_bank_guarantee_case(row): + return True + if _is_bank_payable_case(row) and _bank_payable_desc_approved(row): + return True + if vendor_match and desc_match: + return True + if vendor_match and not _date_tokens_conflict(row.get("ledger_desc"), row.get("voucher_desc")): + return True + if desc_match: + return True + return False + + +def _month_gap(left: date, right: date) -> int: + return abs((left.year - right.year) * 12 + (left.month - right.month)) + + +def _normalize_account_family_text(value: Any) -> str: + return normalize_text(value) + + +def _classify_account_family(account_code: Any, account_name: Any) -> str: + normalized_name = _normalize_account_family_text(account_name) + normalized_code = clean(account_code) + payable_markers = ("미지급금", "외상미지급금") + bank_markers = ("보통예금",) + receivable_markers = ("미수금", "외상매출금", "매출채권", "공사미수금") + sales_markers = ("매출금", "매출", "용역수입", "수입") + vat_input_markers = ("부가세대급금", "매입세액", "부가세매입", "부가가치세대급금") + vat_output_markers = ("부가세예수금", "매출세액", "부가세매출", "부가가치세예수금") + education_markers = ("교육훈련비", "교육훈련") + welfare_markers = ("복리후생비", "복리후생", "회식대") + if any(marker in normalized_name for marker in payable_markers): + return "payable" + if any(marker in normalized_name for marker in bank_markers): + return "bank" + if any(marker in normalized_name for marker in receivable_markers): + return "receivable" + if any(marker in normalized_name for marker in sales_markers): + return "sales" + if any(marker in normalized_name for marker in vat_input_markers): + return "vat_input" + if any(marker in normalized_name for marker in vat_output_markers): + return "vat_output" + if any(marker in normalized_name for marker in education_markers): + return "education_training" + if any(marker in normalized_name for marker in welfare_markers): + return "welfare" + if normalized_code.startswith(("211", "213")): + return "payable" + if normalized_code.startswith(("111", "112")): + return "bank" + if normalized_code.startswith(("108", "120", "112")): + return "receivable" + if normalized_code.startswith(("401", "411", "412", "413", "414", "415")): + return "sales" + if normalized_code.startswith(("135", "136")): + return "vat_input" + if normalized_code.startswith(("255",)): + return "vat_output" + return "" + + +def _classify_account_category(account_code: Any, account_name: Any) -> str: + normalized_name = _normalize_account_family_text(account_name) + normalized_code = clean(account_code) + + asset_markers = ( + "보통예금", "예금", "현금", "미수금", "외상매출금", "매출채권", "공사미수금", + "선급", "대여금", "가수금환급", "부가세대급금", "매입세액", + ) + liability_markers = ( + "미지급금", "외상미지급금", "외상매입금", "매입채무", "예수금", "부가세예수금", "매출세액", "선수금", "차입금", + ) + equity_markers = ("자본금", "이익잉여금", "자본잉여금") + revenue_markers = ("매출", "용역수입", "수입", "수익") + expense_markers = ( + "원가", "비용", "차량유지비", "지급임차료", "임차료", "복리후생비", + "교육훈련비", "급여", "외주비", "수수료", "소모품비", "여비교통비", + ) + + if any(marker in normalized_name for marker in asset_markers): + return "asset" + if any(marker in normalized_name for marker in liability_markers): + return "liability" + if any(marker in normalized_name for marker in equity_markers): + return "equity" + if any(marker in normalized_name for marker in revenue_markers): + return "revenue" + if any(marker in normalized_name for marker in expense_markers): + return "expense" + + family = _classify_account_family(account_code, account_name) + family_category_map = { + "bank": "asset", + "receivable": "asset", + "vat_input": "asset", + "payable": "liability", + "vat_output": "liability", + "sales": "revenue", + "education_training": "expense", + "welfare": "expense", + } + if family in family_category_map: + return family_category_map[family] + + if normalized_code.startswith(("1",)): + return "asset" + if normalized_code.startswith(("2",)): + return "liability" + if normalized_code.startswith(("3",)): + return "equity" + if normalized_code.startswith(("4",)): + return "revenue" + if normalized_code.startswith(("5", "6", "7", "8", "9")): + return "expense" + return "" + + +def _account_nature_signature(account_code: Any, account_name: Any, side: str) -> str: + category = _classify_account_category(account_code, account_name) + if not category or side not in {"debit", "credit"}: + return "" + if category in {"asset", "expense"}: + direction = "increase" if side == "debit" else "decrease" + else: + direction = "increase" if side == "credit" else "decrease" + return f"{category}:{direction}" + + +def _nature_compatible( + ledger_code: Any, + ledger_name: Any, + ledger_side: str, + voucher_code: Any, + voucher_name: Any, + voucher_side: str, +) -> bool: + ledger_signature = _account_nature_signature(ledger_code, ledger_name, ledger_side) + voucher_signature = _account_nature_signature(voucher_code, voucher_name, voucher_side) + if not ledger_signature or not voucher_signature: + return True + return ledger_signature == voucher_signature + + +def _is_vat_family(family: str) -> bool: + return family in {"vat_input", "vat_output"} + + +def _is_income_expense_category(category: str) -> bool: + return category in {"revenue", "expense"} + + +def _is_asset_liability_category(category: str) -> bool: + return category in {"asset", "liability"} + + +def _account_category_pair_allowed( + ledger_code: Any, + ledger_name: Any, + voucher_code: Any, + voucher_name: Any, +) -> bool: + ledger_category = _classify_account_category(ledger_code, ledger_name) + voucher_category = _classify_account_category(voucher_code, voucher_name) + ledger_family = _classify_account_family(ledger_code, ledger_name) + voucher_family = _classify_account_family(voucher_code, voucher_name) + + # VAT rows should be matched only against VAT/tax rows. + if _is_vat_family(ledger_family) or _is_vat_family(voucher_family): + return _is_vat_family(ledger_family) and _is_vat_family(voucher_family) + + # Asset/liability rows should not match revenue/expense rows and vice versa. + if ( + _is_asset_liability_category(ledger_category) + and _is_income_expense_category(voucher_category) + ) or ( + _is_income_expense_category(ledger_category) + and _is_asset_liability_category(voucher_category) + ): + return False + + return True + + +def _build_account_substitution_hint( + ledger_code: Any, + ledger_name: Any, + voucher_code: Any, + voucher_name: Any, +) -> str: + ledger_family = _classify_account_family(ledger_code, ledger_name) + voucher_family = _classify_account_family(voucher_code, voucher_name) + if {ledger_family, voucher_family} == {"payable", "bank"}: + return "미지급금/보통예금 대체 검토" + if {ledger_family, voucher_family} == {"receivable", "sales"}: + return "미수금/매출금 유사 계정" + if ledger_family and ledger_family == voucher_family and ledger_family in {"vat_input", "vat_output"}: + return "부가세 계정 유사" + return "" + + +def _get_row_match_amount(row: dict[str, Any], prefix: str) -> float: + debit = parse_amount(row.get(f"{prefix}_debit")) + credit = parse_amount(row.get(f"{prefix}_credit")) + if debit > 0 and credit <= 0: + return debit + if credit > 0 and debit <= 0: + return credit + return max(debit, credit) + + +def _get_side_amount(row: dict[str, Any], prefix: str, side: str) -> float: + if side == "debit": + return parse_amount(row.get(f"{prefix}_debit")) + if side == "credit": + return parse_amount(row.get(f"{prefix}_credit")) + return _get_row_match_amount(row, prefix) + + +def _build_match_row_features( + row: dict[str, Any], + *, + prefix: str, + row_key_field: str, + account_code_field: str, + account_name_field: str, + vendor_field: str, + desc_field: str, + date_field: str, +) -> MatchRowFeatures: + debit_amount = parse_amount(row.get(f"{prefix}_debit")) + credit_amount = parse_amount(row.get(f"{prefix}_credit")) + if debit_amount > 0 and credit_amount <= 0: + match_amount = debit_amount + primary_side = "debit" + elif credit_amount > 0 and debit_amount <= 0: + match_amount = credit_amount + primary_side = "credit" + else: + match_amount = max(debit_amount, credit_amount) + primary_side = "either" + positive_amounts = tuple( + amount + for amount in { + round(debit_amount, 2), + round(credit_amount, 2), + } + if amount > 0 + ) + desc_text = clean(row.get(desc_field)) + return MatchRowFeatures( + row=row, + row_key=clean(row.get(row_key_field)), + account_code=clean(row.get(account_code_field)), + account_name=clean(row.get(account_name_field)), + vendor_name=clean(row.get(vendor_field)), + desc_text=desc_text, + account_tokens=_tokenize_for_similarity(row.get(account_name_field)), + vendor_tokens=_tokenize_for_similarity(row.get(vendor_field)), + desc_tokens=_tokenize_for_similarity(desc_text), + month_tokens=_extract_month_tokens(desc_text), + date_value=_parse_iso_date(row.get(date_field)), + debit_amount=debit_amount, + credit_amount=credit_amount, + match_amount=match_amount, + primary_side=primary_side, + positive_amounts=positive_amounts, + ) + + +def _get_feature_side_amount(features: MatchRowFeatures, side: str) -> float: + if side == "debit": + return features.debit_amount + if side == "credit": + return features.credit_amount + return features.match_amount + + +def _is_strong_substitution_candidate( + score_result: dict[str, Any], + desc_sim: float, +) -> bool: + return bool( + score_result.get("substitution_hint") + and float(score_result.get("amount_gap", 0)) < 0.5 + and desc_sim >= 0.85 + ) + + +def _is_strong_text_match( + amount_gap: float, + vendor_sim: float, + desc_sim: float, + month_conflict: bool, +) -> bool: + return bool( + amount_gap < 0.5 + and not month_conflict + and ( + (vendor_sim >= 0.65 and desc_sim >= 0.82) + or (vendor_sim >= 0.82 and desc_sim >= 0.55) + or (vendor_sim >= 0.55 and desc_sim >= 0.72) + ) + ) + + +def _is_reviewable_text_match( + amount_gap: float, + vendor_sim: float, + desc_sim: float, + month_conflict: bool, +) -> bool: + return bool( + amount_gap < 0.5 + and not month_conflict + and (vendor_sim >= 0.4 or desc_sim >= 0.6) + ) + + +def _build_substitution_review_rows( + ledger_rows: list[dict[str, Any]], + voucher_rows: list[dict[str, Any]], + existing_pairs: set[tuple[str, str]], +) -> list[dict[str, Any]]: + if not ledger_rows or not voucher_rows: + return [] + + voucher_amount_index, voucher_account_index = _build_amount_account_index(voucher_rows, prefix="voucher") + + built: list[dict[str, Any]] = [] + seen_pairs = set(existing_pairs) + for ledger_row in ledger_rows: + amount = round(_get_row_match_amount(ledger_row, "ledger"), 2) + if amount <= 0: + continue + candidates = _candidate_rows_for_amount_account( + amount, + ledger_row, + voucher_amount_index, + voucher_account_index, + ) + if not candidates: + continue + for voucher_row in candidates: + ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row) + voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) + pair_key = (ledger_key, voucher_key) + if not ledger_key or not voucher_key or pair_key in seen_pairs: + continue + + score_result = _score_pair_match(ledger_row, voucher_row) + desc_sim = _jaccard_similarity(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")) + if not _is_strong_substitution_candidate(score_result, desc_sim): + continue + + item = { + "fiscal_year": ledger_row.get("fiscal_year") or voucher_row.get("fiscal_year"), + "voucher_no": ledger_row.get("voucher_no", ""), + "ledger_date": ledger_row.get("ledger_date", ""), + "draft_no": voucher_row.get("draft_no", ""), + "ledger_account_code": ledger_row.get("ledger_account_code", ""), + "ledger_account_name": ledger_row.get("ledger_account_name", ""), + "voucher_account_code": voucher_row.get("voucher_account_code", ""), + "voucher_account_name": voucher_row.get("voucher_account_name", ""), + "ledger_vendor": ledger_row.get("ledger_vendor", ""), + "voucher_vendor": voucher_row.get("voucher_vendor", ""), + "ledger_debit": parse_amount(ledger_row.get("ledger_debit")), + "ledger_credit": parse_amount(ledger_row.get("ledger_credit")), + "voucher_debit": parse_amount(voucher_row.get("voucher_debit")), + "voucher_credit": parse_amount(voucher_row.get("voucher_credit")), + "ledger_desc": ledger_row.get("ledger_desc", ""), + "voucher_desc": voucher_row.get("voucher_desc", ""), + "review_reason": "SUBSTITUTION_RECHECK", + "review_memo": "", + "substitution_hint": score_result.get("substitution_hint") or "미지급금/보통예금 대체 검토", + } + item["review_key"] = build_review_key(item) + item["match_identity_key"] = build_match_identity_key(item) + item["ledger_row_key"] = ledger_key + item["voucher_row_key"] = voucher_key + built.append(item) + seen_pairs.add(pair_key) + return built + + +def _build_quality_review_rows( + ledger_rows: list[dict[str, Any]], + voucher_rows: list[dict[str, Any]], + existing_pairs: set[tuple[str, str]], +) -> list[dict[str, Any]]: + if not ledger_rows or not voucher_rows: + return [] + + voucher_amount_index, voucher_account_index = _build_amount_account_index(voucher_rows, prefix="voucher") + + built: list[dict[str, Any]] = [] + seen_pairs = set(existing_pairs) + for ledger_row in ledger_rows: + amount = round(_get_row_match_amount(ledger_row, "ledger"), 2) + if amount <= 0: + continue + best_row: dict[str, Any] | None = None + best_score: dict[str, Any] | None = None + for voucher_row in _candidate_rows_for_amount_account( + amount, + ledger_row, + voucher_amount_index, + voucher_account_index, + ): + ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row) + voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) + pair_key = (ledger_key, voucher_key) + if not ledger_key or not voucher_key or pair_key in seen_pairs: + continue + score_result = _score_pair_match(ledger_row, voucher_row) + if score_result.get("auto_eligible"): + continue + if not _is_reviewable_text_match( + float(score_result.get("amount_gap", 0)), + float(score_result.get("vendor_similarity", 0)), + float(score_result.get("desc_similarity", 0)), + bool(score_result.get("month_conflict")), + ): + continue + if best_score is None or float(score_result.get("score", 0)) > float(best_score.get("score", 0)): + best_row = voucher_row + best_score = score_result + if best_row is None or best_score is None: + continue + item = { + "fiscal_year": ledger_row.get("fiscal_year") or best_row.get("fiscal_year"), + "voucher_no": ledger_row.get("voucher_no", ""), + "ledger_date": ledger_row.get("ledger_date", ""), + "draft_no": best_row.get("draft_no", ""), + "ledger_account_code": ledger_row.get("ledger_account_code", ""), + "ledger_account_name": ledger_row.get("ledger_account_name", ""), + "voucher_account_code": best_row.get("voucher_account_code", ""), + "voucher_account_name": best_row.get("voucher_account_name", ""), + "ledger_vendor": ledger_row.get("ledger_vendor", ""), + "voucher_vendor": best_row.get("voucher_vendor", ""), + "ledger_debit": parse_amount(ledger_row.get("ledger_debit")), + "ledger_credit": parse_amount(ledger_row.get("ledger_credit")), + "voucher_debit": parse_amount(best_row.get("voucher_debit")), + "voucher_credit": parse_amount(best_row.get("voucher_credit")), + "ledger_desc": ledger_row.get("ledger_desc", ""), + "voucher_desc": best_row.get("voucher_desc", ""), + "review_reason": "QUALITY_RECHECK", + "review_memo": "", + "substitution_hint": best_score.get("substitution_hint", ""), + } + item["review_key"] = build_review_key(item) + item["match_identity_key"] = build_match_identity_key(item) + item["ledger_row_key"] = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row) + item["voucher_row_key"] = clean(best_row.get("voucher_row_key")) or build_voucher_row_key(best_row) + built.append(item) + seen_pairs.add((item["ledger_row_key"], item["voucher_row_key"])) + return built + + +def _rebalance_matched_rows_for_review( + sections: dict[str, dict[str, Any]], +) -> dict[str, dict[str, Any]]: + kept_rows: list[dict[str, Any]] = [] + moved_rows: list[dict[str, Any]] = [] + moved_to_ledger_only: list[dict[str, Any]] = [] + for row in sections["matched"]["rows"]: + score_result = _score_pair_match(row, row) + if _is_boundary_substitution_row(row): + recheck_row = dict(row) + recheck_row["review_reason"] = "BOUNDARY_SUBSTITUTION_RECHECK" + recheck_row.setdefault("review_memo", "") + recheck_row["substitution_hint"] = ( + score_result.get("substitution_hint") + or clean(recheck_row.get("substitution_hint")) + or "연초/연말 대체전표 매칭 제외" + ) + moved_rows.append(recheck_row) + continue + if not _matched_row_has_voucher_payload(row): + ledger_only_row = dict(row) + ledger_only_row["review_reason"] = "MISSING_MATCH_TARGET" + ledger_only_row["review_memo"] = "" + moved_to_ledger_only.append(ledger_only_row) + continue + if score_result.get("month_conflict") or _date_tokens_conflict(row.get("ledger_desc"), row.get("voucher_desc")): + recheck_row = dict(row) + recheck_row["review_reason"] = "MONTH_CONFLICT_RECHECK" + recheck_row.setdefault("review_memo", "") + moved_rows.append(recheck_row) + continue + if not score_result.get("auto_eligible") and not _is_recheck_row_clear_match(row): + recheck_row = dict(row) + recheck_row["review_reason"] = "WEAK_MATCH_RECHECK" + recheck_row.setdefault("review_memo", "") + moved_rows.append(recheck_row) + continue + kept_rows.append(row) + if moved_rows or moved_to_ledger_only: + sections["matched"]["rows"] = kept_rows + sections["matched"]["count"] = len(kept_rows) + sections["amount_mismatch"]["rows"].extend(moved_rows) + sections["amount_mismatch"]["count"] = len(sections["amount_mismatch"]["rows"]) + sections["ledger_only"]["rows"].extend(moved_to_ledger_only) + sections["ledger_only"]["count"] = len(sections["ledger_only"]["rows"]) + return sections + + +def _promote_recheck_rows_to_matched( + sections: dict[str, dict[str, Any]], +) -> dict[str, dict[str, Any]]: + matched_rows = list(sections["matched"]["rows"]) + recheck_rows = list(sections["amount_mismatch"]["rows"]) + promoted_rows: list[dict[str, Any]] = [] + remaining_rows: list[dict[str, Any]] = [] + + existing_identity_keys = { + clean(row.get("match_identity_key")) or build_match_identity_key(row) + for row in matched_rows + } + + for row in recheck_rows: + score_result = _score_pair_match(row, row) + if score_result.get("auto_eligible") or _is_recheck_row_clear_match(row): + identity_key = clean(row.get("match_identity_key")) or build_match_identity_key(row) + if identity_key not in existing_identity_keys: + promoted = dict(row) + if _is_bank_payable_case(promoted): + promoted["review_reason"] = "BANK_PAYABLE_MATCH" + promoted["matched_case"] = "bank_payable" + promoted["substitution_hint"] = clean(promoted.get("substitution_hint")) or "보통예금/미지급금 case" + else: + promoted["review_reason"] = "자동승격매칭" if score_result.get("auto_eligible") else "RECHECK_CLEAR_MATCH" + promoted_rows.append(promoted) + existing_identity_keys.add(identity_key) + else: + remaining_rows.append(row) + + if promoted_rows: + sections["matched"]["rows"] = matched_rows + promoted_rows + sections["matched"]["count"] = len(sections["matched"]["rows"]) + sections["amount_mismatch"]["rows"] = remaining_rows + sections["amount_mismatch"]["count"] = len(remaining_rows) + return sections + + +def _promote_direct_auto_matches( + sections: dict[str, dict[str, Any]], +) -> dict[str, dict[str, Any]]: + ledger_rows = list(sections["ledger_only"]["rows"]) + voucher_rows = list(sections["voucher_only"]["rows"]) + if not ledger_rows or not voucher_rows: + return sections + + amount_index, account_index = _build_amount_account_index(voucher_rows, prefix="voucher") + + edge_candidates: list[tuple[float, dict[str, Any], dict[str, Any], dict[str, Any]]] = [] + for ledger_row in ledger_rows: + candidate_amounts = { + round(parse_amount(ledger_row.get("ledger_debit")), 2), + round(parse_amount(ledger_row.get("ledger_credit")), 2), + } + seen_voucher_keys: set[str] = set() + for amount in candidate_amounts: + if amount <= 0: + continue + for voucher_row in _candidate_rows_for_amount_account( + amount, + ledger_row, + amount_index, + account_index, + ): + voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) + if voucher_key in seen_voucher_keys: + continue + seen_voucher_keys.add(voucher_key) + score_result = _score_pair_match(ledger_row, voucher_row) + if not score_result.get("auto_eligible"): + continue + boundary_probe = dict(ledger_row) + boundary_probe.update( + { + "proof_date": voucher_row.get("proof_date", ""), + "voucher_account_code": voucher_row.get("voucher_account_code", ""), + "voucher_account_name": voucher_row.get("voucher_account_name", ""), + "voucher_vendor": voucher_row.get("voucher_vendor", ""), + "voucher_desc": voucher_row.get("voucher_desc", ""), + } + ) + if _is_boundary_substitution_row(boundary_probe): + continue + edge_candidates.append((float(score_result.get("score", 0)), ledger_row, voucher_row, score_result)) + + if not edge_candidates: + return sections + + edge_candidates.sort(key=lambda item: item[0], reverse=True) + matched_ledger_keys: set[str] = set() + matched_voucher_keys: set[str] = set() + promoted_rows: list[dict[str, Any]] = [] + + for _score, ledger_row, voucher_row, score_result in edge_candidates: + ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row) + voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) + if ledger_key in matched_ledger_keys or voucher_key in matched_voucher_keys: + continue + merged = dict(ledger_row) + merged.update( + { + "proof_date": voucher_row.get("proof_date", ""), + "draft_no": voucher_row.get("draft_no", ""), + "voucher_account_code": voucher_row.get("voucher_account_code", ""), + "voucher_account_name": voucher_row.get("voucher_account_name", ""), + "voucher_vendor": voucher_row.get("voucher_vendor", ""), + "voucher_debit": voucher_row.get("voucher_debit", 0), + "voucher_credit": voucher_row.get("voucher_credit", 0), + "voucher_desc": voucher_row.get("voucher_desc", ""), + "voucher_row_key": voucher_row.get("voucher_row_key", ""), + "review_reason": "자동승격매칭", + "review_memo": "", + "match_identity_key": build_manual_pair_key(ledger_key, voucher_key), + } + ) + promoted_rows.append(merged) + matched_ledger_keys.add(ledger_key) + matched_voucher_keys.add(voucher_key) + + if not promoted_rows: + return sections + + sections["matched"]["rows"].extend(promoted_rows) + sections["matched"]["count"] = len(sections["matched"]["rows"]) + sections["ledger_only"]["rows"] = [ + row for row in ledger_rows + if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in matched_ledger_keys + ] + sections["ledger_only"]["count"] = len(sections["ledger_only"]["rows"]) + sections["voucher_only"]["rows"] = [ + row for row in voucher_rows + if (clean(row.get("voucher_row_key")) or build_voucher_row_key(row)) not in matched_voucher_keys + ] + sections["voucher_only"]["count"] = len(sections["voucher_only"]["rows"]) + return sections + + +def _matched_row_has_voucher_payload(row: dict[str, Any]) -> bool: + return bool( + clean(row.get("draft_no")) + or clean(row.get("voucher_account_code")) + or clean(row.get("voucher_account_name")) + or clean(row.get("voucher_vendor")) + or clean(row.get("voucher_desc")) + or _get_row_match_amount(row, "voucher") > 0 + ) + + +def _promote_cross_year_auto_matches( + sections: dict[str, dict[str, Any]], +) -> dict[str, dict[str, Any]]: + ledger_rows = list(sections["ledger_only"]["rows"]) + voucher_rows = list(sections["voucher_only"]["rows"]) + if not ledger_rows or not voucher_rows: + return sections + + amount_index, account_index = _build_amount_account_index(voucher_rows, prefix="voucher") + edge_candidates: list[tuple[float, dict[str, Any], dict[str, Any], dict[str, Any]]] = [] + + for ledger_row in ledger_rows: + ledger_year = int(ledger_row.get("fiscal_year") or 0) + if not ledger_year: + continue + candidate_amounts = { + round(parse_amount(ledger_row.get("ledger_debit")), 2), + round(parse_amount(ledger_row.get("ledger_credit")), 2), + } + seen_voucher_keys: set[str] = set() + for amount in candidate_amounts: + if amount <= 0: + continue + for voucher_row in _candidate_rows_for_amount_account(amount, ledger_row, amount_index, account_index): + voucher_year = int(voucher_row.get("fiscal_year") or 0) + if abs(ledger_year - voucher_year) != 1: + continue + voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) + if voucher_key in seen_voucher_keys: + continue + seen_voucher_keys.add(voucher_key) + score_result = _score_pair_match(ledger_row, voucher_row) + probe = dict(ledger_row) + probe.update( + { + "proof_date": voucher_row.get("proof_date", ""), + "draft_no": voucher_row.get("draft_no", ""), + "voucher_account_code": voucher_row.get("voucher_account_code", ""), + "voucher_account_name": voucher_row.get("voucher_account_name", ""), + "voucher_vendor": voucher_row.get("voucher_vendor", ""), + "voucher_debit": voucher_row.get("voucher_debit", 0), + "voucher_credit": voucher_row.get("voucher_credit", 0), + "voucher_desc": voucher_row.get("voucher_desc", ""), + } + ) + if not score_result.get("auto_eligible"): + continue + if not (_same_or_similar_desc(probe) or _same_or_similar_vendor(probe)): + continue + edge_candidates.append((float(score_result.get("score", 0)), ledger_row, voucher_row, score_result)) + + if not edge_candidates: + return sections + + edge_candidates.sort(key=lambda item: item[0], reverse=True) + matched_ledger_keys: set[str] = set() + matched_voucher_keys: set[str] = set() + promoted_rows: list[dict[str, Any]] = [] + + for _score, ledger_row, voucher_row, _score_result in edge_candidates: + ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row) + voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) + if ledger_key in matched_ledger_keys or voucher_key in matched_voucher_keys: + continue + merged = dict(ledger_row) + merged.update( + { + "proof_date": voucher_row.get("proof_date", ""), + "draft_no": voucher_row.get("draft_no", ""), + "voucher_account_code": voucher_row.get("voucher_account_code", ""), + "voucher_account_name": voucher_row.get("voucher_account_name", ""), + "voucher_vendor": voucher_row.get("voucher_vendor", ""), + "voucher_debit": voucher_row.get("voucher_debit", 0), + "voucher_credit": voucher_row.get("voucher_credit", 0), + "voucher_desc": voucher_row.get("voucher_desc", ""), + "voucher_row_key": voucher_row.get("voucher_row_key", ""), + "review_reason": "연도교차자동매칭", + "matched_case": "cross_year", + "review_memo": "", + "match_identity_key": build_manual_pair_key(ledger_key, voucher_key), + } + ) + promoted_rows.append(merged) + matched_ledger_keys.add(ledger_key) + matched_voucher_keys.add(voucher_key) + + if not promoted_rows: + return sections + + sections["matched"]["rows"].extend(promoted_rows) + sections["matched"]["count"] = len(sections["matched"]["rows"]) + sections["ledger_only"]["rows"] = [ + row for row in ledger_rows + if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in matched_ledger_keys + ] + sections["ledger_only"]["count"] = len(sections["ledger_only"]["rows"]) + sections["voucher_only"]["rows"] = [ + row for row in voucher_rows + if (clean(row.get("voucher_row_key")) or build_voucher_row_key(row)) not in matched_voucher_keys + ] + sections["voucher_only"]["count"] = len(sections["voucher_only"]["rows"]) + return sections + + +def _apply_previous_year_erp_candidates( + conn: Any, + year: int, + sections: dict[str, dict[str, Any]], +) -> dict[str, dict[str, Any]]: + if year <= 0: + return sections + ledger_rows = list(sections["ledger_only"]["rows"]) + if not ledger_rows: + return sections + + available_years = set(_discover_available_fiscal_years(conn)) + prior_years = sorted((candidate_year for candidate_year in available_years if candidate_year < year), reverse=True) + next_years = sorted(candidate_year for candidate_year in available_years if candidate_year > year) + candidate_years = prior_years + next_years + if not candidate_years: + return sections + + def jan1_row_is_excluded(row: dict[str, Any]) -> bool: + return _extract_boundary_month_day(row.get("ledger_date"), row.get("fiscal_year")) == "01-01" and _row_has_earlier_fiscal_year_source(conn, row) + + adjacent_voucher_rows: list[dict[str, Any]] = [] + for candidate_year in candidate_years: + payload = _fetch_status_detail_rows_from_db( + conn, + candidate_year, + candidate_year, + "voucher_only", + "", + "", + "", + "", + "", + "", + "", + "", + "", + False, + 0, + 1_000_000, + ) + adjacent_voucher_rows.extend(list(payload.get("rows", []))) + if not adjacent_voucher_rows: + return sections + + def remaining_matched_voucher_keys() -> set[str]: + return { + clean(row.get("voucher_row_key")) or build_voucher_row_key(row) + for row in sections["matched"]["rows"] + if clean(row.get("voucher_row_key")) or build_voucher_row_key(row) + } + + def remaining_matched_ledger_keys() -> set[str]: + return { + clean(row.get("ledger_row_key")) or build_ledger_row_key(row) + for row in sections["matched"]["rows"] + if clean(row.get("ledger_row_key")) or build_ledger_row_key(row) + } + + def apply_adjacent_candidates(eligible_ledger_rows: list[dict[str, Any]], *, boundary_phase: bool) -> list[dict[str, Any]]: + if not eligible_ledger_rows: + return [] + matched_ledger_keys = remaining_matched_ledger_keys() + matched_voucher_keys = remaining_matched_voucher_keys() + usable_voucher_rows = [ + row + for row in adjacent_voucher_rows + if (clean(row.get("voucher_row_key")) or build_voucher_row_key(row)) not in matched_voucher_keys + ] + if not usable_voucher_rows: + return [] + + amount_index, account_index = _build_amount_account_index(usable_voucher_rows, prefix="voucher") + edge_candidates: list[tuple[float, dict[str, Any], dict[str, Any], dict[str, Any]]] = [] + + for ledger_row in eligible_ledger_rows: + ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row) + if not ledger_key or ledger_key in matched_ledger_keys: + continue + if jan1_row_is_excluded(ledger_row): + continue + if boundary_phase != _is_boundary_substitution_row(ledger_row): + continue + candidate_amounts = { + round(parse_amount(ledger_row.get("ledger_debit")), 2), + round(parse_amount(ledger_row.get("ledger_credit")), 2), + } + seen_voucher_keys: set[str] = set() + for amount in candidate_amounts: + if amount <= 0: + continue + for voucher_row in _candidate_rows_for_amount_account( + amount, + ledger_row, + amount_index, + account_index, + ): + voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) + if not voucher_key or voucher_key in matched_voucher_keys or voucher_key in seen_voucher_keys: + continue + seen_voucher_keys.add(voucher_key) + score_result = _score_pair_match(ledger_row, voucher_row) + probe = dict(ledger_row) + probe.update( + { + "proof_date": voucher_row.get("proof_date", ""), + "draft_no": voucher_row.get("draft_no", ""), + "voucher_account_code": voucher_row.get("voucher_account_code", ""), + "voucher_account_name": voucher_row.get("voucher_account_name", ""), + "voucher_vendor": voucher_row.get("voucher_vendor", ""), + "voucher_debit": voucher_row.get("voucher_debit", 0), + "voucher_credit": voucher_row.get("voucher_credit", 0), + "voucher_desc": voucher_row.get("voucher_desc", ""), + } + ) + if boundary_phase != _is_boundary_substitution_row(probe): + continue + if not score_result.get("auto_eligible"): + continue + if not (_same_or_similar_desc(probe) or _same_or_similar_vendor(probe)): + continue + edge_candidates.append((float(score_result.get("score", 0)), ledger_row, voucher_row, score_result)) + + if not edge_candidates: + return [] + + edge_candidates.sort(key=lambda item: item[0], reverse=True) + phase_matched_ledger_keys: set[str] = set() + phase_matched_voucher_keys: set[str] = set() + promoted_rows: list[dict[str, Any]] = [] + + for _score, ledger_row, voucher_row, _score_result in edge_candidates: + ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row) + voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) + if ( + ledger_key in matched_ledger_keys + or voucher_key in matched_voucher_keys + or ledger_key in phase_matched_ledger_keys + or voucher_key in phase_matched_voucher_keys + ): + continue + voucher_year = int(voucher_row.get("fiscal_year") or 0) + review_reason = "전기ERP자동매칭" if voucher_year < year else "차기ERP자동매칭" + matched_case = "previous_year_erp" if voucher_year < year else "next_year_erp" + merged = dict(ledger_row) + merged.update( + { + "proof_date": voucher_row.get("proof_date", ""), + "draft_no": voucher_row.get("draft_no", ""), + "voucher_account_code": voucher_row.get("voucher_account_code", ""), + "voucher_account_name": voucher_row.get("voucher_account_name", ""), + "voucher_vendor": voucher_row.get("voucher_vendor", ""), + "voucher_debit": voucher_row.get("voucher_debit", 0), + "voucher_credit": voucher_row.get("voucher_credit", 0), + "voucher_desc": voucher_row.get("voucher_desc", ""), + "voucher_row_key": voucher_row.get("voucher_row_key", ""), + "review_reason": review_reason, + "matched_case": matched_case, + "review_memo": "", + "match_identity_key": build_manual_pair_key(ledger_key, voucher_key), + } + ) + promoted_rows.append(merged) + phase_matched_ledger_keys.add(ledger_key) + phase_matched_voucher_keys.add(voucher_key) + + if promoted_rows: + sections["matched"]["rows"].extend(promoted_rows) + sections["matched"]["count"] = len(sections["matched"]["rows"]) + remaining_keys = remaining_matched_ledger_keys() + ledger_rows[:] = [ + row + for row in ledger_rows + if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in remaining_keys + ] + sections["ledger_only"]["rows"] = ledger_rows + sections["ledger_only"]["count"] = len(ledger_rows) + return promoted_rows + + def build_adjacent_review_rows(eligible_ledger_rows: list[dict[str, Any]], *, boundary_phase: bool) -> list[dict[str, Any]]: + matched_voucher_keys = remaining_matched_voucher_keys() + matched_ledger_keys = remaining_matched_ledger_keys() + review_voucher_rows = [ + row + for row in adjacent_voucher_rows + if (clean(row.get("voucher_row_key")) or build_voucher_row_key(row)) not in matched_voucher_keys + ] + review_ledger_rows = [ + row + for row in eligible_ledger_rows + if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in matched_ledger_keys + and (_is_boundary_substitution_row(row) == boundary_phase) + and not jan1_row_is_excluded(row) + ] + if not review_ledger_rows or not review_voucher_rows: + return [] + existing_pairs = { + ( + clean(row.get("ledger_row_key")) or build_ledger_row_key(row), + clean(row.get("voucher_row_key")) or build_voucher_row_key(row), + ) + for row in sections["amount_mismatch"]["rows"] + } + review_rows = [] + review_rows.extend(_build_substitution_review_rows(review_ledger_rows, review_voucher_rows, existing_pairs)) + existing_pairs.update( + { + ( + clean(row.get("ledger_row_key")) or build_ledger_row_key(row), + clean(row.get("voucher_row_key")) or build_voucher_row_key(row), + ) + for row in review_rows + } + ) + review_rows.extend(_build_quality_review_rows(review_ledger_rows, review_voucher_rows, existing_pairs)) + return review_rows + + # 1) 일반 전표를 먼저 인접 연도 ERP와 비교 + apply_adjacent_candidates(ledger_rows, boundary_phase=False) + normal_review_rows = build_adjacent_review_rows(ledger_rows, boundary_phase=False) + if normal_review_rows: + review_ledger_keys = { + clean(row.get("ledger_row_key")) or build_ledger_row_key(row) + for row in normal_review_rows + } + sections["amount_mismatch"]["rows"].extend(normal_review_rows) + sections["amount_mismatch"]["count"] = len(sections["amount_mismatch"]["rows"]) + sections["ledger_only"]["rows"] = [ + row + for row in sections["ledger_only"]["rows"] + if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in review_ledger_keys + ] + sections["ledger_only"]["count"] = len(sections["ledger_only"]["rows"]) + ledger_rows = list(sections["ledger_only"]["rows"]) + + # 2) 연초/연말 대체성 전표는 최후 순서로만 인접 연도 ERP와 비교 + apply_adjacent_candidates(ledger_rows, boundary_phase=True) + boundary_review_rows = build_adjacent_review_rows(ledger_rows, boundary_phase=True) + if boundary_review_rows: + review_ledger_keys = { + clean(row.get("ledger_row_key")) or build_ledger_row_key(row) + for row in boundary_review_rows + } + sections["amount_mismatch"]["rows"].extend(boundary_review_rows) + sections["amount_mismatch"]["count"] = len(sections["amount_mismatch"]["rows"]) + sections["ledger_only"]["rows"] = [ + row + for row in sections["ledger_only"]["rows"] + if (clean(row.get("ledger_row_key")) or build_ledger_row_key(row)) not in review_ledger_keys + ] + sections["ledger_only"]["count"] = len(sections["ledger_only"]["rows"]) + return sections + + +def _matched_signature_set(rows: list[dict[str, Any]]) -> set[str]: + signatures: set[str] = set() + for row in rows: + parts = [ + clean(row.get("voucher_no")), + clean(row.get("draft_no")), + normalize_text(row.get("ledger_vendor")), + normalize_text(row.get("voucher_vendor")), + normalize_text(row.get("ledger_desc")), + normalize_text(row.get("voucher_desc")), + clean(row.get("ledger_account_name")), + clean(row.get("voucher_account_name")), + f"{_get_row_match_amount(row, 'ledger'):.2f}", + f"{_get_row_match_amount(row, 'voucher'):.2f}", + ] + signatures.add("|".join(parts)) + return signatures + + +def _drop_recheck_rows_covered_by_matched( + sections: dict[str, dict[str, Any]], +) -> dict[str, dict[str, Any]]: + matched_signatures = _matched_signature_set(sections["matched"]["rows"]) + matched_ledger_keys = { + clean(row.get("ledger_row_key")) or build_ledger_row_key(row) + for row in sections["matched"]["rows"] + if clean(row.get("ledger_row_key")) or build_ledger_row_key(row) + } + matched_voucher_keys = { + clean(row.get("voucher_row_key")) or build_voucher_row_key(row) + for row in sections["matched"]["rows"] + if clean(row.get("voucher_row_key")) or build_voucher_row_key(row) + } + if not matched_signatures and not matched_ledger_keys and not matched_voucher_keys: + return sections + remaining_rows: list[dict[str, Any]] = [] + for row in sections["amount_mismatch"]["rows"]: + ledger_key = clean(row.get("ledger_row_key")) or build_ledger_row_key(row) + voucher_key = clean(row.get("voucher_row_key")) or build_voucher_row_key(row) + if (ledger_key and ledger_key in matched_ledger_keys) or (voucher_key and voucher_key in matched_voucher_keys): + continue + signature = "|".join( + [ + clean(row.get("voucher_no")), + clean(row.get("draft_no")), + normalize_text(row.get("ledger_vendor")), + normalize_text(row.get("voucher_vendor")), + normalize_text(row.get("ledger_desc")), + normalize_text(row.get("voucher_desc")), + clean(row.get("ledger_account_name")), + clean(row.get("voucher_account_name")), + f"{_get_row_match_amount(row, 'ledger'):.2f}", + f"{_get_row_match_amount(row, 'voucher'):.2f}", + ] + ) + if signature in matched_signatures: + continue + remaining_rows.append(row) + sections["amount_mismatch"]["rows"] = remaining_rows + sections["amount_mismatch"]["count"] = len(remaining_rows) + return sections + + +def _append_substitution_review_rows( + sections: dict[str, dict[str, Any]], +) -> dict[str, dict[str, Any]]: + sections = _rebalance_matched_rows_for_review(sections) + sections = _promote_recheck_rows_to_matched(sections) + if not ENABLE_GENERATED_RECHECK_CANDIDATES: + return _drop_recheck_rows_covered_by_matched(sections) + + sections = _promote_direct_auto_matches(sections) + amount_rows = sections["amount_mismatch"]["rows"] + existing_pairs = { + ( + clean(row.get("ledger_row_key")) or build_ledger_row_key(row), + clean(row.get("voucher_row_key")) or build_voucher_row_key(row), + ) + for row in amount_rows + } + + matched_rows = list(sections["matched"]["rows"]) + ledger_only_rows = list(sections["ledger_only"]["rows"]) + voucher_only_rows = list(sections["voucher_only"]["rows"]) + + synthetic_rows = [] + synthetic_rows.extend( + _build_substitution_review_rows(matched_rows + ledger_only_rows, voucher_only_rows, existing_pairs) + ) + existing_pairs.update( + { + ( + clean(row.get("ledger_row_key")) or build_ledger_row_key(row), + clean(row.get("voucher_row_key")) or build_voucher_row_key(row), + ) + for row in synthetic_rows + } + ) + synthetic_rows.extend( + _build_substitution_review_rows(ledger_only_rows, matched_rows + voucher_only_rows, existing_pairs) + ) + existing_pairs.update( + { + ( + clean(row.get("ledger_row_key")) or build_ledger_row_key(row), + clean(row.get("voucher_row_key")) or build_voucher_row_key(row), + ) + for row in synthetic_rows + } + ) + synthetic_rows.extend( + _build_quality_review_rows(ledger_only_rows, voucher_only_rows, existing_pairs) + ) + if not synthetic_rows: + sections = _promote_recheck_rows_to_matched(sections) + return _drop_recheck_rows_covered_by_matched(sections) + + sections["amount_mismatch"]["rows"].extend(synthetic_rows) + sections["amount_mismatch"]["count"] = len(sections["amount_mismatch"]["rows"]) + sections = _promote_recheck_rows_to_matched(sections) + return _drop_recheck_rows_covered_by_matched(sections) + + def _numeric_amount_for_side(row: dict[str, Any], side: str) -> float: if side == "debit": return parse_amount(row.get("ledger_debit") if "ledger_debit" in row else row.get("voucher_debit")) @@ -2452,25 +4982,73 @@ def _determine_primary_side(ledger_row: dict[str, Any]) -> str: def _score_pair_match(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -> dict[str, Any]: + ledger_features = _build_match_row_features( + ledger_row, + prefix="ledger", + row_key_field="ledger_row_key", + account_code_field="ledger_account_code", + account_name_field="ledger_account_name", + vendor_field="ledger_vendor", + desc_field="ledger_desc", + date_field="ledger_date", + ) + voucher_features = _build_match_row_features( + voucher_row, + prefix="voucher", + row_key_field="voucher_row_key", + account_code_field="voucher_account_code", + account_name_field="voucher_account_name", + vendor_field="voucher_vendor", + desc_field="voucher_desc", + date_field="proof_date", + ) + return _score_pair_match_features(ledger_features, voucher_features) + + +def _score_pair_match_features(ledger: MatchRowFeatures, voucher: MatchRowFeatures) -> dict[str, Any]: score = 0.0 reasons: list[str] = [] hard_pass = True + account_supported = False - side = _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) + side = ledger.primary_side + ledger_amount = _get_feature_side_amount(ledger, side) + voucher_same_side_amount = _get_feature_side_amount(voucher, side) + voucher_any_side_amount = voucher.match_amount + amount_gap_same_side = abs(ledger_amount - voucher_same_side_amount) + amount_gap_any_side = abs(ledger_amount - voucher_any_side_amount) + amount_gap = min(amount_gap_same_side, amount_gap_any_side) + side_flipped_match = amount_gap_any_side < 0.5 and amount_gap_same_side >= 0.5 + nature_same_side_ok = _nature_compatible( + ledger.account_code, + ledger.account_name, + side, + voucher.account_code, + voucher.account_name, + side, + ) + opposite_side = "credit" if side == "debit" else "debit" if side == "credit" else "either" + nature_opposite_side_ok = _nature_compatible( + ledger.account_code, + ledger.account_name, + side, + voucher.account_code, + voucher.account_name, + opposite_side, + ) if opposite_side in {"debit", "credit"} else False + family_pair_allowed = _account_category_pair_allowed( + ledger.account_code, + ledger.account_name, + voucher.account_code, + voucher.account_name, + ) + ledger_family = _classify_account_family(ledger.account_code, ledger.account_name) + voucher_family = _classify_account_family(voucher.account_code, voucher.account_name) if amount_gap < 0.5 and ledger_amount > 0: score += 50 reasons.append("금액 일치") + if side_flipped_match: + reasons.append("차대 방향 보정") elif amount_gap < 5 and ledger_amount > 0: score += 35 reasons.append("금액 근접") @@ -2480,26 +5058,81 @@ def _score_pair_match(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) - 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("계정코드 대분류 일치") + if not family_pair_allowed: + hard_pass = False + score -= 60 + reasons.append("계정 성격 교차 불가") + + if side in {"debit", "credit"}: + if amount_gap_same_side < 0.5 and not nature_same_side_ok: + hard_pass = False + score -= 40 + reasons.append("차대 성격 불일치") + elif side_flipped_match: + # 같은 금액이어도 반대 방향이면 원칙적으로 다른 거래 성격으로 본다. + if not nature_opposite_side_ok: + hard_pass = False + score -= 45 + reasons.append("차대 방향 성격 불일치") + + substitution_hint = _build_account_substitution_hint( + ledger.account_code, + ledger.account_name, + voucher.account_code, + voucher.account_name, + ) + account_sim = _jaccard_similarity_tokens(ledger.account_tokens, voucher.account_tokens) + bank_pair = ledger_family == "bank" and voucher_family == "bank" + if ledger.account_code and voucher.account_code and ledger.account_code == voucher.account_code: + score += 4 if bank_pair else 22 + reasons.append("보통예금 코드 일치" if bank_pair else "계정코드 일치") + account_supported = True + elif ledger.account_code and voucher.account_code and ledger.account_code[:4] == voucher.account_code[:4]: + score += 2 if bank_pair else 10 + reasons.append("보통예금 대분류 일치" if bank_pair else "계정코드 대분류 일치") + account_supported = True + elif substitution_hint: + score += 12 + reasons.append(substitution_hint) + account_supported = True else: if account_sim >= 0.8: - score += 16 - reasons.append("계정명 유사도 높음") + score += 3 if bank_pair else 16 + reasons.append("보통예금 계정명 유사" if bank_pair else "계정명 유사도 높음") + account_supported = True elif account_sim >= 0.55: - score += 8 - reasons.append("계정명 유사") + score += 2 if bank_pair else 8 + reasons.append("보통예금 계정명 유사" if bank_pair else "계정명 유사") + account_supported = True + elif _account_base_names_compatible(ledger.account_name, voucher.account_name): + score += 2 if bank_pair else 12 + reasons.append("보통예금 핵심 일치" if bank_pair else "계정명 핵심 일치") + account_supported = True else: - hard_pass = False + if ledger_family and ledger_family == voucher_family: + score += 2 if bank_pair else 10 + reasons.append("보통예금 계정군 일치" if bank_pair else "계정군 일치") + account_supported = True - vendor_sim = _jaccard_similarity(ledger_row.get("ledger_vendor"), voucher_row.get("voucher_vendor")) + vendor_sim = _jaccard_similarity_tokens(ledger.vendor_tokens, voucher.vendor_tokens) + row_for_text = { + "ledger_account_code": ledger.account_code, + "ledger_account_name": ledger.account_name, + "ledger_vendor": ledger.vendor_name, + "ledger_desc": ledger.desc_text, + "voucher_account_code": voucher.account_code, + "voucher_account_name": voucher.account_name, + "voucher_vendor": voucher.vendor_name, + "voucher_desc": voucher.desc_text, + } + account_strong = bool( + (ledger.account_code and voucher.account_code and ledger.account_code == voucher.account_code) + or substitution_hint + or account_sim >= 0.8 + or _account_names_compatible(row_for_text, row_for_text) + ) + vendor_core_match = _same_or_similar_vendor(row_for_text) + desc_core_match = _same_or_similar_desc(row_for_text) if vendor_sim >= 0.9: score += 16 reasons.append("거래처 일치") @@ -2512,7 +5145,7 @@ def _score_pair_match(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) - else: score -= 8 - desc_sim = _jaccard_similarity(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")) + desc_sim = _jaccard_similarity_tokens(ledger.desc_tokens, voucher.desc_tokens) if desc_sim >= 0.85: score += 10 reasons.append("적요 매우 유사") @@ -2522,19 +5155,54 @@ def _score_pair_match(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) - 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: + month_conflict = _has_conflicting_month_token_sets( + ledger.month_tokens, + voucher.month_tokens, + ) or _date_tokens_conflict(ledger.desc_text, voucher.desc_text) + + if substitution_hint and amount_gap < 0.5: + if desc_sim >= 0.85: score += 8 - reasons.append("일자 근접") - elif day_gap <= 10: + reasons.append("대체 적요 일치") + elif desc_sim >= 0.6: score += 4 - elif day_gap <= 45: - score += 1 + reasons.append("대체 적요 유사") + + strong_text_match = _is_strong_text_match(amount_gap, vendor_sim, desc_sim, month_conflict) + if strong_text_match: + score += 10 + reasons.append("적요/거래처 강한 일치") + elif not account_supported: + hard_pass = False + + if ledger.date_value and voucher.date_value: + day_gap = abs((ledger.date_value - voucher.date_value).days) + month_gap = _month_gap(ledger.date_value, voucher.date_value) + if strong_text_match: + if day_gap <= 7: + score += 8 + reasons.append("일자 근접") + elif day_gap <= 31: + score += 5 + reasons.append("일자 차이 허용") + elif month_gap <= PAIR_RECOMMEND_DATE_WINDOW_MONTHS: + score += 2 + reasons.append("전후 8개월 내 일자 차이") + else: + score -= 6 + reasons.append("전후 8개월 초과") else: - score -= 6 + if day_gap <= 3: + score += 8 + reasons.append("일자 근접") + elif day_gap <= 10: + score += 4 + elif month_gap <= PAIR_RECOMMEND_DATE_WINDOW_MONTHS: + score += 1 + reasons.append("전후 8개월 내 일자 차이") + else: + score -= 6 + reasons.append("전후 8개월 초과") confidence = "low" if score >= 88: @@ -2544,13 +5212,14 @@ def _score_pair_match(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) - auto_eligible = bool( hard_pass - and score >= 88 and amount_gap < 0.5 + and not side_flipped_match + and not month_conflict 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 + (score >= 82 and strong_text_match and vendor_sim >= 0.65) + or (score >= 72 and account_strong and vendor_core_match) + or (score >= 68 and account_strong and desc_core_match) ) - and vendor_sim >= 0.65 ) return { "score": round(score, 2), @@ -2560,7 +5229,11 @@ def _score_pair_match(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) - "hard_pass": hard_pass, "vendor_similarity": round(vendor_sim, 4), "account_similarity": round(account_sim, 4), + "desc_similarity": round(desc_sim, 4), "amount_gap": round(amount_gap, 2), + "month_conflict": month_conflict, + "strong_text_match": strong_text_match, + "substitution_hint": substitution_hint, } @@ -2613,7 +5286,317 @@ def _collect_status_rows_for_workbench( return result -def recommend_pair_matches( +def _build_pair_recommend_cache_key( + start_year: int | None, + end_year: int | None, + ledger_voucher_no: str = "", + ledger_review_reason: str = "", + voucher_voucher_no: str = "", + voucher_review_reason: str = "", + limit: int = 300, +) -> str: + safe_limit = max(min(int(limit or 300), 1000), 1) + return "|".join( + [ + PAIR_RECOMMEND_POLICY_VERSION, + str(start_year), + str(end_year), + normalize_text(ledger_voucher_no), + normalize_text(ledger_review_reason), + normalize_text(voucher_voucher_no), + normalize_text(voucher_review_reason), + str(safe_limit), + ] + ) + + +def _delete_stale_pair_recommend_cache(conn: Any) -> None: + conn.execute( + text( + """ + DELETE FROM wehago_pair_recommend_cache + WHERE strftime('%s', 'now') - strftime('%s', updated_at) > :ttl + """ + ), + {"ttl": int(_PAIR_RECOMMEND_PERSIST_TTL_SEC)}, + ) + + +def _load_persisted_pair_recommend_cache(conn: Any, cache_key: str) -> dict[str, Any] | None: + row = conn.execute( + text( + """ + SELECT payload_json + FROM wehago_pair_recommend_cache + WHERE cache_key = :cache_key + AND strftime('%s', 'now') - strftime('%s', updated_at) <= :ttl + """ + ), + {"cache_key": cache_key, "ttl": int(_PAIR_RECOMMEND_PERSIST_TTL_SEC)}, + ).mappings().first() + if not row: + return None + try: + payload = json.loads(row["payload_json"] or "{}") + except json.JSONDecodeError: + return None + conn.execute( + text( + """ + UPDATE wehago_pair_recommend_cache + SET last_accessed_at = CURRENT_TIMESTAMP + WHERE cache_key = :cache_key + """ + ), + {"cache_key": cache_key}, + ) + return payload if isinstance(payload, dict) else None + + +def _store_persisted_pair_recommend_cache( + conn: Any, + cache_key: str, + payload: dict[str, Any], + *, + start_year: int | None, + end_year: int | None, + ledger_voucher_no: str, + ledger_review_reason: str, + voucher_voucher_no: str, + voucher_review_reason: str, + limit: int, +) -> None: + safe_limit = max(min(int(limit or 300), 1000), 1) + payload_json = json.dumps(payload, ensure_ascii=False) + pair_count = len(payload.get("pairs") or []) + conn.execute( + text( + """ + INSERT INTO wehago_pair_recommend_cache ( + cache_key, start_year, end_year, + ledger_voucher_no, ledger_review_reason, voucher_voucher_no, voucher_review_reason, + row_limit, payload_json, pair_count, created_at, updated_at, last_accessed_at + ) VALUES ( + :cache_key, :start_year, :end_year, + :ledger_voucher_no, :ledger_review_reason, :voucher_voucher_no, :voucher_review_reason, + :row_limit, :payload_json, :pair_count, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + ON CONFLICT(cache_key) DO UPDATE SET + start_year = excluded.start_year, + end_year = excluded.end_year, + ledger_voucher_no = excluded.ledger_voucher_no, + ledger_review_reason = excluded.ledger_review_reason, + voucher_voucher_no = excluded.voucher_voucher_no, + voucher_review_reason = excluded.voucher_review_reason, + row_limit = excluded.row_limit, + payload_json = excluded.payload_json, + pair_count = excluded.pair_count, + updated_at = CURRENT_TIMESTAMP, + last_accessed_at = CURRENT_TIMESTAMP + """ + ), + { + "cache_key": cache_key, + "start_year": start_year, + "end_year": end_year, + "ledger_voucher_no": clean(ledger_voucher_no), + "ledger_review_reason": clean(ledger_review_reason), + "voucher_voucher_no": clean(voucher_voucher_no), + "voucher_review_reason": clean(voucher_review_reason), + "row_limit": safe_limit, + "payload_json": payload_json, + "pair_count": pair_count, + }, + ) + + +def clear_persisted_pair_recommend_cache(engine: Any) -> None: + init_wehago_compare_db(engine) + with engine.begin() as conn: + conn.execute(text("DELETE FROM wehago_pair_recommend_cache")) + conn.execute(text("DELETE FROM wehago_background_jobs WHERE job_type = 'pair_recommend_precompute'")) + + +def _ensure_pair_recommend_worker(engine: Any) -> None: + global _PAIR_RECOMMEND_WORKER_STARTED + with _PAIR_RECOMMEND_WORKER_LOCK: + if _PAIR_RECOMMEND_WORKER_STARTED: + return + worker = threading.Thread( + target=_pair_recommend_worker_loop, + args=(engine,), + daemon=True, + name="wehago-pair-recommend-worker", + ) + worker.start() + _PAIR_RECOMMEND_WORKER_STARTED = True + + +def enqueue_pair_recommend_precompute( + engine: Any, + start_year: int | None, + end_year: int | None, + ledger_voucher_no: str = "", + ledger_review_reason: str = "", + voucher_voucher_no: str = "", + voucher_review_reason: str = "", + limit: int = 300, +) -> None: + safe_limit = max(min(int(limit or 300), 1000), 1) + cache_key = _build_pair_recommend_cache_key( + start_year, + end_year, + ledger_voucher_no=ledger_voucher_no, + ledger_review_reason=ledger_review_reason, + voucher_voucher_no=voucher_voucher_no, + voucher_review_reason=voucher_review_reason, + limit=safe_limit, + ) + payload = { + "cache_key": cache_key, + "start_year": start_year, + "end_year": end_year, + "ledger_voucher_no": clean(ledger_voucher_no), + "ledger_review_reason": clean(ledger_review_reason), + "voucher_voucher_no": clean(voucher_voucher_no), + "voucher_review_reason": clean(voucher_review_reason), + "limit": safe_limit, + } + init_wehago_compare_db(engine) + with engine.begin() as conn: + _delete_stale_pair_recommend_cache(conn) + existing_payload = _load_persisted_pair_recommend_cache(conn, cache_key) + if existing_payload is not None: + return + existing_job = conn.execute( + text( + """ + SELECT state + FROM wehago_background_jobs + WHERE job_key = :job_key + AND job_type = 'pair_recommend_precompute' + """ + ), + {"job_key": cache_key}, + ).mappings().first() + if existing_job and clean(existing_job.get("state")) in {"queued", "running"}: + return + conn.execute( + text( + """ + INSERT INTO wehago_background_jobs ( + job_key, job_type, payload_json, state, error_message, created_at, updated_at + ) VALUES ( + :job_key, 'pair_recommend_precompute', :payload_json, 'queued', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + ON CONFLICT(job_key) DO UPDATE SET + payload_json = excluded.payload_json, + state = CASE + WHEN wehago_background_jobs.state = 'running' THEN wehago_background_jobs.state + ELSE 'queued' + END, + error_message = '', + updated_at = CURRENT_TIMESTAMP + """ + ), + {"job_key": cache_key, "payload_json": json.dumps(payload, ensure_ascii=False)}, + ) + _ensure_pair_recommend_worker(engine) + _PAIR_RECOMMEND_JOB_EVENT.set() + + +def _pair_recommend_worker_loop(engine: Any) -> None: + while True: + _PAIR_RECOMMEND_JOB_EVENT.wait(timeout=5.0) + _PAIR_RECOMMEND_JOB_EVENT.clear() + while True: + init_wehago_compare_db(engine) + with engine.begin() as conn: + job = conn.execute( + text( + """ + SELECT job_key, payload_json + FROM wehago_background_jobs + WHERE job_type = 'pair_recommend_precompute' + AND state = 'queued' + ORDER BY created_at ASC + LIMIT 1 + """ + ) + ).mappings().first() + if not job: + break + conn.execute( + text( + """ + UPDATE wehago_background_jobs + SET state = 'running', + error_message = '', + started_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE job_key = :job_key + """ + ), + {"job_key": job["job_key"]}, + ) + try: + payload = json.loads(job["payload_json"] or "{}") + if not isinstance(payload, dict): + payload = {} + result = _compute_pair_recommendations_payload( + engine, + start_year=payload.get("start_year"), + end_year=payload.get("end_year"), + ledger_voucher_no=payload.get("ledger_voucher_no", ""), + ledger_review_reason=payload.get("ledger_review_reason", ""), + voucher_voucher_no=payload.get("voucher_voucher_no", ""), + voucher_review_reason=payload.get("voucher_review_reason", ""), + limit=int(payload.get("limit") or 300), + ) + with engine.begin() as conn: + _store_persisted_pair_recommend_cache( + conn, + payload.get("cache_key") or job["job_key"], + result, + start_year=payload.get("start_year"), + end_year=payload.get("end_year"), + ledger_voucher_no=payload.get("ledger_voucher_no", ""), + ledger_review_reason=payload.get("ledger_review_reason", ""), + voucher_voucher_no=payload.get("voucher_voucher_no", ""), + voucher_review_reason=payload.get("voucher_review_reason", ""), + limit=int(payload.get("limit") or 300), + ) + conn.execute( + text( + """ + UPDATE wehago_background_jobs + SET state = 'done', + error_message = '', + finished_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE job_key = :job_key + """ + ), + {"job_key": job["job_key"]}, + ) + except Exception as exc: + with engine.begin() as conn: + conn.execute( + text( + """ + UPDATE wehago_background_jobs + SET state = 'failed', + error_message = :error_message, + finished_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE job_key = :job_key + """ + ), + {"job_key": job["job_key"], "error_message": clean(exc)}, + ) + + +def _compute_pair_recommendations_payload( engine: Any, start_year: int | None, end_year: int | None, @@ -2624,22 +5607,6 @@ def recommend_pair_matches( limit: int = 300, ) -> dict[str, Any]: safe_limit = max(min(int(limit or 300), 1000), 1) - cache_key = "|".join( - [ - str(start_year), - str(end_year), - normalize_text(ledger_voucher_no), - normalize_text(ledger_review_reason), - normalize_text(voucher_voucher_no), - normalize_text(voucher_review_reason), - str(safe_limit), - ] - ) - now = time.time() - cached = _PAIR_RECOMMEND_CACHE.get(cache_key) - if cached and (now - float(cached.get("ts", 0))) <= _PAIR_RECOMMEND_CACHE_TTL_SEC: - return cached["payload"] - dataset = _collect_status_rows_for_workbench( engine, start_year, @@ -2652,196 +5619,76 @@ def recommend_pair_matches( ledger_rows = dataset["ledger_only"] voucher_rows = dataset["voucher_only"] if not ledger_rows or not voucher_rows: - payload = {"pairs": [], "stats": {"ledger_rows": len(ledger_rows), "voucher_rows": len(voucher_rows), "recommended": 0, "auto_eligible": 0}} - _PAIR_RECOMMEND_CACHE.clear() - _PAIR_RECOMMEND_CACHE[cache_key] = {"ts": now, "payload": payload} - return payload + 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: + ledger_features = [ + _build_match_row_features( + row, + prefix="ledger", + row_key_field="ledger_row_key", + account_code_field="ledger_account_code", + account_name_field="ledger_account_name", + vendor_field="ledger_vendor", + desc_field="ledger_desc", + date_field="ledger_date", + ) + for row in ledger_rows + ] + voucher_features = [ + _build_match_row_features( + row, + prefix="voucher", + row_key_field="voucher_row_key", + account_code_field="voucher_account_code", + account_name_field="voucher_account_name", + vendor_field="voucher_vendor", + desc_field="voucher_desc", + date_field="proof_date", + ) + for row in voucher_rows + ] + + amount_index: dict[float, list[MatchRowFeatures]] = {} + for voucher_feature in voucher_features: + for amount in voucher_feature.positive_amounts: if amount <= 0: continue - amount_index.setdefault(amount, []).append(voucher_row) - - voucher_tokens_by_key: dict[str, tuple[set[str], set[str], set[str], str, str]] = {} - for voucher_row in voucher_rows: - voucher_key = clean(voucher_row.get("voucher_row_key")) - if not voucher_key: - continue - voucher_tokens_by_key[voucher_key] = ( - _tokenize_for_similarity(voucher_row.get("voucher_account_name")), - _tokenize_for_similarity(voucher_row.get("voucher_vendor")), - _tokenize_for_similarity(voucher_row.get("voucher_desc")), - clean(voucher_row.get("voucher_account_code")), - clean(voucher_row.get("voucher_account_name")), - ) + amount_index.setdefault(amount, []).append(voucher_feature) 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]] = [] + for ledger_feature in ledger_features: + voucher_candidates: list[MatchRowFeatures] = [] 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")) + for amount in ledger_feature.positive_amounts: + for voucher_feature in amount_index.get(amount, []): + voucher_key = voucher_feature.row_key if voucher_key and voucher_key not in seen_keys: seen_keys.add(voucher_key) - voucher_candidates.append(voucher_row) + voucher_candidates.append(voucher_feature) if not voucher_candidates: continue - ledger_key = clean(ledger_row.get("ledger_row_key")) - ledger_account_code = clean(ledger_row.get("ledger_account_code")) - ledger_account_tokens = _tokenize_for_similarity(ledger_row.get("ledger_account_name")) - ledger_vendor_tokens = _tokenize_for_similarity(ledger_row.get("ledger_vendor")) - ledger_desc_tokens = _tokenize_for_similarity(ledger_row.get("ledger_desc")) - side = _determine_primary_side(ledger_row) - if side == "debit": - ledger_amount = parse_amount(ledger_row.get("ledger_debit")) - elif side == "credit": - ledger_amount = parse_amount(ledger_row.get("ledger_credit")) - else: - ledger_amount = max(parse_amount(ledger_row.get("ledger_debit")), parse_amount(ledger_row.get("ledger_credit"))) - - top_row: dict[str, Any] | None = None + top_feature: MatchRowFeatures | None = None top_score: dict[str, Any] | None = None second_best_score = -999.0 - for voucher_row in voucher_candidates: - voucher_key = clean(voucher_row.get("voucher_row_key")) - token_payload = voucher_tokens_by_key.get(voucher_key) - if not token_payload: - continue - voucher_account_tokens, voucher_vendor_tokens, voucher_desc_tokens, voucher_code, voucher_account_name = token_payload - - score = 0.0 - reasons: list[str] = [] - hard_pass = True - - if side == "debit": - voucher_amount = parse_amount(voucher_row.get("voucher_debit")) - elif side == "credit": - voucher_amount = parse_amount(voucher_row.get("voucher_credit")) - else: - 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 - - account_sim = _jaccard_similarity_tokens(ledger_account_tokens, voucher_account_tokens) - if ledger_account_code and voucher_code and ledger_account_code == voucher_code: - score += 22 - reasons.append("계정코드 일치") - elif ledger_account_code and voucher_code and ledger_account_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_tokens(ledger_vendor_tokens, voucher_vendor_tokens) - if vendor_sim >= 0.9: - score += 16 - reasons.append("거래처 일치") - elif vendor_sim >= 0.65: - score += 10 - reasons.append("거래처 유사") - elif vendor_sim >= 0.4: - score += 4 - reasons.append("거래처 일부 유사") - else: - score -= 8 - - desc_sim = _jaccard_similarity_tokens(ledger_desc_tokens, voucher_desc_tokens) - if desc_sim >= 0.85: - score += 10 - reasons.append("적요 매우 유사") - elif desc_sim >= 0.6: - score += 6 - reasons.append("적요 유사") - elif desc_sim >= 0.35: - score += 2 - - 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" - - score_result = { - "score": round(score, 2), - "confidence_level": confidence, - "reason": ", ".join(reasons[:4]), - "auto_eligible": bool( - hard_pass - and score >= 88 - and amount_gap < 0.5 - and ( - (ledger_account_code and voucher_code and ledger_account_code == voucher_code) - or account_sim >= 0.8 - ) - and vendor_sim >= 0.65 - ), - "hard_pass": hard_pass, - "vendor_similarity": round(vendor_sim, 4), - "account_similarity": round(account_sim, 4), - "amount_gap": round(amount_gap, 2), - } + for voucher_feature in voucher_candidates: + score_result = _score_pair_match_features(ledger_feature, voucher_feature) if not score_result["hard_pass"] or score_result["score"] < 72: continue if top_score is None or score_result["score"] > float(top_score["score"]): second_best_score = float(top_score["score"]) if top_score else second_best_score - top_row = voucher_row + top_feature = voucher_feature top_score = score_result elif score_result["score"] > second_best_score: second_best_score = score_result["score"] - if top_row is None or top_score is None: + if top_feature is None or top_score is None: continue if top_score["score"] - second_best_score < 6: continue edge_candidates.append( { - "ledger_row": ledger_row, - "voucher_row": top_row, + "ledger_row": ledger_feature.row, + "voucher_row": top_feature.row, "score": top_score["score"], "confidence_level": top_score["confidence_level"], "reason": top_score["reason"], @@ -2849,6 +5696,7 @@ def recommend_pair_matches( "vendor_similarity": top_score.get("vendor_similarity", 0), "account_similarity": top_score.get("account_similarity", 0), "amount_gap": top_score.get("amount_gap", 0), + "substitution_hint": top_score.get("substitution_hint", ""), } ) @@ -2878,6 +5726,7 @@ def recommend_pair_matches( "vendor_similarity": edge.get("vendor_similarity", 0), "account_similarity": edge.get("account_similarity", 0), "amount_gap": edge.get("amount_gap", 0), + "substitution_hint": edge.get("substitution_hint", ""), "ledger_row": edge["ledger_row"], "voucher_row": edge["voucher_row"], } @@ -2895,6 +5744,64 @@ def recommend_pair_matches( "high_confidence": sum(1 for row in picked if row["confidence_level"] == "high"), }, } + return payload + + +def recommend_pair_matches( + engine: Any, + start_year: int | None, + end_year: int | None, + ledger_voucher_no: str = "", + ledger_review_reason: str = "", + voucher_voucher_no: str = "", + voucher_review_reason: str = "", + limit: int = 300, +) -> dict[str, Any]: + safe_limit = max(min(int(limit or 300), 1000), 1) + cache_key = _build_pair_recommend_cache_key( + start_year, + end_year, + ledger_voucher_no=ledger_voucher_no, + ledger_review_reason=ledger_review_reason, + voucher_voucher_no=voucher_voucher_no, + voucher_review_reason=voucher_review_reason, + limit=safe_limit, + ) + now = time.time() + cached = _PAIR_RECOMMEND_CACHE.get(cache_key) + if cached and (now - float(cached.get("ts", 0))) <= _PAIR_RECOMMEND_CACHE_TTL_SEC: + return cached["payload"] + init_wehago_compare_db(engine) + with engine.begin() as conn: + persisted = _load_persisted_pair_recommend_cache(conn, cache_key) + if persisted is not None: + _PAIR_RECOMMEND_CACHE.clear() + _PAIR_RECOMMEND_CACHE[cache_key] = {"ts": now, "payload": persisted} + return persisted + + payload = _compute_pair_recommendations_payload( + engine, + start_year=start_year, + end_year=end_year, + ledger_voucher_no=ledger_voucher_no, + ledger_review_reason=ledger_review_reason, + voucher_voucher_no=voucher_voucher_no, + voucher_review_reason=voucher_review_reason, + limit=safe_limit, + ) + with engine.begin() as conn: + _store_persisted_pair_recommend_cache( + conn, + cache_key, + payload, + start_year=start_year, + end_year=end_year, + ledger_voucher_no=ledger_voucher_no, + ledger_review_reason=ledger_review_reason, + voucher_voucher_no=voucher_voucher_no, + voucher_review_reason=voucher_review_reason, + limit=safe_limit, + ) _PAIR_RECOMMEND_CACHE.clear() _PAIR_RECOMMEND_CACHE[cache_key] = {"ts": now, "payload": payload} return payload @@ -2975,19 +5882,45 @@ def get_individual_pair_recommendations( dataset = _collect_status_rows_for_workbench(engine, start_year, end_year) ledger_rows = dataset["ledger_only"] voucher_rows = dataset["voucher_only"] + ledger_features = [ + _build_match_row_features( + row, + prefix="ledger", + row_key_field="ledger_row_key", + account_code_field="ledger_account_code", + account_name_field="ledger_account_name", + vendor_field="ledger_vendor", + desc_field="ledger_desc", + date_field="ledger_date", + ) + for row in ledger_rows + ] + voucher_features = [ + _build_match_row_features( + row, + prefix="voucher", + row_key_field="voucher_row_key", + account_code_field="voucher_account_code", + account_name_field="voucher_account_name", + vendor_field="voucher_vendor", + desc_field="voucher_desc", + date_field="proof_date", + ) + for row in voucher_rows + ] - source_row = None + source_feature = 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 + for feature in ledger_features: + if feature.row_key == normalized_row_key: + source_feature = feature break else: - for row in voucher_rows: - if clean(row.get("voucher_row_key")) == normalized_row_key: - source_row = row + for feature in voucher_features: + if feature.row_key == normalized_row_key: + source_feature = feature break - if source_row is None: + if source_feature is None: return { "source_status": normalized_source_status, "source_row": None, @@ -2998,61 +5931,48 @@ def get_individual_pair_recommendations( "limit": limit, "has_more": False, "next_offset": 0, + "bank_payable_case_count": 0, } candidates: list[dict[str, Any]] = [] if normalized_source_status == "ledger_only": - source_amounts = { - 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): + source_amounts = set(source_feature.positive_amounts) + for target_feature in voucher_features: + if not (source_amounts & set(target_feature.positive_amounts)): continue - score = _score_pair_match(source_row, target) + score = _score_pair_match_features(source_feature, target_feature) if not score["hard_pass"] or score["score"] < 60: continue candidates.append( { - "pair_key": build_manual_pair_key(clean(source_row.get("ledger_row_key")), clean(target.get("voucher_row_key"))), + "pair_key": build_manual_pair_key(source_feature.row_key, target_feature.row_key), "score": score["score"], "confidence_level": score["confidence_level"], "amount_gap": score.get("amount_gap", 0), "account_similarity": score.get("account_similarity", 0), "vendor_similarity": score.get("vendor_similarity", 0), "reason": score.get("reason", ""), - "target_row": target, + "target_row": target_feature.row, } ) 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): + source_amounts = set(source_feature.positive_amounts) + for target_feature in ledger_features: + if not (source_amounts & set(target_feature.positive_amounts)): continue - score = _score_pair_match(target, source_row) + score = _score_pair_match_features(target_feature, source_feature) if not score["hard_pass"] or score["score"] < 60: continue candidates.append( { - "pair_key": build_manual_pair_key(clean(target.get("ledger_row_key")), clean(source_row.get("voucher_row_key"))), + "pair_key": build_manual_pair_key(target_feature.row_key, source_feature.row_key), "score": score["score"], "confidence_level": score["confidence_level"], "amount_gap": score.get("amount_gap", 0), "account_similarity": score.get("account_similarity", 0), "vendor_similarity": score.get("vendor_similarity", 0), "reason": score.get("reason", ""), - "target_row": target, + "target_row": target_feature.row, } ) @@ -3069,7 +5989,7 @@ def get_individual_pair_recommendations( next_offset = safe_offset + len(rows) return { "source_status": normalized_source_status, - "source_row": source_row, + "source_row": source_feature.row, "rows": rows, "total_count": len(candidates), "shown_count": len(rows), @@ -3139,6 +6059,1290 @@ def _filter_status_row( return True +def _is_bank_payable_matched_row(row: dict[str, Any]) -> bool: + return clean(row.get("matched_case")) == "bank_payable" or clean(row.get("review_reason")) == "BANK_PAYABLE_MATCH" + + +BOUNDARY_EXCLUSION_KEYWORDS = ( + "대체", + "이월", + "전기", + "기초", + "기말", + "마감", + "결산", + "손익", + "잉여금", +) +STRONG_CARRYOVER_KEYWORDS = ( + "전기이월", + "기초이월", + "전기잔액", + "기초잔액", +) + + +def _is_boundary_date_value(value: Any, fiscal_year: Any = None) -> bool: + date_text = clean(value) + if not date_text: + return False + match = re.search(r"(\d{4})[-./](\d{1,2})[-./](\d{1,2})", date_text) + if match: + year_value = int(match.group(1)) + month_day = f"{int(match.group(2)):02d}-{int(match.group(3)):02d}" + target_year = int(fiscal_year or 0) if str(fiscal_year or "").isdigit() else None + return month_day in {"01-01", "12-31"} and (not target_year or year_value == target_year) + return bool(re.search(r"(^|[^0-9])(?:0?1[-./]0?1|12[-./]31)([^0-9]|$)", date_text)) + + +def _extract_boundary_month_day(value: Any, fiscal_year: Any = None) -> str: + date_text = clean(value) + if not date_text: + return "" + match = re.search(r"(\d{4})[-./](\d{1,2})[-./](\d{1,2})", date_text) + if match: + year_value = int(match.group(1)) + month_day = f"{int(match.group(2)):02d}-{int(match.group(3)):02d}" + target_year = int(fiscal_year or 0) if str(fiscal_year or "").isdigit() else None + if month_day in {"01-01", "12-31"} and (not target_year or year_value == target_year): + return month_day + return "" + loose = re.search(r"(^|[^0-9])(0?1[-./]0?1|12[-./]31)([^0-9]|$)", date_text) + if not loose: + return "" + token = loose.group(2) + if token.startswith(("1-1", "01-1", "1/1", "01/1", "1.1", "01.1", "01-01", "01/01", "01.01")): + return "01-01" + return "12-31" + + +def _row_has_earlier_fiscal_year_source(conn: Any, row: dict[str, Any]) -> bool: + try: + fiscal_year = int(row.get("fiscal_year") or 0) + except (TypeError, ValueError): + fiscal_year = 0 + if fiscal_year <= 0: + return False + account_code = clean(row.get("ledger_account_code") or row.get("account_code")) + account_name = clean(row.get("ledger_account_name") or row.get("account_name")) + if not account_code and not account_name: + return False + exists = conn.execute( + text( + """ + SELECT 1 + FROM wehago_ledger_rows + WHERE fiscal_year < :fiscal_year + AND ( + (:account_code <> '' AND COALESCE(account_code, '') = :account_code) + OR (:account_name <> '' AND COALESCE(account_name, '') = :account_name) + ) + LIMIT 1 + """ + ), + { + "fiscal_year": fiscal_year, + "account_code": account_code, + "account_name": account_name, + }, + ).first() + return bool(exists) + + +def _build_earlier_year_account_lookup(conn: Any) -> dict[int, set[str]]: + rows = conn.execute( + text( + """ + SELECT fiscal_year, COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name + FROM wehago_ledger_rows + WHERE fiscal_year IS NOT NULL + AND fiscal_year > 0 + """ + ) + ).mappings() + accounts_by_year: dict[int, set[str]] = {} + for row in rows: + year = int(row["fiscal_year"] or 0) + if year <= 0: + continue + bucket = accounts_by_year.setdefault(year, set()) + account_code = clean(row["account_code"]) + account_name = clean(row["account_name"]) + if account_code: + bucket.add(f"code:{account_code}") + if account_name: + bucket.add(f"name:{account_name}") + earlier_lookup: dict[int, set[str]] = {} + accumulated: set[str] = set() + for year in sorted(accounts_by_year): + earlier_lookup[year] = set(accumulated) + accumulated.update(accounts_by_year[year]) + return earlier_lookup + + +def _row_has_earlier_fiscal_year_source_from_lookup( + row: dict[str, Any], + earlier_lookup: dict[int, set[str]], +) -> bool: + try: + fiscal_year = int(row.get("fiscal_year") or 0) + except (TypeError, ValueError): + fiscal_year = 0 + if fiscal_year <= 0: + return False + earlier_accounts = earlier_lookup.get(fiscal_year, set()) + if not earlier_accounts: + return False + account_code = clean(row.get("ledger_account_code") or row.get("account_code")) + account_name = clean(row.get("ledger_account_name") or row.get("account_name")) + return ( + (bool(account_code) and f"code:{account_code}" in earlier_accounts) + or (bool(account_name) and f"name:{account_name}" in earlier_accounts) + ) + + +def _has_boundary_exclusion_keyword(*values: Any) -> bool: + text_value = normalize_text(" ".join(clean(value) for value in values if clean(value))) + return any(keyword in text_value for keyword in BOUNDARY_EXCLUSION_KEYWORDS) + + +def _has_strong_carryover_keyword(*values: Any) -> bool: + text_value = normalize_text(" ".join(clean(value) for value in values if clean(value))) + compact_value = text_value.replace(" ", "") + return any(keyword in compact_value for keyword in STRONG_CARRYOVER_KEYWORDS) + + +def _is_boundary_excluded_unmatched_row( + row: dict[str, Any], + conn: Any | None = None, + earlier_year_account_lookup: dict[int, set[str]] | None = None, +) -> bool: + if clean(row.get("boundary_excluded")) == "1" or clean(row.get("matched_case")) == "boundary_excluded": + return True + month_day = _extract_boundary_month_day(row.get("ledger_date"), row.get("fiscal_year")) + text_values = ( + row.get("ledger_desc"), + row.get("description"), + row.get("ledger_account_name"), + row.get("account_name"), + row.get("review_reason"), + row.get("notes"), + ) + if month_day == "01-01": + if earlier_year_account_lookup is not None: + return _row_has_earlier_fiscal_year_source_from_lookup(row, earlier_year_account_lookup) + return bool(conn is not None and _row_has_earlier_fiscal_year_source(conn, row)) + if _has_strong_carryover_keyword(*text_values): + return True + if not month_day: + return False + return _has_boundary_exclusion_keyword(*text_values) + + +def _annotate_boundary_excluded_row( + row: dict[str, Any], + conn: Any | None = None, + earlier_year_account_lookup: dict[int, set[str]] | None = None, +) -> dict[str, Any]: + if _is_boundary_excluded_unmatched_row(row, conn, earlier_year_account_lookup): + row["boundary_excluded"] = "1" + row["boundary_excluded_label"] = "연초/연말 대체·이월" + row["matched_case"] = "boundary_excluded" + return row + + +def _refresh_section_counts(sections: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: + for section in sections.values(): + rows = section.get("rows", []) + if isinstance(rows, list): + section["count"] = len(rows) + return sections + + +def _apply_boundary_exclusions_to_sections(conn: Any, sections: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: + earlier_year_account_lookup = _build_earlier_year_account_lookup(conn) + prepared: dict[str, dict[str, Any]] = {} + for status_key, section in sections.items(): + copied = dict(section) + copied["rows"] = [dict(row) for row in section.get("rows", [])] + prepared[status_key] = copied + + ledger_only_rows = prepared.setdefault("ledger_only", {"rows": [], "count": 0, "columns": DETAIL_COLUMN_MAP["ledger_only"]})["rows"] + for status_key in ("matched", "amount_mismatch"): + kept_rows: list[dict[str, Any]] = [] + for row in prepared.get(status_key, {}).get("rows", []): + if _is_boundary_excluded_unmatched_row(row, conn, earlier_year_account_lookup): + ledger_only_rows.append(_annotate_boundary_excluded_row(row, conn, earlier_year_account_lookup)) + else: + kept_rows.append(row) + if status_key in prepared: + prepared[status_key]["rows"] = kept_rows + + prepared["ledger_only"]["rows"] = [ + _annotate_boundary_excluded_row(row, conn, earlier_year_account_lookup) + for row in prepared.get("ledger_only", {}).get("rows", []) + ] + return _refresh_section_counts(prepared) + + +def _boundary_excluded_sql(date_sql: str, *text_sqls: str) -> str: + text_expr = " || ' ' || ".join(f"COALESCE({field}, '')" for field in text_sqls) + compact_text_expr = f"REPLACE(({text_expr}), ' ', '')" + keyword_sql = " OR ".join(f"{text_expr} LIKE '%{keyword}%'" for keyword in BOUNDARY_EXCLUSION_KEYWORDS) + strong_keyword_sql = " OR ".join(f"{compact_text_expr} LIKE '%{keyword}%'" for keyword in STRONG_CARRYOVER_KEYWORDS) + return f"(({strong_keyword_sql}) OR (substr(COALESCE({date_sql}, ''), 6, 5) IN ('01-01', '12-31') AND ({keyword_sql})))" + + +def _append_like_filter(conditions: list[str], params: dict[str, Any], field_sql: str, param_name: str, value: str) -> None: + cleaned = clean(value) + if not cleaned: + return + conditions.append(f"COALESCE({field_sql}, '') LIKE :{param_name}") + params[param_name] = f"%{cleaned}%" + + +def _append_amount_filter(conditions: list[str], params: dict[str, Any], fields: list[str], param_name: str, value: str) -> None: + cleaned = clean(value) + if not cleaned: + return + amount = parse_amount(cleaned) + if abs(amount) < 0.5 and not re.search(r"\d", cleaned): + return + parts: list[str] = [] + for index, field_sql in enumerate(fields): + key = f"{param_name}_{index}" + parts.append(f"ABS(COALESCE({field_sql}, 0) - :{key}) < 0.5") + params[key] = amount + conditions.append("(" + " OR ".join(parts) + ")") + + +def _fetch_status_detail_rows_from_db( + conn: Any, + start_year: int, + end_year: int, + status: str, + voucher_filter: str, + draft_filter: str, + wehago_account_filter: str, + erp_account_filter: str, + wehago_amount_filter: str, + erp_amount_filter: str, + wehago_vendor_filter: str, + erp_vendor_filter: str, + desc_filter: str, + boundary_excluded_filter: bool, + offset: int, + limit: int, +) -> dict[str, Any]: + params: dict[str, Any] = { + "start_year": start_year, + "end_year": end_year, + "status": status, + "limit": limit, + "offset": offset, + } + conditions = [ + "c.status = :status", + "c.fiscal_year >= :start_year", + "c.fiscal_year <= :end_year", + ] + voucher_rep = """ + SELECT * + FROM ( + SELECT fiscal_year, compare_voucher_no, proof_date, confirmed_no, draft_no, + account_code, account_name, vendor_name, desc1, desc2, debit_supply, credit_supply, + ROW_NUMBER() OVER ( + PARTITION BY fiscal_year, compare_voucher_no + ORDER BY row_number + ) AS rn + FROM wehago_voucher_rows + WHERE COALESCE(compare_voucher_no, '') <> '' + ) + WHERE rn = 1 + """ + + if status == "ledger_only": + boundary_sql = _boundary_excluded_sql("l.ledger_date", "l.description", "l.account_name", "c.notes") + select_sql = """ + c.fiscal_year AS fiscal_year, + COALESCE(l.voucher_no, c.voucher_no) AS voucher_no, + COALESCE(l.ledger_date, '') AS ledger_date, + COALESCE(l.account_code, '') AS ledger_account_code, + COALESCE(l.account_name, c.ledger_accounts, '') AS ledger_account_name, + COALESCE(l.vendor_name, c.ledger_vendors, '') AS ledger_vendor, + l.debit AS ledger_debit, + l.credit AS ledger_credit, + COALESCE(l.description, '') AS ledger_desc, + CASE WHEN {boundary_sql} THEN '1' ELSE '' END AS boundary_excluded, + CASE WHEN {boundary_sql} THEN '연초/연말 대체·이월' ELSE '' END AS boundary_excluded_label, + CASE WHEN {boundary_sql} THEN 'boundary_excluded' ELSE '' END AS matched_case + """.format(boundary_sql=boundary_sql) + joins = """ + JOIN wehago_ledger_rows l + ON l.fiscal_year = c.fiscal_year AND l.compare_voucher_no = c.voucher_no + """ + _append_like_filter(conditions, params, "COALESCE(l.voucher_no, c.voucher_no)", "voucher_no", voucher_filter) + _append_like_filter(conditions, params, "COALESCE(l.account_name, c.ledger_accounts)", "wehago_account", wehago_account_filter) + _append_like_filter(conditions, params, "COALESCE(l.vendor_name, c.ledger_vendors)", "wehago_vendor", wehago_vendor_filter) + _append_like_filter(conditions, params, "l.description", "desc_keyword", desc_filter) + _append_amount_filter(conditions, params, ["l.debit", "l.credit"], "wehago_amount", wehago_amount_filter) + if boundary_excluded_filter: + conditions.append(boundary_sql) + elif status == "voucher_only": + select_sql = """ + c.fiscal_year AS fiscal_year, + COALESCE(v.confirmed_no, v.draft_no, c.voucher_no) AS voucher_no, + COALESCE(v.proof_date, '') AS proof_date, + COALESCE(v.draft_no, '') AS draft_no, + COALESCE(v.account_code, '') AS voucher_account_code, + COALESCE(v.account_name, c.voucher_accounts, '') AS voucher_account_name, + COALESCE(v.vendor_name, c.voucher_vendors, '') AS voucher_vendor, + v.debit_supply AS voucher_debit, + v.credit_supply AS voucher_credit, + TRIM(COALESCE(v.desc1, '') || ' ' || COALESCE(v.desc2, '')) AS voucher_desc + """ + joins = """ + JOIN wehago_voucher_rows v + ON v.fiscal_year = c.fiscal_year AND v.compare_voucher_no = c.voucher_no + """ + _append_like_filter(conditions, params, "COALESCE(v.confirmed_no, v.draft_no, c.voucher_no)", "voucher_no", voucher_filter) + _append_like_filter(conditions, params, "v.draft_no", "draft_no", draft_filter) + _append_like_filter(conditions, params, "COALESCE(v.account_name, c.voucher_accounts)", "erp_account", erp_account_filter) + _append_like_filter(conditions, params, "COALESCE(v.vendor_name, c.voucher_vendors)", "erp_vendor", erp_vendor_filter) + _append_like_filter(conditions, params, "TRIM(COALESCE(v.desc1, '') || ' ' || COALESCE(v.desc2, ''))", "desc_keyword", desc_filter) + _append_amount_filter(conditions, params, ["v.debit_supply", "v.credit_supply"], "erp_amount", erp_amount_filter) + else: + select_sql = """ + c.fiscal_year AS fiscal_year, + COALESCE(l.ledger_date, v.proof_date, '') AS ledger_date, + COALESCE(l.voucher_no, v.confirmed_no, v.draft_no, c.voucher_no) AS voucher_no, + COALESCE(v.draft_no, '') AS draft_no, + COALESCE(l.account_code, '') AS ledger_account_code, + COALESCE(l.account_name, c.ledger_accounts, '') AS ledger_account_name, + COALESCE(v.account_code, '') AS voucher_account_code, + COALESCE(v.account_name, c.voucher_accounts, '') AS voucher_account_name, + COALESCE(l.vendor_name, c.ledger_vendors, '') AS ledger_vendor, + COALESCE(v.vendor_name, c.voucher_vendors, '') AS voucher_vendor, + l.debit AS ledger_debit, + l.credit AS ledger_credit, + COALESCE(v.debit_supply, 0) AS voucher_debit, + COALESCE(v.credit_supply, 0) AS voucher_credit, + COALESCE(l.description, '') AS ledger_desc, + TRIM(COALESCE(v.desc1, '') || ' ' || COALESCE(v.desc2, '')) AS voucher_desc, + c.notes AS review_reason + """ + joins = f""" + JOIN wehago_ledger_rows l + ON l.fiscal_year = c.fiscal_year AND l.compare_voucher_no = c.voucher_no + LEFT JOIN ({voucher_rep}) v + ON v.fiscal_year = c.fiscal_year AND v.compare_voucher_no = c.voucher_no + """ + _append_like_filter(conditions, params, "COALESCE(l.voucher_no, v.confirmed_no, v.draft_no, c.voucher_no)", "voucher_no", voucher_filter) + _append_like_filter(conditions, params, "v.draft_no", "draft_no", draft_filter) + _append_like_filter(conditions, params, "COALESCE(l.account_name, c.ledger_accounts)", "wehago_account", wehago_account_filter) + _append_like_filter(conditions, params, "COALESCE(v.account_name, c.voucher_accounts)", "erp_account", erp_account_filter) + _append_like_filter(conditions, params, "COALESCE(l.vendor_name, c.ledger_vendors)", "wehago_vendor", wehago_vendor_filter) + _append_like_filter(conditions, params, "COALESCE(v.vendor_name, c.voucher_vendors)", "erp_vendor", erp_vendor_filter) + _append_like_filter( + conditions, + params, + "COALESCE(l.description, '') || ' ' || COALESCE(v.desc1, '') || ' ' || COALESCE(v.desc2, '') || ' ' || COALESCE(c.notes, '')", + "desc_keyword", + desc_filter, + ) + _append_amount_filter(conditions, params, ["l.debit", "l.credit"], "wehago_amount", wehago_amount_filter) + _append_amount_filter(conditions, params, ["v.debit_supply", "v.credit_supply"], "erp_amount", erp_amount_filter) + + where_sql = " AND ".join(conditions) + from_sql = f"FROM wehago_comparison_results c {joins} WHERE {where_sql}" + total_count = int(conn.execute(text(f"SELECT COUNT(*) {from_sql}"), params).scalar_one() or 0) + rows = [ + {key: clean(value) if isinstance(value, str) else value for key, value in dict(row).items()} + for row in conn.execute( + text( + f""" + SELECT {select_sql} + {from_sql} + ORDER BY c.fiscal_year, c.voucher_no + LIMIT :limit OFFSET :offset + """ + ), + params, + ).mappings() + ] + next_offset = offset + len(rows) + boundary_excluded_count = 0 + if status == "ledger_only": + boundary_sql = _boundary_excluded_sql("l.ledger_date", "l.description", "l.account_name", "c.notes") + boundary_count_conditions = [ + item + for item in conditions + if item != boundary_sql and item != f"NOT {boundary_sql}" + ] + boundary_excluded_count = int( + conn.execute( + text( + f""" + SELECT COUNT(*) + FROM wehago_comparison_results c {joins} + WHERE {' AND '.join(boundary_count_conditions + [boundary_sql])} + """ + ), + params, + ).scalar_one() + or 0 + ) + return { + "columns": DETAIL_COLUMN_MAP[status], + "rows": rows, + "total_count": total_count, + "shown_count": len(rows), + "offset": offset, + "limit": limit, + "has_more": next_offset < total_count, + "next_offset": next_offset, + "notice": "", + "bank_payable_case_count": 0, + "boundary_excluded_count": boundary_excluded_count, + } + + +def _build_status_detail_response_from_rows( + rows_by_status: dict[str, list[dict[str, Any]]], + status: str, + voucher_filter: str, + draft_filter: str, + wehago_account_filter: str, + erp_account_filter: str, + wehago_amount_filter: str, + erp_amount_filter: str, + wehago_vendor_filter: str, + erp_vendor_filter: str, + desc_filter: str, + boundary_excluded_filter: bool, + offset: int, + limit: int, +) -> dict[str, Any]: + source_rows = [ + _annotate_boundary_excluded_row(dict(row)) if status == "ledger_only" else dict(row) + for row in rows_by_status.get(status, []) + ] + filtered_rows = [ + row + for row in source_rows + if _filter_status_row( + row, + voucher_filter, + draft_filter, + wehago_account_filter, + erp_account_filter, + wehago_amount_filter, + erp_amount_filter, + wehago_vendor_filter, + erp_vendor_filter, + desc_filter, + ) + and (not boundary_excluded_filter or _is_boundary_excluded_unmatched_row(row)) + ] + next_offset = offset + min(limit, max(len(filtered_rows) - offset, 0)) + shown_rows = filtered_rows[offset : offset + limit] + return { + "columns": DETAIL_COLUMN_MAP[status], + "rows": shown_rows, + "total_count": len(filtered_rows), + "shown_count": len(shown_rows), + "offset": offset, + "limit": limit, + "has_more": next_offset < len(filtered_rows), + "next_offset": next_offset, + "notice": "", + "bank_payable_case_count": sum(1 for row in filtered_rows if _is_bank_payable_matched_row(row)), + "boundary_excluded_count": sum(1 for row in source_rows if _is_boundary_excluded_unmatched_row(row)) if status == "ledger_only" else 0, + } + + +def _build_voucher_sections_from_rows_by_status( + rows_by_status: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + grouped: dict[tuple[str, int, str, str], dict[str, Any]] = {} + matched_ledger_keys: set[str] = set() + matched_voucher_keys: set[str] = set() + matched_wehago_group_keys: set[tuple[int, str, str]] = set() + + def erp_set_key_from_values(fiscal_year: Any, draft_no: Any, voucher_no: Any, proof_date: Any) -> tuple[int, str]: + normalized_draft = clean(draft_no) + normalized_voucher = clean(voucher_no) + normalized_date = clean(proof_date) + base = normalized_draft or normalized_voucher + if base: + base = re.sub(r"-\d+$", "", base) + if not base and normalized_date and normalized_voucher: + base = f"{normalized_date}|{normalized_voucher}" + return (int(fiscal_year or 0), base) + + def wehago_group_key(row: dict[str, Any]) -> tuple[int, str, str]: + return ( + int(row.get("fiscal_year") or 0), + clean(row.get("voucher_no")), + clean(row.get("ledger_date")), + ) + + def erp_group_key(row: dict[str, Any]) -> tuple[int, str]: + return erp_set_key_from_values( + row.get("fiscal_year"), + row.get("draft_no"), + row.get("voucher_no"), + row.get("proof_date"), + ) + + def row_origin_rank(row: dict[str, Any]) -> int: + status_label = clean(row.get("status_label")) + if status_label == "Matched": + return 0 + if status_label == "Unmatched": + return 1 + if status_label == "ERP Unmatched": + return 2 + if status_label == "Recheck": + return 3 + return 4 + + def ensure_group(status_key: str, row: dict[str, Any]) -> dict[str, Any]: + fiscal_year = int(row.get("fiscal_year") or 0) + voucher_no = clean(row.get("voucher_no")) + draft_no = clean(row.get("draft_no")) + ledger_date = clean(row.get("ledger_date")) + proof_date = clean(row.get("proof_date")) + fallback_voucher_key = clean( + row.get("ledger_row_key") + or row.get("voucher_row_key") + or row.get("review_key") + or ledger_date + or proof_date + or row.get("ledger_desc") + or row.get("voucher_desc") + ) + group_key_parts = [part for part in [voucher_no, draft_no, ledger_date, proof_date] if part] + if status_key in {"matched", "ledger_only", "amount_mismatch"}: + if voucher_no and ledger_date: + group_key = "|".join([str(fiscal_year), ledger_date, voucher_no]) + elif voucher_no and proof_date: + group_key = "|".join([str(fiscal_year), proof_date, voucher_no]) + elif group_key_parts: + group_key = "|".join([str(fiscal_year), *group_key_parts]) + else: + group_key = fallback_voucher_key + elif draft_no and proof_date: + group_key = "|".join([str(fiscal_year), proof_date, draft_no]) + elif voucher_no and proof_date: + group_key = "|".join([str(fiscal_year), proof_date, voucher_no]) + elif group_key_parts: + group_key = "|".join([str(fiscal_year), *group_key_parts]) + else: + group_key = fallback_voucher_key + key = (status_key, fiscal_year, group_key, "") + current = grouped.get(key) + if current is None: + status_label_map = { + "matched": "Matched", + "ledger_only": "Unmatched", + "voucher_only": "ERP Unmatched", + "amount_mismatch": "Recheck", + } + current = { + "fiscal_year": fiscal_year, + "status_label": status_label_map.get(status_key, status_key), + "ledger_date": ledger_date, + "proof_date": proof_date, + "voucher_no": voucher_no, + "draft_no": draft_no, + "ledger_row_count": 0, + "voucher_row_count": 0, + "ledger_debit": 0.0, + "ledger_credit": 0.0, + "voucher_debit": 0.0, + "voucher_credit": 0.0, + "ledger_accounts": [], + "voucher_accounts": [], + "ledger_vendors": [], + "voucher_vendors": [], + "review_reason": [], + "rows": [], + "wehago_group_key": wehago_group_key(row), + "erp_group_keys": set(), + "draft_nos": [], + } + grouped[key] = current + return current + + def append_unique(target: list[str], value: Any) -> None: + text_value = clean(value) + if text_value and text_value not in target: + target.append(text_value) + + for status_key in ("matched", "ledger_only", "voucher_only", "amount_mismatch"): + for row in rows_by_status.get(status_key, []): + current = ensure_group(status_key, row) + if not current.get("ledger_date"): + current["ledger_date"] = clean(row.get("ledger_date")) + if not current.get("proof_date"): + current["proof_date"] = clean(row.get("proof_date")) + if clean(row.get("ledger_account_name")) or clean(row.get("ledger_desc")): + current["ledger_row_count"] += 1 + if clean(row.get("voucher_account_name")) or clean(row.get("voucher_desc")): + current["voucher_row_count"] += 1 + current["ledger_debit"] += parse_amount(row.get("ledger_debit")) + current["ledger_credit"] += parse_amount(row.get("ledger_credit")) + current["voucher_debit"] += parse_amount(row.get("voucher_debit")) + current["voucher_credit"] += parse_amount(row.get("voucher_credit")) + append_unique(current["ledger_accounts"], row.get("ledger_account_name")) + append_unique(current["voucher_accounts"], row.get("voucher_account_name")) + append_unique(current["ledger_vendors"], row.get("ledger_vendor")) + append_unique(current["voucher_vendors"], row.get("voucher_vendor")) + append_unique(current["review_reason"], row.get("review_reason")) + append_unique(current["draft_nos"], row.get("draft_no")) + row_payload = dict(row) + row_payload.setdefault("status_label", current.get("status_label")) + current["rows"].append(row_payload) + if status_key in {"matched", "amount_mismatch"}: + current["erp_group_keys"].add(erp_group_key(row)) + if status_key == "matched": + ledger_key = clean(row.get("ledger_row_key")) + voucher_key = clean(row.get("voucher_row_key")) + if ledger_key: + matched_ledger_keys.add(ledger_key) + if voucher_key: + matched_voucher_keys.add(voucher_key) + current["erp_group_keys"].add(erp_group_key(row)) + + ledger_only_index: dict[tuple[int, str, str], list[dict[str, Any]]] = {} + for row in rows_by_status.get("ledger_only", []): + ledger_only_index.setdefault(wehago_group_key(row), []).append(dict(row)) + + voucher_only_index: dict[tuple[int, str], list[dict[str, Any]]] = {} + for row in rows_by_status.get("voucher_only", []): + voucher_only_index.setdefault(erp_group_key(row), []).append(dict(row)) + + recheck_by_wehago_index: dict[tuple[int, str, str], list[dict[str, Any]]] = {} + recheck_by_erp_index: dict[tuple[int, str], list[dict[str, Any]]] = {} + for row in rows_by_status.get("amount_mismatch", []): + row_copy = dict(row) + recheck_by_wehago_index.setdefault(wehago_group_key(row_copy), []).append(row_copy) + recheck_by_erp_index.setdefault(erp_group_key(row_copy), []).append(row_copy) + recheck_wehago_group_keys = set(recheck_by_wehago_index.keys()) + + def recheck_identity(row: dict[str, Any]) -> str: + return ( + clean(row.get("review_key")) + or "|".join( + [ + clean(row.get("ledger_row_key")), + clean(row.get("voucher_row_key")), + clean(row.get("voucher_no")), + clean(row.get("draft_no")), + clean(row.get("ledger_account_name")), + clean(row.get("voucher_account_name")), + clean(row.get("ledger_desc")), + clean(row.get("voucher_desc")), + ] + ) + ) + + def append_recheck_row(target_rows: list[dict[str, Any]], extra_recheck: dict[str, Any], appended_recheck_ids: set[str]) -> None: + identity = recheck_identity(extra_recheck) + if identity and identity in appended_recheck_ids: + return + if identity: + appended_recheck_ids.add(identity) + recheck_payload = dict(extra_recheck) + recheck_payload["status_label"] = "Recheck" + target_rows.append(recheck_payload) + + voucher_sections = { + "voucher_matched": [], + "erp_voucher_matched": [], + "voucher_unmatched": [], + "voucher_recheck": [], + } + for (status_key, _fiscal_year, _voucher_no, _draft_no), row in grouped.items(): + if status_key == "matched": + matched_wehago_group_keys.add(row.get("wehago_group_key")) + for extra_ledger in ledger_only_index.get(row.get("wehago_group_key"), []): + ledger_key = clean(extra_ledger.get("ledger_row_key")) + if ledger_key and ledger_key in matched_ledger_keys: + continue + row["rows"].append( + { + **dict(extra_ledger), + "status_label": "Unmatched", + "voucher_account_code": "", + "voucher_account_name": "", + "voucher_vendor": "", + "voucher_debit": "", + "voucher_credit": "", + "voucher_desc": "", + "draft_no": "", + "proof_date": "", + } + ) + appended_recheck_ids: set[str] = set() + for extra_recheck in recheck_by_wehago_index.get(row.get("wehago_group_key"), []): + append_recheck_row(row["rows"], extra_recheck, appended_recheck_ids) + appended_voucher_keys: set[str] = set() + for erp_key in row.get("erp_group_keys", set()): + for extra_voucher in voucher_only_index.get(erp_key, []): + voucher_key = clean(extra_voucher.get("voucher_row_key")) + if voucher_key and voucher_key in matched_voucher_keys: + continue + if voucher_key and voucher_key in appended_voucher_keys: + continue + if voucher_key: + appended_voucher_keys.add(voucher_key) + row["rows"].append( + { + **dict(extra_voucher), + "status_label": "ERP Unmatched", + "ledger_account_code": "", + "ledger_account_name": "", + "ledger_vendor": "", + "ledger_debit": "", + "ledger_credit": "", + "ledger_desc": "", + "ledger_date": row.get("ledger_date", ""), + "voucher_no": row.get("voucher_no", extra_voucher.get("voucher_no", "")), + } + ) + for extra_recheck in recheck_by_erp_index.get(erp_key, []): + append_recheck_row(row["rows"], extra_recheck, appended_recheck_ids) + row["rows"].sort( + key=lambda item: ( + clean(item.get("ledger_date")) or clean(item.get("proof_date")), + clean(item.get("voucher_no")), + row_origin_rank(item), + 0 if (clean(item.get("ledger_account_name")) and clean(item.get("voucher_account_name"))) else 1 if clean(item.get("ledger_account_name")) else 2, + clean(item.get("ledger_account_name")) or clean(item.get("voucher_account_name")), + clean(item.get("draft_no")), + -parse_amount(item.get("ledger_debit") or item.get("voucher_debit")), + -parse_amount(item.get("ledger_credit") or item.get("voucher_credit")), + clean(item.get("ledger_desc")) or clean(item.get("voucher_desc")), + ) + ) + normalized_summary = dict(row) + normalized_summary["ledger_accounts"] = ", ".join(row["ledger_accounts"]) + normalized_summary["voucher_accounts"] = ", ".join(row["voucher_accounts"]) + normalized_summary["ledger_vendors"] = ", ".join(row["ledger_vendors"]) + normalized_summary["voucher_vendors"] = ", ".join(row["voucher_vendors"]) + normalized_summary["review_reason"] = " / ".join(row["review_reason"]) + normalized_summary["draft_no"] = ", ".join(row["draft_nos"]) + normalized_group = { + "summary": normalized_summary, + "rows": list(row["rows"]), + } + if status_key == "matched": + voucher_sections["voucher_matched"].append(normalized_group) + elif status_key == "ledger_only" and row.get("wehago_group_key") not in matched_wehago_group_keys and row.get("wehago_group_key") not in recheck_wehago_group_keys: + voucher_sections["voucher_unmatched"].append(normalized_group) + elif status_key == "amount_mismatch" and row.get("wehago_group_key") not in matched_wehago_group_keys: + for extra_ledger in ledger_only_index.get(row.get("wehago_group_key"), []): + ledger_key = clean(extra_ledger.get("ledger_row_key")) + if ledger_key and ledger_key in matched_ledger_keys: + continue + row["rows"].append( + { + **dict(extra_ledger), + "status_label": "Unmatched", + "voucher_account_code": "", + "voucher_account_name": "", + "voucher_vendor": "", + "voucher_debit": "", + "voucher_credit": "", + "voucher_desc": "", + "draft_no": "", + "proof_date": "", + } + ) + appended_recheck_ids: set[str] = {recheck_identity(existing_row) for existing_row in row["rows"]} + appended_voucher_keys: set[str] = set() + for erp_key in row.get("erp_group_keys", set()): + for extra_voucher in voucher_only_index.get(erp_key, []): + voucher_key = clean(extra_voucher.get("voucher_row_key")) + if voucher_key and voucher_key in matched_voucher_keys: + continue + if voucher_key and voucher_key in appended_voucher_keys: + continue + if voucher_key: + appended_voucher_keys.add(voucher_key) + row["rows"].append( + { + **dict(extra_voucher), + "status_label": "ERP Unmatched", + "ledger_account_code": "", + "ledger_account_name": "", + "ledger_vendor": "", + "ledger_debit": "", + "ledger_credit": "", + "ledger_desc": "", + "ledger_date": row.get("ledger_date", ""), + "voucher_no": row.get("voucher_no", extra_voucher.get("voucher_no", "")), + } + ) + for extra_recheck in recheck_by_erp_index.get(erp_key, []): + append_recheck_row(row["rows"], extra_recheck, appended_recheck_ids) + row["rows"].sort( + key=lambda item: ( + clean(item.get("ledger_date")) or clean(item.get("proof_date")), + clean(item.get("voucher_no")), + row_origin_rank(item), + 0 if (clean(item.get("ledger_account_name")) and clean(item.get("voucher_account_name"))) else 1 if clean(item.get("ledger_account_name")) else 2, + clean(item.get("ledger_account_name")) or clean(item.get("voucher_account_name")), + clean(item.get("draft_no")), + -parse_amount(item.get("ledger_debit") or item.get("voucher_debit")), + -parse_amount(item.get("ledger_credit") or item.get("voucher_credit")), + clean(item.get("ledger_desc")) or clean(item.get("voucher_desc")), + ) + ) + normalized_summary = dict(row) + normalized_summary["ledger_accounts"] = ", ".join(row["ledger_accounts"]) + normalized_summary["voucher_accounts"] = ", ".join(row["voucher_accounts"]) + normalized_summary["ledger_vendors"] = ", ".join(row["ledger_vendors"]) + normalized_summary["voucher_vendors"] = ", ".join(row["voucher_vendors"]) + normalized_summary["review_reason"] = " / ".join(row["review_reason"]) + normalized_summary["draft_no"] = ", ".join(row["draft_nos"]) + normalized_group = { + "summary": normalized_summary, + "rows": list(row["rows"]), + } + voucher_sections["voucher_recheck"].append(normalized_group) + + erp_grouped: dict[tuple[int, str], dict[str, Any]] = {} + for row in rows_by_status.get("matched", []): + erp_key = erp_group_key(row) + fiscal_year = int(row.get("fiscal_year") or 0) + current = erp_grouped.get(erp_key) + if current is None: + current = { + "fiscal_year": fiscal_year, + "status_label": "Matched", + "ledger_date": clean(row.get("ledger_date")), + "proof_date": clean(row.get("proof_date")), + "voucher_no": clean(row.get("voucher_no")), + "draft_no": clean(row.get("draft_no")), + "ledger_row_count": 0, + "voucher_row_count": 0, + "ledger_debit": 0.0, + "ledger_credit": 0.0, + "voucher_debit": 0.0, + "voucher_credit": 0.0, + "ledger_accounts": [], + "voucher_accounts": [], + "ledger_vendors": [], + "voucher_vendors": [], + "review_reason": [], + "rows": [], + "wehago_group_keys": set(), + "erp_group_key": erp_key, + "voucher_nos": [], + "draft_nos": [], + } + erp_grouped[erp_key] = current + if not current.get("ledger_date"): + current["ledger_date"] = clean(row.get("ledger_date")) + if not current.get("proof_date"): + current["proof_date"] = clean(row.get("proof_date")) + if not current.get("voucher_no"): + current["voucher_no"] = clean(row.get("voucher_no")) + if not current.get("draft_no"): + current["draft_no"] = clean(row.get("draft_no")) + if clean(row.get("ledger_account_name")) or clean(row.get("ledger_desc")): + current["ledger_row_count"] += 1 + if clean(row.get("voucher_account_name")) or clean(row.get("voucher_desc")): + current["voucher_row_count"] += 1 + current["ledger_debit"] += parse_amount(row.get("ledger_debit")) + current["ledger_credit"] += parse_amount(row.get("ledger_credit")) + current["voucher_debit"] += parse_amount(row.get("voucher_debit")) + current["voucher_credit"] += parse_amount(row.get("voucher_credit")) + append_unique(current["ledger_accounts"], row.get("ledger_account_name")) + append_unique(current["voucher_accounts"], row.get("voucher_account_name")) + append_unique(current["ledger_vendors"], row.get("ledger_vendor")) + append_unique(current["voucher_vendors"], row.get("voucher_vendor")) + append_unique(current["review_reason"], row.get("review_reason")) + append_unique(current["voucher_nos"], row.get("voucher_no")) + append_unique(current["draft_nos"], row.get("draft_no")) + current["wehago_group_keys"].add(wehago_group_key(row)) + row_payload = dict(row) + row_payload.setdefault("status_label", "Matched") + current["rows"].append(row_payload) + + for row in erp_grouped.values(): + appended_ledger_keys: set[str] = set() + appended_recheck_ids: set[str] = set() + for wehago_key in row.get("wehago_group_keys", set()): + for extra_ledger in ledger_only_index.get(wehago_key, []): + ledger_key = clean(extra_ledger.get("ledger_row_key")) + if ledger_key and ledger_key in matched_ledger_keys: + continue + if ledger_key and ledger_key in appended_ledger_keys: + continue + if ledger_key: + appended_ledger_keys.add(ledger_key) + row["rows"].append( + { + **dict(extra_ledger), + "status_label": "Unmatched", + "voucher_account_code": "", + "voucher_account_name": "", + "voucher_vendor": "", + "voucher_debit": "", + "voucher_credit": "", + "voucher_desc": "", + "draft_no": row.get("draft_no", ""), + "proof_date": row.get("proof_date", ""), + } + ) + for extra_recheck in recheck_by_wehago_index.get(wehago_key, []): + append_recheck_row(row["rows"], extra_recheck, appended_recheck_ids) + appended_voucher_keys: set[str] = set() + for extra_voucher in voucher_only_index.get(row.get("erp_group_key"), []): + voucher_key = clean(extra_voucher.get("voucher_row_key")) + if voucher_key and voucher_key in matched_voucher_keys: + continue + if voucher_key and voucher_key in appended_voucher_keys: + continue + if voucher_key: + appended_voucher_keys.add(voucher_key) + row["rows"].append( + { + **dict(extra_voucher), + "status_label": "ERP Unmatched", + "ledger_account_code": "", + "ledger_account_name": "", + "ledger_vendor": "", + "ledger_debit": "", + "ledger_credit": "", + "ledger_desc": "", + "ledger_date": row.get("ledger_date", ""), + "voucher_no": row.get("voucher_no", extra_voucher.get("voucher_no", "")), + } + ) + for extra_recheck in recheck_by_erp_index.get(row.get("erp_group_key"), []): + append_recheck_row(row["rows"], extra_recheck, appended_recheck_ids) + row["rows"].sort( + key=lambda item: ( + clean(item.get("proof_date")) or clean(item.get("ledger_date")), + clean(item.get("draft_no")), + row_origin_rank(item), + 0 if (clean(item.get("ledger_account_name")) and clean(item.get("voucher_account_name"))) else 1 if clean(item.get("voucher_account_name")) else 2, + clean(item.get("voucher_account_name")) or clean(item.get("ledger_account_name")), + clean(item.get("voucher_no")), + -parse_amount(item.get("voucher_debit") or item.get("ledger_debit")), + -parse_amount(item.get("voucher_credit") or item.get("ledger_credit")), + clean(item.get("voucher_desc")) or clean(item.get("ledger_desc")), + ) + ) + normalized_summary = dict(row) + normalized_summary["ledger_accounts"] = ", ".join(row["ledger_accounts"]) + normalized_summary["voucher_accounts"] = ", ".join(row["voucher_accounts"]) + normalized_summary["ledger_vendors"] = ", ".join(row["ledger_vendors"]) + normalized_summary["voucher_vendors"] = ", ".join(row["voucher_vendors"]) + normalized_summary["review_reason"] = " / ".join(row["review_reason"]) + normalized_summary["voucher_no"] = ", ".join(row["voucher_nos"]) + normalized_summary["draft_no"] = ", ".join(row["draft_nos"]) + voucher_sections["erp_voucher_matched"].append( + { + "summary": normalized_summary, + "rows": list(row["rows"]), + } + ) + for status_key in voucher_sections: + voucher_sections[status_key].sort( + key=lambda item: ( + int(item.get("summary", {}).get("fiscal_year") or 0), + clean(item.get("summary", {}).get("proof_date")) or clean(item.get("summary", {}).get("ledger_date")), + clean(item.get("summary", {}).get("voucher_no")), + clean(item.get("summary", {}).get("draft_no")), + ) + ) + return voucher_sections + + +def _filter_voucher_status_row( + row: dict[str, Any], + voucher_filter: str, + draft_filter: str, + wehago_account_filter: str, + erp_account_filter: str, + wehago_amount_filter: str, + erp_amount_filter: str, + wehago_vendor_filter: str, + erp_vendor_filter: str, + desc_filter: str, +) -> bool: + summary = row.get("summary", row) + if voucher_filter and voucher_filter not in normalize_text(row.get("voucher_no")): + if voucher_filter not in normalize_text(summary.get("voucher_no")): + return False + if draft_filter and draft_filter not in normalize_text(summary.get("draft_no")): + return False + if wehago_account_filter and wehago_account_filter not in normalize_text(summary.get("ledger_accounts")): + return False + if erp_account_filter and erp_account_filter not in normalize_text(summary.get("voucher_accounts")): + return False + if wehago_vendor_filter and wehago_vendor_filter not in normalize_text(summary.get("ledger_vendors")): + return False + if erp_vendor_filter and erp_vendor_filter not in normalize_text(summary.get("voucher_vendors")): + return False + if desc_filter: + haystack = " ".join( + [ + clean(summary.get("review_reason")), + clean(summary.get("ledger_accounts")), + clean(summary.get("voucher_accounts")), + clean(summary.get("ledger_vendors")), + clean(summary.get("voucher_vendors")), + " ".join(clean(item.get("ledger_desc")) for item in row.get("rows", [])), + " ".join(clean(item.get("voucher_desc")) for item in row.get("rows", [])), + ] + ) + if desc_filter not in normalize_text(haystack): + return False + if wehago_amount_filter: + amount_text = " ".join( + [ + str(int(round(parse_amount(summary.get("ledger_debit"))))), + str(int(round(parse_amount(summary.get("ledger_credit"))))), + ] + ) + if clean(wehago_amount_filter) not in amount_text: + return False + if erp_amount_filter: + amount_text = " ".join( + [ + str(int(round(parse_amount(summary.get("voucher_debit"))))), + str(int(round(parse_amount(summary.get("voucher_credit"))))), + ] + ) + if clean(erp_amount_filter) not in amount_text: + return False + return True + + +def _build_voucher_status_detail_response_from_rows( + rows_by_status: dict[str, list[dict[str, Any]]], + status: str, + voucher_filter: str, + draft_filter: str, + wehago_account_filter: str, + erp_account_filter: str, + wehago_amount_filter: str, + erp_amount_filter: str, + wehago_vendor_filter: str, + erp_vendor_filter: str, + desc_filter: str, + offset: int, + limit: int, +) -> dict[str, Any]: + voucher_sections = _build_voucher_sections_from_rows_by_status(rows_by_status) + source_rows = voucher_sections.get(status, []) + filtered_rows = [ + group + for group in source_rows + if _filter_voucher_status_row( + group, + voucher_filter, + draft_filter, + wehago_account_filter, + erp_account_filter, + wehago_amount_filter, + erp_amount_filter, + wehago_vendor_filter, + erp_vendor_filter, + desc_filter, + ) + ] + next_offset = offset + min(limit, max(len(filtered_rows) - offset, 0)) + shown_groups = filtered_rows[offset : offset + limit] + return { + "columns": DETAIL_COLUMN_MAP[status], + "rows": [dict(group.get("summary", {})) for group in shown_groups], + "groups": shown_groups, + "total_count": len(filtered_rows), + "shown_count": len(shown_groups), + "offset": offset, + "limit": limit, + "has_more": next_offset < len(filtered_rows), + "next_offset": next_offset, + "notice": "", + "bank_payable_case_count": 0, + "boundary_excluded_count": 0, + } + + +def _fetch_voucher_status_detail_rows_from_db( + conn: Any, + start_year: int, + end_year: int, + status: str, + voucher_filter: str, + draft_filter: str, + wehago_account_filter: str, + erp_account_filter: str, + wehago_amount_filter: str, + erp_amount_filter: str, + wehago_vendor_filter: str, + erp_vendor_filter: str, + desc_filter: str, + offset: int, + limit: int, +) -> dict[str, Any]: + if status in {"voucher_matched", "erp_voucher_matched"}: + status_condition = "c.status = 'matched'" + elif status == "voucher_unmatched": + status_condition = "c.status = 'ledger_only'" + else: + status_condition = "c.status = 'amount_mismatch'" + account_filter = normalize_text(wehago_account_filter or erp_account_filter) + vendor_filter = normalize_text(wehago_vendor_filter or erp_vendor_filter) + amount_filter = clean(wehago_amount_filter or erp_amount_filter) + params: dict[str, Any] = { + "start_year": start_year, + "end_year": end_year, + "voucher_no": voucher_filter, + "voucher_like": f"%{voucher_filter}%", + "draft_no": draft_filter, + "draft_like": f"%{draft_filter}%", + "account_keyword": account_filter, + "account_like": f"%{account_filter}%", + "vendor_keyword": vendor_filter, + "vendor_like": f"%{vendor_filter}%", + "desc_keyword": desc_filter, + "desc_like": f"%{desc_filter}%", + "amount_keyword": amount_filter, + "limit": limit, + "offset": offset, + } + conditions = [ + build_year_filter_sql("c.fiscal_year"), + status_condition, + "(:voucher_no = '' OR COALESCE(l.voucher_no, v.confirmed_no, v.draft_no, c.voucher_no) LIKE :voucher_like)", + "(:draft_no = '' OR COALESCE(v.draft_no, '') LIKE :draft_like)", + "(:account_keyword = '' OR COALESCE(c.ledger_accounts, '') LIKE :account_like OR COALESCE(c.voucher_accounts, '') LIKE :account_like)", + "(:vendor_keyword = '' OR COALESCE(c.ledger_vendors, '') LIKE :vendor_like OR COALESCE(c.voucher_vendors, '') LIKE :vendor_like)", + """( + :desc_keyword = '' + OR COALESCE(c.notes, '') LIKE :desc_like + OR EXISTS ( + SELECT 1 + FROM wehago_ledger_rows l2 + WHERE l2.fiscal_year = c.fiscal_year + AND l2.compare_voucher_no = c.voucher_no + AND COALESCE(l2.description, '') LIKE :desc_like + ) + OR EXISTS ( + SELECT 1 + FROM wehago_voucher_rows v2 + WHERE v2.fiscal_year = c.fiscal_year + AND v2.compare_voucher_no = c.voucher_no + AND TRIM(COALESCE(v2.desc1, '') || ' ' || COALESCE(v2.desc2, '')) LIKE :desc_like + ) + )""", + """( + :amount_keyword = '' + OR CAST(ABS(COALESCE(c.ledger_debit, 0)) AS TEXT) LIKE '%' || :amount_keyword || '%' + OR CAST(ABS(COALESCE(c.ledger_credit, 0)) AS TEXT) LIKE '%' || :amount_keyword || '%' + OR CAST(ABS(COALESCE(c.voucher_debit, 0)) AS TEXT) LIKE '%' || :amount_keyword || '%' + OR CAST(ABS(COALESCE(c.voucher_credit, 0)) AS TEXT) LIKE '%' || :amount_keyword || '%' + )""", + ] + voucher_rep = """ + SELECT * + FROM ( + SELECT fiscal_year, compare_voucher_no, proof_date, confirmed_no, draft_no, + ROW_NUMBER() OVER ( + PARTITION BY fiscal_year, compare_voucher_no + ORDER BY row_number + ) AS rn + FROM wehago_voucher_rows + WHERE COALESCE(compare_voucher_no, '') <> '' + ) + WHERE rn = 1 + """ + ledger_rep = """ + SELECT * + FROM ( + SELECT fiscal_year, compare_voucher_no, ledger_date, voucher_no, + ROW_NUMBER() OVER ( + PARTITION BY fiscal_year, compare_voucher_no + ORDER BY row_number + ) AS rn + FROM wehago_ledger_rows + WHERE COALESCE(compare_voucher_no, '') <> '' + ) + WHERE rn = 1 + """ + from_sql = f""" + FROM wehago_comparison_results c + LEFT JOIN ({ledger_rep}) l + ON l.fiscal_year = c.fiscal_year AND l.compare_voucher_no = c.voucher_no + LEFT JOIN ({voucher_rep}) v + ON v.fiscal_year = c.fiscal_year AND v.compare_voucher_no = c.voucher_no + WHERE {' AND '.join(conditions)} + """ + select_sql = """ + c.fiscal_year AS fiscal_year, + CASE c.status + WHEN 'matched' THEN 'Matched' + WHEN 'ledger_only' THEN 'Unmatched' + WHEN 'voucher_only' THEN 'ERP Unmatched' + WHEN 'amount_mismatch' THEN 'Recheck' + ELSE COALESCE(c.status, '') + END AS status_label, + COALESCE(l.ledger_date, '') AS ledger_date, + COALESCE(v.proof_date, '') AS proof_date, + COALESCE(l.voucher_no, v.confirmed_no, v.draft_no, c.voucher_no) AS voucher_no, + COALESCE(v.draft_no, '') AS draft_no, + COALESCE(c.ledger_row_count, 0) AS ledger_row_count, + COALESCE(c.voucher_row_count, 0) AS voucher_row_count, + COALESCE(c.ledger_debit, 0) AS ledger_debit, + COALESCE(c.ledger_credit, 0) AS ledger_credit, + COALESCE(c.voucher_debit, 0) AS voucher_debit, + COALESCE(c.voucher_credit, 0) AS voucher_credit, + COALESCE(c.ledger_accounts, '') AS ledger_accounts, + COALESCE(c.voucher_accounts, '') AS voucher_accounts, + COALESCE(c.ledger_vendors, '') AS ledger_vendors, + COALESCE(c.voucher_vendors, '') AS voucher_vendors, + COALESCE(c.notes, '') AS review_reason + """ + total_count = int(conn.execute(text(f"SELECT COUNT(*) {from_sql}"), params).scalar_one() or 0) + rows = [ + {key: clean(value) if isinstance(value, str) else value for key, value in dict(row).items()} + for row in conn.execute( + text( + f""" + SELECT {select_sql} + {from_sql} + ORDER BY c.fiscal_year, c.voucher_no + LIMIT :limit OFFSET :offset + """ + ), + params, + ).mappings().all() + ] + next_offset = offset + len(rows) + return { + "columns": DETAIL_COLUMN_MAP[status], + "rows": rows, + "total_count": total_count, + "shown_count": len(rows), + "offset": offset, + "limit": limit, + "has_more": next_offset < total_count, + "next_offset": next_offset, + "notice": "", + "bank_payable_case_count": 0, + "boundary_excluded_count": 0, + } + + def _apply_broad_query_guard( rows: list[dict[str, Any]], voucher_no: str, @@ -3169,6 +7373,7 @@ def get_status_detail_rows( erp_vendor: str = "", desc_keyword: str = "", review_reason: str = "", + boundary_excluded: str = "", offset: int = 0, limit: int = 200, ) -> dict[str, Any]: @@ -3192,10 +7397,14 @@ def get_status_detail_rows( "ledgeronly": "ledger_only", "amountmismatch": "amount_mismatch", "voucheronly": "voucher_only", + "vouchermatched": "voucher_matched", + "erpvouchermatched": "erp_voucher_matched", + "voucherunmatched": "voucher_unmatched", + "voucherrecheck": "voucher_recheck", } - if normalized_status not in {"matched", "ledger_only", "amount_mismatch", "voucher_only"}: + if normalized_status not in {"matched", "ledger_only", "amount_mismatch", "voucher_only", "voucher_matched", "erp_voucher_matched", "voucher_unmatched", "voucher_recheck"}: normalized_status = allowed.get(normalized_status, normalized_status) - if normalized_status not in {"matched", "ledger_only", "amount_mismatch", "voucher_only"}: + if normalized_status not in {"matched", "ledger_only", "amount_mismatch", "voucher_only", "voucher_matched", "erp_voucher_matched", "voucher_unmatched", "voucher_recheck"}: raise ValueError("상태 값이 올바르지 않습니다.") voucher_filter = normalize_text(voucher_no) @@ -3205,11 +7414,80 @@ def get_status_detail_rows( wehago_vendor_filter = normalize_text(wehago_vendor) erp_vendor_filter = normalize_text(erp_vendor) desc_filter = normalize_text(desc_keyword or review_reason) + boundary_excluded_filter = clean(boundary_excluded).lower() in {"1", "true", "yes", "y", "on"} safe_offset = max(int(offset or 0), 0) safe_limit = max(min(int(limit or 200), 500), 1) - has_filters = any( - [ + with engine.begin() as conn: + can_use_resolved_cache = True + for year in range(start_year, end_year + 1): + cached_sections = _load_year_resolved_sections_cache(conn, year) + if cached_sections is None: + can_use_resolved_cache = False + break + + if normalized_status in {"voucher_matched", "erp_voucher_matched", "voucher_unmatched", "voucher_recheck"}: + rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year) + voucher_sections = _get_cached_voucher_sections_by_range(engine, start_year, end_year, rows_by_status) + source_rows = voucher_sections.get(normalized_status, []) + filtered_rows = [ + group + for group in source_rows + if _filter_voucher_status_row( + group, + voucher_filter, + draft_filter, + wehago_account_filter, + erp_account_filter, + clean(wehago_amount), + clean(erp_amount), + wehago_vendor_filter, + erp_vendor_filter, + desc_filter, + ) + ] + next_offset = safe_offset + min(safe_limit, max(len(filtered_rows) - safe_offset, 0)) + shown_groups = filtered_rows[safe_offset : safe_offset + safe_limit] + return { + "columns": DETAIL_COLUMN_MAP[normalized_status], + "rows": [dict(group.get("summary", {})) for group in shown_groups], + "groups": shown_groups, + "total_count": len(filtered_rows), + "shown_count": len(shown_groups), + "offset": safe_offset, + "limit": safe_limit, + "has_more": next_offset < len(filtered_rows), + "next_offset": next_offset, + "notice": "", + "bank_payable_case_count": 0, + "boundary_excluded_count": 0, + } + if can_use_resolved_cache: + rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year) + return _build_status_detail_response_from_rows( + rows_by_status, + normalized_status, + voucher_filter, + draft_filter, + wehago_account_filter, + erp_account_filter, + clean(wehago_amount), + clean(erp_amount), + wehago_vendor_filter, + erp_vendor_filter, + desc_filter, + boundary_excluded_filter, + safe_offset, + safe_limit, + ) + + warm_status_cache_async(engine, start_year, end_year) + warm_metric_counts_async(engine, start_year, end_year) + return _fetch_status_detail_rows_from_db( + conn, + start_year, + end_year, + normalized_status, voucher_filter, draft_filter, wehago_account_filter, @@ -3219,63 +7497,10 @@ def get_status_detail_rows( 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": "", - } + boundary_excluded_filter, + safe_offset, + safe_limit, + ) def get_status_field_suggestions( @@ -3296,12 +7521,20 @@ def get_status_field_suggestions( "amountmismatch": "amount_mismatch", "ledgeronly": "ledger_only", "voucheronly": "voucher_only", + "vouchermatched": "voucher_matched", + "erpvouchermatched": "erp_voucher_matched", + "voucherunmatched": "voucher_unmatched", + "voucherrecheck": "voucher_recheck", "ledger_only": "ledger_only", "voucher_only": "voucher_only", "amount_mismatch": "amount_mismatch", + "voucher_matched": "voucher_matched", + "erp_voucher_matched": "erp_voucher_matched", + "voucher_unmatched": "voucher_unmatched", + "voucher_recheck": "voucher_recheck", } normalized_status = status_map.get(normalized_status, normalized_status) - if normalized_status not in {"matched", "amount_mismatch", "ledger_only", "voucher_only"}: + if normalized_status not in {"matched", "amount_mismatch", "ledger_only", "voucher_only", "voucher_matched", "erp_voucher_matched", "voucher_unmatched", "voucher_recheck"}: raise ValueError("자동완성 상태 값이 올바르지 않습니다.") normalized_field = normalize_text(field).lower() @@ -3322,6 +7555,7 @@ def get_status_field_suggestions( normalized_field, str(start_year), str(end_year), + _current_logic_signature(), _build_bundle_signature(start_year, end_year), normalize_text(keyword), ] @@ -3330,6 +7564,68 @@ def get_status_field_suggestions( cached = _SUGGEST_CACHE.get(cache_key) if cached and (now - float(cached.get("ts", 0))) <= _SUGGEST_CACHE_TTL_SEC: all_rows = cached["rows"] + elif normalized_status in {"voucher_matched", "erp_voucher_matched", "voucher_unmatched", "voucher_recheck"}: + with engine.begin() as conn: + can_use_resolved_cache = True + for year in range(start_year, end_year + 1): + if _load_year_resolved_sections_cache(conn, year) is None: + can_use_resolved_cache = False + break + if can_use_resolved_cache: + source_rows = _build_voucher_sections_from_rows_by_status( + _get_cached_status_rows_by_range(engine, start_year, end_year) + ).get(normalized_status, []) + else: + source_rows = _fetch_voucher_status_detail_rows_from_db( + conn, + start_year, + end_year, + normalized_status, + "", + "", + "", + "", + "", + "", + "", + "", + "", + 0, + 100000, + ).get("rows", []) + keyword_norm = normalize_text(keyword) + seen: set[str] = set() + all_rows = [] + for group in source_rows: + row = group.get("summary", group) + voucher_no = clean(row.get("voucher_no")) + draft_no = clean(row.get("draft_no")) + if normalized_field == "account": + labels = [clean(row.get("ledger_accounts")), clean(row.get("voucher_accounts"))] + elif normalized_field == "vendor": + labels = [clean(row.get("ledger_vendors")), clean(row.get("voucher_vendors"))] + elif normalized_field == "draftno": + labels = [draft_no] + else: + labels = [voucher_no] + for label in labels: + key = clean(label) + if not key or key in seen: + continue + if keyword_norm and keyword_norm not in normalize_text(key): + continue + seen.add(key) + all_rows.append( + { + "voucher_no": voucher_no, + "code": "", + "name": "", + "label": key, + } + ) + _SUGGEST_CACHE.clear() + _PAIR_RECOMMEND_CACHE.clear() + _SUGGEST_CACHE[cache_key] = {"ts": now, "rows": all_rows} else: rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year) keyword_norm = normalize_text(keyword) @@ -3548,7 +7844,8 @@ def get_erp_filtered_rows( 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) + rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year) + _get_cached_voucher_sections_by_range(engine, start_year, end_year, rows_by_status) except Exception: pass finally: @@ -3578,6 +7875,9 @@ def get_wehago_compare_dashboard( engine: Any, start_year: int | None = None, end_year: int | None = None, + *, + include_metric_counts: bool = True, + warm_caches: bool = True, ) -> dict[str, Any]: init_wehago_compare_db(engine) years = discover_available_years() @@ -3597,24 +7897,30 @@ def get_wehago_compare_dashboard( 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) + with engine.begin() as conn: + metric_counts = ( + get_dashboard_metric_counts(conn, start_year, end_year) + if include_metric_counts + else {status_key: 0 for status_key, _, _ in STATUS_META} + ) + account_options = build_account_options_from_db(conn, start_year, end_year) + latest_upload = fetch_latest_upload_meta(conn) + last_action = get_last_action_summary(conn=conn) metric_sections = [ { "key": status_key, "label": label, "description": description, - "count": len(rows_by_status.get(status_key, [])), + "count": int(metric_counts.get(status_key, 0) or 0), "columns": DETAIL_COLUMN_MAP[status_key], "rows": [], } for status_key, label, description in STATUS_META ] - - 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) + if warm_caches: + warm_metric_counts_async(engine, start_year, end_year) + if start_year == end_year: + warm_status_cache_async(engine, start_year, end_year) return { "page_title": "전표비교", @@ -3629,3 +7935,63 @@ def get_wehago_compare_dashboard( "latest_upload": latest_upload, "last_action": last_action, } + + +def get_wehago_compare_summary( + engine: Any, + start_year: int | None = None, + end_year: int | None = None, +) -> dict[str, Any]: + dashboard = get_wehago_compare_dashboard( + engine, + start_year=start_year, + end_year=end_year, + include_metric_counts=False, + warm_caches=False, + ) + metric_counts, pending = get_dashboard_metric_counts_nonblocking( + engine, + dashboard.get("selected_start_year"), + dashboard.get("selected_end_year"), + ) + metric_sections = [ + { + **section, + "count": int(metric_counts.get(section.get("key"), 0) or 0), + } + for section in dashboard.get("metric_sections", []) + ] + return { + "selected_start_year": dashboard.get("selected_start_year"), + "selected_end_year": dashboard.get("selected_end_year"), + "metric_sections": metric_sections, + "last_action": dashboard.get("last_action"), + "pending": pending, + } + + +def enqueue_default_pair_recommend_precompute( + engine: Any, + start_year: int | None = None, + end_year: int | None = None, + limit: int = 300, +) -> None: + years = discover_available_years() + default_year = get_default_year(years) + resolved_start = start_year + resolved_end = end_year + if resolved_start is None and resolved_end is None: + resolved_start = default_year + resolved_end = default_year + elif resolved_start is None: + resolved_start = resolved_end + elif resolved_end is None: + resolved_end = resolved_start + if resolved_start is None or resolved_end is None: + return + enqueue_pair_recommend_precompute( + engine, + start_year=resolved_start, + end_year=resolved_end, + limit=limit, + )