diff --git a/.gitignore b/.gitignore index 591f524..d2c1d07 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ __pycache__/ *.pyc data.db-shm data.db-wal +backups/ +tmp_*.py +static/exports/ +scripts/data_download/ diff --git a/data.db b/data.db index e138198..512571a 100644 Binary files a/data.db and b/data.db differ diff --git a/main.py b/main.py index 6c9fffb..4f06858 100644 --- a/main.py +++ b/main.py @@ -23,10 +23,12 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from openpyxl import load_workbook from sqlalchemy import bindparam, create_engine, event, text +from sqlalchemy.exc import OperationalError from datasette.app import Datasette from wehago_compare import ( enqueue_default_pair_recommend_precompute, get_erp_filtered_rows, + get_compare_snapshot_status, get_individual_pair_recommendations, get_last_action_summary, get_status_field_suggestions, @@ -36,6 +38,7 @@ from wehago_compare import ( get_wehago_filtered_rows, import_uploaded_erp_voucher_file, init_wehago_compare_db, + request_compare_snapshot_rebuild, recommend_pair_matches, save_recommended_pair_matches, save_manual_pair_matches, @@ -54,8 +57,13 @@ _DB_BACKUP_STATE: dict[str, Any] = { "last_run_at": 0.0, } _DB_BACKUP_LOCK = threading.Lock() +_DB_INIT_LOCK = threading.Lock() +_DB_INIT_DONE = False +_DB_ANALYZE_LOCK = threading.Lock() +_DB_ANALYZE_LAST_ATTEMPT_AT = 0.0 DB_BACKUP_MIN_INTERVAL_SECONDS = 900.0 DB_BACKUP_KEEP_COUNT = 24 +DB_ANALYZE_MIN_INTERVAL_SECONDS = 6 * 60 * 60 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]] = {} @@ -395,8 +403,30 @@ DEFAULT_APP_KEYWORD_RULES = { def ensure_default_app_config(conn: Any) -> None: + def _clean_text(value: Any) -> str: + return "" if value is None else str(value).strip() + + existing_option_rows = conn.execute( + text( + """ + SELECT group_key, item_key, label, value_text, sort_order + FROM app_option_items + """ + ) + ).mappings().all() + existing_options = { + (_clean_text(row.get("group_key")), _clean_text(row.get("item_key"))): ( + _clean_text(row.get("label")), + _clean_text(row.get("value_text")), + int(row.get("sort_order") or 0), + ) + for row in existing_option_rows + } for group_key, items in DEFAULT_APP_OPTION_ITEMS.items(): for sort_order, (item_key, label, value_text) in enumerate(items): + existing_value = existing_options.get((group_key, item_key)) + if existing_value == (label, value_text, sort_order): + continue conn.execute( text( """ @@ -420,8 +450,22 @@ def ensure_default_app_config(conn: Any) -> None: }, ) + existing_rule_rows = conn.execute( + text( + """ + SELECT rule_group, category_key, keyword, sort_order + FROM app_keyword_rules + """ + ) + ).mappings().all() + existing_rules = { + (_clean_text(row.get("rule_group")), _clean_text(row.get("category_key")), _clean_text(row.get("keyword"))): int(row.get("sort_order") or 0) + for row in existing_rule_rows + } for rule_group, items in DEFAULT_APP_KEYWORD_RULES.items(): for sort_order, (category_key, keyword) in enumerate(items): + if existing_rules.get((rule_group, category_key, keyword)) == sort_order: + continue conn.execute( text( """ @@ -445,12 +489,38 @@ def ensure_default_app_config(conn: Any) -> None: load_app_config.cache_clear() +def _maybe_run_db_analyze() -> None: + global _DB_ANALYZE_LAST_ATTEMPT_AT + now = time.time() + if now - _DB_ANALYZE_LAST_ATTEMPT_AT < DB_ANALYZE_MIN_INTERVAL_SECONDS: + return + with _DB_ANALYZE_LOCK: + now = time.time() + if now - _DB_ANALYZE_LAST_ATTEMPT_AT < DB_ANALYZE_MIN_INTERVAL_SECONDS: + return + _DB_ANALYZE_LAST_ATTEMPT_AT = now + try: + with engine.begin() as conn: + conn.execute(text("ANALYZE")) + except OperationalError as exc: + logger.warning("ANALYZE skipped due to database lock: %s", exc) + except Exception as exc: + logger.warning("ANALYZE skipped due to unexpected error: %s", exc) + + def init_db() -> None: - with engine.begin() as conn: + global _DB_INIT_DONE + if _DB_INIT_DONE: + return + with _DB_INIT_LOCK: + if _DB_INIT_DONE: + return + conn = engine.connect() + trans = conn.begin() conn.execute( - text( - """ - CREATE TABLE IF NOT EXISTS transactions ( + text( + """ + CREATE TABLE IF NOT EXISTS transactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, approval_status TEXT, voucher_number TEXT, @@ -1333,8 +1403,11 @@ def init_db() -> None: migrate_project_status_entries(conn) migrate_project_basic_info(conn) ensure_default_app_config(conn) - conn.execute(text("ANALYZE")) - init_wehago_compare_db(engine) + trans.commit() + conn.close() + init_wehago_compare_db(engine) + _DB_INIT_DONE = True + _maybe_run_db_analyze() @lru_cache(maxsize=1) @@ -8459,11 +8532,36 @@ def render_wehago_compare_page( return templates.TemplateResponse(request, "wehago_compare.html", context) +def build_wehago_compare_health_payload() -> dict[str, Any]: + init_db() + payload = get_wehago_compare_dashboard( + engine, + include_metric_counts=False, + warm_caches=False, + ) + return { + "status": "ok", + "selected_start_year": payload.get("selected_start_year"), + "selected_end_year": payload.get("selected_end_year"), + "available_year_count": len(payload.get("available_years") or []), + "metric_section_count": len(payload.get("metric_sections") or []), + } + + @app.get("/health") async def health() -> dict[str, str]: return build_health_payload() +@app.get("/health/wehago-compare") +async def health_wehago_compare() -> JSONResponse: + try: + return JSONResponse(content=jsonable_encoder(build_wehago_compare_health_payload())) + except Exception as exc: + logger.exception("전표비교 readiness 에러: %s", exc) + return JSONResponse(content={"status": "error", "error": str(exc)}, status_code=500) + + @app.get("/") async def home(request: Request, edit_id: int | None = None, overview_year: int | None = None): try: @@ -9114,6 +9212,44 @@ async def wehago_compare_summary( return JSONResponse(content={"error": str(exc)}, status_code=500) +@app.get("/wehago-compare/api/snapshot-status") +async def wehago_compare_snapshot_status( + start_year: int | None = None, + end_year: int | None = None, +): + try: + payload = get_compare_snapshot_status( + 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.post("/wehago-compare/api/snapshot-rebuild") +async def wehago_compare_snapshot_rebuild(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + payload = {} + start_year = payload.get("start_year") + end_year = payload.get("end_year") + include_metric_counts = normalize_text(payload.get("include_metric_counts")) not in {"0", "false", "n", "no", "off"} + response = request_compare_snapshot_rebuild( + engine, + start_year=int(start_year) if start_year is not None else None, + end_year=int(end_year) if end_year is not None else None, + include_metric_counts=include_metric_counts, + ) + return JSONResponse(content=jsonable_encoder({"ok": True, **response})) + 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, diff --git a/scripts/click_left_code_9225.cmd b/scripts/click_left_code_9225.cmd new file mode 100644 index 0000000..669d51c --- /dev/null +++ b/scripts/click_left_code_9225.cmd @@ -0,0 +1,4 @@ +@echo off +set "WEHAGO_CHROME_DEBUGGER_ADDRESS=127.0.0.1:9225" +cd /d C:\Windows +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\click_left_code_9225.py" %* diff --git a/scripts/click_left_code_9225.py b/scripts/click_left_code_9225.py new file mode 100644 index 0000000..7dd591d --- /dev/null +++ b/scripts/click_left_code_9225.py @@ -0,0 +1,69 @@ +import json +import os +import sys +import time + +from selenium import webdriver +from selenium.webdriver.chrome.options import Options + + +target = sys.argv[1] if len(sys.argv) > 1 else "110" +options = Options() +options.add_experimental_option("debuggerAddress", os.environ.get("WEHAGO_CHROME_DEBUGGER_ADDRESS", "127.0.0.1:9225")) +driver = webdriver.Chrome(options=options) + +info = driver.execute_script( + r""" +const code = arguments[0]; +const out = []; +for (const cell of document.querySelectorAll('.rg-data-cell, td[class*=rg-data-cell], [class*=rg-data-cell], td')) { + const r = cell.getBoundingClientRect(); + const text = (cell.textContent || '').trim(); + if (text === code && r.left < 220 && r.top > 145 && r.width > 0 && r.height > 0) { + out.push({left:r.left, top:r.top, width:r.width, height:r.height, text}); + } +} +return out.sort((a,b)=>a.top-b.top)[0] || null; +""", + target, +) +print("target", target, "info", json.dumps(info, ensure_ascii=False)) +if info: + x = info["left"] + info["width"] / 2 + y = info["top"] + info["height"] / 2 + for typ in ["mouseMoved", "mousePressed", "mouseReleased"]: + payload = {"type": typ, "x": x, "y": y, "button": "left", "clickCount": 1} + if typ == "mouseMoved": + payload["button"] = "none" + driver.execute_cdp_cmd("Input.dispatchMouseEvent", payload) + time.sleep(0.5) + +visible = driver.execute_script( + r""" +const rows = []; +const colorScore = (color) => { + const m = String(color || '').match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (!m) return 0; + const r = Number(m[1]), g = Number(m[2]), b = Number(m[3]); + return Math.max(0, b - r) + Math.max(0, g - r) + (b > 120 ? 20 : 0); +}; +for (const cell of document.querySelectorAll('.rg-data-cell, td[class*=rg-data-cell], [class*=rg-data-cell], td')) { + const r = cell.getBoundingClientRect(); + const code = (cell.textContent || '').trim(); + if (!/^\d{3,6}$/.test(code) || r.left > 220 || r.top < 145 || r.width <= 0 || r.height <= 0) continue; + let node = cell, score = 0, cls = ''; + while (node && node !== document.body) { + const style = window.getComputedStyle(node); + score = Math.max(score, colorScore(style.backgroundColor)); + cls += ' ' + String(node.className || ''); + node = node.parentElement; + } + if (/selected|select|focus|current|active|checked/i.test(cls)) score += 80; + rows.push({code, top:r.top, score, cls: cls.slice(0,120)}); +} +rows.sort((a,b)=>(b.score-a.score)||(a.top-b.top)); +return rows.slice(0,8); +""" +) +print(json.dumps(visible, ensure_ascii=False, indent=2)) +driver.quit() diff --git a/scripts/click_left_search_button_9225.cmd b/scripts/click_left_search_button_9225.cmd new file mode 100644 index 0000000..cd521b5 --- /dev/null +++ b/scripts/click_left_search_button_9225.cmd @@ -0,0 +1,5 @@ +@echo off +set "PYTHONIOENCODING=utf-8" +set "WEHAGO_CHROME_DEBUGGER_ADDRESS=127.0.0.1:9225" +cd /d C:\Windows +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\click_left_search_button_9225.py" diff --git a/scripts/click_left_search_button_9225.py b/scripts/click_left_search_button_9225.py new file mode 100644 index 0000000..ce71e75 --- /dev/null +++ b/scripts/click_left_search_button_9225.py @@ -0,0 +1,27 @@ +import json +import os +import time + +from selenium import webdriver +from selenium.webdriver.chrome.options import Options + +options = Options() +options.add_experimental_option("debuggerAddress", os.environ.get("WEHAGO_CHROME_DEBUGGER_ADDRESS", "127.0.0.1:9225")) +driver = webdriver.Chrome(options=options) + +for typ in ["mouseMoved", "mousePressed", "mouseReleased"]: + driver.execute_cdp_cmd( + "Input.dispatchMouseEvent", + {"type": typ, "x": 130, "y": 188, "button": "left" if typ != "mouseMoved" else "none", "clickCount": 1}, + ) +time.sleep(0.8) +items = driver.execute_script( + r""" +return Array.from(document.querySelectorAll('input,button,[role=button],a,div')).map(el=>{ + const r=el.getBoundingClientRect(); const st=getComputedStyle(el); + return {tag:el.tagName,id:el.id||'',cls:String(el.className||'').slice(0,80),text:(el.textContent||'').trim().slice(0,80),value:el.value||'',placeholder:el.getAttribute('placeholder')||'',title:el.getAttribute('title')||'',left:Math.round(r.left),top:Math.round(r.top),width:Math.round(r.width),height:Math.round(r.height),display:st.display,vis:st.visibility}; +}).filter(x=>x.width>0&&x.height>0&&x.display!=='none'&&x.vis!=='hidden'&&x.top<400&&x.left<700).sort((a,b)=>(a.top-b.top)||(a.left-b.left)).slice(0,200); +""" +) +print(json.dumps(items, ensure_ascii=False, indent=2)) +driver.quit() diff --git a/scripts/export_voucher_card_diff.py b/scripts/export_voucher_card_diff.py new file mode 100644 index 0000000..82dcc73 --- /dev/null +++ b/scripts/export_voucher_card_diff.py @@ -0,0 +1,437 @@ +from __future__ import annotations + +import argparse +import sys +from datetime import datetime +from pathlib import Path +from typing import Any, Iterable + +from openpyxl import Workbook +from openpyxl.cell import WriteOnlyCell +from openpyxl.styles import Alignment, Font, PatternFill +from openpyxl.utils import get_column_letter + +BASE_DIR = Path(__file__).resolve().parent.parent +if str(BASE_DIR) not in sys.path: + sys.path.insert(0, str(BASE_DIR)) + +from main import engine +from wehago_compare import ( + _get_cached_status_rows_by_range, + _get_cached_voucher_sections_by_range, + discover_available_years, +) + +EXPORT_DIR = BASE_DIR / "static" / "exports" +HEADER_FILL = PatternFill(fill_type="solid", fgColor="1F2937") +HEADER_FONT = Font(color="FFFFFF", bold=True) +SUMMARY_FILL = PatternFill(fill_type="solid", fgColor="E5E7EB") +RECHECK_FILL = PatternFill(fill_type="solid", fgColor="FEF3C7") +UNMATCHED_FILL = PatternFill(fill_type="solid", fgColor="FDE68A") +ERP_UNMATCHED_FILL = PatternFill(fill_type="solid", fgColor="DBEAFE") + + +def clean(value: Any) -> str: + return "" if value is None else str(value).strip() + + +def amount(value: Any) -> float: + text = clean(value).replace(",", "") + if not text: + return 0.0 + try: + return float(text) + except ValueError: + return 0.0 + + +def autosize(ws) -> None: + widths: dict[int, int] = {} + for row in ws.iter_rows(): + for cell in row: + value = "" if cell.value is None else str(cell.value) + widths[cell.column] = min(max(widths.get(cell.column, 0), len(value) + 2), 42) + for col_idx, width in widths.items(): + ws.column_dimensions[get_column_letter(col_idx)].width = width + + +def style_sheet(ws) -> None: + ws.freeze_panes = "A2" + ws.auto_filter.ref = ws.dimensions + for cell in ws[1]: + cell.fill = HEADER_FILL + cell.font = HEADER_FONT + cell.alignment = Alignment(horizontal="center", vertical="center") + for row in ws.iter_rows(min_row=2): + status_value = clean(row[0].value) + if status_value == "Recheck": + fill = RECHECK_FILL + elif status_value == "Unmatched": + fill = UNMATCHED_FILL + elif status_value == "ERP Unmatched": + fill = ERP_UNMATCHED_FILL + else: + fill = None + if fill: + for cell in row: + cell.fill = fill + for cell in row: + cell.alignment = Alignment(vertical="top", wrap_text=True) + autosize(ws) + + +def append_rows(ws, headers: list[str], rows: Iterable[list[Any]]) -> None: + ws.append(headers) + for row in rows: + ws.append(row) + style_sheet(ws) + + +def append_rows_write_only(workbook: Workbook, title: str, headers: list[str], rows: Iterable[list[Any]]) -> None: + ws = workbook.create_sheet(title) + header_row: list[WriteOnlyCell] = [] + for value in headers: + cell = WriteOnlyCell(ws, value=value) + cell.fill = HEADER_FILL + cell.font = HEADER_FONT + cell.alignment = Alignment(horizontal="center", vertical="center") + header_row.append(cell) + ws.append(header_row) + for row in rows: + ws.append(list(row)) + + +def flatten_groups( + groups: list[dict[str, Any]], + card_label: str, + only_statuses: set[str] | None = None, +) -> list[list[Any]]: + rows: list[list[Any]] = [] + for group_index, group in enumerate(groups, start=1): + summary = group.get("summary", {}) + link_count = len(summary.get("erp_group_keys") or summary.get("wehago_group_keys") or []) + group_rows = group.get("rows", []) + for row_index, row in enumerate(group_rows, start=1): + status_label = clean(row.get("status_label")) + if only_statuses and status_label not in only_statuses: + continue + rows.append( + [ + card_label, + group_index, + row_index, + status_label, + summary.get("fiscal_year"), + clean(summary.get("ledger_date")), + clean(summary.get("proof_date")), + clean(summary.get("voucher_no")), + clean(summary.get("draft_no")), + clean(summary.get("ledger_accounts")), + clean(summary.get("voucher_accounts")), + clean(summary.get("ledger_vendors")), + clean(summary.get("voucher_vendors")), + clean(summary.get("review_reason")), + len(group_rows), + link_count, + clean(row.get("ledger_date")), + clean(row.get("proof_date")), + 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_vendor")), + clean(row.get("voucher_vendor")), + amount(row.get("ledger_debit")), + amount(row.get("ledger_credit")), + amount(row.get("voucher_debit")), + amount(row.get("voucher_credit")), + clean(row.get("ledger_desc")), + clean(row.get("voucher_desc")), + ] + ) + return rows + + +def iter_card_issue_rows(groups: list[dict[str, Any]], card_kind: str) -> list[list[Any]]: + issues: list[list[Any]] = [] + for group in groups: + summary = group.get("summary", {}) + parent_year = summary.get("fiscal_year") + parent_date = clean(summary.get("ledger_date") or summary.get("proof_date")) + parent_voucher_no = clean(summary.get("voucher_no")) + parent_draft_no = clean(summary.get("draft_no")) + for row in group.get("rows", []): + status_label = clean(row.get("status_label")) + if card_kind == "wehago" and status_label not in {"ERP Unmatched", "Recheck"}: + continue + if card_kind == "voucher" and status_label not in {"Unmatched", "Recheck"}: + continue + issues.append( + [ + status_label, + parent_year, + parent_date, + parent_voucher_no, + parent_draft_no, + clean(summary.get("ledger_accounts")), + clean(summary.get("voucher_accounts")), + clean(summary.get("ledger_vendors")), + clean(summary.get("voucher_vendors")), + clean(summary.get("review_reason")), + clean(row.get("ledger_date")), + clean(row.get("proof_date")), + 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_vendor")), + clean(row.get("voucher_vendor")), + amount(row.get("ledger_debit")), + amount(row.get("ledger_credit")), + amount(row.get("voucher_debit")), + amount(row.get("voucher_credit")), + clean(row.get("ledger_desc")), + clean(row.get("voucher_desc")), + ] + ) + return issues + + +def build_relation_rows( + wehago_groups: list[dict[str, Any]], + voucher_groups: list[dict[str, Any]], +) -> list[list[Any]]: + rows: list[list[Any]] = [] + for group in wehago_groups: + summary = group.get("summary", {}) + erp_keys = summary.get("erp_group_keys") or set() + if len(erp_keys) <= 1: + continue + rows.append( + [ + "WEHAGO 1 : ERP N", + summary.get("fiscal_year"), + clean(summary.get("ledger_date")), + clean(summary.get("proof_date")), + clean(summary.get("voucher_no")), + clean(summary.get("draft_no")), + clean(summary.get("ledger_accounts")), + clean(summary.get("voucher_accounts")), + len(erp_keys), + len(group.get("rows", [])), + clean(summary.get("review_reason")), + " | ".join(sorted(clean(key) for key in erp_keys if clean(key))), + ] + ) + for group in voucher_groups: + summary = group.get("summary", {}) + wehago_keys = summary.get("wehago_group_keys") or set() + if len(wehago_keys) <= 1: + continue + rows.append( + [ + "ERP 1 : WEHAGO N", + summary.get("fiscal_year"), + clean(summary.get("ledger_date")), + clean(summary.get("proof_date")), + clean(summary.get("voucher_no")), + clean(summary.get("draft_no")), + clean(summary.get("ledger_accounts")), + clean(summary.get("voucher_accounts")), + len(wehago_keys), + len(group.get("rows", [])), + clean(summary.get("review_reason")), + " | ".join(sorted(clean(key) for key in wehago_keys if clean(key))), + ] + ) + return rows + + +def export_voucher_card_diff(start_year: int, end_year: int) -> Path: + EXPORT_DIR.mkdir(parents=True, exist_ok=True) + workbook = Workbook(write_only=True) + + detail_headers = [ + "카드", + "그룹순번", + "행순번", + "행상태", + "연도", + "부모 WEHAGO 일자", + "부모 ERP 일자", + "부모 전표번호", + "부모 가전표번호", + "부모 WEHAGO 계정", + "부모 ERP 계정", + "부모 WEHAGO 거래처", + "부모 ERP 거래처", + "부모 검증근거", + "부모 그룹 행수", + "연결된 반대 그룹 수", + "행 WEHAGO 일자", + "행 ERP 일자", + "행 전표번호", + "행 가전표번호", + "행 WEHAGO 계정", + "행 ERP 계정", + "행 WEHAGO 거래처", + "행 ERP 거래처", + "행 WEHAGO 차변", + "행 WEHAGO 대변", + "행 ERP 차변", + "행 ERP 대변", + "행 WEHAGO 적요", + "행 ERP 적요", + ] + + all_wehago_match_rows: list[list[Any]] = [] + all_wehago_unmatched_rows: list[list[Any]] = [] + all_voucher_match_rows: list[list[Any]] = [] + all_voucher_unmatched_rows: list[list[Any]] = [] + diff_summary_rows: list[list[Any]] = [] + relation_rows_all: list[list[Any]] = [] + + for year in range(start_year, end_year + 1): + rows_by_status = _get_cached_status_rows_by_range(engine, year, year) + sections = _get_cached_voucher_sections_by_range(engine, year, year, rows_by_status) + wehago_groups = sections.get("voucher_matched", []) + voucher_groups = sections.get("erp_voucher_matched", []) + wehago_unmatched_groups = sections.get("voucher_unmatched", []) + wehago_issue_rows = iter_card_issue_rows(wehago_groups, "wehago") + voucher_issue_rows = iter_card_issue_rows(voucher_groups, "voucher") + relation_rows = build_relation_rows(wehago_groups, voucher_groups) + wehago_multi = sum(1 for row in relation_rows if row[0] == "WEHAGO 1 : ERP N") + voucher_multi = sum(1 for row in relation_rows if row[0] == "ERP 1 : WEHAGO N") + all_wehago_match_rows.extend(flatten_groups(wehago_groups, "WEHAGO")) + all_wehago_unmatched_rows.extend(flatten_groups(wehago_unmatched_groups, "WEHAGO Unmatched")) + all_voucher_match_rows.extend(flatten_groups(voucher_groups, "Voucher")) + all_voucher_unmatched_rows.extend(flatten_groups(voucher_groups, "Voucher", {"Unmatched", "Recheck"})) + relation_rows_all.extend(relation_rows) + diff_summary_rows.append( + [ + "요약", + year, + len(wehago_groups), + len(wehago_unmatched_groups), + len(voucher_groups), + len(voucher_issue_rows), + len(wehago_groups) - len(voucher_groups), + wehago_multi, + voucher_multi, + len(wehago_issue_rows), + len(voucher_issue_rows), + "WEHAGO 카드는 WEHAGO 전표 기준 그룹 수, Voucher 카드는 ERP 전표 기준 그룹 수입니다.", + ] + ) + + append_rows_write_only( + workbook, + "1_WEHAGO_Match", + detail_headers, + all_wehago_match_rows or [["WEHAGO", "", "", "", "", "", "", "", "", "", "", "", "", "데이터가 없습니다.", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]], + ) + append_rows_write_only( + workbook, + "2_WEHAGO_Unmatched", + detail_headers, + all_wehago_unmatched_rows or [["WEHAGO Unmatched", "", "", "", "", "", "", "", "", "", "", "", "", "데이터가 없습니다.", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]], + ) + append_rows_write_only( + workbook, + "3_Voucher_Match", + detail_headers, + all_voucher_match_rows or [["Voucher", "", "", "", "", "", "", "", "", "", "", "", "", "데이터가 없습니다.", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]], + ) + append_rows_write_only( + workbook, + "4_Voucher_Unmatched", + detail_headers, + all_voucher_unmatched_rows or [["Voucher", "", "", "", "", "", "", "", "", "", "", "", "", "Voucher 카드 안에서 Unmatched/Recheck로 남은 행이 없습니다.", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]], + ) + + diff_headers = [ + "구분", + "연도", + "WEHAGO 매치 그룹 수", + "WEHAGO Unmatched 그룹 수", + "Voucher 매치 그룹 수", + "Voucher unmatched 행 수", + "WEHAGO-Voucher 그룹 수 차이", + "WEHAGO 1:ERP N 건수", + "ERP 1:WEHAGO N 건수", + "WEHAGO 카드 내 ERP Unmatched/Recheck 행 수", + "Voucher 카드 내 Unmatched/Recheck 행 수", + "설명", + ] + diff_rows: list[list[Any]] = [] + diff_rows.append(diff_headers) + diff_rows.extend(diff_summary_rows) + if diff_summary_rows: + total_wehago = sum(int(row[2]) for row in diff_summary_rows) + total_wehago_unmatched = sum(int(row[3]) for row in diff_summary_rows) + total_voucher = sum(int(row[4]) for row in diff_summary_rows) + total_voucher_unmatched = sum(int(row[5]) for row in diff_summary_rows) + total_diff = sum(int(row[6]) for row in diff_summary_rows) + total_wehago_multi = sum(int(row[7]) for row in diff_summary_rows) + total_voucher_multi = sum(int(row[8]) for row in diff_summary_rows) + total_wehago_issues = sum(int(row[9]) for row in diff_summary_rows) + total_voucher_issues = sum(int(row[10]) for row in diff_summary_rows) + diff_rows.append( + [ + "전체", + f"{start_year}~{end_year}", + total_wehago, + total_wehago_unmatched, + total_voucher, + total_voucher_unmatched, + total_diff, + total_wehago_multi, + total_voucher_multi, + total_wehago_issues, + total_voucher_issues, + "그룹 수 차이는 한쪽 전표 1건이 반대쪽 여러 전표와 연결되는 경우에서 주로 발생합니다.", + ] + ) + diff_rows.append([]) + diff_rows.append(["관계 유형", "연도", "WEHAGO 일자", "ERP 일자", "전표번호", "가전표번호", "WEHAGO 계정", "ERP 계정", "연결된 반대 그룹 수", "그룹 행 수", "검증 근거", "연결된 그룹 키"]) + diff_rows.extend(relation_rows_all or [["", "", "", "", "", "", "", "", "", "", "수 차이를 만든 다대일/일대다 사례가 없습니다.", ""]]) + diff_ws = workbook.create_sheet("5_Count_Diff") + for index, row in enumerate(diff_rows, start=1): + if index in {1, len(diff_summary_rows) + 3}: + cells: list[WriteOnlyCell] = [] + for value in row: + cell = WriteOnlyCell(diff_ws, value=value) + cell.fill = HEADER_FILL + cell.font = HEADER_FONT + cell.alignment = Alignment(horizontal="center", vertical="center") + cells.append(cell) + diff_ws.append(cells) + else: + diff_ws.append(row) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output_path = EXPORT_DIR / f"voucher_card_diff_{start_year}_{end_year}_{timestamp}.xlsx" + workbook.save(output_path) + return output_path + + +def parse_args() -> argparse.Namespace: + years = discover_available_years() + default_start = min(years) if years else datetime.now().year + default_end = max(years) if years else default_start + parser = argparse.ArgumentParser(description="WEHAGO/Voucher 카드 차이 분석 엑셀 생성") + parser.add_argument("--start-year", type=int, default=default_start) + parser.add_argument("--end-year", type=int, default=default_end) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + start_year = min(args.start_year, args.end_year) + end_year = max(args.start_year, args.end_year) + output_path = export_voucher_card_diff(start_year, end_year) + print(output_path) + + +if __name__ == "__main__": + main() diff --git a/scripts/export_voucher_count_gap.py b/scripts/export_voucher_count_gap.py new file mode 100644 index 0000000..6c5ad27 --- /dev/null +++ b/scripts/export_voucher_count_gap.py @@ -0,0 +1,316 @@ +from __future__ import annotations + +import argparse +import sys +from collections import Counter, defaultdict, deque +from datetime import datetime +from pathlib import Path +from typing import Any + +from openpyxl import Workbook +from openpyxl.cell import WriteOnlyCell +from openpyxl.styles import Alignment, Font, PatternFill + +BASE_DIR = Path(__file__).resolve().parent.parent +if str(BASE_DIR) not in sys.path: + sys.path.insert(0, str(BASE_DIR)) + +from main import engine +from wehago_compare import _get_cached_status_rows_by_range, _get_cached_voucher_sections_by_range + +EXPORT_DIR = BASE_DIR / "static" / "exports" +HEADER_FILL = PatternFill(fill_type="solid", fgColor="111827") +HEADER_FONT = Font(color="FFFFFF", bold=True) +POS_FILL = PatternFill(fill_type="solid", fgColor="DCFCE7") +NEG_FILL = PatternFill(fill_type="solid", fgColor="DBEAFE") + + +def clean(value: Any) -> str: + return "" if value is None else str(value).strip() + + +def amount(value: Any) -> float: + text = clean(value).replace(",", "") + if not text: + return 0.0 + try: + return float(text) + except ValueError: + return 0.0 + + +def write_row(ws, values: list[Any], fill: PatternFill | None = None, header: bool = False) -> None: + row = [] + for value in values: + cell = WriteOnlyCell(ws, value=value) + cell.alignment = Alignment(vertical="top", wrap_text=True) + if header: + cell.fill = HEADER_FILL + cell.font = HEADER_FONT + cell.alignment = Alignment(horizontal="center", vertical="center") + elif fill: + cell.fill = fill + row.append(cell) + ws.append(row) + + +def summarize_group(summary: dict[str, Any], side: str) -> dict[str, Any]: + if side == "w": + return { + "date": clean(summary.get("ledger_date")), + "voucher_no": clean(summary.get("voucher_no")), + "draft_no": clean(summary.get("draft_no")), + "accounts": clean(summary.get("ledger_accounts")), + "vendors": clean(summary.get("ledger_vendors")), + "debit": amount(summary.get("ledger_debit")), + "credit": amount(summary.get("ledger_credit")), + "links": len(summary.get("erp_group_keys") or []), + "reason": clean(summary.get("review_reason")), + } + return { + "date": clean(summary.get("proof_date")) or clean(summary.get("ledger_date")), + "voucher_no": clean(summary.get("voucher_no")), + "draft_no": clean(summary.get("draft_no")), + "accounts": clean(summary.get("voucher_accounts")), + "vendors": clean(summary.get("voucher_vendors")), + "debit": amount(summary.get("voucher_debit")), + "credit": amount(summary.get("voucher_credit")), + "links": len(summary.get("wehago_group_keys") or []), + "reason": clean(summary.get("review_reason")), + } + + +def build_gap_report(year: int) -> Path: + rows = _get_cached_status_rows_by_range(engine, year, year) + sections = _get_cached_voucher_sections_by_range(engine, year, year, rows) + wehago_groups = sections["voucher_matched"] + voucher_groups = sections["erp_voucher_matched"] + + wehago_by_key: dict[Any, dict[str, Any]] = {} + voucher_by_key: dict[Any, dict[str, Any]] = {} + adj: dict[tuple[str, Any], set[tuple[str, Any]]] = defaultdict(set) + + for group in wehago_groups: + summary = group["summary"] + w_key = summary.get("wehago_group_key") + wehago_by_key[w_key] = group + for e_key in summary.get("erp_group_keys") or set(): + adj[("w", w_key)].add(("e", e_key)) + adj[("e", e_key)].add(("w", w_key)) + for group in voucher_groups: + summary = group["summary"] + e_key = summary.get("erp_group_key") + voucher_by_key[e_key] = group + for w_key in summary.get("wehago_group_keys") or set(): + adj[("e", e_key)].add(("w", w_key)) + adj[("w", w_key)].add(("e", e_key)) + + components: list[dict[str, Any]] = [] + visited: set[tuple[str, Any]] = set() + for node in list(adj): + if node in visited: + continue + q = deque([node]) + visited.add(node) + w_keys: list[Any] = [] + e_keys: list[Any] = [] + while q: + side, key = q.popleft() + if side == "w": + w_keys.append(key) + else: + e_keys.append(key) + for nxt in adj[node if False else (side, key)]: + if nxt not in visited: + visited.add(nxt) + q.append(nxt) + components.append({"w_keys": w_keys, "e_keys": e_keys}) + + shape_counter = Counter((len(item["w_keys"]), len(item["e_keys"])) for item in components) + gap_components = [item for item in components if len(item["w_keys"]) != len(item["e_keys"])] + gap_components.sort(key=lambda item: (abs(len(item["w_keys"]) - len(item["e_keys"])) * -1, len(item["w_keys"]) * -1, len(item["e_keys"]) * -1)) + + EXPORT_DIR.mkdir(parents=True, exist_ok=True) + wb = Workbook(write_only=True) + + ws_summary = wb.create_sheet("summary") + write_row(ws_summary, ["항목", "값", "설명"], header=True) + write_row(ws_summary, ["연도", year, "분석 기준 연도"]) + write_row(ws_summary, ["WEHAGO 매치 그룹 수", len(wehago_groups), "WEHAGO 카드에 표시되는 매치 전표 그룹 수"]) + write_row(ws_summary, ["Voucher 매치 그룹 수", len(voucher_groups), "Voucher 카드에 표시되는 매치 전표 그룹 수"]) + write_row(ws_summary, ["그룹 수 차이", len(wehago_groups) - len(voucher_groups), "WEHAGO 그룹 수 - Voucher 그룹 수"]) + write_row(ws_summary, ["전체 연결 성분 수", len(components), "WEHAGO/ERP 매치 관계를 그래프로 묶은 연결 단위 수"]) + write_row(ws_summary, ["1:1 성분 수", shape_counter.get((1, 1), 0), "양쪽 그룹 수가 같은 일반적인 경우"]) + write_row(ws_summary, ["차이 발생 성분 수", len(gap_components), "WEHAGO 그룹 수와 Voucher 그룹 수가 다른 연결 성분 수"]) + write_row(ws_summary, ["WEHAGO 쪽 초과 합계", sum(max(len(item['w_keys']) - len(item['e_keys']), 0) for item in gap_components), "WEHAGO 그룹 수가 더 많은 만큼의 합계"]) + write_row(ws_summary, ["Voucher 쪽 초과 합계", sum(max(len(item['e_keys']) - len(item['w_keys']), 0) for item in gap_components), "Voucher 그룹 수가 더 많은 만큼의 합계"]) + write_row(ws_summary, []) + write_row(ws_summary, ["형태", "건수", "설명"], header=True) + for (w_count, e_count), count in shape_counter.most_common(20): + desc = "정상 1:1" if (w_count, e_count) == (1, 1) else ("WEHAGO가 더 많이 쪼개짐" if w_count > e_count else "Voucher가 더 많이 쪼개짐") + write_row(ws_summary, [f"{w_count}:{e_count}", count, desc]) + + ws_gap = wb.create_sheet("gap_components") + write_row( + ws_gap, + [ + "component_id", + "형태", + "WEHAGO 그룹 수", + "Voucher 그룹 수", + "차이(WEHAGO-Voucher)", + "WEHAGO 대표 전표", + "Voucher 대표 전표", + "WEHAGO 전표 목록", + "Voucher 전표 목록", + "WEHAGO 계정 요약", + "Voucher 계정 요약", + ], + header=True, + ) + for idx, item in enumerate(gap_components, start=1): + w_summaries = [summarize_group(wehago_by_key[key]["summary"], "w") for key in item["w_keys"] if key in wehago_by_key] + e_summaries = [summarize_group(voucher_by_key[key]["summary"], "e") for key in item["e_keys"] if key in voucher_by_key] + fill = POS_FILL if len(item["w_keys"]) > len(item["e_keys"]) else NEG_FILL + write_row( + ws_gap, + [ + idx, + f"{len(item['w_keys'])}:{len(item['e_keys'])}", + len(item["w_keys"]), + len(item["e_keys"]), + len(item["w_keys"]) - len(item["e_keys"]), + " | ".join(f"{row['date']} {row['voucher_no']}" for row in w_summaries[:3]), + " | ".join(f"{row['date']} {row['voucher_no']}/{row['draft_no']}" for row in e_summaries[:3]), + "\n".join(f"{row['date']} / {row['voucher_no']} / {row['accounts']}" for row in w_summaries), + "\n".join(f"{row['date']} / {row['voucher_no']} / {row['draft_no']} / {row['accounts']}" for row in e_summaries), + "\n".join(row["accounts"] for row in w_summaries), + "\n".join(row["accounts"] for row in e_summaries), + ], + fill=fill, + ) + + ws_wehago = wb.create_sheet("wehago_extra_groups") + write_row( + ws_wehago, + [ + "component_id", + "형태", + "WEHAGO 일자", + "WEHAGO 전표번호", + "WEHAGO 계정", + "WEHAGO 거래처", + "WEHAGO 차변", + "WEHAGO 대변", + "연결 ERP 그룹 수", + "연결 ERP 전표 목록", + "검증 근거", + ], + header=True, + ) + for idx, item in enumerate(gap_components, start=1): + if len(item["w_keys"]) <= len(item["e_keys"]): + continue + linked_erp = [summarize_group(voucher_by_key[key]["summary"], "e") for key in item["e_keys"] if key in voucher_by_key] + linked_erp_text = "\n".join(f"{row['date']} / {row['voucher_no']} / {row['draft_no']}" for row in linked_erp) + for w_key in item["w_keys"]: + group = wehago_by_key.get(w_key) + if not group: + continue + s = summarize_group(group["summary"], "w") + write_row( + ws_wehago, + [ + idx, + f"{len(item['w_keys'])}:{len(item['e_keys'])}", + s["date"], + s["voucher_no"], + s["accounts"], + s["vendors"], + s["debit"], + s["credit"], + s["links"], + linked_erp_text, + s["reason"], + ], + fill=POS_FILL, + ) + + ws_voucher = wb.create_sheet("voucher_extra_groups") + write_row( + ws_voucher, + [ + "component_id", + "형태", + "ERP 일자", + "ERP 전표번호", + "ERP 가전표번호", + "ERP 계정", + "ERP 거래처", + "ERP 차변", + "ERP 대변", + "연결 WEHAGO 그룹 수", + "연결 WEHAGO 전표 목록", + "검증 근거", + ], + header=True, + ) + for idx, item in enumerate(gap_components, start=1): + if len(item["e_keys"]) <= len(item["w_keys"]): + continue + linked_wehago = [summarize_group(wehago_by_key[key]["summary"], "w") for key in item["w_keys"] if key in wehago_by_key] + linked_wehago_text = "\n".join(f"{row['date']} / {row['voucher_no']}" for row in linked_wehago) + for e_key in item["e_keys"]: + group = voucher_by_key.get(e_key) + if not group: + continue + s = summarize_group(group["summary"], "e") + write_row( + ws_voucher, + [ + idx, + f"{len(item['w_keys'])}:{len(item['e_keys'])}", + s["date"], + s["voucher_no"], + s["draft_no"], + s["accounts"], + s["vendors"], + s["debit"], + s["credit"], + s["links"], + linked_wehago_text, + s["reason"], + ], + fill=NEG_FILL, + ) + + ws_examples = wb.create_sheet("top_patterns") + write_row(ws_examples, ["형태", "건수", "설명"], header=True) + for (w_count, e_count), count in shape_counter.most_common(50): + if (w_count, e_count) == (1, 1): + continue + write_row( + ws_examples, + [ + f"{w_count}:{e_count}", + count, + "WEHAGO 전표 그룹 수가 더 많음" if w_count > e_count else "Voucher 전표 그룹 수가 더 많음", + ], + fill=POS_FILL if w_count > e_count else NEG_FILL, + ) + + output = EXPORT_DIR / f"voucher_count_gap_{year}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + wb.save(output) + return output + + +def main() -> None: + parser = argparse.ArgumentParser(description="WEHAGO/Voucher 매치 그룹 수 차이 분석 엑셀 생성") + parser.add_argument("--year", type=int, default=2025) + args = parser.parse_args() + output = build_gap_report(args.year) + print(output) + + +if __name__ == "__main__": + main() diff --git a/scripts/import_hanmac_erp_260507.py b/scripts/import_hanmac_erp_260507.py new file mode 100644 index 0000000..4f0aea6 --- /dev/null +++ b/scripts/import_hanmac_erp_260507.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +from openpyxl import load_workbook +from sqlalchemy import bindparam, create_engine, text + +BASE_DIR = Path(__file__).resolve().parent.parent +if str(BASE_DIR) not in sys.path: + sys.path.insert(0, str(BASE_DIR)) + +from wehago_compare import ( + WEHAGO_SOURCE_ROOT, + _fallback_metric_counts_from_db, + compute_file_hash, + detect_file_kind, + import_ledger_rows, + import_voucher_rows, + infer_year_hint, + init_wehago_compare_db, + rebuild_comparison_results, + upsert_source_file, +) + + +DB_PATH = BASE_DIR / "data.db" +ENGINE = create_engine(f"sqlite:///{DB_PATH}", connect_args={"check_same_thread": False}) + +LEDGER_FILES = { + 2022: WEHAGO_SOURCE_ROOT / "data_download/2022/ledger_2022_20220101_20221231_260507.xlsx", + 2023: WEHAGO_SOURCE_ROOT / "data_download/wehago_account_ledger_20260506_2023_compare/ledger_2023_20230101_20231231_260506.xlsx", + 2024: WEHAGO_SOURCE_ROOT / "data_download/wehago_account_ledger_20260506_2024_compare/ledger_2024_20240101_20241231_260504.xlsx", + 2025: WEHAGO_SOURCE_ROOT / "data_download/wehago_account_ledger_20260423/ledger_2025_20250101_20251231_260423.xlsx", +} +VOUCHER_FILES = [ + { + "path": WEHAGO_SOURCE_ROOT / "voucher_sort_20220101_20221231_260507.xlsx", + "expected_year": 2022, + "year_hint": 2022, + "allowed_years": {2022}, + }, + { + "path": WEHAGO_SOURCE_ROOT / "voucher_sort_20230101_20251231_260424.xlsx", + "expected_year": 2023, + "year_hint": None, + "allowed_years": {2023, 2024}, + }, + { + "path": WEHAGO_SOURCE_ROOT / "data_download/wehago_account_ledger_20260423/voucher_2025_20250101_20251231_260422.xlsx", + "expected_year": 2025, + "year_hint": 2025, + "allowed_years": {2025}, + }, +] +_DEFAULT_YEAR_HINT = object() + + +def _load_sheet(path: Path): + workbook = load_workbook(path, read_only=True, data_only=True) + return workbook, workbook.worksheets[0] + + +def _delete_year_data(conn, *, ledger_years: set[int], voucher_years: set[int]) -> dict[str, int]: + ledger_deleted = conn.execute( + text("DELETE FROM wehago_ledger_rows WHERE fiscal_year IN :years").bindparams(bindparam("years", expanding=True)), + {"years": sorted(ledger_years)}, + ).rowcount + voucher_deleted = conn.execute( + text("DELETE FROM wehago_voucher_rows WHERE fiscal_year IN :years").bindparams(bindparam("years", expanding=True)), + {"years": sorted(voucher_years)}, + ).rowcount + + unused_source_ids = { + int(row[0]) + for row in conn.execute( + text( + """ + SELECT sf.id + FROM wehago_source_files sf + LEFT JOIN wehago_ledger_rows l ON l.source_file_id = sf.id + LEFT JOIN wehago_voucher_rows v ON v.source_file_id = sf.id + GROUP BY sf.id + HAVING COUNT(l.id) = 0 AND COUNT(v.id) = 0 + """ + ) + ) + } + if unused_source_ids: + conn.execute( + text("DELETE FROM wehago_source_files WHERE id IN :ids").bindparams(bindparam("ids", expanding=True)), + {"ids": sorted(unused_source_ids)}, + ) + conn.execute( + text("DELETE FROM wehago_comparison_results WHERE fiscal_year IN :years").bindparams(bindparam("years", expanding=True)), + {"years": sorted(ledger_years | voucher_years)}, + ) + conn.execute(text("DELETE FROM wehago_metric_count_cache")) + conn.execute(text("DELETE FROM wehago_result_row_cache")) + conn.execute(text("DELETE FROM wehago_pair_recommend_cache")) + conn.execute(text("DELETE FROM wehago_background_jobs WHERE job_type = 'pair_recommend_precompute'")) + return {"ledger_deleted": int(ledger_deleted or 0), "voucher_deleted": int(voucher_deleted or 0), "source_deleted": len(unused_source_ids)} + + +def _import_one( + conn, + path: Path, + expected_kind: str, + expected_year: int, + *, + allowed_years: set[int] | None = None, + year_hint_override: int | None | object = _DEFAULT_YEAR_HINT, +) -> dict[str, object]: + file_kind, header, sheet_name = detect_file_kind(path) + if file_kind != expected_kind: + raise ValueError(f"{path} 형식이 {expected_kind}가 아닙니다: {file_kind}") + + workbook, sheet = _load_sheet(path) + try: + sample_rows = list(sheet.iter_rows(min_row=2, max_row=51, values_only=True)) + if year_hint_override is _DEFAULT_YEAR_HINT: + year_hint = infer_year_hint(path, file_kind, sample_rows) or expected_year + else: + year_hint = year_hint_override + source_id, _changed = upsert_source_file(conn, path, file_kind, year_hint, sheet_name, header) + if file_kind == "ledger": + conn.execute(text("DELETE FROM wehago_ledger_rows WHERE source_file_id = :source_id"), {"source_id": source_id}) + inserted = import_ledger_rows(conn, source_id, sheet_name, sheet.iter_rows(min_row=2, values_only=True), year_hint) + else: + conn.execute(text("DELETE FROM wehago_voucher_rows WHERE source_file_id = :source_id"), {"source_id": source_id}) + inserted = import_voucher_rows(conn, source_id, sheet_name, sheet.iter_rows(min_row=2, values_only=True), year_hint) + allowed = sorted(allowed_years or {expected_year}) + if allowed: + conn.execute( + text( + """ + DELETE FROM wehago_ledger_rows + WHERE source_file_id = :source_id + AND fiscal_year NOT IN :allowed_years + """ + ).bindparams(bindparam("allowed_years", expanding=True)), + {"source_id": source_id, "allowed_years": allowed}, + ) + conn.execute( + text( + """ + DELETE FROM wehago_voucher_rows + WHERE source_file_id = :source_id + AND fiscal_year NOT IN :allowed_years + """ + ).bindparams(bindparam("allowed_years", expanding=True)), + {"source_id": source_id, "allowed_years": allowed}, + ) + inserted = int( + conn.execute( + text(f"SELECT COUNT(*) FROM wehago_{file_kind}_rows WHERE source_file_id = :source_id"), + {"source_id": source_id}, + ).scalar_one() + or 0 + ) + conn.execute( + text("UPDATE wehago_source_files SET row_count = :row_count, imported_at = CURRENT_TIMESTAMP WHERE id = :source_id"), + {"row_count": inserted, "source_id": source_id}, + ) + return { + "path": str(path), + "kind": file_kind, + "year_hint": year_hint, + "allowed_years": allowed, + "source_id": source_id, + "inserted": inserted, + "file_hash": compute_file_hash(path), + } + finally: + workbook.close() + + +def _fetch_counts(conn) -> dict[str, object]: + counts: dict[str, object] = {} + for table in ("wehago_ledger_rows", "wehago_voucher_rows", "wehago_comparison_results"): + rows = conn.execute( + text(f"SELECT fiscal_year, COUNT(*) AS row_count FROM {table} GROUP BY fiscal_year ORDER BY fiscal_year") + ).mappings() + counts[table] = {str(row["fiscal_year"]): int(row["row_count"]) for row in rows} + return counts + + +def _fetch_quality(conn) -> dict[str, object]: + total_rows = conn.execute( + text( + """ + SELECT COUNT(*) + FROM wehago_ledger_rows + WHERE REPLACE(COALESCE(description, ''), ' ', '') IN ('[월계]', '[누계]', '월계', '누계') + OR REPLACE(COALESCE(ledger_date, ''), ' ', '') IN ('[월계]', '[누계]', '월계', '누계') + """ + ) + ).scalar_one() + blank_account_rows = conn.execute( + text( + """ + SELECT COUNT(*) + FROM wehago_ledger_rows + WHERE fiscal_year IN (2022, 2023, 2024) + AND (COALESCE(account_code, '') = '' OR COALESCE(account_name, '') = '') + """ + ) + ).scalar_one() + account_names = conn.execute( + text( + """ + SELECT fiscal_year, account_code, COUNT(DISTINCT account_name) AS name_count, + GROUP_CONCAT(DISTINCT account_name) AS names + FROM wehago_ledger_rows + WHERE fiscal_year IN (2022, 2023, 2024) + AND COALESCE(account_code, '') <> '' + GROUP BY fiscal_year, account_code + HAVING COUNT(DISTINCT account_name) > 1 + ORDER BY fiscal_year, account_code + LIMIT 20 + """ + ) + ).mappings() + voucher_counts_by_source = conn.execute( + text( + """ + SELECT sf.file_name, v.fiscal_year, COUNT(*) AS row_count + FROM wehago_voucher_rows v + JOIN wehago_source_files sf ON sf.id = v.source_file_id + WHERE v.fiscal_year IN (2022, 2023, 2024, 2025) + GROUP BY sf.file_name, v.fiscal_year + ORDER BY v.fiscal_year, sf.file_name + """ + ) + ).mappings() + return { + "monthly_cumulative_rows": int(total_rows or 0), + "blank_account_rows_2022_2024": int(blank_account_rows or 0), + "multi_name_accounts": [dict(row) for row in account_names], + "voucher_counts_by_source": [dict(row) for row in voucher_counts_by_source], + } + + +def main() -> None: + voucher_paths = [item["path"] for item in VOUCHER_FILES] + missing = [path for path in [*LEDGER_FILES.values(), *voucher_paths] if not path.exists()] + if missing: + raise FileNotFoundError("\\n".join(str(path) for path in missing)) + + init_wehago_compare_db(ENGINE) + summary: dict[str, object] = {"deleted": {}, "imported": []} + with ENGINE.begin() as conn: + summary["deleted"] = _delete_year_data(conn, ledger_years={2022, 2023, 2024, 2025}, voucher_years={2022, 2023, 2024, 2025}) + for year, path in LEDGER_FILES.items(): + summary["imported"].append(_import_one(conn, path, "ledger", year, allowed_years={year})) + for item in VOUCHER_FILES: + summary["imported"].append( + _import_one( + conn, + item["path"], + "voucher", + int(item["expected_year"]), + allowed_years=set(item["allowed_years"]), + year_hint_override=item["year_hint"], + ) + ) + rebuild_comparison_results(conn) + summary["dashboard_counts"] = { + str(year): _fallback_metric_counts_from_db(conn, year, year) + for year in range(2022, 2026) + } + summary["counts"] = _fetch_counts(conn) + summary["quality"] = _fetch_quality(conn) + + print(json.dumps(summary, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/inspect_realgrid_9225.cmd b/scripts/inspect_realgrid_9225.cmd new file mode 100644 index 0000000..4e91c94 --- /dev/null +++ b/scripts/inspect_realgrid_9225.cmd @@ -0,0 +1,4 @@ +@echo off +set "WEHAGO_CHROME_DEBUGGER_ADDRESS=127.0.0.1:9225" +cd /d C:\Windows +python -u "\\wsl.localhost\Ubuntu\home\b17301\WEHAGO_DB\find_realgrid_registry.py" diff --git a/scripts/inspect_realgrid_views_9225.cmd b/scripts/inspect_realgrid_views_9225.cmd new file mode 100644 index 0000000..7b3a9c4 --- /dev/null +++ b/scripts/inspect_realgrid_views_9225.cmd @@ -0,0 +1,4 @@ +@echo off +set "WEHAGO_CHROME_DEBUGGER_ADDRESS=127.0.0.1:9225" +cd /d C:\Windows +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\inspect_realgrid_views_9225.py" diff --git a/scripts/inspect_realgrid_views_9225.py b/scripts/inspect_realgrid_views_9225.py new file mode 100644 index 0000000..7ec8996 --- /dev/null +++ b/scripts/inspect_realgrid_views_9225.py @@ -0,0 +1,49 @@ +import json +import os + +from selenium import webdriver +from selenium.webdriver.chrome.options import Options + + +options = Options() +options.add_experimental_option("debuggerAddress", os.environ.get("WEHAGO_CHROME_DEBUGGER_ADDRESS", "127.0.0.1:9225")) +driver = webdriver.Chrome(options=options) +driver.set_script_timeout(10) + +script = r""" +const ids = Array.from(document.querySelectorAll('.rg-body')).map((el) => ({ + view: String(el.$_view || ''), + text: (el.textContent || '').slice(0, 200), + rect: (() => { const r = el.getBoundingClientRect(); return {left:r.left, top:r.top, width:r.width, height:r.height}; })() +})); +const summarize = (name, value) => { + const out = {name, type: typeof value, text: String(value).slice(0, 300)}; + if (value && typeof value === 'object') { + let keys = []; + try { keys = Object.getOwnPropertyNames(value).concat(Object.keys(value)); } catch(e) {} + out.keys = Array.from(new Set(keys)).slice(0, 250); + out.samples = out.keys.slice(0, 80).map((key) => { + try { + const v = value[key]; + return {key, type: typeof v, text: String(v).slice(0, 160)}; + } catch (e) { + return {key, error: String(e)}; + } + }); + } + return out; +}; +const out = {ids, globals: []}; +const names = ['Grids', 'RealGridJS', 'RealGrid', 'realgrid']; +for (const name of names) out.globals.push(summarize(name, window[name])); +for (const key of Object.keys(window)) { + if (/grid|real|view|provider/i.test(key)) { + try { out.globals.push(summarize('window.' + key, window[key])); } catch(e) {} + } + if (out.globals.length > 80) break; +} +return out; +""" + +print(json.dumps(driver.execute_script(script), ensure_ascii=False, indent=2)[:100000]) +driver.quit() diff --git a/scripts/inspect_rg_body_props_9225.cmd b/scripts/inspect_rg_body_props_9225.cmd new file mode 100644 index 0000000..3552ccb --- /dev/null +++ b/scripts/inspect_rg_body_props_9225.cmd @@ -0,0 +1,4 @@ +@echo off +set "WEHAGO_CHROME_DEBUGGER_ADDRESS=127.0.0.1:9225" +cd /d C:\Windows +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\inspect_rg_body_props_9225.py" diff --git a/scripts/inspect_rg_body_props_9225.py b/scripts/inspect_rg_body_props_9225.py new file mode 100644 index 0000000..39a9c20 --- /dev/null +++ b/scripts/inspect_rg_body_props_9225.py @@ -0,0 +1,43 @@ +import json +import os + +from selenium import webdriver +from selenium.webdriver.chrome.options import Options + + +options = Options() +options.add_experimental_option("debuggerAddress", os.environ.get("WEHAGO_CHROME_DEBUGGER_ADDRESS", "127.0.0.1:9225")) +driver = webdriver.Chrome(options=options) +driver.set_script_timeout(10) + +script = r""" +const out = []; +for (const body of document.querySelectorAll('.rg-body')) { + const item = {view: String(body.$_view || ''), props: []}; + const keys = []; + let node = body; + while (node) { + try { keys.push(...Object.getOwnPropertyNames(node)); } catch(e) {} + node = Object.getPrototypeOf(node); + if (!node || node === Object.prototype) break; + } + for (const key of Array.from(new Set(keys)).slice(0, 500)) { + if (!/^\$_|grid|view|data|provider|handler|container|model|_/.test(key)) continue; + try { + const v = body[key]; + const rec = {key, type: typeof v, text: String(v).slice(0, 200)}; + if (v && typeof v === 'object') { + let sub = []; + try { sub = Object.getOwnPropertyNames(v).concat(Object.keys(v)); } catch(e) {} + rec.keys = Array.from(new Set(sub)).slice(0, 80); + rec.funcs = rec.keys.filter(k => { try { return typeof v[k] === 'function'; } catch(e) { return false; } }).slice(0, 40); + } + item.props.push(rec); + } catch(e) {} + } + out.push(item); +} +return out; +""" +print(json.dumps(driver.execute_script(script), ensure_ascii=False, indent=2)[:80000]) +driver.quit() diff --git a/scripts/inspect_wehago_grid_9225.cmd b/scripts/inspect_wehago_grid_9225.cmd new file mode 100644 index 0000000..8ebe49d --- /dev/null +++ b/scripts/inspect_wehago_grid_9225.cmd @@ -0,0 +1,4 @@ +@echo off +set "WEHAGO_CHROME_DEBUGGER_ADDRESS=127.0.0.1:9225" +cd /d C:\Windows +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\inspect_wehago_grid_9225.py" diff --git a/scripts/inspect_wehago_grid_9225.py b/scripts/inspect_wehago_grid_9225.py new file mode 100644 index 0000000..288bb2c --- /dev/null +++ b/scripts/inspect_wehago_grid_9225.py @@ -0,0 +1,49 @@ +import json +import os + +from selenium import webdriver +from selenium.webdriver.chrome.options import Options + + +options = Options() +options.add_experimental_option("debuggerAddress", os.environ.get("WEHAGO_CHROME_DEBUGGER_ADDRESS", "127.0.0.1:9225")) +driver = webdriver.Chrome(options=options) +driver.set_script_timeout(10) + +script = r""" +const g = window.Grids && window.Grids.realgrid; +const safe = (fn) => { + try { return fn(); } catch (e) { return {error: String(e)}; } +}; +const out = {}; +out.current = safe(() => g && g.getCurrent && g.getCurrent()); +out.fullItemCount = safe(() => g && g.fullItemCount && g.fullItemCount()); +out.columns = safe(() => g && g.getColumns && g.getColumns().map(c => ({ + name: c.name || c.fieldName || c.field, + fieldName: c.fieldName, + header: c.header && (c.header.text || c.header) +})).slice(0, 80)); +out.rows = []; +if (g) { + const n = Math.min(30, Number(out.fullItemCount) || 30); + for (let i = 0; i < n; i += 1) { + out.rows.push({ + item: i, + dataRow: safe(() => g.getDataRow(i)), + values: safe(() => g.getValues(i)), + display: safe(() => g.getDisplayValuesOfRow ? g.getDisplayValuesOfRow(i) : g.getDisplayValues(i)) + }); + } +} +out.visibleText = Array.from(document.querySelectorAll('.rg-data-cell, td[class*=rg-data-cell], [class*=rg-data-cell], td')) + .map(el => { + const r = el.getBoundingClientRect(); + return {text: (el.textContent || '').trim(), left: Math.round(r.left), top: Math.round(r.top), width: Math.round(r.width), height: Math.round(r.height)}; + }) + .filter(x => x.text && x.width > 0 && x.height > 0 && x.top > 120) + .slice(0, 120); +return out; +""" + +print(json.dumps(driver.execute_script(script), ensure_ascii=False, indent=2)[:60000]) +driver.quit() diff --git a/scripts/inspect_wehago_inputs_9225.cmd b/scripts/inspect_wehago_inputs_9225.cmd new file mode 100644 index 0000000..4f59f41 --- /dev/null +++ b/scripts/inspect_wehago_inputs_9225.cmd @@ -0,0 +1,4 @@ +@echo off +set "WEHAGO_CHROME_DEBUGGER_ADDRESS=127.0.0.1:9225" +cd /d C:\Windows +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\inspect_wehago_inputs_9225.py" diff --git a/scripts/inspect_wehago_inputs_9225.py b/scripts/inspect_wehago_inputs_9225.py new file mode 100644 index 0000000..2c48d1b --- /dev/null +++ b/scripts/inspect_wehago_inputs_9225.py @@ -0,0 +1,35 @@ +import json +import os + +from selenium import webdriver +from selenium.webdriver.chrome.options import Options + + +options = Options() +options.add_experimental_option("debuggerAddress", os.environ.get("WEHAGO_CHROME_DEBUGGER_ADDRESS", "127.0.0.1:9225")) +driver = webdriver.Chrome(options=options) + +script = r""" +const items = []; +for (const el of document.querySelectorAll('input, textarea, select, button, [role=button], a')) { + const r = el.getBoundingClientRect(); + const style = getComputedStyle(el); + if (r.width <= 0 || r.height <= 0 || style.display === 'none' || style.visibility === 'hidden') continue; + items.push({ + tag: el.tagName, + type: el.getAttribute('type'), + text: (el.textContent || '').trim(), + value: el.value || '', + placeholder: el.getAttribute('placeholder') || '', + title: el.getAttribute('title') || '', + aria: el.getAttribute('aria-label') || '', + name: el.getAttribute('name') || '', + id: el.id || '', + cls: String(el.className || '').slice(0, 120), + left: Math.round(r.left), top: Math.round(r.top), width: Math.round(r.width), height: Math.round(r.height) + }); +} +return items.sort((a,b)=>(a.top-b.top)||(a.left-b.left)); +""" +print(json.dumps(driver.execute_script(script), ensure_ascii=False, indent=2)[:50000]) +driver.quit() diff --git a/scripts/mark_wehago_2022_no_data.cmd b/scripts/mark_wehago_2022_no_data.cmd new file mode 100644 index 0000000..26bfef4 --- /dev/null +++ b/scripts/mark_wehago_2022_no_data.cmd @@ -0,0 +1,3 @@ +@echo off +cd /d C:\Windows +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\mark_wehago_2022_no_data.py" diff --git a/scripts/mark_wehago_2022_no_data.py b/scripts/mark_wehago_2022_no_data.py new file mode 100644 index 0000000..6744917 --- /dev/null +++ b/scripts/mark_wehago_2022_no_data.py @@ -0,0 +1,33 @@ +from pathlib import Path + +from openpyxl import load_workbook + + +base = Path(r"\\wsl.localhost\Ubuntu\home\b17301\WEHAGO_DB\data_download\2022") +target = max(base.glob("ledger_*.xlsx"), key=lambda item: item.stat().st_mtime) +no_data = {"377", "400", "636", "643", "805", "819", "820", "824", "843", "932", "936", "950", "951"} + +workbook = load_workbook(target) +status_ws = workbook["계정별상태"] +error_ws = workbook["오류_누락"] + +for row in status_ws.iter_rows(min_row=2): + code = str(row[0].value) + if code in no_data: + row[2].value = "데이터없음" + row[5].value = "2022년 WEHAGO 계정별원장 화면에서 거래 데이터가 없어 다운로드 파일을 생성하지 않았습니다." + +for row_index in range(2, error_ws.max_row + 1): + for col_index in range(1, error_ws.max_column + 1): + error_ws.cell(row_index, col_index).value = None + +write_row = 2 +for row in status_ws.iter_rows(min_row=2, values_only=True): + code, name, status, file_name, _row_count, message = row[:6] + if status != "정상": + for col_index, value in enumerate([code, name, status, file_name, message], start=1): + error_ws.cell(write_row, col_index).value = value + write_row += 1 + +workbook.save(target) +print(target) diff --git a/scripts/mark_wehago_no_data.cmd b/scripts/mark_wehago_no_data.cmd new file mode 100644 index 0000000..4472408 --- /dev/null +++ b/scripts/mark_wehago_no_data.cmd @@ -0,0 +1,3 @@ +@echo off +cd /d C:\Windows +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\mark_wehago_no_data.py" %* diff --git a/scripts/mark_wehago_no_data.py b/scripts/mark_wehago_no_data.py new file mode 100644 index 0000000..dd16d63 --- /dev/null +++ b/scripts/mark_wehago_no_data.py @@ -0,0 +1,42 @@ +import sys +from pathlib import Path + +from openpyxl import load_workbook + + +YEAR_NO_DATA = { + "2022": {"377", "400", "636", "643", "805", "819", "820", "824", "843", "932", "936", "950", "951"}, + "2023": {"263", "417", "636", "805", "819", "843", "932", "933", "936", "950", "951"}, + "2024": {"116", "263", "417", "805", "950", "951"}, +} + + +year = sys.argv[1] +base = Path(r"\\wsl.localhost\Ubuntu\home\b17301\WEHAGO_DB\data_download") / year +target = max(base.glob("ledger_*.xlsx"), key=lambda item: item.stat().st_mtime) +no_data = YEAR_NO_DATA[year] + +workbook = load_workbook(target) +status_ws = workbook["계정별상태"] +error_ws = workbook["오류_누락"] + +for row in status_ws.iter_rows(min_row=2): + code = str(row[0].value) + if code in no_data: + row[2].value = "데이터없음" + row[5].value = f"{year}년 WEHAGO 계정별원장 화면에서 거래 데이터가 없어 다운로드 파일을 생성하지 않았습니다." + +for row_index in range(2, error_ws.max_row + 1): + for col_index in range(1, error_ws.max_column + 1): + error_ws.cell(row_index, col_index).value = None + +write_row = 2 +for row in status_ws.iter_rows(min_row=2, values_only=True): + code, name, status, file_name, _row_count, message = row[:6] + if status != "정상": + for col_index, value in enumerate([code, name, status, file_name, message], start=1): + error_ws.cell(write_row, col_index).value = value + write_row += 1 + +workbook.save(target) +print(target) diff --git a/scripts/merge_wehago_2022_9225.cmd b/scripts/merge_wehago_2022_9225.cmd new file mode 100644 index 0000000..7a5775b --- /dev/null +++ b/scripts/merge_wehago_2022_9225.cmd @@ -0,0 +1,4 @@ +@echo off +set "WEHAGO_DOWNLOAD_DIR=\\wsl.localhost\Ubuntu\home\b17301\WEHAGO_DB\data_download\2022" +cd /d C:\Windows +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\wehago_data_download_2022_work.py" --merge-only diff --git a/scripts/merge_wehago_2023_9225.cmd b/scripts/merge_wehago_2023_9225.cmd new file mode 100644 index 0000000..f1e20e7 --- /dev/null +++ b/scripts/merge_wehago_2023_9225.cmd @@ -0,0 +1,4 @@ +@echo off +set "WEHAGO_DOWNLOAD_DIR=\\wsl.localhost\Ubuntu\home\b17301\WEHAGO_DB\data_download\2023" +cd /d C:\Windows +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\wehago_data_download_2022_work.py" --merge-only diff --git a/scripts/merge_wehago_2024_9225.cmd b/scripts/merge_wehago_2024_9225.cmd new file mode 100644 index 0000000..360504b --- /dev/null +++ b/scripts/merge_wehago_2024_9225.cmd @@ -0,0 +1,4 @@ +@echo off +set "WEHAGO_DOWNLOAD_DIR=\\wsl.localhost\Ubuntu\home\b17301\WEHAGO_DB\data_download\2024" +cd /d C:\Windows +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\wehago_data_download_2022_work.py" --merge-only diff --git a/scripts/probe_realgrid_active_9225.cmd b/scripts/probe_realgrid_active_9225.cmd new file mode 100644 index 0000000..798c48e --- /dev/null +++ b/scripts/probe_realgrid_active_9225.cmd @@ -0,0 +1,4 @@ +@echo off +set "WEHAGO_CHROME_DEBUGGER_ADDRESS=127.0.0.1:9225" +cd /d C:\Windows +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\probe_realgrid_active_9225.py" diff --git a/scripts/probe_realgrid_active_9225.py b/scripts/probe_realgrid_active_9225.py new file mode 100644 index 0000000..34220f6 --- /dev/null +++ b/scripts/probe_realgrid_active_9225.py @@ -0,0 +1,71 @@ +import json +import os +import time + +from selenium import webdriver +from selenium.webdriver.chrome.options import Options + + +options = Options() +options.add_experimental_option("debuggerAddress", os.environ.get("WEHAGO_CHROME_DEBUGGER_ADDRESS", "127.0.0.1:9225")) +driver = webdriver.Chrome(options=options) +driver.set_script_timeout(10) + +script = r""" +const body = Array.from(document.querySelectorAll('.rg-body')).find(el => String(el.$_view) === '75'); +if (body) { + const r = body.getBoundingClientRect(); + const x = r.left + 80, y = r.top + 85; + for (const type of ['mouseover','mousemove','mousedown','mouseup','click']) { + body.dispatchEvent(new MouseEvent(type,{bubbles:true,cancelable:true,view:window,clientX:x,clientY:y,button:0})); + } +} +const summarize = (obj) => { + if (!obj) return null; + let keys = []; + try { keys = Object.getOwnPropertyNames(obj).concat(Object.keys(obj)); } catch(e) {} + const funcs = Array.from(new Set(keys)).filter(k => { + try { return typeof obj[k] === 'function'; } catch(e) { return false; } + }).slice(0, 160); + const props = {}; + for (const k of keys.slice(0, 100)) { + try { + const v = obj[k]; + if (['string','number','boolean'].includes(typeof v)) props[k]=v; + } catch(e) {} + } + return {text:String(obj).slice(0,200), keys:Array.from(new Set(keys)).slice(0,200), funcs, props}; +}; +const active = Grids.getActiveGrid && Grids.getActiveGrid(); +const handler = active && active.getHandler && active.getHandler(); +const out = { + active: summarize(active), + handler: summarize(handler), + current: null, + itemCount: null, + columns: null, + rows: [] +}; +const targets = [active, handler].filter(Boolean); +for (const g of targets) { + if (!out.current) { + try { if (g.getCurrent) out.current = g.getCurrent(); } catch(e) { out.current = {error:String(e)}; } + } + if (!out.itemCount) { + try { if (g.getItemCount) out.itemCount = g.getItemCount(); } catch(e) {} + try { if (!out.itemCount && g.fullItemCount) out.itemCount = g.fullItemCount(); } catch(e) {} + } + if (!out.columns) { + try { if (g.getColumns) out.columns = g.getColumns().map(c => ({name:c.name, fieldName:c.fieldName, text:c.header && c.header.text})); } catch(e) {} + } + try { + if (g.getValues) { + for (let i=0; i/dev/null 2>&1; then - echo "이미 실행 중입니다: $HEALTH_URL" - exit 0 + if curl -fsS --max-time 4 "$COMPARE_HEALTH_URL" >/dev/null 2>&1; then + echo "이미 실행 중입니다: $HEALTH_URL" + exit 0 + fi + echo "서버 포트는 열려 있지만 전표비교 readiness 가 실패했습니다. 재시작합니다." + EXISTING_PID="$(ss -ltnp "( sport = :${PORT} )" | awk 'NR>1 {print $NF}' | sed -n 's/.*pid=\([0-9][0-9]*\).*/\1/p' | head -n 1)" + if [ -n "${EXISTING_PID}" ]; then + kill "${EXISTING_PID}" >/dev/null 2>&1 || true + sleep 2 + fi fi if ss -ltn "( sport = :${PORT} )" | tail -n +2 | grep -q .; then diff --git a/scripts/run_wehago_download_2022_9225.cmd b/scripts/run_wehago_download_2022_9225.cmd new file mode 100644 index 0000000..d5f99eb --- /dev/null +++ b/scripts/run_wehago_download_2022_9225.cmd @@ -0,0 +1,12 @@ +@echo off +set "WEHAGO_CHROME_DEBUGGER_ADDRESS=127.0.0.1:9225" +set "WEHAGO_OPEN_ACCOUNT_LEDGER_URL=1" +set "WEHAGO_REFRESH_BEFORE_RUN=" +set "WEHAGO_ALLOW_UNCHANGED_DETAIL=" +set "WEHAGO_SCAN_EXISTING_ACCOUNT_MODE=" +set "WEHAGO_EXPECTED_PERIOD_START=2022.01.01" +set "WEHAGO_EXPECTED_PERIOD_END=2022.12.31" +set "WEHAGO_DOWNLOAD_DIR=\\wsl.localhost\Ubuntu\home\b17301\WEHAGO_DB\data_download\2022" +set "WEHAGO_ACCOUNT_LEDGER_URL=https://smarta.wehago.com/#/smarta/account/SABK0107?sao&cno=1173867&cd_com=biz202103030006368&gisu=27&yminsa=2026&searchData=2022010120221231&color=#1C90FB&companyName=(%%EC%%A3%%BC)%%ED%%95%%9C%%EB%%A7%%A5%%EA%%B8%%B0%%EC%%88%%A0&companyID=b21344" +cd /d C:\Windows +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\wehago_data_download_2022_work.py" --no-pause-on-failure --no-merge %* diff --git a/scripts/run_wehago_download_2022_allow_unchanged_9225.cmd b/scripts/run_wehago_download_2022_allow_unchanged_9225.cmd new file mode 100644 index 0000000..2c440c6 --- /dev/null +++ b/scripts/run_wehago_download_2022_allow_unchanged_9225.cmd @@ -0,0 +1,12 @@ +@echo off +set "WEHAGO_CHROME_DEBUGGER_ADDRESS=127.0.0.1:9225" +set "WEHAGO_OPEN_ACCOUNT_LEDGER_URL=1" +set "WEHAGO_REFRESH_BEFORE_RUN=" +set "WEHAGO_ALLOW_UNCHANGED_DETAIL=1" +set "WEHAGO_SCAN_EXISTING_ACCOUNT_MODE=" +set "WEHAGO_EXPECTED_PERIOD_START=2022.01.01" +set "WEHAGO_EXPECTED_PERIOD_END=2022.12.31" +set "WEHAGO_DOWNLOAD_DIR=\\wsl.localhost\Ubuntu\home\b17301\WEHAGO_DB\data_download\2022" +set "WEHAGO_ACCOUNT_LEDGER_URL=https://smarta.wehago.com/#/smarta/account/SABK0107?sao&cno=1173867&cd_com=biz202103030006368&gisu=27&yminsa=2026&searchData=2022010120221231&color=#1C90FB&companyName=(%%EC%%A3%%BC)%%ED%%95%%9C%%EB%%A7%%A5%%EA%%B8%%B0%%EC%%88%%A0&companyID=b21344" +cd /d C:\Windows +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\wehago_data_download_2022_work.py" --no-pause-on-failure --no-merge %* diff --git a/scripts/run_wehago_download_2023_allow_unchanged_9225.cmd b/scripts/run_wehago_download_2023_allow_unchanged_9225.cmd new file mode 100644 index 0000000..04a4fd6 --- /dev/null +++ b/scripts/run_wehago_download_2023_allow_unchanged_9225.cmd @@ -0,0 +1,12 @@ +@echo off +set "WEHAGO_CHROME_DEBUGGER_ADDRESS=127.0.0.1:9225" +set "WEHAGO_OPEN_ACCOUNT_LEDGER_URL=1" +set "WEHAGO_REFRESH_BEFORE_RUN=" +set "WEHAGO_ALLOW_UNCHANGED_DETAIL=1" +set "WEHAGO_SCAN_EXISTING_ACCOUNT_MODE=" +set "WEHAGO_EXPECTED_PERIOD_START=2023.01.01" +set "WEHAGO_EXPECTED_PERIOD_END=2023.12.31" +set "WEHAGO_DOWNLOAD_DIR=\\wsl.localhost\Ubuntu\home\b17301\WEHAGO_DB\data_download\2023" +set "WEHAGO_ACCOUNT_LEDGER_URL=https://smarta.wehago.com/#/smarta/account/SABK0107?sao&cno=1173867&cd_com=biz202103030006368&gisu=28&yminsa=2026&searchData=2023010120231231&color=#1C90FB&companyName=(%%EC%%A3%%BC)%%ED%%95%%9C%%EB%%A7%%A5%%EA%%B8%%B0%%EC%%88%%A0&companyID=b21344" +cd /d C:\Windows +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\wehago_data_download_2022_work.py" --no-pause-on-failure --no-merge %* diff --git a/scripts/run_wehago_download_2024_allow_unchanged_9225.cmd b/scripts/run_wehago_download_2024_allow_unchanged_9225.cmd new file mode 100644 index 0000000..2cc838d --- /dev/null +++ b/scripts/run_wehago_download_2024_allow_unchanged_9225.cmd @@ -0,0 +1,12 @@ +@echo off +set "WEHAGO_CHROME_DEBUGGER_ADDRESS=127.0.0.1:9225" +set "WEHAGO_OPEN_ACCOUNT_LEDGER_URL=1" +set "WEHAGO_REFRESH_BEFORE_RUN=" +set "WEHAGO_ALLOW_UNCHANGED_DETAIL=1" +set "WEHAGO_SCAN_EXISTING_ACCOUNT_MODE=" +set "WEHAGO_EXPECTED_PERIOD_START=2024.01.01" +set "WEHAGO_EXPECTED_PERIOD_END=2024.12.31" +set "WEHAGO_DOWNLOAD_DIR=\\wsl.localhost\Ubuntu\home\b17301\WEHAGO_DB\data_download\2024" +set "WEHAGO_ACCOUNT_LEDGER_URL=https://smarta.wehago.com/#/smarta/account/SABK0107?sao&cno=1173867&cd_com=biz202103030006368&gisu=29&yminsa=2026&searchData=2024010120241231&color=#1C90FB&companyName=(%%EC%%A3%%BC)%%ED%%95%%9C%%EB%%A7%%A5%%EA%%B8%%B0%%EC%%88%%A0&companyID=b21344" +cd /d C:\Windows +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\wehago_data_download_2022_work.py" --no-pause-on-failure --no-merge %* diff --git a/scripts/test_left_click_modes_9225.cmd b/scripts/test_left_click_modes_9225.cmd new file mode 100644 index 0000000..d41dedb --- /dev/null +++ b/scripts/test_left_click_modes_9225.cmd @@ -0,0 +1,4 @@ +@echo off +set "WEHAGO_CHROME_DEBUGGER_ADDRESS=127.0.0.1:9225" +cd /d C:\Windows +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\test_left_click_modes_9225.py" %* diff --git a/scripts/test_left_click_modes_9225.py b/scripts/test_left_click_modes_9225.py new file mode 100644 index 0000000..a3418d9 --- /dev/null +++ b/scripts/test_left_click_modes_9225.py @@ -0,0 +1,55 @@ +import json +import os +import sys +import time + +from selenium import webdriver +from selenium.webdriver.chrome.options import Options + + +target = sys.argv[1] if len(sys.argv) > 1 else "110" +mode = sys.argv[2] if len(sys.argv) > 2 else "name-double" +options = Options() +options.add_experimental_option("debuggerAddress", os.environ.get("WEHAGO_CHROME_DEBUGGER_ADDRESS", "127.0.0.1:9225")) +driver = webdriver.Chrome(options=options) + +result = driver.execute_script( + r""" +const code = arguments[0], mode = arguments[1]; +const cells = Array.from(document.querySelectorAll('.rg-data-cell, td[class*=rg-data-cell], [class*=rg-data-cell], td')); +const codeCell = cells.find(cell => { + const r = cell.getBoundingClientRect(); + return (cell.textContent || '').trim() === code && r.left < 220 && r.top > 145 && r.width > 0 && r.height > 0; +}); +if (!codeCell) return {error:'code not visible'}; +const cr = codeCell.getBoundingClientRect(); +const nameCell = cells.find(cell => { + const r = cell.getBoundingClientRect(); + return Math.abs(r.top - cr.top) < 3 && r.left > 180 && r.left < 360 && r.width > 0 && r.height > 0; +}); +const targetEl = mode.includes('name') && nameCell ? nameCell : codeCell; +const r = targetEl.getBoundingClientRect(); +const x = r.left + (mode.includes('left') ? 8 : r.width / 2); +const y = r.top + r.height / 2; +const send = (type, detail=1) => { + targetEl.dispatchEvent(new MouseEvent(type, {bubbles:true,cancelable:true,view:window,clientX:x,clientY:y,button:0,detail})); +}; +const seq = ['mouseover','mousemove','mousedown','mouseup','click']; +for (const type of seq) send(type, 1); +if (mode.includes('double')) { + for (const type of ['mousedown','mouseup','click','dblclick']) send(type, 2); +} +return {clicked: code, mode, x, y, codeTop: cr.top, nameText: nameCell && nameCell.textContent}; +""", + target, + mode, +) +time.sleep(1.0) +right = driver.execute_script( + r""" +const body = Array.from(document.querySelectorAll('.rg-body')).find(el => String(el.$_view) === '327'); +return body ? (body.textContent || '').slice(0, 500) : ''; +""" +) +print(json.dumps({"result": result, "right": right}, ensure_ascii=False, indent=2)) +driver.quit() diff --git a/scripts/test_left_list_search_9225.cmd b/scripts/test_left_list_search_9225.cmd new file mode 100644 index 0000000..e318ed4 --- /dev/null +++ b/scripts/test_left_list_search_9225.cmd @@ -0,0 +1,4 @@ +@echo off +set "WEHAGO_CHROME_DEBUGGER_ADDRESS=127.0.0.1:9225" +cd /d C:\Windows +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\test_left_list_search_9225.py" %* diff --git a/scripts/test_left_list_search_9225.py b/scripts/test_left_list_search_9225.py new file mode 100644 index 0000000..3e76e5d --- /dev/null +++ b/scripts/test_left_list_search_9225.py @@ -0,0 +1,75 @@ +import json +import os +import sys +import time + +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys + + +code = sys.argv[1] if len(sys.argv) > 1 else "263" +options = Options() +options.add_experimental_option("debuggerAddress", os.environ.get("WEHAGO_CHROME_DEBUGGER_ADDRESS", "127.0.0.1:9225")) +driver = webdriver.Chrome(options=options) + +def visible_controls(): + return driver.execute_script( + r""" +const items=[]; +for (const el of document.querySelectorAll('input,button,[role=button],a')) { + const r=el.getBoundingClientRect(); + const st=getComputedStyle(el); + if (r.width<=0 || r.height<=0 || st.display==='none' || st.visibility==='hidden') continue; + items.push({tag:el.tagName,id:el.id||'',cls:String(el.className||'').slice(0,80),text:(el.textContent||'').trim(),value:el.value||'',placeholder:el.getAttribute('placeholder')||'',title:el.getAttribute('title')||'',left:Math.round(r.left),top:Math.round(r.top),width:Math.round(r.width),height:Math.round(r.height)}); +} +return items.sort((a,b)=>(a.top-b.top)||(a.left-b.left)); +""" + ) + +before = visible_controls() +button = None +for el in driver.find_elements(By.CSS_SELECTOR, "button, [role=button], a"): + if not el.is_displayed(): + continue + r = driver.execute_script("const r=arguments[0].getBoundingClientRect(); return {left:r.left,top:r.top,width:r.width,height:r.height};", el) + text = (el.text or "").strip() + title = el.get_attribute("title") or "" + cls = el.get_attribute("class") or "" + if r["left"] < 180 and 150 <= r["top"] <= 210 and (text or title or "LSbutton" in cls): + button = el + break +if button: + button.click() + time.sleep(0.5) + +after_click = visible_controls() +inputs = driver.find_elements(By.CSS_SELECTOR, "input") +typed = [] +for inp in inputs: + if not inp.is_displayed() or not inp.is_enabled(): + continue + r = driver.execute_script("const r=arguments[0].getBoundingClientRect(); return {left:r.left,top:r.top,width:r.width,height:r.height};", inp) + if 120 <= r["top"] <= 260 and r["left"] < 500: + try: + inp.click() + inp.send_keys(Keys.CONTROL, "a") + inp.send_keys(code) + inp.send_keys(Keys.ENTER) + typed.append(r) + time.sleep(0.8) + break + except Exception as exc: + typed.append({"error": str(exc), **r}) + +visible = driver.execute_script( + r""" +return Array.from(document.querySelectorAll('.rg-data-cell, td[class*=rg-data-cell], [class*=rg-data-cell], td')).map(cell=>{ + const r=cell.getBoundingClientRect(); + return {text:(cell.textContent||'').trim(),left:Math.round(r.left),top:Math.round(r.top),width:Math.round(r.width),height:Math.round(r.height)}; +}).filter(x=>x.text && x.left<500 && x.top>140 && x.width>0 && x.height>0).slice(0,80); +""" +) +print(json.dumps({"clickedButton": bool(button), "typed": typed, "before": before[:40], "afterClick": after_click[:80], "visible": visible}, ensure_ascii=False, indent=2)) +driver.quit() diff --git a/scripts/test_selenium_left_click_9225.cmd b/scripts/test_selenium_left_click_9225.cmd new file mode 100644 index 0000000..7a3c86d --- /dev/null +++ b/scripts/test_selenium_left_click_9225.cmd @@ -0,0 +1,4 @@ +@echo off +set "WEHAGO_CHROME_DEBUGGER_ADDRESS=127.0.0.1:9225" +cd /d C:\Windows +python -u "\\wsl.localhost\Ubuntu\home\b17301\my-intranet-app\scripts\test_selenium_left_click_9225.py" %* diff --git a/scripts/test_selenium_left_click_9225.py b/scripts/test_selenium_left_click_9225.py new file mode 100644 index 0000000..6138bba --- /dev/null +++ b/scripts/test_selenium_left_click_9225.py @@ -0,0 +1,33 @@ +import json +import os +import sys +import time + +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.common.by import By +from selenium.webdriver.common.action_chains import ActionChains + + +target = sys.argv[1] if len(sys.argv) > 1 else "110" +options = Options() +options.add_experimental_option("debuggerAddress", os.environ.get("WEHAGO_CHROME_DEBUGGER_ADDRESS", "127.0.0.1:9225")) +driver = webdriver.Chrome(options=options) + +clicked = None +for el in driver.find_elements(By.XPATH, f"//*[normalize-space(.)='{target}']"): + if not el.is_displayed(): + continue + r = driver.execute_script("const r=arguments[0].getBoundingClientRect(); return {left:r.left, top:r.top, width:r.width, height:r.height};", el) + if r["left"] < 220 and r["top"] > 145 and r["width"] > 0 and r["height"] > 0: + clicked = r + try: + el.click() + except Exception: + ActionChains(driver).move_to_element(el).click(el).perform() + break + +time.sleep(1.0) +right = driver.execute_script("const b=Array.from(document.querySelectorAll('.rg-body')).find(el=>String(el.$_view)==='327'); return b ? (b.textContent||'').slice(0,500) : '';") +print(json.dumps({"clicked": clicked, "right": right}, ensure_ascii=False, indent=2)) +driver.quit() diff --git a/scripts/wehago_data_download_2022_work.py b/scripts/wehago_data_download_2022_work.py new file mode 100755 index 0000000..d070942 --- /dev/null +++ b/scripts/wehago_data_download_2022_work.py @@ -0,0 +1,3024 @@ +""" +WEHAGO 계정별원장 엑셀 자동 다운로드 + +사용 흐름 +1. 이 파일을 실행한다. +2. 자동으로 열린 Chrome에서 WEHAGO에 직접 로그인한다. +3. 계정별원장 화면까지 이동한 뒤 콘솔에서 Enter를 누른다. +4. 이후 왼쪽 계정 목록에서 계정을 선택하고, 우클릭 메뉴의 엑셀 다운로드와 파일명 변경을 자동 처리한다. + +주의 +- WEHAGO 화면의 HTML 구조는 업데이트될 수 있습니다. +- 처음 실행에서 요소를 찾지 못하면 아래 CONFIG 영역의 선택자를 README 안내에 따라 조정하세요. +""" + +from __future__ import annotations + +import argparse +import csv +import os +import re +import subprocess +import sys +import time +import zipfile +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Iterable +from xml.etree import ElementTree as ET + +from openpyxl import Workbook, load_workbook +from openpyxl.styles import Alignment, Font, PatternFill +from selenium import webdriver +from selenium.common.exceptions import ( + ElementClickInterceptedException, + NoSuchElementException, + StaleElementReferenceException, + TimeoutException, + WebDriverException, +) +from selenium.webdriver import ChromeOptions +from selenium.webdriver.chrome.service import Service +from selenium.webdriver.common.action_chains import ActionChains +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.remote.webdriver import WebDriver +from selenium.webdriver.remote.webelement import WebElement +from webdriver_manager.chrome import ChromeDriverManager + + +# ============================================================================= +# CONFIG: 이 영역만 상황에 맞게 수정하면 됩니다. +# ============================================================================= + +BASE_DIR = Path(__file__).resolve().parent +WEHAGO_START_URL = "https://www.wehago.com/" + +# 계정별원장 화면 URL입니다. +# WEHAGO 내부 URL은 회사/기수/사용자 세션 쿼리값이 붙어야 정상 동작하는 경우가 많습니다. +# 잘못된 고정 URL을 넣으면 404가 나므로, 기본값은 비워둡니다. +ACCOUNT_LEDGER_URL = os.environ.get("WEHAGO_ACCOUNT_LEDGER_URL", "").strip() +EXPECTED_PERIOD_START = os.environ.get("WEHAGO_EXPECTED_PERIOD_START", "").strip() +EXPECTED_PERIOD_END = os.environ.get("WEHAGO_EXPECTED_PERIOD_END", "").strip() +LEDGER_URL_FILE = BASE_DIR / "wehago_account_ledger_url.txt" +USE_SAVED_LEDGER_URL = False +READY_SIGNAL_FILE = BASE_DIR / "wehago_ready.signal" + +# Chrome 로그인 정보를 저장할 자동화 전용 프로필입니다. +# 첫 로그인 뒤에는 같은 프로필을 계속 사용합니다. +CHROME_USER_DATA_DIR = BASE_DIR / ".chrome-wehago-profile" +CHROME_PROFILE_NAME = "Default" +CHROME_DEBUGGER_ADDRESS = os.environ.get("WEHAGO_CHROME_DEBUGGER_ADDRESS", "").strip() +SEQUENTIAL_ACCOUNT_MODE = os.environ.get("WEHAGO_SEQUENTIAL_ACCOUNT_MODE", "").strip().lower() in {"1", "true", "yes", "y"} +SCAN_EXISTING_ACCOUNT_MODE = os.environ.get("WEHAGO_SCAN_EXISTING_ACCOUNT_MODE", "").strip().lower() in {"1", "true", "yes", "y"} +FORCE_REDOWNLOAD = os.environ.get("WEHAGO_FORCE_REDOWNLOAD", "").strip().lower() in {"1", "true", "yes", "y"} +REFRESH_BEFORE_RUN = os.environ.get("WEHAGO_REFRESH_BEFORE_RUN", "").strip().lower() in {"1", "true", "yes", "y"} +ALLOW_UNCHANGED_DETAIL = os.environ.get("WEHAGO_ALLOW_UNCHANGED_DETAIL", "").strip().lower() in {"1", "true", "yes", "y"} +OPEN_ACCOUNT_LEDGER_URL = os.environ.get("WEHAGO_OPEN_ACCOUNT_LEDGER_URL", "").strip().lower() in {"1", "true", "yes", "y"} + +# 다운로드 폴더입니다. 결과 파일은 이 폴더 안에 계정코드_계정명.xlsx 형태로 저장됩니다. +DATA_DOWNLOAD_DIR = BASE_DIR / "data_download" +DOWNLOAD_DIR = Path(os.environ.get("WEHAGO_DOWNLOAD_DIR", "")).expanduser() if os.environ.get("WEHAGO_DOWNLOAD_DIR") else DATA_DOWNLOAD_DIR / f"wehago_account_ledger_{datetime.now():%Y%m%d}" +DIAGNOSTIC_DIR = DATA_DOWNLOAD_DIR / "diagnostics" +FALLBACK_DOWNLOAD_DIR = Path.home() / "Downloads" + +# 왼쪽 계정 목록 그리드 후보입니다. +ACCOUNT_LIST_SELECTORS = [ + ".realgrid", + ".rg-root", + ".rg-grid", + ".grid-container", + ".grid_container", + ".gridWrap", + ".grid-wrap", + "[class*='realgrid']", + "[class*='grid']", +] + +# 우클릭할 계정별원장 상세 그리드 후보입니다. 오른쪽의 큰 그리드를 우선 선택합니다. +GRID_SELECTORS = [ + ".realgrid", + ".rg-root", + ".rg-grid", + ".grid-container", + ".grid_container", + ".gridWrap", + ".grid-wrap", + "[class*='realgrid']", + "[class*='grid']", +] + +# 우클릭 메뉴에서 엑셀 다운로드 항목 후보입니다. +EXCEL_MENU_XPATHS = [ + "//*[@id='context:엑셀변환']", + "//*[contains(normalize-space(.), '엑셀')]", + "//*[contains(normalize-space(.), 'Excel')]", + "//*[contains(normalize-space(.), 'EXCEL')]", + "//*[contains(normalize-space(.), '다운로드')]", +] +EXCEL_MENU_TEXTS = ["엑셀변환", "엑셀", "Excel", "EXCEL", "다운로드"] + +WAIT_SECONDS = 20 +DOWNLOAD_TIMEOUT_SECONDS = 60 +DELAY_AFTER_ACCOUNT_CLICK_SECONDS = 0.05 +ACCOUNT_SCROLL_TRIES = 3 +ACCOUNT_SCROLL_PIXELS = 900 +ACCOUNT_SCROLL_SETTLE_SECONDS = 0.05 +MAX_DOWNLOAD_ATTEMPTS = 3 +PAUSE_ON_FAILURE = True +OVERLAY_WAIT_SECONDS = 30 +DETAIL_ROW_WAIT_SECONDS = 3 +DETAIL_CHANGE_WAIT_SECONDS = 3 +DOWNLOAD_POLL_INTERVAL_SECONDS = 0.1 +ACCOUNT_CHECK_TIMEOUT_SECONDS = 1.0 +ACCOUNT_SELECT_TIMEOUT_SECONDS = 1.0 +ACCOUNT_EXISTS_TIMEOUT_SECONDS = 0.5 +NEXT_ACCOUNT_SEARCH_TIMEOUT_SECONDS = 0.5 +EXCEL_MENU_SELECT_TIMEOUT_SECONDS = 0.5 + +ACCOUNT_CODE_COLUMN = 8 +ACCOUNT_NAME_COLUMN = 9 + +# 재실행 시 이미 같은 이름의 엑셀 파일이 있으면 건너뜁니다. +SKIP_ALREADY_DOWNLOADED = not FORCE_REDOWNLOAD + + +@dataclass(frozen=True) +class Account: + code: str + name: str + + @property + def safe_stem(self) -> str: + return safe_filename(f"{self.code}_{self.name}") + + @property + def safe_filename(self) -> str: + return f"{self.safe_stem}.xlsx" + + +class AccountNotAvailable(Exception): + """현재 조회된 계정 목록에 대상 계정이 없을 때 다운로드를 건너뛰기 위한 예외입니다.""" + + +ACCOUNT_INDEX_BY_CODE: dict[str, int] = {} + + +# 이미지에 보이는 계정 목록입니다. 필요한 계정을 추가/삭제해도 됩니다. +ACCOUNTS: list[Account] = [ + Account("103", "보통예금"), + Account("104", "국고보조금"), + Account("106", "기타예금"), + Account("108", "외상매출금"), + Account("109", "대손충당금"), + Account("110", "받을어음"), + Account("112", "공사미수금"), + Account("114", "단기대여금"), + Account("116", "미수수익"), + Account("120", "미수금"), + Account("131", "선급금"), + Account("133", "선급비용"), + Account("135", "부가세대급금"), + Account("136", "선납세금"), + Account("137", "주.종단기채권"), + Account("138", "전도금"), + Account("170", "미완성공사도급"), + Account("179", "장기대여금"), + Account("183", "매도가능증권"), + Account("192", "단체퇴직보험예금"), + Account("201", "토지"), + Account("202", "건물"), + Account("203", "감가상각누계액"), + Account("206", "기계장치"), + Account("207", "감가상각누계액"), + Account("208", "차량운반구"), + Account("209", "감가상각누계액"), + Account("210", "공구와기구"), + Account("211", "감가상각누계액"), + Account("212", "비품"), + Account("213", "감가상각누계액"), + Account("219", "시설장치"), + Account("220", "감가상각누계액"), + Account("221", "연구기자재"), + Account("222", "감가상각누계액"), + Account("223", "국고보조금"), + Account("231", "영업권"), + Account("234", "실용신안권"), + Account("241", "사용수익기부자산"), + Account("251", "외상매입금"), + Account("253", "미지급금"), + Account("254", "예수금"), + Account("255", "부가세예수금"), + Account("259", "선수금"), + Account("260", "단기차입금"), + Account("261", "미지급세금"), + Account("262", "미지급비용"), + Account("263", ""), + Account("271", "공사선수금"), + Account("274", "예수국민연금"), + Account("275", "예수건강보험"), + Account("276", "예수고용보험"), + Account("290", "주.종단기차입금"), + Account("294", "임대보증금"), + Account("331", "자본금"), + Account("342", "감자차익"), + Account("351", "이익준비금"), + Account("375", "이월이익잉여금"), + Account("377", "미처분이익잉여금"), + Account("383", "자기주식"), + Account("400", "손익"), + Account("411", "설계용역수입"), + Account("412", "감리용역수입"), + Account("413", "임대료수입"), + Account("414", "주차료수입"), + Account("415", "안전점검수입"), + Account("417", ""), + Account("452", "도급공사매출원가"), + Account("602", "외주비"), + Account("604", "임금"), + Account("606", "잡금"), + Account("609", "퇴직급여"), + Account("611", "복리후생비"), + Account("612", "여비교통비"), + Account("614", "통신비"), + Account("615", "가스수도료"), + Account("616", "전력비"), + Account("617", "세금과공과금"), + Account("618", "감가상각비"), + Account("619", "지급임차료"), + Account("620", "수선비"), + Account("621", "보험료"), + Account("622", "차량유지비"), + Account("623", "운반비"), + Account("625", "교육훈련비"), + Account("626", "도서인쇄비"), + Account("629", "사무용품비"), + Account("630", "소모품비"), + Account("631", "지급수수료"), + Account("634", "접대비"), + Account("636", "광고선전비"), + Account("637", "관리비"), + Account("639", "보증수수료"), + Account("643", "해외출장비"), + Account("644", "행사비용"), + Account("645", "관리현장운영비"), + Account("646", "부서비"), + Account("650", "연구개발비"), + Account("802", "직원급여"), + Account("805", ""), + Account("808", "퇴직급여"), + Account("811", "복리후생비"), + Account("812", "여비교통비"), + Account("813", "접대비(기업업무추진비)"), + Account("814", "통신비"), + Account("815", "수도광열비"), + Account("816", "전력비"), + Account("817", "세금과공과금"), + Account("818", "감가상각비"), + Account("819", "지급임차료"), + Account("820", "수선비"), + Account("821", "보험료"), + Account("822", "차량유지비"), + Account("823", "경상연구개발비"), + Account("824", "운반비"), + Account("825", "교육훈련비"), + Account("826", "도서인쇄비"), + Account("829", "사무용품비"), + Account("830", "소모품비"), + Account("831", "지급수수료"), + Account("835", "대손상각비"), + Account("837", "건물관리비"), + Account("840", "무형고정자산상각"), + Account("843", "해외출장비"), + Account("846", "부서비"), + Account("901", "이자수익"), + Account("903", "배당금수익"), + Account("905", "투자주식평가이익"), + Account("910", "외화환산이익"), + Account("914", "유형자산처분이익"), + Account("930", "잡이익"), + Account("931", "이자비용"), + Account("932", "임대자산"), + Account("933", "기부금"), + Account("936", "매출채권처분손실"), + Account("937", "투자주식평가손실"), + Account("950", ""), + Account("951", ""), + Account("960", "잡손실"), + Account("962", "민사보전금"), + Account("964", "기타보증금"), + Account("989", "매도가능증권평가이익"), + Account("998", "법인세등"), +] + +ACCOUNT_INDEX_BY_CODE = {account.code: index for index, account in enumerate(ACCOUNTS)} +AVAILABLE_ACCOUNT_CODES: set[str] | None = None + + +def safe_filename(name: str) -> str: + cleaned = re.sub(r'[\\/:*?"<>|]', "_", name) + cleaned = re.sub(r"\s+", " ", cleaned).strip() + return cleaned + + +def log(message: str) -> None: + print(f"[{datetime.now():%H:%M:%S}] {message}", flush=True) + + +def save_diagnostics(driver: WebDriver, label: str, error: Exception | None = None) -> Path: + DIAGNOSTIC_DIR.mkdir(parents=True, exist_ok=True) + safe_label = safe_filename(label) + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + base_path = DIAGNOSTIC_DIR / f"{stamp}_{safe_label}" + + png_path = base_path.with_suffix(".png") + txt_path = base_path.with_suffix(".txt") + html_path = base_path.with_suffix(".html") + + try: + driver.save_screenshot(str(png_path)) + except Exception: + pass + + try: + title = driver.title + except Exception: + title = "" + + try: + url = driver.current_url + except Exception: + url = "" + + try: + html = driver.page_source + html_path.write_text(html, encoding="utf-8") + except Exception: + html_path = Path("") + + message_lines = [ + f"label={label}", + f"time={datetime.now():%Y-%m-%d %H:%M:%S}", + f"url={url}", + f"title={title}", + ] + if error is not None: + message_lines.append(f"error={type(error).__name__}: {error}") + message_lines.extend( + [ + f"screenshot={png_path.resolve() if png_path.exists() else ''}", + f"html={html_path.resolve() if html_path and html_path.exists() else ''}", + f"download_dir={DOWNLOAD_DIR.resolve()}", + f"fallback_download_dir={FALLBACK_DOWNLOAD_DIR.resolve()}", + ] + ) + txt_path.write_text("\n".join(message_lines), encoding="utf-8") + return txt_path + + +def build_driver(download_dir: Path, headless: bool = False) -> WebDriver: + download_dir.mkdir(parents=True, exist_ok=True) + + chrome_download_dir = download_dir.resolve() + if CHROME_DEBUGGER_ADDRESS: + try: + converted = subprocess.run( + ["wslpath", "-w", str(chrome_download_dir)], + check=True, + capture_output=True, + text=True, + ) + chrome_download_path = converted.stdout.strip() or str(chrome_download_dir) + except (OSError, subprocess.CalledProcessError): + chrome_download_path = str(chrome_download_dir) + else: + chrome_download_path = str(chrome_download_dir) + + options = ChromeOptions() + if CHROME_DEBUGGER_ADDRESS: + options.add_experimental_option("debuggerAddress", CHROME_DEBUGGER_ADDRESS) + else: + options.add_argument(f"--user-data-dir={CHROME_USER_DATA_DIR}") + options.add_argument(f"--profile-directory={CHROME_PROFILE_NAME}") + if not CHROME_DEBUGGER_ADDRESS: + options.add_argument("--start-maximized") + options.add_argument("--disable-popup-blocking") + options.add_argument("--log-level=3") + options.add_argument("--disable-logging") + options.add_argument("--disable-features=WebUSB,WebBluetooth") + options.add_experimental_option("excludeSwitches", ["enable-logging"]) + if not CHROME_DEBUGGER_ADDRESS: + options.add_experimental_option("detach", True) + options.add_experimental_option( + "prefs", + { + "download.default_directory": chrome_download_path, + "download.prompt_for_download": False, + "download.directory_upgrade": True, + "safebrowsing.enabled": True, + "profile.default_content_setting_values.automatic_downloads": 1, + "profile.default_content_settings.popups": 0, + }, + ) + if headless: + options.add_argument("--headless=new") + options.add_argument("--window-size=1920,1080") + + if CHROME_DEBUGGER_ADDRESS: + log(f"열려 있는 Chrome 원격 디버깅 주소에 연결합니다: {CHROME_DEBUGGER_ADDRESS}") + log(f"Chrome 다운로드 경로: {chrome_download_path}") + + try: + service = Service(ChromeDriverManager().install(), log_output=subprocess.DEVNULL) + driver = webdriver.Chrome(service=service, options=options) + except WebDriverException as exc: + log("ChromeDriverManager로 ChromeDriver를 시작하지 못했습니다. Selenium Manager 방식으로 다시 시도합니다.") + log("그래도 실패하면 WSL Ubuntu에 Chrome 및 ChromeDriver 실행 라이브러리가 설치되어 있는지 확인해야 합니다.") + log("필요 패키지 예: google-chrome-stable, libnss3, libnspr4, libgtk-3-0, libgbm1") + log(f"첫 번째 오류: {exc}") + driver = webdriver.Chrome(options=options) + driver.set_page_load_timeout(30) + driver.set_script_timeout(10) + driver.execute_cdp_cmd( + "Page.setDownloadBehavior", + {"behavior": "allow", "downloadPath": chrome_download_path}, + ) + return driver + + +def wait_for_ready(prompt: str) -> None: + """대화형 터미널이 없을 때는 signal 파일로 사용자의 준비 완료를 받습니다.""" + if sys.stdin.isatty(): + input(prompt) + return + + log(f"{prompt.strip()} 입력 대기 대신 {READY_SIGNAL_FILE.name} 파일을 기다립니다.") + log(f"준비가 끝나면 파일을 만들어 주세요: {READY_SIGNAL_FILE.resolve()}") + if READY_SIGNAL_FILE.exists(): + READY_SIGNAL_FILE.unlink() + while not READY_SIGNAL_FILE.exists(): + time.sleep(2) + READY_SIGNAL_FILE.unlink() + + +def switch_to_frame_containing(driver: WebDriver, selectors: Iterable[str]) -> None: + """현재 문서와 iframe을 순회하며 후보 선택자가 있는 프레임으로 이동합니다.""" + driver.switch_to.default_content() + if any(driver.find_elements(By.CSS_SELECTOR, selector) for selector in selectors): + return + + frames = driver.find_elements(By.CSS_SELECTOR, "iframe, frame") + for frame in frames: + driver.switch_to.default_content() + driver.switch_to.frame(frame) + if any(driver.find_elements(By.CSS_SELECTOR, selector) for selector in selectors): + return + + driver.switch_to.default_content() + + +def first_visible_by_css(driver: WebDriver, selectors: Iterable[str], timeout: int = WAIT_SECONDS) -> WebElement: + selector_list = list(selectors) + deadline = time.time() + timeout + + while time.time() < deadline: + for selector in selector_list: + for element in driver.find_elements(By.CSS_SELECTOR, selector): + if element.is_displayed(): + return element + time.sleep(0.25) + + raise TimeoutException(f"요소를 찾지 못했습니다. 후보: {selector_list}") + + +def first_clickable_by_css(driver: WebDriver, selectors: Iterable[str], timeout: int = WAIT_SECONDS) -> WebElement: + selector_list = list(selectors) + deadline = time.time() + timeout + + while time.time() < deadline: + for selector in selector_list: + for element in driver.find_elements(By.CSS_SELECTOR, selector): + if element.is_displayed() and element.is_enabled(): + return element + time.sleep(0.25) + + raise TimeoutException(f"클릭 가능한 요소를 찾지 못했습니다. 후보: {selector_list}") + + +def click_visible_text_button(driver: WebDriver, texts: Iterable[str], timeout: float = 5.0) -> bool: + """Click a visible button-like element with one of the given labels.""" + labels = list(texts) + deadline = time.time() + timeout + fast_script = """ + const labels = arguments[0]; + const candidates = []; + for (const el of document.querySelectorAll('button, [role=button], a, div, span')) { + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden' || style.pointerEvents === 'none') continue; + const r = el.getBoundingClientRect(); + if (r.width <= 0 || r.height <= 0 || r.width > 220 || r.height > 90) continue; + const text = (el.textContent || '').trim(); + if (!labels.includes(text)) continue; + candidates.push({el, score: r.top * 10000 + r.left}); + } + if (!candidates.length) return null; + candidates.sort((a, b) => a.score - b.score); + return candidates[0].el; + """ + + while time.time() < deadline: + for _ in contexts_with_default_first(driver): + try: + fast_button = driver.execute_script(fast_script, labels) + except WebDriverException: + fast_button = None + if fast_button is not None: + try: + fast_button.click() + except WebDriverException: + try: + cdp_click_element(driver, fast_button) + except WebDriverException: + js_click_element(driver, fast_button) + time.sleep(0.1) + driver.switch_to.default_content() + return True + + for label in labels: + xpath = f"//*[normalize-space(.)='{label}']" + for element in driver.find_elements(By.XPATH, xpath): + if not element.is_displayed() or not element.is_enabled(): + continue + rect = rect_of(driver, element) + if rect["width"] <= 0 or rect["height"] <= 0: + continue + try: + js_click_element(driver, element) + except WebDriverException: + try: + cdp_click_element(driver, element) + except WebDriverException: + element.click() + time.sleep(0.1) + driver.switch_to.default_content() + return True + time.sleep(0.1) + + driver.switch_to.default_content() + return False + + +def accept_download_complete_popup(driver: WebDriver, timeout: float = 1.5) -> bool: + """Close WEHAGO's Excel-download complete confirmation popup if it appears.""" + try: + alert = driver.switch_to.alert + alert.accept() + time.sleep(0.1) + return True + except Exception: + driver.switch_to.default_content() + + return click_visible_text_button(driver, ["확인", "OK", "Ok", "예"], timeout=timeout) + + +def contexts_with_default_first(driver: WebDriver): + driver.switch_to.default_content() + yield + + frames = driver.find_elements(By.CSS_SELECTOR, "iframe, frame") + for frame in frames: + driver.switch_to.default_content() + driver.switch_to.frame(frame) + yield + + +def rect_of(driver: WebDriver, element: WebElement) -> dict: + return driver.execute_script( + """ + const r = arguments[0].getBoundingClientRect(); + return {left: r.left, top: r.top, width: r.width, height: r.height}; + """, + element, + ) + + +def viewport_width(driver: WebDriver) -> int: + return int(driver.execute_script("return window.innerWidth || document.documentElement.clientWidth;")) + + +def js_click_element(driver: WebDriver, element: WebElement) -> None: + """Selenium click이 0 크기 자식요소에 걸릴 때를 피하기 위해 요소 중앙에 마우스 이벤트를 보냅니다.""" + driver.execute_script( + """ + const source = arguments[0]; + const el = source.closest('button, a, tr, li, div') || source; + const r = el.getBoundingClientRect(); + const x = r.left + r.width / 2; + const y = r.top + r.height / 2; + for (const type of ['mouseover', 'mousemove', 'mousedown', 'mouseup', 'click']) { + el.dispatchEvent(new MouseEvent(type, { + bubbles: true, + cancelable: true, + view: window, + clientX: x, + clientY: y, + button: 0 + })); + } + """, + element, + ) + + +def rect_of_in_top_viewport(driver: WebDriver, element: WebElement) -> dict: + return driver.execute_script( + """ + const el = arguments[0]; + const r = el.getBoundingClientRect(); + let left = r.left; + let top = r.top; + let win = window; + while (win !== win.parent) { + const parent = win.parent; + let frame = null; + try { + for (const candidate of parent.document.querySelectorAll('iframe, frame')) { + if (candidate.contentWindow === win) { + frame = candidate; + break; + } + } + } catch (e) { + break; + } + if (!frame) break; + const fr = frame.getBoundingClientRect(); + left += fr.left; + top += fr.top; + win = parent; + } + return {left, top, width: r.width, height: r.height}; + """, + element, + ) + + +def cdp_mouse_event_at_element(driver: WebDriver, element: WebElement, event_type: str, button: str = "left") -> None: + rect = rect_of_in_top_viewport(driver, element) + x = rect["left"] + min(max(rect["width"] / 2, 4), max(rect["width"] - 4, 4)) + y = rect["top"] + min(max(rect["height"] / 2, 4), max(rect["height"] - 4, 4)) + driver.execute_cdp_cmd( + "Input.dispatchMouseEvent", + { + "type": event_type, + "x": x, + "y": y, + "button": button, + "clickCount": 1, + }, + ) + + +def cdp_click_element(driver: WebDriver, element: WebElement) -> None: + cdp_mouse_event_at_element(driver, element, "mouseMoved", "none") + cdp_mouse_event_at_element(driver, element, "mousePressed", "left") + cdp_mouse_event_at_element(driver, element, "mouseReleased", "left") + + +def cdp_context_click_element(driver: WebDriver, element: WebElement) -> None: + cdp_mouse_event_at_element(driver, element, "mouseMoved", "none") + cdp_mouse_event_at_element(driver, element, "mousePressed", "right") + cdp_mouse_event_at_element(driver, element, "mouseReleased", "right") + + +def cdp_wheel_element(driver: WebDriver, element: WebElement, delta_y: int) -> None: + rect = rect_of_in_top_viewport(driver, element) + x = rect["left"] + rect["width"] / 2 + y = rect["top"] + rect["height"] / 2 + driver.execute_cdp_cmd( + "Input.dispatchMouseEvent", + { + "type": "mouseWheel", + "x": x, + "y": y, + "deltaX": 0, + "deltaY": delta_y, + }, + ) + + +def drag_left_realgrid_scrollbar_to_fraction(driver: WebDriver, fraction: float) -> bool: + fraction = max(0.0, min(1.0, fraction)) + script = """ + const codeCells = Array.from(document.querySelectorAll('.rg-data-cell, td[class*=rg-data-cell], [class*=rg-data-cell], td')) + .filter((cell) => { + const r = cell.getBoundingClientRect(); + const text = (cell.textContent || '').trim(); + return /^\\d{3,6}$/.test(text) && r.left < 220 && r.top > 145 && r.width > 0 && r.height > 0; + }); + if (!codeCells.length) return null; + let body = codeCells[Math.floor(codeCells.length / 2)].closest('.rg-body'); + if (!body) { + let node = codeCells[Math.floor(codeCells.length / 2)]; + while (node && node !== document.body) { + if (String(node.className || '').includes('rg-body')) { + body = node; + break; + } + node = node.parentElement; + } + } + if (!body) return null; + const br = body.getBoundingClientRect(); + const candidates = []; + for (const el of document.querySelectorAll('div, span')) { + const r = el.getBoundingClientRect(); + const cls = String(el.className || ''); + if (r.left < br.right - 20 || r.left > br.right + 30) continue; + if (r.top < br.top - 20 || r.bottom > br.bottom + 40) continue; + if (r.width > 30 || r.height < 20) continue; + const looksScrollbar = /scroll|bar|thumb|vscroll|rg-scroll/i.test(cls) || r.width <= 18; + if (looksScrollbar) candidates.push({el, left: r.left, top: r.top, width: r.width, height: r.height}); + } + candidates.sort((a, b) => (a.width * a.height) - (b.width * b.height)); + const thumb = candidates.find((item) => item.height < br.height * 0.9) || candidates[0]; + return {body, thumb: thumb ? thumb.el : null, bodyRect: {left: br.left, top: br.top, right: br.right, bottom: br.bottom, width: br.width, height: br.height}}; + """ + for _ in contexts_with_default_first(driver): + try: + info = driver.execute_script(script) + except WebDriverException: + info = None + if not info: + continue + + try: + thumb = info.get("thumb") + body_rect = info.get("bodyRect") + if thumb is not None: + tr = rect_of_in_top_viewport(driver, thumb) + start_x = tr["left"] + tr["width"] / 2 + start_y = tr["top"] + tr["height"] / 2 + else: + br = body_rect + top_rect = rect_of_in_top_viewport(driver, info["body"]) + start_x = top_rect["left"] + top_rect["width"] - 6 + start_y = top_rect["top"] + 12 + tr = {"height": 24} + + body_top = rect_of_in_top_viewport(driver, info["body"]) + target_x = body_top["left"] + body_top["width"] - 6 + target_y = body_top["top"] + 10 + (body_top["height"] - 20) * fraction + driver.execute_cdp_cmd("Input.dispatchMouseEvent", {"type": "mouseMoved", "x": start_x, "y": start_y, "button": "none"}) + driver.execute_cdp_cmd("Input.dispatchMouseEvent", {"type": "mousePressed", "x": start_x, "y": start_y, "button": "left", "clickCount": 1}) + driver.execute_cdp_cmd("Input.dispatchMouseEvent", {"type": "mouseMoved", "x": target_x, "y": target_y, "button": "left", "buttons": 1}) + driver.execute_cdp_cmd("Input.dispatchMouseEvent", {"type": "mouseReleased", "x": target_x, "y": target_y, "button": "left", "clickCount": 1}) + time.sleep(0.15) + driver.switch_to.default_content() + return True + except (WebDriverException, KeyError, TypeError): + continue + + driver.switch_to.default_content() + return False + + +def js_contextmenu_element(driver: WebDriver, element: WebElement) -> None: + driver.execute_script( + """ + const source = arguments[0]; + const el = source.closest('button, a, tr, li, div') || source; + const r = el.getBoundingClientRect(); + const x = r.left + Math.min(Math.max(r.width / 2, 4), Math.max(r.width - 4, 4)); + const y = r.top + Math.min(Math.max(r.height / 2, 4), Math.max(r.height - 4, 4)); + for (const type of ['mouseover', 'mousemove', 'contextmenu']) { + el.dispatchEvent(new MouseEvent(type, { + bubbles: true, + cancelable: true, + view: window, + clientX: x, + clientY: y, + button: 2, + buttons: type === 'contextmenu' ? 0 : 2 + })); + } + """, + element, + ) + + +def has_blocking_overlay(driver: WebDriver) -> bool: + """WEHAGO 로딩/모달 투명 레이어가 클릭을 가로막는지 확인합니다.""" + try: + return bool( + driver.execute_script( + """ + const viewportArea = window.innerWidth * window.innerHeight; + const nodes = Array.from(document.querySelectorAll('div')); + return nodes.some((el) => { + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + if (style.pointerEvents === 'none') return false; + const z = Number.parseInt(style.zIndex || '0', 10); + if (!Number.isFinite(z) || z < 1000) return false; + if (style.position !== 'fixed' && style.position !== 'absolute') return false; + const r = el.getBoundingClientRect(); + if (r.width <= 0 || r.height <= 0) return false; + const area = r.width * r.height; + const coversScreen = area > viewportArea * 0.5; + const coversTopLeft = r.left <= 5 && r.top <= 5 && r.right >= window.innerWidth * 0.8 && r.bottom >= window.innerHeight * 0.8; + return coversScreen || coversTopLeft; + }); + """ + ) + ) + except WebDriverException: + return False + + +def wait_for_blocking_overlay_gone(driver: WebDriver, timeout: int = OVERLAY_WAIT_SECONDS) -> None: + deadline = time.time() + timeout + last_log_at = 0.0 + while time.time() < deadline: + if time.time() - last_log_at >= 7: + log("WEHAGO 로딩/팝업 상태 확인 중...") + last_log_at = time.time() + if not has_blocking_overlay(driver): + return + if click_visible_text_button(driver, ["확인", "OK", "Ok"], timeout=0.05): + time.sleep(0.05) + continue + time.sleep(0.1) + + diagnostic = save_diagnostics(driver, "blocking_overlay_timeout") + raise TimeoutException(f"화면을 가리는 로딩/팝업 레이어가 사라지지 않았습니다. 진단 파일: {diagnostic.resolve()}") + + +def close_open_menus(driver: WebDriver) -> None: + # Do not send ESC or click outside the grid here. + # In WEHAGO, global keyboard shortcuts can cancel the current ledger view + # and reset the searched account-ledger screen to an empty initial state. + click_visible_text_button(driver, ["확인", "OK", "Ok"], timeout=0.05) + + +def mark_clicked_left_account(driver: WebDriver, account: Account) -> None: + try: + driver.execute_script( + "window.__wehagoLastClickedLeftAccount = {code: arguments[0], name: arguments[1], at: Date.now()};", + account.code, + account.name, + ) + except WebDriverException: + pass + + +def click_left_account_text(driver: WebDriver, account: Account) -> bool: + """왼쪽 계정 목록 RealGrid에서 계정코드가 정확히 일치하는 행을 클릭합니다.""" + width = viewport_width(driver) + left_limit = width * 0.35 + candidates: list[tuple[float, float, WebElement]] = [] + + # WEHAGO RealGrid sometimes ignores a single click on the Code cell even + # when the cell is visible. The account-name cell double click is the + # interaction that reliably changes the right-side ledger. + for _ in contexts_with_default_first(driver): + try: + target = driver.execute_script( + """ + const code = arguments[0]; + const cellSelector = '.rg-data-cell, td[class*=rg-data-cell], [class*=rg-data-cell], td'; + const rows = []; + for (const cell of document.querySelectorAll(cellSelector)) { + const cr = cell.getBoundingClientRect(); + if (cr.left > 190 || cr.top < 145 || cr.width <= 0 || cr.height <= 0) continue; + if ((cell.textContent || '').trim() !== code) continue; + const row = cell.closest('tr, [class*=rg-data-row], [class*=rg-row], div') || cell; + const cells = Array.from(row.querySelectorAll(cellSelector)); + const nameCell = cells.find((item) => { + const ir = item.getBoundingClientRect(); + const text = (item.textContent || '').trim(); + return ir.left > cr.left + 20 && ir.left < 520 && text && !/^\\d{3,6}$/.test(text); + }); + rows.push({ + top: cr.top, + codeCell: cell, + nameCell: nameCell || cell, + name: nameCell ? (nameCell.textContent || '').trim() : '' + }); + } + if (!rows.length) return null; + rows.sort((a, b) => a.top - b.top); + return rows[0]; + """, + account.code, + ) + except WebDriverException: + target = None + if target is not None and target.get("nameCell") is not None: + clicked_account = Account(account.code, str(target.get("name") or account.name)) + try: + cdp_click_element(driver, target["nameCell"]) + time.sleep(0.05) + cdp_click_element(driver, target["nameCell"]) + except WebDriverException: + try: + js_click_element(driver, target["nameCell"]) + except WebDriverException: + try: + cdp_click_element(driver, target["codeCell"]) + except WebDriverException: + pass + mark_clicked_left_account(driver, clicked_account) + driver.switch_to.default_content() + return True + + for _ in contexts_with_default_first(driver): + xpath = f"//*[normalize-space(.)='{account.code}']" + for element in driver.find_elements(By.XPATH, xpath): + try: + if not element.is_displayed(): + continue + rect = rect_of(driver, element) + except StaleElementReferenceException: + continue + if rect["left"] < left_limit and rect["width"] > 0 and rect["height"] > 0: + candidates.append((rect["top"], rect["left"], element)) + + if candidates: + _, _, element = sorted(candidates, key=lambda item: (item[0], item[1]))[0] + driver.execute_script("arguments[0].scrollIntoView({block: 'center', inline: 'center'});", element) + time.sleep(0.15) + wait_for_blocking_overlay_gone(driver) + try: + element.click() + except ElementClickInterceptedException: + wait_for_blocking_overlay_gone(driver) + element.click() + except WebDriverException: + try: + ActionChains(driver).move_to_element(element).click(element).perform() + except WebDriverException: + cdp_click_element(driver, element) + mark_clicked_left_account(driver, account) + driver.switch_to.default_content() + return True + + driver.switch_to.default_content() + + # Fallback for virtualized RealGrid rows whose text nodes are not exposed as + # Selenium elements. This can focus the row, but the real Selenium click above + # is preferred because WEHAGO sometimes ignores synthetic DOM clicks. + for _ in contexts_with_default_first(driver): + try: + cell = driver.execute_script( + """ + const code = arguments[0]; + const matches = []; + const cellSelector = '.rg-data-cell, td[class*=rg-data-cell], [class*=rg-data-cell], td'; + for (const cell of document.querySelectorAll(cellSelector)) { + const cr0 = cell.getBoundingClientRect(); + if (cr0.left > 190 || cr0.top < 145 || cr0.width <= 0 || cr0.height <= 0) continue; + const cellText = (cell.textContent || '').trim(); + if (cellText !== code) continue; + const row = cell.closest('tr, [class*=rg-data-row], [class*=rg-row], div') || cell; + const rr = row.getBoundingClientRect(); + matches.push({top: cr0.top, cell, row, rowWidth: rr.width}); + } + if (!matches.length) return null; + matches.sort((a, b) => a.top - b.top); + return matches[0].cell; + """, + account.code, + ) + except WebDriverException: + cell = None + if cell is not None: + try: + cdp_click_element(driver, cell) + except WebDriverException: + try: + js_click_element(driver, cell) + except WebDriverException: + pass + mark_clicked_left_account(driver, account) + driver.switch_to.default_content() + return True + + driver.switch_to.default_content() + return False + + +def visible_text_exists_in_left_area(driver: WebDriver, text: str) -> bool: + width = viewport_width(driver) + left_limit = width * 0.35 + + for _ in contexts_with_default_first(driver): + xpath = f"//*[normalize-space(.)='{text}']" + for element in driver.find_elements(By.XPATH, xpath): + if not element.is_displayed(): + continue + rect = rect_of(driver, element) + if rect["left"] < left_limit and rect["width"] > 0 and rect["height"] > 0: + return True + + driver.switch_to.default_content() + return False + + +def account_list_has_any_expected_account(driver: WebDriver, accounts: list[Account]) -> bool: + for account in accounts[: min(10, len(accounts))]: + if visible_text_exists_in_left_area(driver, account.code) or visible_text_exists_in_left_area(driver, account.name): + return True + return False + + +def extract_available_account_codes(driver: WebDriver) -> set[str]: + """좌측 계정 RealGrid의 데이터 공급자에서 현재 조회된 계정코드 전체를 빠르게 읽습니다.""" + codes: set[str] = set() + script = """ + const codes = new Set(); + const add = (value) => { + const text = String(value ?? '').trim(); + if (/^\\d{3,6}$/.test(text)) codes.add(text); + }; + const readProvider = (provider) => { + if (!provider || typeof provider !== 'object') return; + let count = 0; + try { + if (typeof provider.getRowCount === 'function') count = provider.getRowCount(); + else if (typeof provider.getRows === 'function') count = provider.getRows().length; + } catch (e) {} + if (!count || count > 5000) return; + for (let i = 0; i < count; i += 1) { + try { + if (typeof provider.getJsonRow === 'function') { + const row = provider.getJsonRow(i); + if (row && typeof row === 'object') Object.values(row).forEach(add); + } + if (typeof provider.getValues === 'function') { + const values = provider.getValues(i); + if (Array.isArray(values)) values.forEach(add); + } + if (typeof provider.getValue === 'function') { + for (let c = 0; c < 8; c += 1) add(provider.getValue(i, c)); + } + } catch (e) {} + } + }; + const tryGrid = (grid) => { + if (!grid || typeof grid !== 'object') return; + try { + if (typeof grid.getDataSource === 'function') readProvider(grid.getDataSource()); + } catch (e) {} + try { + if (grid._dataProvider) readProvider(grid._dataProvider); + if (grid.dataProvider) readProvider(grid.dataProvider); + } catch (e) {} + }; + for (const key of Object.keys(window)) { + try { + const value = window[key]; + if (!value || typeof value !== 'object') continue; + if (typeof value.getDataSource === 'function' || value._dataProvider || value.dataProvider) tryGrid(value); + if (Array.isArray(value)) value.slice(0, 20).forEach(tryGrid); + } catch (e) {} + } + for (const row of document.querySelectorAll('tr.rg-data-row')) { + const r = row.getBoundingClientRect(); + if (r.left > 160 || r.width > 700 || r.height <= 0) continue; + const cell = row.querySelector('.rg-data-cell, td'); + if (cell) add(cell.textContent); + } + return Array.from(codes); + """ + for _ in contexts_with_default_first(driver): + try: + for code in driver.execute_script(script) or []: + codes.add(str(code)) + except WebDriverException: + continue + driver.switch_to.default_content() + return codes + + +def ensure_available_account_codes(driver: WebDriver) -> set[str]: + global AVAILABLE_ACCOUNT_CODES + if AVAILABLE_ACCOUNT_CODES is None: + started_at = time.perf_counter() + AVAILABLE_ACCOUNT_CODES = extract_available_account_codes(driver) + if AVAILABLE_ACCOUNT_CODES: + preview = ", ".join(sorted(AVAILABLE_ACCOUNT_CODES)[:8]) + log(f"현재 조회된 좌측 계정코드 {len(AVAILABLE_ACCOUNT_CODES)}개를 메모리에서 확인했습니다. 예: {preview}") + else: + log("좌측 계정코드 전체 목록을 메모리에서 읽지 못해 화면 탐색 방식으로 보완합니다.") + elapsed = time.perf_counter() - started_at + if elapsed > ACCOUNT_CHECK_TIMEOUT_SECONDS: + log(f"계정 목록 확인이 목표 시간({ACCOUNT_CHECK_TIMEOUT_SECONDS:.1f}초)을 초과했습니다: {elapsed:.1f}초") + return AVAILABLE_ACCOUNT_CODES + + +def visible_left_account_codes(driver: WebDriver) -> list[str]: + codes: list[str] = [] + script = """ + const codes = []; + const add = (value) => { + const text = String(value ?? '').trim(); + if (/^\\d{3,6}$/.test(text) && !codes.includes(text)) codes.push(text); + }; + const cellSelector = '.rg-data-cell, td[class*=rg-data-cell], [class*=rg-data-cell], td'; + for (const cell of document.querySelectorAll(cellSelector)) { + const r = cell.getBoundingClientRect(); + if (r.left > 190 || r.top < 145 || r.width <= 0 || r.height <= 0) continue; + add(cell.textContent); + } + return codes; + """ + for _ in contexts_with_default_first(driver): + try: + for code in driver.execute_script(script) or []: + codes.append(str(code)) + except WebDriverException: + continue + driver.switch_to.default_content() + return list(dict.fromkeys(codes)) + + +def selected_left_account(driver: WebDriver) -> Account | None: + script = """ + const rows = []; + const codeCellSelector = '.rg-data-cell, td[class*=rg-data-cell], [class*=rg-data-cell], td'; + const colorScore = (color) => { + const m = String(color || '').match(/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)/); + if (!m) return 0; + const r = Number(m[1]), g = Number(m[2]), b = Number(m[3]); + return Math.max(0, b - r) + Math.max(0, g - r) + (b > 120 ? 20 : 0); + }; + for (const cell of document.querySelectorAll(codeCellSelector)) { + const r = cell.getBoundingClientRect(); + const code = (cell.textContent || '').trim(); + if (!/^\\d{3,6}$/.test(code) || r.left > 220 || r.top < 145 || r.width <= 0 || r.height <= 0) continue; + const row = cell.closest('tr, [class*=rg-data-row], [class*=rg-row], div') || cell; + const cells = Array.from(row.querySelectorAll(codeCellSelector)); + const nameCell = cells.find((item) => { + const ir = item.getBoundingClientRect(); + return ir.left > r.left + 20 && ir.left < 360 && (item.textContent || '').trim() && !/^\\d{3,6}$/.test((item.textContent || '').trim()); + }); + let node = cell; + let score = 0; + let classText = ''; + while (node && node !== document.body) { + const style = window.getComputedStyle(node); + score = Math.max(score, colorScore(style.backgroundColor)); + classText += ' ' + String(node.className || ''); + node = node.parentElement; + } + if (/selected|select|focus|current|active|checked/i.test(classText)) score += 80; + rows.push({code, name: nameCell ? (nameCell.textContent || '').trim() : '', top: r.top, score}); + } + if (!rows.length) return null; + rows.sort((a, b) => (b.score - a.score) || (a.top - b.top)); + return rows[0].score > 0 ? rows[0] : null; + """ + for _ in contexts_with_default_first(driver): + try: + row = driver.execute_script(script) + except WebDriverException: + row = None + if row and row.get("code"): + driver.switch_to.default_content() + return Account(str(row.get("code")), str(row.get("name") or "")) + driver.switch_to.default_content() + return None + + + + +def recently_clicked_left_account(driver: WebDriver, account: Account, max_age_seconds: float = 10.0) -> Account | None: + try: + marker = driver.execute_script("return window.__wehagoLastClickedLeftAccount || null;") + except WebDriverException: + return None + if not marker or str(marker.get("code")) != account.code: + return None + try: + clicked_at = float(marker.get("at") or 0) / 1000.0 + except (TypeError, ValueError): + return None + if clicked_at <= 0 or abs(time.time() - clicked_at) > max_age_seconds: + return None + return Account(account.code, str(marker.get("name") or account.name)) + + +def assert_selected_account(driver: WebDriver, account: Account, timeout: float = 2.0) -> Account: + """목표 계정 선택을 검증하되, WEHAGO가 선택 행 스타일을 노출하지 않는 경우를 보완합니다.""" + deadline = time.time() + timeout + last_selected: Account | None = None + while time.time() < deadline: + selected = selected_left_account(driver) + if selected is not None: + last_selected = selected + if selected.code == account.code: + if selected.name and selected.name != account.name: + log(f"{account.code}: 화면 계정명은 '{selected.name}', 저장 계정명은 '{account.name}'입니다. 코드는 일치하므로 계속합니다.") + return selected + recent = recently_clicked_left_account(driver, account) + if recent is not None: + return recent + time.sleep(0.1) + observed = f"{last_selected.code} {last_selected.name}" if last_selected else "선택 계정 확인 불가" + raise TimeoutException( + f"선택 계정 검증 실패: 목표 {account.code} {account.name}, 현재 {observed}. " + "같은 계정을 다른 계정 이름으로 저장하는 것을 막기 위해 다운로드를 중단합니다." + ) + +def first_visible_left_account(driver: WebDriver) -> Account | None: + script = """ + const rows = []; + const cellSelector = '.rg-data-cell, td[class*=rg-data-cell], [class*=rg-data-cell], td'; + for (const cell of document.querySelectorAll(cellSelector)) { + const r = cell.getBoundingClientRect(); + const code = (cell.textContent || '').trim(); + if (!/^\\d{3,6}$/.test(code) || r.left > 220 || r.top < 145 || r.width <= 0 || r.height <= 0) continue; + const row = cell.closest('tr, [class*=rg-data-row], [class*=rg-row], div') || cell; + const cells = Array.from(row.querySelectorAll(cellSelector)); + const nameCell = cells.find((item) => { + const ir = item.getBoundingClientRect(); + return ir.left > r.left + 20 && ir.left < 360 && (item.textContent || '').trim() && !/^\\d{3,6}$/.test((item.textContent || '').trim()); + }); + rows.push({code, name: nameCell ? (nameCell.textContent || '').trim() : '', top: r.top}); + } + rows.sort((a, b) => a.top - b.top); + return rows[0] || null; + """ + for _ in contexts_with_default_first(driver): + try: + row = driver.execute_script(script) + except WebDriverException: + row = None + if row and row.get("code"): + driver.switch_to.default_content() + return Account(str(row.get("code")), str(row.get("name") or "")) + driver.switch_to.default_content() + return None + + +def visible_codes_pass_target(codes: list[str], target_code: str) -> bool: + try: + target = int(target_code) + numeric_codes = sorted(int(code) for code in codes if code.isdigit()) + except ValueError: + return False + return bool(numeric_codes and numeric_codes[0] < target < numeric_codes[-1] and str(target) not in codes) + + +def fast_select_account_in_left_grid(driver: WebDriver, account: Account) -> str: + """브라우저 안에서 좌측 RealGrid를 페이지 단위로 훑어 목표 계정을 클릭합니다.""" + script = r""" + const targetCode = arguments[0]; + const target = Number.parseInt(targetCode, 10); + const maxLoops = 8; + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const cellSelector = '.rg-data-cell, td[class*=rg-data-cell], [class*=rg-data-cell], td'; + const gridSelector = '.rg-root, .realgrid, [class*=realgrid], [class*=rg-grid], [class*=grid]'; + const visibleRows = () => { + const rowsByCode = new Map(); + for (const cell of document.querySelectorAll(cellSelector)) { + const r = cell.getBoundingClientRect(); + if (r.left > 190 || r.top < 145 || r.width <= 0 || r.height <= 0) continue; + const code = (cell.textContent || '').trim(); + if (!/^\d{3,6}$/.test(code)) continue; + if (!rowsByCode.has(code)) { + const row = cell.closest('tr, [class*=rg-data-row], [class*=rg-row], div') || cell; + rowsByCode.set(code, {code, num: Number.parseInt(code, 10), row, cell, top: r.top}); + } + } + const rows = Array.from(rowsByCode.values()); + rows.sort((a, b) => a.top - b.top); + return rows; + }; + const clickRow = (item) => { + const targetEl = item.cell.closest('tr, div') || item.cell; + targetEl.scrollIntoView({block: 'center', inline: 'center'}); + const r = targetEl.getBoundingClientRect(); + const x = r.left + Math.min(Math.max(r.width / 2, 4), Math.max(r.width - 4, 4)); + const y = r.top + Math.min(Math.max(r.height / 2, 4), Math.max(r.height - 4, 4)); + for (const type of ['mouseover', 'mousemove', 'mousedown', 'mouseup', 'click']) { + targetEl.dispatchEvent(new MouseEvent(type, {bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 0})); + } + }; + const scrollLeftGrid = (direction, rows) => { + let grid = null; + let node = rows.length ? rows[Math.floor(rows.length / 2)].cell : null; + while (node && node !== document.body) { + const r = node.getBoundingClientRect(); + if (r.left < 260 && node.scrollHeight > node.clientHeight + 20) { + grid = node; + break; + } + node = node.parentElement; + } + if (grid) { + const before = grid.scrollTop; + grid.scrollTop += direction * Math.max(240, grid.clientHeight * 0.9); + return grid.scrollTop !== before; + } + grid = Array.from(document.querySelectorAll(gridSelector)).find((el) => { + const r = el.getBoundingClientRect(); + return r.left < 220 && r.top > 120 && r.width > 80 && r.height > 120; + }); + if (!grid) return false; + const r = grid.getBoundingClientRect(); + grid.dispatchEvent(new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + view: window, + clientX: r.left + r.width / 2, + clientY: r.top + r.height / 2, + deltaY: direction * 1200 + })); + return true; + }; + + for (let i = 0; i < maxLoops; i += 1) { + const rows = visibleRows(); + const found = rows.find((row) => row.code === targetCode); + if (found) { + clickRow(found); + return {status: 'selected', visible: rows.map((row) => row.code)}; + } + if (rows.length) { + const nums = rows.map((row) => row.num).filter(Number.isFinite).sort((a, b) => a - b); + const min = nums[0]; + const max = nums[nums.length - 1]; + if (min < target && target < max) return {status: 'missing', visible: rows.map((row) => row.code)}; + const direction = target > max ? 1 : -1; + if (!scrollLeftGrid(direction, rows)) return {status: 'not_found', visible: rows.map((row) => row.code)}; + } else if (!scrollLeftGrid(1, rows)) { + return {status: 'not_found', visible: []}; + } + await sleep(35); + } + return {status: 'not_found', visible: visibleRows().map((row) => row.code)}; + """ + for _ in contexts_with_default_first(driver): + try: + result = driver.execute_async_script( + """ + const done = arguments[arguments.length - 1]; + (async () => { + """ + + script + + """ + })().then(done).catch((error) => done({status: 'error', message: String(error)})); + """, + account.code, + ) + except WebDriverException: + continue + status = (result or {}).get("status") + if status in {"selected", "missing", "not_found", "error"}: + driver.switch_to.default_content() + if status == "error": + raise WebDriverException((result or {}).get("message", "fast select failed")) + return status + driver.switch_to.default_content() + return "not_found" + + +def click_query_button(driver: WebDriver) -> bool: + """상단 조건 영역의 조회 버튼을 누릅니다. 메뉴 검색 버튼과 혼동하지 않도록 위치를 제한합니다.""" + viewport = viewport_width(driver) + candidates: list[tuple[float, WebElement]] = [] + + for _ in contexts_with_default_first(driver): + for text in ("조회", "검색"): + xpath = f"//*[normalize-space(.)='{text}']" + for element in driver.find_elements(By.XPATH, xpath): + try: + if not element.is_displayed() or not element.is_enabled(): + continue + rect = rect_of(driver, element) + except StaleElementReferenceException: + continue + if rect["width"] <= 0 or rect["height"] <= 0: + continue + # 계정별원장 상단 조회 버튼은 화면 상단 조건줄 오른쪽에 있습니다. + if 70 <= rect["top"] <= 240 and rect["left"] > viewport * 0.35: + candidates.append((rect["left"], element)) + + if candidates: + _, button = sorted(candidates, key=lambda item: item[0], reverse=True)[0] + js_click_element(driver, button) + wait_for_blocking_overlay_gone(driver) + return True + + driver.switch_to.default_content() + return False + + +def wait_for_account_list_data(driver: WebDriver, accounts: list[Account], timeout: int = 5) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if has_blocking_overlay(driver): + time.sleep(0.1) + continue + if visible_left_account_codes(driver): + return True + time.sleep(0.1) + return False + + +def ensure_ledger_data_loaded(driver: WebDriver, accounts: list[Account]) -> None: + if visible_left_account_codes(driver): + return + + log("계정 목록이 비어 있어 조회 버튼을 누르고 데이터 로딩을 기다립니다.") + + if click_query_button(driver): + if wait_for_account_list_data(driver, accounts): + log("계정 목록 데이터가 로딩되었습니다.") + return + + diagnostic = save_diagnostics(driver, "account_list_empty_after_query") + raise RuntimeError( + "조회 후에도 왼쪽 계정 목록에 계정 데이터가 없습니다. " + "기간/회사/계정과목 조건을 확인하고, 화면에 계정 목록이 보이는 상태에서 다시 실행하세요. " + f"진단 파일: {diagnostic.resolve()}" + ) + + +def find_left_scroll_container(driver: WebDriver) -> WebElement | None: + """왼쪽 계정 목록을 담은 스크롤 가능한 영역을 찾습니다.""" + width = viewport_width(driver) + left_limit = width * 0.35 + + for _ in contexts_with_default_first(driver): + best: tuple[float, WebElement] | None = None + for element in driver.find_elements(By.CSS_SELECTOR, "div, section, article"): + if not element.is_displayed(): + continue + + rect = rect_of(driver, element) + if rect["left"] >= left_limit or rect["height"] < 100 or rect["width"] < 100: + continue + + scrollable = driver.execute_script( + "return arguments[0].scrollHeight > arguments[0].clientHeight + 20;", + element, + ) + if not scrollable: + continue + + score = rect["width"] * rect["height"] + if best is None or score > best[0]: + best = (score, element) + + if best is not None: + return best[1] + + driver.switch_to.default_content() + return None + + +def find_left_account_grid(driver: WebDriver) -> WebElement | None: + """스크롤 컨테이너를 못 찾을 때 휠을 보낼 왼쪽 계정 그리드를 찾습니다.""" + script = """ + const cellSelector = '.rg-data-cell, td[class*=rg-data-cell], [class*=rg-data-cell], td'; + const cells = []; + for (const cell of document.querySelectorAll(cellSelector)) { + const r = cell.getBoundingClientRect(); + const code = (cell.textContent || '').trim(); + if (/^\\d{3,6}$/.test(code) && r.left < 220 && r.top > 145 && r.width > 0 && r.height > 0) { + cells.push(cell); + } + } + if (cells.length) { + let node = cells[Math.floor(cells.length / 2)]; + let best = null; + while (node && node !== document.body) { + const r = node.getBoundingClientRect(); + const classText = String(node.className || ''); + const looksGrid = /realgrid|rg-|grid/i.test(classText); + if (r.left < 260 && r.top > 110 && r.width > 120 && r.height > 140) { + best = node; + if (looksGrid) break; + } + node = node.parentElement; + } + if (best) return best; + } + return null; + """ + for _ in contexts_with_default_first(driver): + try: + grid = driver.execute_script(script) + except WebDriverException: + grid = None + if grid is not None: + driver.switch_to.default_content() + return grid + + width = viewport_width(driver) + left_limit = width * 0.35 + + for _ in contexts_with_default_first(driver): + best: tuple[float, WebElement] | None = None + for selector in ACCOUNT_LIST_SELECTORS: + for element in driver.find_elements(By.CSS_SELECTOR, selector): + if not element.is_displayed(): + continue + + rect = rect_of(driver, element) + if rect["left"] >= left_limit or rect["height"] < 100 or rect["width"] < 100: + continue + + score = rect["width"] * rect["height"] + if best is None or score > best[0]: + best = (score, element) + + if best is not None: + return best[1] + + driver.switch_to.default_content() + return None + + +def scroll_left_account_list(driver: WebDriver) -> bool: + grid = find_left_account_grid(driver) + if grid is None: + return False + + try: + before = visible_left_account_codes(driver) + cdp_wheel_element(driver, grid, ACCOUNT_SCROLL_PIXELS) + time.sleep(ACCOUNT_SCROLL_SETTLE_SECONDS) + after = visible_left_account_codes(driver) + return after != before + except WebDriverException: + return False + + +def reset_left_account_list_scroll(driver: WebDriver) -> None: + grid = find_left_account_grid(driver) + if grid is None: + return + + try: + cdp_wheel_element(driver, grid, -10000) + time.sleep(ACCOUNT_SCROLL_SETTLE_SECONDS) + except WebDriverException: + pass + + +def jump_left_account_list_near(driver: WebDriver, account: Account) -> None: + """계정 코드 순번으로 가상 스크롤 위치를 대략 맞춰 긴 순차 스크롤을 줄입니다.""" + index = ACCOUNT_INDEX_BY_CODE.get(account.code) + if index is None or len(ACCOUNTS) <= 1: + return + + fraction = index / max(1, len(ACCOUNTS) - 1) + if drag_left_realgrid_scrollbar_to_fraction(driver, fraction): + return + + grid = find_left_account_grid(driver) + if grid is None: + return + try: + direction = 1 if index > len(ACCOUNTS) // 2 else -1 + amount = direction * min(9000, max(1200, index * 120)) + cdp_wheel_element(driver, grid, amount) + time.sleep(ACCOUNT_SCROLL_SETTLE_SECONDS) + except WebDriverException: + pass + + +def select_account_from_left_list(driver: WebDriver, account: Account) -> None: + """계정 코드 입력칸 대신 화면 왼쪽 계정 목록의 행을 직접 선택합니다.""" + if click_left_account_text(driver, account): + return + + jump_left_account_list_near(driver, account) + if click_left_account_text(driver, account): + return + + status = fast_select_account_in_left_grid(driver, account) + if status == "selected": + return + if status == "missing": + raise AccountNotAvailable(f"현재 조회된 왼쪽 계정 목록의 주변 코드 범위에 {account.code} {account.name} 계정이 없습니다.") + + if click_left_account_text(driver, account): + return + if visible_codes_pass_target(visible_left_account_codes(driver), account.code): + raise AccountNotAvailable(f"현재 조회된 왼쪽 계정 목록의 주변 코드 범위에 {account.code} {account.name} 계정이 없습니다.") + + for _ in range(ACCOUNT_SCROLL_TRIES): + if not scroll_left_account_list(driver): + break + if click_left_account_text(driver, account): + return + if visible_codes_pass_target(visible_left_account_codes(driver), account.code): + raise AccountNotAvailable(f"현재 조회된 왼쪽 계정 목록의 주변 코드 범위에 {account.code} {account.name} 계정이 없습니다.") + + reset_left_account_list_scroll(driver) + jump_left_account_list_near(driver, account) + for _ in range(max(3, ACCOUNT_SCROLL_TRIES // 2)): + if click_left_account_text(driver, account): + return + if visible_codes_pass_target(visible_left_account_codes(driver), account.code): + raise AccountNotAvailable(f"현재 조회된 왼쪽 계정 목록의 주변 코드 범위에 {account.code} {account.name} 계정이 없습니다.") + if not scroll_left_account_list(driver): + break + + raise AccountNotAvailable(f"현재 조회된 왼쪽 계정 목록에 {account.code} {account.name} 계정이 없습니다.") + + +def select_next_left_account(driver: WebDriver, account: Account) -> None: + """가상 스크롤 그리드에서 텍스트 검색이 실패할 때 현재 행의 다음 계정을 선택합니다.""" + grid = find_left_account_grid(driver) + if grid is None: + raise TimeoutException(f"왼쪽 계정 그리드를 찾지 못해 {account.code} {account.name} 다음 행 선택에 실패했습니다.") + + try: + cdp_click_element(driver, grid) + driver.execute_cdp_cmd("Input.dispatchKeyEvent", {"type": "keyDown", "key": "ArrowDown", "code": "ArrowDown"}) + driver.execute_cdp_cmd("Input.dispatchKeyEvent", {"type": "keyUp", "key": "ArrowDown", "code": "ArrowDown"}) + except WebDriverException as exc: + raise TimeoutException(f"왼쪽 계정 목록에서 다음 행 선택에 실패했습니다: {account.code} {account.name}") from exc + + +def select_first_existing_left_account(driver: WebDriver) -> Account: + first = first_visible_left_account(driver) + if first is None: + raise TimeoutException("왼쪽 계정 목록에서 첫 번째 계정을 읽지 못했습니다.") + if not click_left_account_text(driver, first): + raise TimeoutException(f"왼쪽 계정 목록 첫 계정 선택에 실패했습니다: {first.code} {first.name}") + time.sleep(DELAY_AFTER_ACCOUNT_CLICK_SECONDS) + wait_for_blocking_overlay_gone(driver) + wait_for_detail_data_cell(driver, timeout=DETAIL_ROW_WAIT_SECONDS) + return selected_left_account(driver) or first + + +def advance_to_next_existing_left_account(driver: WebDriver, previous_code: str) -> Account | None: + """좌측 계정 목록에서 실제 클릭으로 다음 계정을 선택합니다. + + 이전 구현은 DOM MouseEvent를 합성한 뒤 다음 행으로 이동했다고 가정했습니다. + WEHAGO RealGrid가 이 합성 클릭을 무시하면 화면은 이전 계정에 남는데, + 스캔 루프만 다음 코드로 진행해 같은 페이지를 다른 계정명으로 저장할 수 있습니다. + 따라서 여기서는 화면에 보이는 다음 코드 셀을 Selenium/CDP 클릭 경로로 누르고, + 선택 계정이 실제로 바뀐 경우에만 다음 계정으로 인정합니다. + """ + def click_and_confirm(candidate_code: str) -> Account | None: + if candidate_code == previous_code: + return None + if not click_left_account_text(driver, Account(candidate_code, "")): + return None + time.sleep(DELAY_AFTER_ACCOUNT_CLICK_SECONDS) + wait_for_blocking_overlay_gone(driver) + try: + wait_for_detail_data_cell(driver, timeout=DETAIL_ROW_WAIT_SECONDS) + except TimeoutException: + pass + selected = selected_left_account(driver) + if selected is not None and selected.code != previous_code: + return selected + if selected is not None and selected.code == candidate_code: + return selected + return None + + previous_num: int | None + try: + previous_num = int(previous_code) + except ValueError: + previous_num = None + + for _ in range(max(3, ACCOUNT_SCROLL_TRIES + 2)): + codes = visible_left_account_codes(driver) + if previous_code in codes: + index = codes.index(previous_code) + for candidate_code in codes[index + 1 :]: + selected = click_and_confirm(candidate_code) + if selected is not None: + return selected + elif previous_num is not None: + larger_codes = [] + for code in codes: + try: + if int(code) > previous_num: + larger_codes.append(code) + except ValueError: + continue + for candidate_code in larger_codes: + selected = click_and_confirm(candidate_code) + if selected is not None: + return selected + + before = codes + if not scroll_left_account_list(driver): + # 마지막으로 키보드 이동을 시도합니다. 이 경우에도 실제 선택 변경이 확인되어야 합니다. + try: + select_next_left_account(driver, Account(previous_code, "")) + time.sleep(DELAY_AFTER_ACCOUNT_CLICK_SECONDS) + wait_for_blocking_overlay_gone(driver) + selected = selected_left_account(driver) + if selected is not None and selected.code != previous_code: + return selected + except Exception: + pass + return None + after = visible_left_account_codes(driver) + if after == before: + return None + + return None + +def wait_until_download_finished(download_dir: Path, before_files: dict[Path, set[Path]]) -> Path: + deadline = time.time() + DOWNLOAD_TIMEOUT_SECONDS + + last_log_at = 0.0 + + while time.time() < deadline: + if time.time() - last_log_at >= 5: + remaining = max(0, int(deadline - time.time())) + log(f"다운로드 완료 대기 중... 남은 제한시간 {remaining}초") + last_log_at = time.time() + completed: list[Path] = [] + temp_files: list[Path] = [] + for folder, previous_files in before_files.items(): + current_files = set(folder.glob("*")) + new_files = current_files - previous_files + temp_files.extend(p for p in current_files if p.suffix.lower() in {".crdownload", ".tmp"}) + completed.extend( + p + for p in new_files + if p.is_file() + and p.suffix.lower() in {".xlsx", ".xls", ".csv"} + and not p.name.startswith("~$") + ) + + if completed and not temp_files: + return max(completed, key=lambda p: p.stat().st_mtime) + + time.sleep(DOWNLOAD_POLL_INTERVAL_SECONDS) + + scan_targets = ", ".join(str(folder.resolve()) for folder in before_files) + raise TimeoutException(f"{DOWNLOAD_TIMEOUT_SECONDS}초 동안 다운로드 완료 파일을 찾지 못했습니다. 확인 위치: {scan_targets}") + + +def click_excel_menu_in_current_context(driver: WebDriver, timeout: int = 3) -> bool: + deadline = time.time() + timeout + fast_script = """ + const keywords = arguments[0]; + const candidates = []; + const selector = 'li, button, a, td, div[id^=context], div[class*=menu], span[id^=context], [role=menuitem]'; + for (const el of document.querySelectorAll(selector)) { + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') continue; + const r = el.getBoundingClientRect(); + if (r.width <= 0 || r.height <= 0) continue; + if (r.width > 420 || r.height > 80) continue; + const text = (el.textContent || '').trim(); + const id = el.id || ''; + if (!keywords.some((keyword) => text.includes(keyword) || id.includes(keyword))) continue; + let score = r.width * r.height; + if (id === 'context:엑셀변환') score = -10000; + else if (id.includes('엑셀변환')) score = -5000; + else if (text === '엑셀변환') score = -1000; + candidates.push({el, score}); + } + if (!candidates.length) return null; + candidates.sort((a, b) => a.score - b.score); + return candidates[0].el; + """ + while time.time() < deadline: + try: + fast_menu = driver.execute_script(fast_script, list(EXCEL_MENU_TEXTS)) + if fast_menu is not None: + try: + cdp_click_element(driver, fast_menu) + except WebDriverException: + try: + fast_menu.click() + except WebDriverException: + js_click_element(driver, fast_menu) + time.sleep(0.1) + return True + except WebDriverException: + pass + + menu_candidates: list[tuple[float, WebElement]] = [] + + for xpath in EXCEL_MENU_XPATHS: + for menu in driver.find_elements(By.XPATH, xpath): + if not menu.is_displayed(): + continue + + rect = rect_of(driver, menu) + if rect["width"] <= 0 or rect["height"] <= 0: + continue + + text = (menu.text or "").strip() + element_id = menu.get_attribute("id") or "" + if not any(keyword in text or keyword in element_id for keyword in EXCEL_MENU_TEXTS): + continue + + score = 0 if element_id == "context:엑셀변환" else rect["width"] * rect["height"] + menu_candidates.append((score, menu)) + + if menu_candidates: + _, menu = sorted(menu_candidates, key=lambda item: item[0])[0] + try: + js_click_element(driver, menu) + except WebDriverException: + try: + cdp_click_element(driver, menu) + except WebDriverException: + menu.click() + time.sleep(0.1) + return True + + time.sleep(0.08) + + return False + + +def find_detail_grid(driver: WebDriver) -> WebElement: + """오른쪽 상세 내역 그리드를 찾습니다.""" + width = viewport_width(driver) + right_start = width * 0.25 + best: tuple[float, WebElement] | None = None + + for _ in contexts_with_default_first(driver): + for selector in GRID_SELECTORS: + for element in driver.find_elements(By.CSS_SELECTOR, selector): + if not element.is_displayed(): + continue + + rect = rect_of(driver, element) + if rect["left"] < right_start or rect["width"] < 250 or rect["height"] < 120: + continue + + score = rect["width"] * rect["height"] + if best is None or score > best[0]: + best = (score, element) + + if best is not None: + return best[1] + + driver.switch_to.default_content() + raise TimeoutException("오른쪽 상세 내역 그리드를 찾지 못했습니다.") + + +def find_detail_data_cell(driver: WebDriver) -> WebElement | None: + """오른쪽 상세 그리드 안의 실제 데이터 셀을 찾습니다.""" + script = """ + const rightStart = (window.innerWidth || document.documentElement.clientWidth) * 0.25; + const cells = []; + for (const el of document.querySelectorAll('.rg-data-cell, td[class*=rg-data-cell], [class*=rg-data-cell]')) { + const r = el.getBoundingClientRect(); + if (r.left < rightStart || r.width <= 0 || r.height <= 0) continue; + const text = (el.textContent || '').trim(); + if (text || r.top > 180) cells.push({el, score: r.top * 10000 + r.left}); + } + if (!cells.length) return null; + cells.sort((a, b) => a.score - b.score); + return cells[0].el; + """ + + for _ in contexts_with_default_first(driver): + try: + element = driver.execute_script(script) + except WebDriverException: + element = None + if element is not None: + return element + + driver.switch_to.default_content() + return None + + +def detail_grid_signature(driver: WebDriver) -> str: + script = """ + const rightStart = (window.innerWidth || document.documentElement.clientWidth) * 0.25; + const parts = []; + for (const el of document.querySelectorAll('.rg-data-cell, td[class*=rg-data-cell], [class*=rg-data-cell]')) { + const r = el.getBoundingClientRect(); + if (r.left < rightStart || r.width <= 0 || r.height <= 0) continue; + const text = (el.textContent || '').trim(); + if (text) parts.push(text); + if (parts.length >= 20) break; + } + return parts.join('|'); + """ + + for _ in contexts_with_default_first(driver): + try: + signature = driver.execute_script(script) or "" + except WebDriverException: + signature = "" + if signature: + driver.switch_to.default_content() + return str(signature) + + driver.switch_to.default_content() + return "" + + +def wait_for_detail_change( + driver: WebDriver, + before_signature: str, + expected_account: Account | None = None, + timeout: int = DETAIL_CHANGE_WAIT_SECONDS, +) -> None: + if ALLOW_UNCHANGED_DETAIL: + log("주의: 오른쪽 원장 변경 확인 생략 허용이 켜져 있습니다. 계정별 다운로드에는 권장하지 않습니다.") + deadline = time.time() + timeout + last_signature = "" + while time.time() < deadline: + wait_for_blocking_overlay_gone(driver) + current_signature = detail_grid_signature(driver) + last_signature = current_signature + if current_signature and current_signature != before_signature: + return + if find_detail_data_cell(driver) is not None and not before_signature: + return + if expected_account is not None and current_signature and find_detail_data_cell(driver) is not None: + selected = selected_left_account(driver) + if selected is not None and selected.code == expected_account.code: + log( + f"{expected_account.code} {expected_account.name}: 선택 계정 확인 완료. " + "상세 첫 화면값이 직전 계정과 같아도 다운로드를 계속합니다." + ) + return + if ALLOW_UNCHANGED_DETAIL and current_signature and find_detail_data_cell(driver) is not None: + log("상세 그리드가 이미 같은 계정으로 표시되어 있어 변경 대기 없이 계속 진행합니다.") + return + time.sleep(0.15) + raise TimeoutException( + "계정 선택 후 오른쪽 원장 상세 내용이 바뀌지 않았습니다. " + "잘못된 계정 파일 생성을 막기 위해 다운로드를 중단합니다. " + f"이전 화면값: {before_signature[:120]} / 현재 화면값: {last_signature[:120]}" + ) + + +def wait_for_detail_data_cell(driver: WebDriver, timeout: int = DETAIL_ROW_WAIT_SECONDS) -> WebElement | None: + deadline = time.time() + timeout + while time.time() < deadline: + wait_for_blocking_overlay_gone(driver) + cell = find_detail_data_cell(driver) + if cell is not None: + return cell + time.sleep(0.15) + return None + + +def find_excel_menu_after_context_click(driver: WebDriver, timeout: float = 0.8) -> bool: + if click_excel_menu_in_current_context(driver, timeout=timeout): + return True + + driver.switch_to.default_content() + if click_excel_menu_in_current_context(driver, timeout=timeout): + return True + + frames = driver.find_elements(By.CSS_SELECTOR, "iframe, frame") + for frame in frames: + try: + driver.switch_to.default_content() + driver.switch_to.frame(frame) + if click_excel_menu_in_current_context(driver, timeout=timeout): + return True + except (NoSuchFrameException, StaleElementReferenceException): + continue + + driver.switch_to.default_content() + return False + + +def context_click_excel_download(driver: WebDriver) -> None: + close_open_menus(driver) + wait_for_blocking_overlay_gone(driver) + target = wait_for_detail_data_cell(driver) or find_detail_grid(driver) + + click_attempts = [ + lambda: js_contextmenu_element(driver, target), + lambda: cdp_context_click_element(driver, target), + lambda: (cdp_click_element(driver, target), cdp_context_click_element(driver, target)), + ] + + last_error: Exception | None = None + for context_click in click_attempts: + close_open_menus(driver) + wait_for_blocking_overlay_gone(driver) + try: + context_click() + except WebDriverException as exc: + last_error = exc + continue + + time.sleep(0.04) + if find_excel_menu_after_context_click(driver, timeout=0.35): + return + + if last_error is not None: + log(f"우클릭 실행 중 마지막 오류: {last_error}") + raise NoSuchElementException("우클릭 메뉴에서 엑셀/다운로드 항목을 찾지 못했습니다.") + + +def rename_downloaded_file(downloaded_file: Path, account: Account, download_dir: Path) -> Path: + suffix = downloaded_file.suffix if downloaded_file.suffix.lower() in {".xlsx", ".xls", ".csv"} else ".xlsx" + target = download_dir / f"{account.safe_stem}{suffix.lower()}" + if target.exists(): + target.unlink() + if downloaded_file.parent.resolve() == download_dir.resolve(): + downloaded_file.rename(target) + else: + target.write_bytes(downloaded_file.read_bytes()) + downloaded_file.unlink() + return target + + +def write_clean_xlsx_with_account_columns(path: Path, account: Account) -> None: + raw_rows = read_rows_from_xlsx_raw(path) + workbook = Workbook() + sheet = workbook.active + sheet.title = "Sheet1" + + header_found = False + for _, _, values in raw_rows: + normalized = ["" if value is None else str(value).strip() for value in values] + if is_ledger_header(values): + header_found = True + break + + in_table = False + for _, _, values in raw_rows: + row_values = list(values) + if header_found and is_ledger_header(row_values): + in_table = True + row_values = row_values[: ACCOUNT_CODE_COLUMN - 1] + while len(row_values) < ACCOUNT_CODE_COLUMN - 1: + row_values.append(None) + row_values.extend(["계정코드", "계정명"]) + elif in_table and non_empty_row(row_values): + row_values = row_values[: ACCOUNT_CODE_COLUMN - 1] + while len(row_values) < ACCOUNT_CODE_COLUMN - 1: + row_values.append(None) + row_values.extend([account.code, account.name]) + sheet.append(row_values) + + workbook.save(path) + workbook.close() + + +def account_columns_already_in_h_i(path: Path, account: Account) -> bool: + rows = read_downloaded_rows(path) + found_header = False + checked_data_rows = 0 + + for _, _, values in rows: + normalized = normalize_row(values) + if is_ledger_header(values): + found_header = True + if len(normalized) < ACCOUNT_NAME_COLUMN: + return False + if normalized[ACCOUNT_CODE_COLUMN - 1 : ACCOUNT_NAME_COLUMN] != ["계정코드", "계정명"]: + return False + continue + + if not found_header or not non_empty_row(values): + continue + + if len(normalized) < ACCOUNT_NAME_COLUMN: + return False + if normalized[ACCOUNT_CODE_COLUMN - 1 : ACCOUNT_NAME_COLUMN] != [account.code, account.name]: + return False + checked_data_rows += 1 + if checked_data_rows >= 20: + return True + + return found_header + + +def add_account_columns_to_excel(path: Path, account: Account) -> None: + if path.suffix.lower() != ".xlsx": + return + + try: + if account_columns_already_in_h_i(path, account): + return + except Exception: + pass + + try: + write_clean_xlsx_with_account_columns(path, account) + return + except Exception: + pass + + try: + workbook = load_workbook(path) + except Exception: + write_clean_xlsx_with_account_columns(path, account) + return + + try: + for sheet in workbook.worksheets: + header_cell = None + for row in sheet.iter_rows(): + for cell in row: + value = "" if cell.value is None else str(cell.value).strip() + if value == "전표번호": + header_cell = cell + break + if header_cell is not None: + break + + if header_cell is None: + continue + + row_index = header_cell.row + code_col = ACCOUNT_CODE_COLUMN + name_col = ACCOUNT_NAME_COLUMN + code_header = sheet.cell(row=row_index, column=code_col).value + name_header = sheet.cell(row=row_index, column=name_col).value + + if str(code_header).strip() != "계정코드" or str(name_header).strip() != "계정명": + sheet.cell(row=row_index, column=code_col, value="계정코드") + sheet.cell(row=row_index, column=name_col, value="계정명") + + for data_row in range(row_index + 1, sheet.max_row + 1): + values = [sheet.cell(row=data_row, column=col).value for col in range(1, sheet.max_column + 1)] + if non_empty_row(values): + sheet.cell(row=data_row, column=code_col, value=account.code) + sheet.cell(row=data_row, column=name_col, value=account.name) + + workbook.save(path) + finally: + workbook.close() + + +def account_file_candidates(account: Account, download_dir: Path) -> list[Path]: + return [ + download_dir / f"{account.safe_stem}.xlsx", + download_dir / f"{account.safe_stem}.xls", + download_dir / f"{account.safe_stem}.csv", + ] + + +def find_account_file(account: Account, download_dir: Path) -> Path | None: + for candidate in account_file_candidates(account, download_dir): + if candidate.exists(): + return candidate + matches = sorted(download_dir.glob(f"{account.code}_*.xlsx")) + sorted(download_dir.glob(f"{account.code}_*.xls")) + sorted(download_dir.glob(f"{account.code}_*.csv")) + return matches[0] if matches else None + +def account_with_inferred_name(account: Account, path: Path | None = None) -> Account: + if account.name: + return account + if path is not None: + stem = path.stem + prefix = f"{account.code}_" + if stem.startswith(prefix) and len(stem) > len(prefix): + return Account(account.code, stem[len(prefix):]) + return account + + +def download_scan_dirs(download_dir: Path) -> list[Path]: + dirs = [download_dir] + if FALLBACK_DOWNLOAD_DIR.resolve() != download_dir.resolve(): + dirs.append(FALLBACK_DOWNLOAD_DIR) + return dirs + + +def snapshot_download_files(download_dir: Path) -> dict[Path, set[Path]]: + snapshots: dict[Path, set[Path]] = {} + for folder in download_scan_dirs(download_dir): + folder.mkdir(parents=True, exist_ok=True) + snapshots[folder] = set(folder.glob("*")) + return snapshots + + +def non_empty_row(values: Iterable[object]) -> bool: + return any(value is not None and str(value).strip() != "" for value in values) + + +def is_month_or_cumulative_total_row(values: Iterable[object]) -> bool: + normalized = normalize_row(values) + joined = " ".join(normalized) + compact = re.sub(r"\s+", "", joined) + return "[월계]" in compact or "[누계]" in compact + + +def excel_column_number(cell_reference: str) -> int: + letters = "".join(ch for ch in cell_reference if ch.isalpha()).upper() + number = 0 + for letter in letters: + number = number * 26 + (ord(letter) - ord("A") + 1) + return number + + +def read_shared_strings_from_xlsx(path: Path) -> list[str]: + namespace = {"x": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"} + with zipfile.ZipFile(path) as archive: + if "xl/sharedStrings.xml" not in archive.namelist(): + return [] + root = ET.fromstring(archive.read("xl/sharedStrings.xml")) + + strings: list[str] = [] + for item in root.findall("x:si", namespace): + parts = [node.text or "" for node in item.findall(".//x:t", namespace)] + strings.append("".join(parts)) + return strings + + +def read_rows_from_xlsx_raw(path: Path) -> list[tuple[str, int, tuple[object, ...]]]: + namespace = {"x": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"} + shared_strings = read_shared_strings_from_xlsx(path) + rows: list[tuple[str, int, tuple[object, ...]]] = [] + + with zipfile.ZipFile(path) as archive: + sheet_names = [name for name in archive.namelist() if name.startswith("xl/worksheets/sheet") and name.endswith(".xml")] + for sheet_index, sheet_name in enumerate(sorted(sheet_names), start=1): + root = ET.fromstring(archive.read(sheet_name)) + for row_node in root.findall(".//x:sheetData/x:row", namespace): + row_number = int(row_node.attrib.get("r", len(rows) + 1)) + values_by_column: dict[int, object] = {} + + for cell in row_node.findall("x:c", namespace): + reference = cell.attrib.get("r", "") + column_number = excel_column_number(reference) + cell_type = cell.attrib.get("t", "") + value_node = cell.find("x:v", namespace) + inline_node = cell.find("x:is", namespace) + + value: object = None + if cell_type == "s" and value_node is not None and value_node.text is not None: + index = int(value_node.text) + value = shared_strings[index] if index < len(shared_strings) else value_node.text + elif cell_type == "inlineStr" and inline_node is not None: + value = "".join(node.text or "" for node in inline_node.findall(".//x:t", namespace)) + elif value_node is not None: + value = value_node.text + + if value is not None: + values_by_column[column_number] = value + + if values_by_column: + max_column = max(values_by_column) + values = tuple(values_by_column.get(column) for column in range(1, max_column + 1)) + if non_empty_row(values): + rows.append((f"Sheet{sheet_index}", row_number, values)) + + return rows + + +def read_rows_from_xlsx(path: Path) -> list[tuple[str, int, tuple[object, ...]]]: + rows: list[tuple[str, int, tuple[object, ...]]] = [] + try: + workbook = load_workbook(path, read_only=True, data_only=True) + try: + for sheet in workbook.worksheets: + for row_number, row in enumerate(sheet.iter_rows(values_only=True), start=1): + values = tuple(row) + if non_empty_row(values): + rows.append((sheet.title, row_number, values)) + finally: + workbook.close() + return rows + except Exception: + return read_rows_from_xlsx_raw(path) + + +def read_rows_from_csv(path: Path) -> list[tuple[str, int, tuple[object, ...]]]: + encodings = ["utf-8-sig", "cp949", "euc-kr"] + last_error: Exception | None = None + + for encoding in encodings: + try: + rows: list[tuple[str, int, tuple[object, ...]]] = [] + with path.open("r", encoding=encoding, newline="") as file: + reader = csv.reader(file) + for row_number, row in enumerate(reader, start=1): + values = tuple(row) + if non_empty_row(values): + rows.append(("CSV", row_number, values)) + return rows + except UnicodeDecodeError as exc: + last_error = exc + + raise UnicodeDecodeError("csv", b"", 0, 1, f"CSV 인코딩을 읽지 못했습니다: {last_error}") + + +def read_downloaded_rows(path: Path) -> list[tuple[str, int, tuple[object, ...]]]: + suffix = path.suffix.lower() + if suffix == ".xlsx": + return read_rows_from_xlsx(path) + if suffix == ".csv": + return read_rows_from_csv(path) + raise ValueError(".xls 파일은 현재 자동 취합에서 읽을 수 없습니다. WEHAGO 다운로드 형식을 .xlsx로 바꿔주세요.") + + +def normalize_row(values: Iterable[object]) -> list[str]: + return ["" if value is None else str(value).strip() for value in values] + + +def is_ledger_header(values: Iterable[object]) -> bool: + normalized = normalize_row(values) + return "전표번호" in normalized and ("일자" in normalized or "적요" in normalized) + + +def ensure_account_header_columns(values: tuple[object, ...]) -> tuple[object, ...]: + row = list(values) + while len(row) < ACCOUNT_NAME_COLUMN: + row.append(None) + row[ACCOUNT_CODE_COLUMN - 1] = "계정코드" + row[ACCOUNT_NAME_COLUMN - 1] = "계정명" + return tuple(row) + + +def ensure_account_data_columns(values: tuple[object, ...], account: Account) -> tuple[object, ...]: + row = list(values) + while len(row) < ACCOUNT_NAME_COLUMN: + row.append(None) + row[ACCOUNT_CODE_COLUMN - 1] = account.code + row[ACCOUNT_NAME_COLUMN - 1] = account.name + return tuple(row) + + +def extract_ledger_data_rows( + rows: list[tuple[str, int, tuple[object, ...]]], + account: Account, +) -> tuple[list[object], list[tuple[str, int, tuple[object, ...]]]]: + headers: list[object] = [] + data_rows: list[tuple[str, int, tuple[object, ...]]] = [] + found_header = False + + for sheet_name, row_number, values in rows: + if is_ledger_header(values): + header = ensure_account_header_columns(values) + found_header = True + if len(header) > len(headers): + headers = list(header) + continue + + if not found_header: + continue + + if not non_empty_row(values): + continue + + if is_ledger_header(values): + continue + + if is_month_or_cumulative_total_row(values): + continue + + row = ensure_account_data_columns(values, account) + data_rows.append((sheet_name, row_number, row)) + + return headers, data_rows + + +def autosize_columns(ws, max_width: int = 42) -> None: + for column_cells in ws.columns: + column_letter = column_cells[0].column_letter + values = [cell.value for cell in column_cells if cell.value is not None] + if not values: + continue + width = min(max(len(str(value)) for value in values) + 2, max_width) + ws.column_dimensions[column_letter].width = width + + +def style_header(ws, row: int = 1) -> None: + fill = PatternFill("solid", fgColor="D9EAF7") + font = Font(bold=True) + for cell in ws[row]: + cell.fill = fill + cell.font = font + cell.alignment = Alignment(horizontal="center") + + +def build_summary_rows( + accounts: list[Account], + download_dir: Path, + failures: list[tuple[Account, str]], + skipped: list[tuple[Account, str]] | None = None, +) -> list[dict[str, object]]: + failure_map = {account.code: message for account, message in failures} + skipped_map = {account.code: message for account, message in (skipped or [])} + rows: list[dict[str, object]] = [] + + for account in accounts: + path = find_account_file(account, download_dir) + account = account_with_inferred_name(account, path) + if account.code in failure_map: + status = "오류" + message = failure_map[account.code] + elif account.code in skipped_map: + status = "계정없음" + message = skipped_map[account.code] + elif path is None: + status = "누락" + message = "다운로드 파일을 찾지 못했습니다." + else: + status = "정상" + message = "" + + rows.append( + { + "계정코드": account.code, + "계정명": account.name, + "상태": status, + "파일명": path.name if path else "", + "데이터행수": 0, + "메시지": message, + } + ) + + return rows + + +def consolidate_downloads( + accounts: list[Account], + download_dir: Path, + failures: list[tuple[Account, str]] | None = None, + skipped: list[tuple[Account, str]] | None = None, +) -> Path: + failures = failures or [] + skipped = skipped or [] + output_path = download_dir / f"ledger_계정별원장_{datetime.now():%Y%m%d_%H%M%S}.xlsx" + summary_rows = build_summary_rows(accounts, download_dir, failures, skipped) + summary_by_code = {str(row["계정코드"]): row for row in summary_rows} + + workbook = Workbook() + data_ws = workbook.active + data_ws.title = "취합데이터" + status_ws = workbook.create_sheet("계정별상태") + error_ws = workbook.create_sheet("오류_누락") + + data_header = ["계정코드", "계정명", "원본파일", "원본시트", "원본행번호"] + data_ws.append(data_header) + + max_value_columns = 0 + source_headers: list[object] = [] + for account in accounts: + path = find_account_file(account, download_dir) + if path is None: + continue + account = account_with_inferred_name(account, path) + + try: + add_account_columns_to_excel(path, account) + rows = read_downloaded_rows(path) + summary_by_code[account.code]["데이터행수"] = len(rows) + if not rows: + summary_by_code[account.code]["상태"] = "확인필요" + summary_by_code[account.code]["메시지"] = "파일은 있으나 읽을 수 있는 데이터 행이 없습니다." + continue + + file_headers, ledger_rows = extract_ledger_data_rows(rows, account) + if file_headers and len(file_headers) > len(source_headers): + source_headers = file_headers + + data_row_count = 0 + for sheet_name, row_number, values in ledger_rows: + max_value_columns = max(max_value_columns, len(values)) + data_ws.append([account.code, account.name, path.name, sheet_name, row_number, *values]) + data_row_count += 1 + summary_by_code[account.code]["데이터행수"] = data_row_count + + if not ledger_rows: + summary_by_code[account.code]["상태"] = "확인필요" + summary_by_code[account.code]["메시지"] = "표 헤더는 찾았지만 취합할 거래 데이터 행이 없습니다." + + except Exception as exc: + summary_by_code[account.code]["상태"] = "취합오류" + summary_by_code[account.code]["메시지"] = f"{type(exc).__name__}: {exc}" + + for column_index in range(1, max_value_columns + 1): + header_value = source_headers[column_index - 1] if column_index <= len(source_headers) else f"원본열{column_index}" + data_ws.cell(row=1, column=len(data_header) + column_index, value=header_value) + + status_ws.append(["계정코드", "계정명", "상태", "파일명", "데이터행수", "메시지"]) + for row in summary_rows: + status_ws.append([row["계정코드"], row["계정명"], row["상태"], row["파일명"], row["데이터행수"], row["메시지"]]) + + error_ws.append(["계정코드", "계정명", "상태", "파일명", "메시지"]) + for row in summary_rows: + if row["상태"] != "정상": + error_ws.append([row["계정코드"], row["계정명"], row["상태"], row["파일명"], row["메시지"]]) + + for ws in (data_ws, status_ws, error_ws): + style_header(ws) + ws.freeze_panes = "A2" + ws.auto_filter.ref = ws.dimensions + autosize_columns(ws) + + workbook.save(output_path) + return output_path + + +def merge_scope_accounts(all_accounts: list[Account]) -> list[Account]: + """취합 대상은 계정코드 100 이상 전체로 고정합니다.""" + scoped: list[Account] = [] + for account in all_accounts: + try: + if int(account.code) >= 100: + scoped.append(account) + except ValueError: + continue + return scoped + + +def load_saved_ledger_url() -> str: + if not USE_SAVED_LEDGER_URL or not LEDGER_URL_FILE.exists(): + return "" + return LEDGER_URL_FILE.read_text(encoding="utf-8").strip() + + +def save_ledger_url(url: str) -> None: + if USE_SAVED_LEDGER_URL and "smarta.wehago.com" in url and "SABK0107" in url: + LEDGER_URL_FILE.write_text(url, encoding="utf-8") + log(f"계정별원장 주소 저장: {LEDGER_URL_FILE.resolve()}") + + +def is_wehago_not_found_page(driver: WebDriver) -> bool: + try: + title = driver.title or "" + body_text = driver.find_element(By.TAG_NAME, "body").text + except Exception: + return False + + checks = [title, body_text] + return any("페이지를 찾을 수 없습니다" in text or "404 Error" in text for text in checks) + + +def ledger_screen_ready(driver: WebDriver) -> bool: + if is_wehago_not_found_page(driver): + return False + + try: + left_grid = find_left_account_grid(driver) + detail_grid = find_detail_grid(driver) + return left_grid is not None and detail_grid is not None + except Exception: + driver.switch_to.default_content() + return False + + +def wait_for_ledger_screen_ready(driver: WebDriver, timeout: int = 30) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if ledger_screen_ready(driver): + return True + time.sleep(0.5) + return False + + +def read_top_period_values(driver: WebDriver) -> tuple[str, str]: + """계정별원장 상단 기간 입력값을 읽습니다.""" + script = """ + const values = []; + const addDates = (text) => { + const matches = String(text || '').match(/\\d{4}[.\\-/]\\d{2}[.\\-/]\\d{2}/g) || []; + for (const value of matches) values.push(value.replace(/[\\-/]/g, '.')); + }; + for (const el of document.querySelectorAll('input, button, span, div')) { + const r = el.getBoundingClientRect(); + if (r.top < 0 || r.top > 235 || r.width <= 0 || r.height <= 0) continue; + addDates(el.value || el.getAttribute('value') || el.textContent || ''); + if (values.length >= 2) break; + } + return values.slice(0, 2); + """ + for _ in contexts_with_default_first(driver): + try: + values = driver.execute_script(script) or [] + except WebDriverException: + values = [] + if len(values) >= 2: + driver.switch_to.default_content() + return str(values[0]), str(values[1]) + driver.switch_to.default_content() + return "", "" + + +def ensure_expected_period(driver: WebDriver) -> None: + if not EXPECTED_PERIOD_START or not EXPECTED_PERIOD_END: + return + + expected_start = EXPECTED_PERIOD_START.replace("-", ".").replace("/", ".") + expected_end = EXPECTED_PERIOD_END.replace("-", ".").replace("/", ".") + expected_search_data = f"{expected_start.replace('.', '')}{expected_end.replace('.', '')}" + deadline = time.time() + 8 + last_start = "" + last_end = "" + + while time.time() < deadline: + wait_for_blocking_overlay_gone(driver) + last_start, last_end = read_top_period_values(driver) + current_url = "" + try: + current_url = driver.current_url + except WebDriverException: + current_url = "" + if last_start == expected_start and last_end == expected_end and expected_search_data in current_url: + log(f"상단 기간 확인 완료: {last_start} ~ {last_end}") + return + time.sleep(0.25) + + diagnostic = save_diagnostics(driver, f"period_mismatch_{expected_start}_{expected_end}") + raise RuntimeError( + f"상단 기간 불일치로 다운로드를 중단합니다. 기대값: {expected_start} ~ {expected_end}, " + f"현재 화면: {last_start or '읽기 실패'} ~ {last_end or '읽기 실패'}. 진단 파일: {diagnostic.resolve()}" + ) + + +def open_wehago(driver: WebDriver) -> None: + saved_url = "" + target_url = ACCOUNT_LEDGER_URL.strip() or WEHAGO_START_URL + current_url = "" + try: + current_url = driver.current_url + except WebDriverException: + current_url = "" + + if OPEN_ACCOUNT_LEDGER_URL and ACCOUNT_LEDGER_URL.strip(): + log(f"설정된 계정별원장 주소를 엽니다: {ACCOUNT_LEDGER_URL.strip()}") + if CHROME_DEBUGGER_ADDRESS and "wehago" in current_url.lower(): + driver.get("about:blank") + time.sleep(0.3) + driver.get(ACCOUNT_LEDGER_URL.strip()) + elif CHROME_DEBUGGER_ADDRESS and "wehago" in current_url.lower(): + log(f"이미 열려 있는 WEHAGO 화면을 그대로 사용합니다: {current_url}") + else: + driver.get(target_url) + + if REFRESH_BEFORE_RUN and "wehago" in (current_url or driver.current_url).lower(): + log("이전 실패 상태를 지우기 위해 현재 WEHAGO 화면을 새로고침합니다.") + driver.refresh() + wait_for_blocking_overlay_gone(driver) + + log("Chrome이 열렸습니다.") + log(f"계정별 다운로드 저장 위치: {DOWNLOAD_DIR.resolve()}") + log(f"보조 확인 위치: {FALLBACK_DOWNLOAD_DIR.resolve()}") + + if saved_url: + log(f"저장된 계정별원장 주소를 열었습니다: {saved_url}") + elif ACCOUNT_LEDGER_URL.strip(): + log(f"설정된 계정별원장 주소를 열었습니다: {ACCOUNT_LEDGER_URL.strip()}") + + if not wait_for_ledger_screen_ready(driver, timeout=30): + log("계정별원장 화면이 아직 준비되지 않았습니다. 로그인 또는 화면 이동이 필요하면 완료 후 신호를 보내세요.") + log("만약 404 화면이면 WEHAGO 메뉴에서 계정별원장 화면을 직접 열어주세요.") + wait_for_ready("계정별원장 화면 준비 후 Enter: ") + + if is_wehago_not_found_page(driver): + log("현재 화면이 WEHAGO 404 페이지입니다. 고정 URL 또는 저장 URL이 현재 세션에서 유효하지 않습니다.") + log("브라우저에서 WEHAGO 메인으로 이동한 뒤, 메뉴로 계정별원장 화면을 직접 열어주세요.") + log("정상 계정별원장 화면이 보이면 다시 Enter를 누르세요.") + wait_for_ready("계정별원장 화면을 직접 연 뒤 Enter: ") + + if not ledger_screen_ready(driver): + diagnostic = save_diagnostics(driver, "ledger_screen_not_ready") + raise RuntimeError( + "계정별원장 화면의 왼쪽 계정 목록 또는 오른쪽 상세 그리드를 찾지 못했습니다. " + f"진단 파일: {diagnostic.resolve()}" + ) + + ensure_expected_period(driver) + ensure_ledger_data_loaded(driver, ACCOUNTS) + log("계정별원장 화면 확인 완료. 필요한 경우 스크립트가 조회 버튼을 눌러 계정 목록을 불러왔습니다.") + + +def recover_after_account_failure(driver: WebDriver) -> None: + """계정 하나가 실패한 뒤 프레임/스크롤 상태를 정리해 다음 계정 연쇄 실패를 줄입니다.""" + try: + driver.switch_to.default_content() + except WebDriverException: + return + + try: + wait_for_blocking_overlay_gone(driver, timeout=5) + except Exception: + pass + + try: + reset_left_account_list_scroll(driver) + except Exception: + pass + + +def download_account_ledger(driver: WebDriver, account: Account, index: int, total: int) -> Path: + started_at = time.perf_counter() + close_open_menus(driver) + wait_for_blocking_overlay_gone(driver) + log(f"[{index}/{total}] {account.code} {account.name}: 왼쪽 계정 목록에서 선택") + before_files = snapshot_download_files(DOWNLOAD_DIR) + before_detail = detail_grid_signature(driver) + + select_started_at = time.perf_counter() + if SEQUENTIAL_ACCOUNT_MODE: + log(f"[{index}/{total}] {account.code} {account.name}: 순차 모드로 현재 선택 위치에서 다음 행으로 이동") + select_next_left_account(driver, account) + else: + select_account_from_left_list(driver, account) + selected_account = assert_selected_account(driver, account) + if selected_account.name and selected_account.name != account.name: + account = Account(account.code, selected_account.name) + select_elapsed = time.perf_counter() - select_started_at + time.sleep(DELAY_AFTER_ACCOUNT_CLICK_SECONDS) + wait_for_blocking_overlay_gone(driver) + + load_started_at = time.perf_counter() + wait_for_detail_change(driver, before_detail, expected_account=account, timeout=DETAIL_CHANGE_WAIT_SECONDS) + selected_account = assert_selected_account(driver, account) + if selected_account.name and selected_account.name != account.name: + account = Account(account.code, selected_account.name) + wait_for_detail_data_cell(driver, timeout=DETAIL_ROW_WAIT_SECONDS) + load_elapsed = time.perf_counter() - load_started_at + + log(f"[{index}/{total}] {account.code} {account.name}: 우클릭 엑셀 다운로드") + menu_started_at = time.perf_counter() + context_click_excel_download(driver) + menu_elapsed = time.perf_counter() - menu_started_at + + downloaded = wait_until_download_finished(DOWNLOAD_DIR, before_files) + if accept_download_complete_popup(driver): + log(f"[{index}/{total}] {account.code} {account.name}: 다운로드 완료 확인창 닫음") + renamed = rename_downloaded_file(downloaded, account, DOWNLOAD_DIR) + add_account_columns_to_excel(renamed, account) + elapsed = time.perf_counter() - started_at + log( + f"[{index}/{total}] 완료: {renamed.name} ({elapsed:.1f}초, " + f"선택 {select_elapsed:.2f}초 / 로드 {load_elapsed:.2f}초 / 다운메뉴 {menu_elapsed:.2f}초)" + ) + return renamed + + +def download_selected_account_ledger(driver: WebDriver, account: Account, index: int, total: int) -> Path: + started_at = time.perf_counter() + close_open_menus(driver) + wait_for_blocking_overlay_gone(driver) + before_files = snapshot_download_files(DOWNLOAD_DIR) + + log(f"[{index}/{total}] {account.code} {account.name}: 선택된 계정 엑셀 다운로드") + menu_started_at = time.perf_counter() + assert_selected_account(driver, account) + wait_for_detail_data_cell(driver, timeout=DETAIL_ROW_WAIT_SECONDS) + context_click_excel_download(driver) + menu_elapsed = time.perf_counter() - menu_started_at + + downloaded = wait_until_download_finished(DOWNLOAD_DIR, before_files) + if accept_download_complete_popup(driver): + log(f"[{index}/{total}] {account.code} {account.name}: 다운로드 완료 확인창 닫음") + renamed = rename_downloaded_file(downloaded, account, DOWNLOAD_DIR) + add_account_columns_to_excel(renamed, account) + elapsed = time.perf_counter() - started_at + log(f"[{index}/{total}] 완료: {renamed.name} ({elapsed:.1f}초, 다운메뉴 {menu_elapsed:.2f}초)") + return renamed + + +def run_scan_existing_accounts( + driver: WebDriver, + accounts: list[Account], + selected_accounts: list[Account], + failures: list[tuple[Account, str]], + skipped_accounts: list[tuple[Account, str]], +) -> None: + target_by_code = {account.code: account for account in selected_accounts} + seen_codes: set[str] = set() + expected_existing_count = len(target_by_code) + reset_left_account_list_scroll(driver) + selected = select_first_existing_left_account(driver) + row_number = 0 + no_move_count = 0 + + while selected is not None and row_number < 500: + row_number += 1 + code = selected.code + if code in seen_codes: + no_move_count += 1 + if no_move_count >= 2: + break + else: + no_move_count = 0 + seen_codes.add(code) + + target = target_by_code.get(code) + if target is not None: + account = Account(target.code, target.name or selected.name) + existing_file = find_account_file(account, DOWNLOAD_DIR) + if SKIP_ALREADY_DOWNLOADED and existing_file is not None: + add_account_columns_to_excel(existing_file, account) + log(f"[{row_number}/{expected_existing_count}] {account.code} {account.name}: 이미 있음, 건너뜀") + else: + try: + download_selected_account_ledger(driver, account, row_number, expected_existing_count) + except Exception as exc: + message = f"{type(exc).__name__}: {exc}" + failures.append((account, message)) + diagnostic = save_diagnostics(driver, f"scan_failed_{account.code}_{account.name}", exc) + log(f"[실패] {account.code} {account.name}: {message}") + log(f"[진단 저장] {diagnostic.resolve()}") + recover_after_account_failure(driver) + elif row_number <= 5 or row_number % 20 == 0: + log(f"[스캔] 현재 실제 계정 {code} {selected.name} 확인") + + next_selected = advance_to_next_existing_left_account(driver, code) + if next_selected is None: + break + selected = next_selected + + for account in selected_accounts: + if account.code not in seen_codes: + skipped_accounts.append((account, "현재 2022년 계정별원장 좌측 실제 계정 목록에 표시되지 않았습니다.")) + + +def run( + accounts: list[Account], + start_index: int = 0, + end_index: int | None = None, + headless: bool = False, + merge: bool = True, + pause_on_failure: bool = PAUSE_ON_FAILURE, +) -> None: + global AVAILABLE_ACCOUNT_CODES + AVAILABLE_ACCOUNT_CODES = None + selected_accounts = accounts[start_index:end_index] + if not selected_accounts: + raise ValueError("처리할 계정이 없습니다. start/end 옵션을 확인하세요.") + + driver = build_driver(DOWNLOAD_DIR, headless=headless) + failures: list[tuple[Account, str]] = [] + skipped_accounts: list[tuple[Account, str]] = [] + should_keep_browser_open = False + + try: + open_wehago(driver) + ensure_ledger_data_loaded(driver, accounts) + + total = len(selected_accounts) + if SCAN_EXISTING_ACCOUNT_MODE: + log("좌측 실제 계정 목록을 순차 스캔하는 방식으로 다운로드합니다.") + run_scan_existing_accounts(driver, accounts, selected_accounts, failures, skipped_accounts) + log(f"작업 완료. 저장 위치: {DOWNLOAD_DIR.resolve()}") + if merge: + log("다운로드 파일 취합을 시작합니다.") + merged_file = consolidate_downloads(merge_scope_accounts(accounts), DOWNLOAD_DIR, failures, skipped_accounts) + log(f"통합 엑셀 생성 완료: {merged_file.resolve()}") + if failures: + should_keep_browser_open = True + raise RuntimeError("일부 계정 다운로드에 실패했습니다. 통합 파일의 오류_누락 시트를 확인하세요.") + return + + pending_accounts = list(selected_accounts) + last_errors: dict[str, str] = {} + + for attempt in range(1, MAX_DOWNLOAD_ATTEMPTS + 1): + if not pending_accounts: + break + + if attempt > 1: + log(f"{attempt}회차 재시도를 시작합니다. 대상: {len(pending_accounts)}개 계정") + + next_pending: list[Account] = [] + for account in pending_accounts: + original_index = selected_accounts.index(account) + 1 + existing_file = find_account_file(account, DOWNLOAD_DIR) + if SKIP_ALREADY_DOWNLOADED and existing_file is not None: + add_account_columns_to_excel(existing_file, account) + log(f"[{original_index}/{total}] {account.code} {account.name}: 이미 있음, 건너뜀") + last_errors.pop(account.code, None) + continue + + try: + download_account_ledger(driver, account, original_index, total) + last_errors.pop(account.code, None) + except AccountNotAvailable as exc: + message = str(exc) + log(f"[{original_index}/{total}] {account.code} {account.name}: 계정 없음, 다운로드 건너뜀") + skipped_accounts.append((account, message)) + last_errors.pop(account.code, None) + recover_after_account_failure(driver) + except Exception as exc: + message = f"{type(exc).__name__}: {exc}" + last_errors[account.code] = message + diagnostic = save_diagnostics(driver, f"attempt{attempt}_failed_{account.code}_{account.name}", exc) + log(f"[실패 {attempt}/{MAX_DOWNLOAD_ATTEMPTS}] {account.code} {account.name}: {message}") + log(f"[진단 저장] {diagnostic.resolve()}") + recover_after_account_failure(driver) + if attempt < MAX_DOWNLOAD_ATTEMPTS: + next_pending.append(account) + + pending_accounts = next_pending + + failures = [ + (account, last_errors.get(account.code, "3회 재시도 후에도 다운로드 파일을 만들지 못했습니다.")) + for account in selected_accounts + if find_account_file(account, DOWNLOAD_DIR) is None + and account.code not in {skipped_account.code for skipped_account, _ in skipped_accounts} + ] + if failures: + should_keep_browser_open = True + + for index, account in enumerate([], start=1): + existing_file = find_account_file(account, DOWNLOAD_DIR) + if SKIP_ALREADY_DOWNLOADED and existing_file is not None: + log(f"[{index}/{total}] {account.code} {account.name}: 이미 있음, 건너뜀") + continue + + try: + close_open_menus(driver) + wait_for_blocking_overlay_gone(driver) + log(f"[{index}/{total}] {account.code} {account.name}: 왼쪽 계정 목록에서 선택") + before_files = snapshot_download_files(DOWNLOAD_DIR) + + select_account_from_left_list(driver, account) + time.sleep(DELAY_AFTER_ACCOUNT_CLICK_SECONDS) + wait_for_blocking_overlay_gone(driver) + + log(f"[{index}/{total}] {account.code} {account.name}: 우클릭 엑셀 다운로드") + context_click_excel_download(driver) + + downloaded = wait_until_download_finished(DOWNLOAD_DIR, before_files) + if accept_download_complete_popup(driver): + log(f"[{index}/{total}] {account.code} {account.name}: 다운로드 완료 확인창 닫음") + renamed = rename_downloaded_file(downloaded, account, DOWNLOAD_DIR) + add_account_columns_to_excel(renamed, account) + log(f"[{index}/{total}] 완료: {renamed.name}") + + except Exception as exc: # 계정 하나 실패해도 다음 계정으로 진행합니다. + message = f"{type(exc).__name__}: {exc}" + failures.append((account, message)) + should_keep_browser_open = True + diagnostic = save_diagnostics(driver, f"failed_{account.code}_{account.name}", exc) + log(f"[실패] {account.code} {account.name}: {message}") + log(f"[진단 저장] {diagnostic.resolve()}") + recover_after_account_failure(driver) + + log(f"작업 완료. 저장 위치: {DOWNLOAD_DIR.resolve()}") + if merge: + log("다운로드 파일 취합을 시작합니다.") + merged_file = consolidate_downloads(merge_scope_accounts(accounts), DOWNLOAD_DIR, failures, skipped_accounts) + log(f"통합 엑셀 생성 완료: {merged_file.resolve()}") + + if failures: + log("실패한 계정 목록") + for account, message in failures: + log(f"- {account.code} {account.name}: {message}") + should_keep_browser_open = True + if pause_on_failure and not headless: + log("실패가 있어 Chrome 창을 바로 닫지 않습니다.") + log("화면을 확인한 뒤 Enter를 누르면 스크립트만 종료되고 Chrome 창은 유지됩니다.") + input("확인 후 Enter: ") + raise RuntimeError("일부 계정 다운로드에 실패했습니다. 통합 파일의 오류_누락 시트를 확인하세요.") + + finally: + if should_keep_browser_open and pause_on_failure and not headless: + log("진단을 위해 Chrome 창을 유지합니다. 필요하면 직접 닫으세요.") + else: + driver.quit() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="WEHAGO 계정별원장 엑셀 자동 다운로드") + parser.add_argument("--start", type=int, default=0, help="0부터 시작하는 시작 순번") + parser.add_argument("--end", type=int, default=None, help="0부터 시작하는 끝 순번. 이 순번은 포함하지 않음") + parser.add_argument("--headless", action="store_true", help="브라우저 창 없이 실행. 로그인/화면 확인 뒤에는 권장") + parser.add_argument("--no-merge", action="store_true", help="다운로드 후 통합 엑셀을 만들지 않음") + parser.add_argument("--merge-only", action="store_true", help="브라우저 자동화 없이 기존 다운로드 파일만 취합") + parser.add_argument("--no-pause-on-failure", action="store_true", help="실패해도 화면 확인 대기 없이 종료") + parser.add_argument("--reset-ledger-url", action="store_true", help="저장된 계정별원장 주소를 삭제하고 WEHAGO 메인부터 시작") + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_args() + try: + if args.reset_ledger_url and LEDGER_URL_FILE.exists(): + LEDGER_URL_FILE.unlink() + log(f"저장된 계정별원장 주소를 삭제했습니다: {LEDGER_URL_FILE.resolve()}") + + if args.merge_only: + merged = consolidate_downloads(merge_scope_accounts(ACCOUNTS), DOWNLOAD_DIR) + log(f"통합 엑셀 생성 완료: {merged.resolve()}") + else: + run( + ACCOUNTS, + start_index=args.start, + end_index=args.end, + headless=args.headless, + merge=not args.no_merge, + pause_on_failure=not args.no_pause_on_failure, + ) + except KeyboardInterrupt: + log("사용자가 중지했습니다.") + except RuntimeError as exc: + log(str(exc)) + sys.exit(1) + except WebDriverException as exc: + log(f"Chrome/Selenium 오류: {exc}") + log("Chrome이 이미 실행 중이면 모두 닫고 다시 시도하세요.") + sys.exit(1) diff --git a/templates/wehago_compare.html b/templates/wehago_compare.html index ec3ef79..1f87216 100644 --- a/templates/wehago_compare.html +++ b/templates/wehago_compare.html @@ -217,6 +217,157 @@ align-items: stretch; } + .snapshot-panel { + margin-top: 10px; + border: 1px solid var(--line); + border-radius: 12px; + background: #fff; + padding: 10px 12px; + display: grid; + gap: 10px; + } + + .snapshot-panel[hidden] { + display: none !important; + } + + .snapshot-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + } + + .snapshot-panel-title { + display: grid; + gap: 3px; + } + + .snapshot-panel-title strong { + font-size: 14px; + } + + .snapshot-policy { + color: var(--muted); + font-size: 12px; + line-height: 1.4; + } + + .snapshot-summary-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + + .snapshot-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 5px 8px; + border-radius: 999px; + border: 1px solid #dbe3eb; + background: #f8fafc; + font-size: 12px; + color: #334155; + } + + .snapshot-badge strong { + color: #0f172a; + } + + .snapshot-years { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); + gap: 8px; + } + + .snapshot-year-card { + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #fff; + padding: 8px 9px; + display: grid; + gap: 5px; + } + + .snapshot-year-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + .snapshot-year-head strong { + font-size: 13px; + } + + .snapshot-state-pill { + display: inline-flex; + align-items: center; + padding: 2px 7px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + border: 1px solid transparent; + white-space: nowrap; + } + + .snapshot-state-pill.ready { background: #ecfdf5; border-color: #bbf7d0; color: #166534; } + .snapshot-state-pill.queued { background: #eff6ff; border-color: #bfdbfe; color: #1d4ed8; } + .snapshot-state-pill.running { background: #eff6ff; border-color: #93c5fd; color: #1d4ed8; } + .snapshot-state-pill.stale { background: #fff7ed; border-color: #fed7aa; color: #c2410c; } + .snapshot-state-pill.failed { background: #fef2f2; border-color: #fecaca; color: #b91c1c; } + .snapshot-state-pill.missing { background: #f8fafc; border-color: #cbd5e1; color: #475569; } + + .snapshot-year-meta, + .snapshot-job-meta { + color: var(--muted); + font-size: 12px; + line-height: 1.45; + } + + .snapshot-jobs { + display: grid; + gap: 6px; + } + + .snapshot-job-row { + border-top: 1px solid #eef2f7; + padding-top: 6px; + display: grid; + gap: 2px; + } + + .snapshot-job-row:first-child { + border-top: 0; + padding-top: 0; + } + + .snapshot-job-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 12px; + } + + .icon-button-label { + display: inline-flex; + align-items: center; + justify-content: center; + } + + .icon-button-label svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + } + .status-card { min-width: 0; width: 100%; @@ -1048,7 +1199,14 @@ - + + {% if wehago_compare.last_action %} 최근 작업: {{ wehago_compare.last_action.action_type }} / {{ "{:,}".format(wehago_compare.last_action.count or 0) }}건 @@ -1155,6 +1313,22 @@ {% endfor %} + + @@ -1440,6 +1614,15 @@ return payload; }; + const stateLabelMap = { + ready: '준비됨', + queued: '대기중', + running: '생성중', + stale: '구버전', + failed: '실패', + missing: '없음', + }; + const applySummaryPayload = (payload) => { const sections = Array.isArray(payload?.metric_sections) ? payload.metric_sections : []; sections.forEach((section) => { @@ -1462,6 +1645,84 @@ } }; + const renderSnapshotStatus = (payload) => { + const snapshotPolicyText = document.getElementById('snapshotPolicyText'); + const snapshotSummaryMeta = document.getElementById('snapshotSummaryMeta'); + const snapshotSummaryBadges = document.getElementById('snapshotSummaryBadges'); + const snapshotYears = document.getElementById('snapshotYears'); + const snapshotJobs = document.getElementById('snapshotJobs'); + if (!snapshotPolicyText || !snapshotSummaryMeta || !snapshotSummaryBadges || !snapshotYears || !snapshotJobs) { + return; + } + const aggregate = payload?.aggregate || payload?.snapshot_aggregate || {}; + const policy = payload?.snapshot_policy || {}; + const years = Array.isArray(payload?.years || payload?.snapshot_years) ? (payload.years || payload.snapshot_years) : []; + const recentJobs = Array.isArray(payload?.recent_jobs) ? payload.recent_jobs : []; + const modeSummary = Array.isArray(policy.year_modes) + ? policy.year_modes.map((item) => `${item.year}:${item.mode}`).join(', ') + : ''; + snapshotPolicyText.textContent = `백필 시작 ${policy.backfill_hint_start_year || '-'} / 운영 연도 ${policy.rolling_update_start_year || '-'}+ / 월별 반영 기준 익월 ${policy.monthly_expected_ready_day || '-'}일 이후`; + snapshotSummaryMeta.textContent = modeSummary || '연도별 스냅샷 정책 정보가 없습니다.'; + const badgeOrder = ['ready', 'queued', 'running', 'stale', 'failed', 'missing']; + snapshotSummaryBadges.innerHTML = badgeOrder.map((key) => { + const label = stateLabelMap[key] || key; + const value = Number(aggregate[key] || 0).toLocaleString(); + return `${escapeHtml(label)}${escapeHtml(value)}`; + }).join(''); + snapshotYears.innerHTML = years.length + ? years.map((item) => { + const stateKey = String(item.state || 'missing'); + const counts = item.row_counts || {}; + const summaryBits = [ + `M ${Number(counts.matched || 0).toLocaleString()}`, + `U ${Number(counts.ledger_only || 0).toLocaleString()}`, + `R ${Number(counts.amount_mismatch || 0).toLocaleString()}`, + `E ${Number(counts.voucher_only || 0).toLocaleString()}`, + ].join(' / '); + const metaBits = [ + item.mode ? `mode ${item.mode}` : '', + item.last_built_at ? `built ${item.last_built_at}` : '', + item.last_requested_at ? `req ${item.last_requested_at}` : '', + item.cache_state ? `cache ${item.cache_state}` : '', + ].filter(Boolean).join(' / '); + return ` +
+
+ ${escapeHtml(item.fiscal_year)} + ${escapeHtml(stateLabelMap[stateKey] || stateKey)} +
+
${escapeHtml(summaryBits)}
+
${escapeHtml(metaBits)}
+ ${item.error_message ? `
${escapeHtml(item.error_message)}
` : ''} +
+ `; + }).join('') + : '
표시할 연도 상태가 없습니다.
'; + snapshotJobs.innerHTML = recentJobs.length + ? recentJobs.slice(0, 8).map((job) => { + const bits = []; + if (job.job_type === 'year_snapshot_rebuild' && job.payload?.fiscal_year) { + bits.push(`연도 ${job.payload.fiscal_year}`); + } + if (job.job_type === 'metric_counts_rebuild') { + bits.push(`범위 ${job.payload?.start_year ?? '-'}~${job.payload?.end_year ?? '-'}`); + } + bits.push(`우선순위 ${job.priority ?? 0}`); + return ` +
+
+ ${escapeHtml(job.job_type)} + ${escapeHtml(stateLabelMap[job.state] || job.state || '')} +
+
${escapeHtml(bits.join(' / '))}
+
${escapeHtml([job.created_at, job.started_at, job.finished_at].filter(Boolean).join(' / '))}
+ ${job.error_message ? `
${escapeHtml(job.error_message)}
` : ''} +
+ `; + }).join('') + : '
최근 재생성 이력이 없습니다.
'; + }; + const loadDashboardSummary = async (attempt = 0) => { const params = new URLSearchParams(); if (startYearValue) params.set('start_year', startYearValue); @@ -1469,6 +1730,7 @@ try { const payload = await fetchJson(`/wehago-compare/api/summary?${params.toString()}`); applySummaryPayload(payload); + renderSnapshotStatus(payload); if (payload?.pending && attempt < 6) { window.setTimeout(() => loadDashboardSummary(attempt + 1), 1200); } @@ -1477,6 +1739,30 @@ } }; + const loadSnapshotStatus = async () => { + 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/snapshot-status?${params.toString()}`); + renderSnapshotStatus(payload); + snapshotStatusLoaded = true; + return payload; + } catch (_error) { + return null; + } + }; + + const setSnapshotPanelOpen = async (open) => { + if (!snapshotPanel || !snapshotToggleBtn) return; + snapshotPanel.hidden = !open; + snapshotToggleBtn.setAttribute('aria-expanded', open ? 'true' : 'false'); + snapshotToggleBtn.textContent = open ? '스냅샷 상태 숨기기' : '스냅샷 상태'; + if (open && !snapshotStatusLoaded) { + await loadSnapshotStatus(); + } + }; + const buildQuery = (form, extra = {}) => { const params = new URLSearchParams(); if (startYearValue) params.set('start_year', startYearValue); @@ -1509,11 +1795,15 @@ const recommendClose = document.getElementById('recommendClose'); const recommendSortTools = document.getElementById('recommendSortTools'); const recommendSortButtons = Array.from(document.querySelectorAll('[data-recommend-sort]')); + const snapshotToggleBtn = document.getElementById('snapshotToggleBtn'); + const snapshotPanel = document.getElementById('snapshotPanel'); const undoLastActionBtn = document.getElementById('undoLastActionBtn'); const lastActionMeta = document.getElementById('lastActionMeta'); + const snapshotRebuildBtn = document.getElementById('snapshotRebuildBtn'); const recommendSelections = new Map(); let recommendSortKey = 'score'; let recommendMode = 'batch'; + let snapshotStatusLoaded = false; const individualState = { sourceStatus: '', sourceRow: null, @@ -2551,6 +2841,45 @@ }); } + if (snapshotRebuildBtn) { + snapshotRebuildBtn.addEventListener('click', async () => { + const confirmed = window.confirm('현재 기간의 전표 스냅샷과 현황 캐시를 다시 준비할까요?'); + if (!confirmed) return; + snapshotRebuildBtn.disabled = true; + try { + const response = await fetch('/wehago-compare/api/snapshot-rebuild', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + start_year: startYearValue || null, + end_year: endYearValue || null, + include_metric_counts: true, + }), + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || '스냅샷 재생성 요청 중 오류가 발생했습니다.'); + } + await loadSnapshotStatus(); + window.setTimeout(() => loadDashboardSummary(), 1200); + } catch (error) { + alert(error.message); + } finally { + snapshotRebuildBtn.disabled = false; + } + }); + } + + if (snapshotToggleBtn) { + snapshotToggleBtn.addEventListener('click', async () => { + const nextOpen = snapshotPanel ? snapshotPanel.hidden : true; + await setSnapshotPanelOpen(nextOpen); + }); + } + document.querySelectorAll('[data-table-wrap]').forEach((wrap) => { const statusKey = wrap.dataset.tableWrap; if (!statusKey) { diff --git a/wehago_compare.py b/wehago_compare.py index 76780d3..0729614 100644 --- a/wehago_compare.py +++ b/wehago_compare.py @@ -236,6 +236,15 @@ _STATUS_CACHE_WARMING: set[str] = set() _STATUS_CACHE_WARMING_LOCK = threading.Lock() _METRIC_COUNTS_WARMING: set[str] = set() _METRIC_COUNTS_WARMING_LOCK = threading.Lock() +_COMPARE_SNAPSHOT_JOB_EVENT = threading.Event() +_COMPARE_SNAPSHOT_WORKER_LOCK = threading.Lock() +_COMPARE_SNAPSHOT_WORKER_STARTED = False +COMPARE_BACKFILL_HINT_START_YEAR = 2016 +COMPARE_ROLLING_UPDATE_START_YEAR = 2026 +COMPARE_MONTHLY_EXPECTED_READY_DAY = 10 +COMPARE_YEAR_REQUEUE_COOLDOWN_RECENT_SEC = 15 * 60 +COMPARE_YEAR_REQUEUE_COOLDOWN_ROLLING_SEC = 60 * 60 +COMPARE_YEAR_REQUEUE_COOLDOWN_BACKFILL_SEC = 6 * 60 * 60 ENABLE_GENERATED_RECHECK_CANDIDATES = False RECHECK_RESOLUTION_POLICY_VERSION = "recheck-flex-strong-conditions-v13-cross-year-and-match-validation" METRIC_COUNT_CACHE_VERSION = "voucher-summary-v5" @@ -271,6 +280,80 @@ def _current_logic_signature() -> str: ) +def _today_local_date() -> date: + return datetime.now().date() + + +def _is_after_monthly_expected_ready_day(today: date | None = None) -> bool: + resolved_today = today or _today_local_date() + return resolved_today.day >= COMPARE_MONTHLY_EXPECTED_READY_DAY + + +def _classify_compare_year_mode(year: int, today: date | None = None) -> str: + resolved_today = today or _today_local_date() + if year >= COMPARE_ROLLING_UPDATE_START_YEAR: + if year > resolved_today.year + 1: + return "future" + return "rolling" + if year >= COMPARE_BACKFILL_HINT_START_YEAR: + return "backfill" + return "legacy" + + +def _compare_year_requeue_cooldown_seconds(year: int, today: date | None = None) -> int: + resolved_today = today or _today_local_date() + mode = _classify_compare_year_mode(year, resolved_today) + if mode == "rolling": + if year == resolved_today.year and _is_after_monthly_expected_ready_day(resolved_today): + return COMPARE_YEAR_REQUEUE_COOLDOWN_RECENT_SEC + if year >= resolved_today.year - 1: + return COMPARE_YEAR_REQUEUE_COOLDOWN_ROLLING_SEC + return COMPARE_YEAR_REQUEUE_COOLDOWN_ROLLING_SEC + if mode == "backfill": + return COMPARE_YEAR_REQUEUE_COOLDOWN_BACKFILL_SEC + return COMPARE_YEAR_REQUEUE_COOLDOWN_ROLLING_SEC + + +def _compare_year_job_priority(year: int, today: date | None = None) -> int: + resolved_today = today or _today_local_date() + mode = _classify_compare_year_mode(year, resolved_today) + if mode == "rolling": + if year == resolved_today.year: + return 10 if _is_after_monthly_expected_ready_day(resolved_today) else 14 + if year == resolved_today.year - 1: + return 12 if _is_after_monthly_expected_ready_day(resolved_today) else 16 + if year == resolved_today.year + 1: + return 20 + return 30 + abs(resolved_today.year - year) + if mode == "backfill": + return 90 + max(0, resolved_today.year - year) + return 140 + max(0, COMPARE_BACKFILL_HINT_START_YEAR - year) + + +def _compare_range_job_priority(start_year: int | None, end_year: int | None, today: date | None = None) -> int: + resolved_today = today or _today_local_date() + if start_year is None or end_year is None: + return 80 + span = max(0, int(end_year) - int(start_year)) + if start_year <= resolved_today.year <= end_year and end_year >= COMPARE_ROLLING_UPDATE_START_YEAR: + return 25 + min(span, 20) + if end_year >= COMPARE_ROLLING_UPDATE_START_YEAR: + return 35 + min(span, 20) + return 70 + min(span, 50) + + +def _parse_db_timestamp(value: Any) -> datetime | None: + text_value = clean(value) + if not text_value: + return None + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"): + try: + return datetime.strptime(text_value, fmt) + except ValueError: + continue + return None + + def _fast_metric_counts_from_result_files(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int]: counts = {"matched": 0, "ledger_only": 0, "voucher_only": 0, "amount_mismatch": 0} if start_year is None or end_year is None: @@ -348,6 +431,15 @@ def _empty_metric_counts() -> dict[str, int]: return {status_key: 0 for status_key, _, _ in STATUS_META} +def _empty_status_rows_by_status() -> dict[str, list[dict[str, Any]]]: + return { + "matched": [], + "ledger_only": [], + "amount_mismatch": [], + "voucher_only": [], + } + + def _metric_counts_signature(conn: Any, start_year: int | None, end_year: int | None) -> str: return "|".join( [ @@ -448,6 +540,105 @@ def _store_metric_counts_cache( ) +def _load_summary_range_cache( + conn: Any, + start_year: int | None, + end_year: int | None, +) -> dict[str, Any] | None: + if start_year is None or end_year is None: + return None + signature = _metric_counts_signature(conn, start_year, end_year) + cached = conn.execute( + text( + """ + SELECT payload_json + FROM wehago_summary_range_cache + WHERE start_year = :start_year + AND end_year = :end_year + AND signature = :signature + LIMIT 1 + """ + ), + {"start_year": start_year, "end_year": end_year, "signature": signature}, + ).first() + if not cached or not cached[0]: + return None + try: + payload = json.loads(str(cached[0])) + except Exception: + return None + return payload if isinstance(payload, dict) else None + + +def _load_latest_summary_range_cache_any_signature( + conn: Any, + start_year: int | None, + end_year: int | None, +) -> dict[str, Any] | None: + if start_year is None or end_year is None: + return None + cached = conn.execute( + text( + """ + SELECT payload_json + FROM wehago_summary_range_cache + WHERE start_year = :start_year + AND end_year = :end_year + ORDER BY updated_at DESC, created_at DESC + LIMIT 1 + """ + ), + {"start_year": start_year, "end_year": end_year}, + ).first() + if not cached or not cached[0]: + return None + try: + payload = json.loads(str(cached[0])) + except Exception: + return None + return payload if isinstance(payload, dict) else None + + +def _store_summary_range_cache( + conn: Any, + start_year: int | None, + end_year: int | None, + counts: dict[str, int], + snapshot_state: dict[str, list[int]], +) -> None: + if start_year is None or end_year is None: + return + signature = _metric_counts_signature(conn, start_year, end_year) + payload = { + "counts": {status_key: int(counts.get(status_key, 0) or 0) for status_key, _, _ in STATUS_META}, + "snapshot_state": { + "ready": [int(year) for year in snapshot_state.get("ready", [])], + "stale": [int(year) for year in snapshot_state.get("stale", [])], + "missing": [int(year) for year in snapshot_state.get("missing", [])], + }, + } + conn.execute( + text( + """ + INSERT INTO wehago_summary_range_cache ( + start_year, end_year, signature, payload_json, created_at, updated_at + ) VALUES ( + :start_year, :end_year, :signature, :payload_json, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + ON CONFLICT(start_year, end_year, signature) DO UPDATE SET + payload_json = excluded.payload_json, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "start_year": start_year, + "end_year": end_year, + "signature": signature, + "payload_json": json.dumps(payload, ensure_ascii=False), + }, + ) + + def _fallback_metric_counts_from_db(conn: Any, start_year: int | None, end_year: int | None) -> dict[str, int]: year_sql = build_year_filter_sql("c.fiscal_year") return { @@ -657,12 +848,34 @@ def get_dashboard_metric_counts_nonblocking( end_year: int | None, ) -> tuple[dict[str, int], bool]: init_wehago_compare_db(engine) + pending = False with engine.begin() as conn: + cached_summary = _load_summary_range_cache(conn, start_year, end_year) + if cached_summary: + counts = cached_summary.get("counts") + if isinstance(counts, dict): + return { + status_key: int(counts.get(status_key, 0) or 0) + for status_key, _, _ in STATUS_META + }, False cached = _load_metric_counts_cache(conn, start_year, end_year) if cached is not None: return cached, False - fallback = _load_latest_metric_counts_cache_any_signature(conn, start_year, end_year) - return fallback or _empty_metric_counts(), fallback is None + snapshot_state = _get_compare_snapshot_state(conn, start_year, end_year) + fallback_summary = _load_latest_summary_range_cache_any_signature(conn, start_year, end_year) + fallback_counts = None + if fallback_summary and isinstance(fallback_summary.get("counts"), dict): + fallback_counts = { + status_key: int(fallback_summary["counts"].get(status_key, 0) or 0) + for status_key, _, _ in STATUS_META + } + fallback = fallback_counts or _load_latest_metric_counts_cache_any_signature(conn, start_year, end_year) + pending = bool(snapshot_state["missing"] or snapshot_state["stale"] or fallback is None) + years_to_enqueue = snapshot_state["missing"] + snapshot_state["stale"] + if years_to_enqueue: + enqueue_year_snapshot_rebuild(engine, years_to_enqueue) + enqueue_metric_count_rebuild(engine, start_year, end_year) + return fallback or _empty_metric_counts(), pending def _warm_metric_counts_worker(engine: Any, start_year: int, end_year: int, warm_key: str) -> None: @@ -671,6 +884,13 @@ def _warm_metric_counts_worker(engine: Any, start_year: int, end_year: int, warm with engine.begin() as conn: counts = _resolved_metric_counts(conn, start_year, end_year) _store_metric_counts_cache(conn, start_year, end_year, counts) + _store_summary_range_cache( + conn, + start_year, + end_year, + counts, + _get_compare_snapshot_state(conn, start_year, end_year), + ) finally: with _METRIC_COUNTS_WARMING_LOCK: _METRIC_COUNTS_WARMING.discard(warm_key) @@ -691,13 +911,11 @@ def warm_metric_counts_async(engine: Any, start_year: int | None, end_year: int 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() + try: + enqueue_metric_count_rebuild(engine, start_year, end_year) + finally: + with _METRIC_COUNTS_WARMING_LOCK: + _METRIC_COUNTS_WARMING.discard(warm_key) @dataclass @@ -1180,6 +1398,7 @@ def init_wehago_compare_db(engine: Any) -> None: job_key TEXT PRIMARY KEY, job_type TEXT NOT NULL, payload_json TEXT NOT NULL DEFAULT '{}', + priority INTEGER NOT NULL DEFAULT 100, state TEXT NOT NULL DEFAULT 'queued', error_message TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -1190,6 +1409,39 @@ def init_wehago_compare_db(engine: Any) -> None: """ ) ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS wehago_summary_range_cache ( + start_year INTEGER NOT NULL, + end_year INTEGER NOT NULL, + signature TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (start_year, end_year, signature) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS wehago_snapshot_status ( + fiscal_year INTEGER PRIMARY KEY, + snapshot_signature TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT 'missing', + source_kind TEXT NOT NULL DEFAULT 'resolved', + row_counts_json TEXT NOT NULL DEFAULT '{}', + error_message TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_requested_at TEXT NOT NULL DEFAULT '', + last_built_at TEXT NOT NULL DEFAULT '' + ) + """ + ) + ) ensure_column(conn, "wehago_result_row_cache", "resolved_state_signature", "TEXT NOT NULL DEFAULT ''") ensure_column(conn, "wehago_result_row_cache", "parse_state_signature", "TEXT NOT NULL DEFAULT ''") ensure_column(conn, "wehago_result_row_cache", "resolved_payload_json", "TEXT NOT NULL DEFAULT ''") @@ -1199,6 +1451,7 @@ def init_wehago_compare_db(engine: Any) -> None: ensure_column(conn, "wehago_manual_pair_matches", "confidence_score", "REAL NOT NULL DEFAULT 0") ensure_column(conn, "wehago_manual_pair_matches", "confidence_level", "TEXT NOT NULL DEFAULT ''") ensure_column(conn, "wehago_manual_pair_matches", "match_reason", "TEXT NOT NULL DEFAULT ''") + ensure_column(conn, "wehago_background_jobs", "priority", "INTEGER NOT NULL DEFAULT 100") conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_voucher_source ON wehago_voucher_rows(source_file_id)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_voucher_year_vno ON wehago_voucher_rows(fiscal_year, compare_voucher_no)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_ledger_source ON wehago_ledger_rows(source_file_id)")) @@ -1210,7 +1463,9 @@ def init_wehago_compare_db(engine: Any) -> None: 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)")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_background_jobs_state_created ON wehago_background_jobs(state, priority, created_at)")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_summary_range_cache_range ON wehago_summary_range_cache(start_year, end_year, updated_at)")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_snapshot_status_state_updated ON wehago_snapshot_status(state, updated_at)")) def upsert_source_file( @@ -2737,6 +2992,140 @@ def _load_year_resolved_sections_cache( return None +def _load_latest_year_resolved_sections_cache_any_signature( + conn: Any, + year: int, +) -> dict[str, dict[str, Any]] | None: + cached = conn.execute( + text( + """ + SELECT resolved_payload_json + FROM wehago_result_row_cache + WHERE fiscal_year = :fiscal_year + AND ledger_result_path = 'db' + AND voucher_result_path = 'db' + AND COALESCE(resolved_payload_json, '') <> '' + ORDER BY COALESCE(resolved_created_at, created_at) DESC, created_at DESC + LIMIT 1 + """ + ), + {"fiscal_year": year}, + ).first() + if cached and cached[0]: + try: + payload = json.loads(str(cached[0])) + if isinstance(payload, dict): + return payload + except Exception: + pass + return None + + +def _status_row_counts_from_sections(sections: dict[str, dict[str, Any]] | None) -> dict[str, int]: + counts = { + "matched": 0, + "ledger_only": 0, + "amount_mismatch": 0, + "voucher_only": 0, + } + if not sections: + return counts + for status_key in counts: + counts[status_key] = len(sections.get(status_key, {}).get("rows", [])) + return counts + + +def _upsert_snapshot_status( + conn: Any, + year: int, + *, + signature: str, + state: str, + source_kind: str = "resolved", + row_counts: dict[str, int] | None = None, + error_message: str = "", + touch_requested: bool = False, + built_now: bool = False, +) -> None: + row_counts_json = json.dumps(row_counts or {}, ensure_ascii=False) + conn.execute( + text( + """ + INSERT INTO wehago_snapshot_status ( + fiscal_year, + snapshot_signature, + state, + source_kind, + row_counts_json, + error_message, + created_at, + updated_at, + last_requested_at, + last_built_at + ) VALUES ( + :fiscal_year, + :snapshot_signature, + :state, + :source_kind, + :row_counts_json, + :error_message, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + CASE WHEN :touch_requested = 1 THEN CURRENT_TIMESTAMP ELSE '' END, + CASE WHEN :built_now = 1 THEN CURRENT_TIMESTAMP ELSE '' END + ) + ON CONFLICT(fiscal_year) DO UPDATE SET + snapshot_signature = excluded.snapshot_signature, + state = excluded.state, + source_kind = excluded.source_kind, + row_counts_json = excluded.row_counts_json, + error_message = excluded.error_message, + updated_at = CURRENT_TIMESTAMP, + last_requested_at = CASE + WHEN :touch_requested = 1 THEN CURRENT_TIMESTAMP + ELSE wehago_snapshot_status.last_requested_at + END, + last_built_at = CASE + WHEN :built_now = 1 THEN CURRENT_TIMESTAMP + ELSE wehago_snapshot_status.last_built_at + END + """ + ), + { + "fiscal_year": year, + "snapshot_signature": signature, + "state": state, + "source_kind": source_kind, + "row_counts_json": row_counts_json, + "error_message": clean(error_message), + "touch_requested": 1 if touch_requested else 0, + "built_now": 1 if built_now else 0, + }, + ) + + +def _get_compare_snapshot_state( + conn: Any, + start_year: int | None, + end_year: int | None, +) -> dict[str, list[int]]: + state = {"missing": [], "stale": [], "ready": []} + if start_year is None or end_year is None: + return state + for year in range(start_year, end_year + 1): + signature = _build_db_state_signature(conn, year, year) + exact = _load_year_resolved_sections_cache(conn, year, signature) + if exact is not None: + state["ready"].append(year) + continue + stale = _load_latest_year_resolved_sections_cache_any_signature(conn, year) + if stale is not None: + state["stale"].append(year) + else: + state["missing"].append(year) + return state + + def _store_year_resolved_sections_cache( conn: Any, year: int, @@ -2791,6 +3180,28 @@ def _store_year_resolved_sections_cache( ) +def _refresh_year_resolved_sections(conn: Any, year: int) -> dict[str, dict[str, Any]] | None: + signature = _build_db_state_signature(conn, year, year) + parsed_sections = _get_or_create_year_cached_sections(conn, year) + if parsed_sections is not None: + sections = _apply_boundary_exclusions_to_sections(conn, parsed_sections) + sections = apply_saved_recheck_reviews(sections, get_saved_recheck_review_keys(conn, year, year)) + sections = apply_saved_manual_pair_matches(sections, get_saved_manual_pair_matches(conn, year, year)) + sections = _apply_previous_year_erp_candidates(conn, year, sections) + else: + sections = _build_db_status_sections(conn, year) + _store_year_resolved_sections_cache(conn, year, signature, sections) + _upsert_snapshot_status( + conn, + year, + signature=signature, + state="ready", + row_counts=_status_row_counts_from_sections(sections), + built_now=True, + ) + return sections + + def _build_db_state_signature(conn: Any, start_year: int | None, end_year: int | None) -> str: source = conn.execute( text( @@ -2843,6 +3254,229 @@ def _build_db_state_signature(conn: Any, start_year: int | None, end_year: int | ) +def _build_year_snapshot_job_key(year: int, signature: str) -> str: + return f"year_snapshot:{year}:{signature}" + + +def _build_metric_rebuild_job_key(start_year: int | None, end_year: int | None, signature: str) -> str: + return f"metric_counts:{start_year}:{end_year}:{signature}" + + +def _ensure_compare_snapshot_worker(engine: Any) -> None: + global _COMPARE_SNAPSHOT_WORKER_STARTED + with _COMPARE_SNAPSHOT_WORKER_LOCK: + if _COMPARE_SNAPSHOT_WORKER_STARTED: + return + worker = threading.Thread( + target=_compare_snapshot_worker_loop, + args=(engine,), + daemon=True, + name="wehago-compare-snapshot-worker", + ) + worker.start() + _COMPARE_SNAPSHOT_WORKER_STARTED = True + + +def enqueue_year_snapshot_rebuild( + engine: Any, + years: Iterable[int], +) -> None: + years = sorted({int(year) for year in years if int(year or 0) > 0}) + if not years: + return + init_wehago_compare_db(engine) + with engine.begin() as conn: + status_map = _load_snapshot_status_map(conn, years) + for year in years: + signature = _build_db_state_signature(conn, year, year) + if not _should_enqueue_year_snapshot(conn, year, signature, status_map.get(year)): + continue + job_key = _build_year_snapshot_job_key(year, signature) + priority = _compare_year_job_priority(year) + conn.execute( + text( + """ + INSERT INTO wehago_background_jobs ( + job_key, job_type, payload_json, priority, state, error_message, created_at, updated_at + ) VALUES ( + :job_key, 'year_snapshot_rebuild', :payload_json, :priority, 'queued', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + ON CONFLICT(job_key) DO UPDATE SET + state = CASE + WHEN wehago_background_jobs.state = 'running' THEN wehago_background_jobs.state + ELSE 'queued' + END, + priority = excluded.priority, + payload_json = excluded.payload_json, + error_message = '', + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "job_key": job_key, + "priority": priority, + "payload_json": json.dumps({"fiscal_year": year, "signature": signature}, ensure_ascii=False), + }, + ) + _upsert_snapshot_status( + conn, + year, + signature=signature, + state="queued", + touch_requested=True, + ) + _ensure_compare_snapshot_worker(engine) + _COMPARE_SNAPSHOT_JOB_EVENT.set() + + +def enqueue_metric_count_rebuild( + engine: Any, + start_year: int | None, + end_year: int | None, +) -> None: + if start_year is None or end_year is None: + return + init_wehago_compare_db(engine) + with engine.begin() as conn: + signature = _metric_counts_signature(conn, start_year, end_year) + job_key = _build_metric_rebuild_job_key(start_year, end_year, signature) + priority = _compare_range_job_priority(start_year, end_year) + conn.execute( + text( + """ + INSERT INTO wehago_background_jobs ( + job_key, job_type, payload_json, priority, state, error_message, created_at, updated_at + ) VALUES ( + :job_key, 'metric_counts_rebuild', :payload_json, :priority, 'queued', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + ON CONFLICT(job_key) DO UPDATE SET + state = CASE + WHEN wehago_background_jobs.state = 'running' THEN wehago_background_jobs.state + ELSE 'queued' + END, + priority = excluded.priority, + payload_json = excluded.payload_json, + error_message = '', + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "job_key": job_key, + "priority": priority, + "payload_json": json.dumps( + { + "start_year": start_year, + "end_year": end_year, + "signature": signature, + }, + ensure_ascii=False, + ), + }, + ) + _ensure_compare_snapshot_worker(engine) + _COMPARE_SNAPSHOT_JOB_EVENT.set() + + +def _compare_snapshot_worker_loop(engine: Any) -> None: + while True: + _COMPARE_SNAPSHOT_JOB_EVENT.wait(timeout=5.0) + _COMPARE_SNAPSHOT_JOB_EVENT.clear() + while True: + init_wehago_compare_db(engine) + with engine.begin() as conn: + job = conn.execute( + text( + """ + SELECT job_key, job_type, payload_json + FROM wehago_background_jobs + WHERE job_type IN ('year_snapshot_rebuild', 'metric_counts_rebuild') + AND state = 'queued' + ORDER BY priority ASC, created_at ASC + LIMIT 1 + """ + ) + ).mappings().first() + if not job: + break + conn.execute( + text( + """ + UPDATE wehago_background_jobs + SET state = 'running', + error_message = '', + started_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE job_key = :job_key + """ + ), + {"job_key": job["job_key"]}, + ) + try: + payload = json.loads(job.get("payload_json") or "{}") + if not isinstance(payload, dict): + payload = {} + with engine.begin() as conn: + if job["job_type"] == "year_snapshot_rebuild": + year = int(payload.get("fiscal_year") or 0) + if year > 0: + _refresh_year_resolved_sections(conn, year) + elif job["job_type"] == "metric_counts_rebuild": + start_year = payload.get("start_year") + end_year = payload.get("end_year") + counts = _resolved_metric_counts(conn, start_year, end_year) + _store_metric_counts_cache(conn, start_year, end_year, counts) + _store_summary_range_cache( + conn, + start_year, + end_year, + counts, + _get_compare_snapshot_state(conn, start_year, end_year), + ) + conn.execute( + text( + """ + UPDATE wehago_background_jobs + SET state = 'done', + error_message = '', + finished_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE job_key = :job_key + """ + ), + {"job_key": job["job_key"]}, + ) + except Exception as exc: + with engine.begin() as conn: + payload = {} + try: + payload = json.loads(job.get("payload_json") or "{}") + except Exception: + payload = {} + if job["job_type"] == "year_snapshot_rebuild": + year = int(payload.get("fiscal_year") or 0) + if year > 0: + _upsert_snapshot_status( + conn, + year, + signature=clean(payload.get("signature")), + state="failed", + error_message=clean(exc), + ) + conn.execute( + text( + """ + UPDATE wehago_background_jobs + SET state = 'failed', + error_message = :error_message, + finished_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE job_key = :job_key + """ + ), + {"job_key": job["job_key"], "error_message": clean(exc)}, + ) + + def _discover_available_fiscal_years(conn: Any) -> list[int]: rows = conn.execute( text( @@ -2864,9 +3498,269 @@ def _discover_available_fiscal_years(conn: Any) -> list[int]: return [int(row[0]) for row in rows if str(row[0] or "").isdigit()] +def _load_snapshot_status_map(conn: Any, years: Iterable[int]) -> dict[int, dict[str, Any]]: + year_list = sorted({int(year) for year in years if int(year or 0) > 0}) + if not year_list: + return {} + placeholders = ", ".join(f":year_{idx}" for idx in range(len(year_list))) + params = {f"year_{idx}": year for idx, year in enumerate(year_list)} + rows = conn.execute( + text( + f""" + SELECT fiscal_year, snapshot_signature, state, row_counts_json, error_message, + created_at, updated_at, last_requested_at, last_built_at + FROM wehago_snapshot_status + WHERE fiscal_year IN ({placeholders}) + """ + ), + params, + ).mappings().all() + return {int(row["fiscal_year"]): dict(row) for row in rows} + + +def _should_enqueue_year_snapshot( + conn: Any, + year: int, + signature: str, + status_row: dict[str, Any] | None = None, + *, + force: bool = False, + today: date | None = None, +) -> bool: + if year <= 0: + return False + if force: + return True + row = status_row or {} + if clean(row.get("snapshot_signature")) == clean(signature) and clean(row.get("state")) == "ready": + return False + cooldown = _compare_year_requeue_cooldown_seconds(year, today) + state_text = clean(row.get("state")).lower() + recent_requested_at = _parse_db_timestamp(row.get("last_requested_at")) + if state_text in {"queued", "running"} and recent_requested_at: + if (datetime.now() - recent_requested_at).total_seconds() < cooldown: + return False + if recent_requested_at and (datetime.now() - recent_requested_at).total_seconds() < cooldown: + return False + return True + + +def _build_compare_snapshot_policy( + start_year: int | None, + end_year: int | None, + available_years: Iterable[int] | None = None, +) -> dict[str, Any]: + year_list = sorted({int(year) for year in (available_years or []) if int(year or 0) > 0}) + if start_year is not None and end_year is not None and not year_list: + year_list = list(range(start_year, end_year + 1)) + resolved_today = _today_local_date() + return { + "backfill_hint_start_year": COMPARE_BACKFILL_HINT_START_YEAR, + "rolling_update_start_year": COMPARE_ROLLING_UPDATE_START_YEAR, + "monthly_expected_ready_day": COMPARE_MONTHLY_EXPECTED_READY_DAY, + "range_start_year": start_year, + "range_end_year": end_year, + "year_modes": [ + {"year": int(year), "mode": _classify_compare_year_mode(int(year), resolved_today)} + for year in year_list + if start_year is None or end_year is None or start_year <= int(year) <= end_year + ], + } + + +def _compute_year_snapshot_runtime_state( + conn: Any, + year: int, + status_row: dict[str, Any] | None = None, +) -> dict[str, Any]: + signature = _build_db_state_signature(conn, year, year) + exact_sections = _load_year_resolved_sections_cache(conn, year, signature) + stale_sections = None if exact_sections is not None else _load_latest_year_resolved_sections_cache_any_signature(conn, year) + row = status_row or {} + persisted_state = clean(row.get("state")).lower() + cache_state = "exact" if exact_sections is not None else "stale" if stale_sections is not None else "missing" + if persisted_state in {"queued", "running", "failed"}: + display_state = persisted_state + elif cache_state == "exact": + display_state = "ready" + elif cache_state == "stale": + display_state = "stale" + else: + display_state = "missing" + row_counts: dict[str, int] = {} + try: + parsed_counts = json.loads(clean(row.get("row_counts_json")) or "{}") + if isinstance(parsed_counts, dict): + row_counts = {str(key): int(value or 0) for key, value in parsed_counts.items()} + except Exception: + row_counts = {} + if not row_counts: + row_counts = _status_row_counts_from_sections(exact_sections or stale_sections) + return { + "fiscal_year": int(year), + "mode": _classify_compare_year_mode(year), + "state": display_state, + "cache_state": cache_state, + "signature_matches": clean(row.get("snapshot_signature")) == clean(signature), + "row_counts": row_counts, + "last_requested_at": clean(row.get("last_requested_at")), + "last_built_at": clean(row.get("last_built_at")), + "updated_at": clean(row.get("updated_at")), + "error_message": clean(row.get("error_message")), + } + + +def _load_recent_compare_jobs( + conn: Any, + start_year: int | None, + end_year: int | None, + limit: int = 20, +) -> list[dict[str, Any]]: + rows = conn.execute( + text( + """ + SELECT job_key, job_type, payload_json, priority, state, error_message, + created_at, updated_at, started_at, finished_at + FROM wehago_background_jobs + WHERE job_type IN ('year_snapshot_rebuild', 'metric_counts_rebuild') + ORDER BY updated_at DESC, created_at DESC + LIMIT :limit + """ + ), + {"limit": int(max(limit, 1))}, + ).mappings().all() + filtered: list[dict[str, Any]] = [] + for row in rows: + payload = {} + try: + payload = json.loads(clean(row.get("payload_json")) or "{}") + except Exception: + payload = {} + job_type = clean(row.get("job_type")) + include = True + if job_type == "year_snapshot_rebuild": + year = int(payload.get("fiscal_year") or 0) + if start_year is not None and year < start_year: + include = False + if end_year is not None and year > end_year: + include = False + elif job_type == "metric_counts_rebuild": + payload_start = payload.get("start_year") + payload_end = payload.get("end_year") + if start_year is not None and end_year is not None: + include = payload_start == start_year and payload_end == end_year + if not include: + continue + filtered.append( + { + "job_key": clean(row.get("job_key")), + "job_type": job_type, + "priority": int(row.get("priority") or 0), + "state": clean(row.get("state")), + "error_message": clean(row.get("error_message")), + "created_at": clean(row.get("created_at")), + "updated_at": clean(row.get("updated_at")), + "started_at": clean(row.get("started_at")), + "finished_at": clean(row.get("finished_at")), + "payload": payload if isinstance(payload, dict) else {}, + } + ) + return filtered + + +def get_compare_snapshot_status( + engine: Any, + start_year: int | None, + end_year: int | None, +) -> dict[str, Any]: + init_wehago_compare_db(engine) + with engine.begin() as conn: + available_years = _discover_available_fiscal_years(conn) + selected_years = [ + int(year) + for year in available_years + if (start_year is None or int(year) >= start_year) and (end_year is None or int(year) <= end_year) + ] + status_map = _load_snapshot_status_map(conn, selected_years) + years = [ + _compute_year_snapshot_runtime_state(conn, year, status_map.get(year)) + for year in selected_years + ] + exact_summary = _load_summary_range_cache(conn, start_year, end_year) + latest_summary = _load_latest_summary_range_cache_any_signature(conn, start_year, end_year) + recent_jobs = _load_recent_compare_jobs(conn, start_year, end_year, limit=20) + snapshot_state = _get_compare_snapshot_state(conn, start_year, end_year) + aggregate = { + "ready": sum(1 for item in years if item["state"] == "ready"), + "queued": sum(1 for item in years if item["state"] == "queued"), + "running": sum(1 for item in years if item["state"] == "running"), + "stale": sum(1 for item in years if item["state"] == "stale"), + "failed": sum(1 for item in years if item["state"] == "failed"), + "missing": sum(1 for item in years if item["state"] == "missing"), + } + return { + "range_start_year": start_year, + "range_end_year": end_year, + "snapshot_state": snapshot_state, + "snapshot_policy": _build_compare_snapshot_policy(start_year, end_year, available_years), + "aggregate": aggregate, + "years": years, + "summary_cache": { + "exact": bool(exact_summary), + "latest_available": bool(latest_summary), + "counts": (exact_summary or latest_summary or {}).get("counts", {}), + "snapshot_state": (exact_summary or latest_summary or {}).get("snapshot_state", {}), + }, + "recent_jobs": recent_jobs, + } + + +def request_compare_snapshot_rebuild( + engine: Any, + start_year: int | None, + end_year: int | None, + *, + include_metric_counts: bool = True, +) -> dict[str, Any]: + if start_year is None or end_year is None: + raise ValueError("재생성할 기간이 필요합니다.") + years = list(range(min(start_year, end_year), max(start_year, end_year) + 1)) + enqueue_year_snapshot_rebuild(engine, years) + if include_metric_counts: + enqueue_metric_count_rebuild(engine, start_year, end_year) + return { + "queued_years": years, + "include_metric_counts": bool(include_metric_counts), + } + + +def _collect_cached_status_rows_by_range( + conn: Any, + start_year: int | None, + end_year: int | None, +) -> tuple[dict[str, list[dict[str, Any]]], dict[str, list[int]]]: + rows_by_status = _empty_status_rows_by_status() + snapshot_state = {"missing": [], "stale": [], "ready": []} + if start_year is None or end_year is None: + return rows_by_status, snapshot_state + for year in range(start_year, end_year + 1): + signature = _build_db_state_signature(conn, year, year) + sections = _load_year_resolved_sections_cache(conn, year, signature) + state_name = "ready" + if sections is None: + sections = _load_latest_year_resolved_sections_cache_any_signature(conn, year) + state_name = "stale" if sections is not None else "missing" + snapshot_state[state_name].append(year) + if not sections: + continue + for status_key in rows_by_status: + rows_by_status[status_key].extend(sections.get(status_key, {}).get("rows", [])) + return rows_by_status, snapshot_state + + def _get_cached_status_rows_by_range(engine: Any, start_year: int | None, end_year: int | None) -> dict[str, list[dict[str, Any]]]: if start_year is None or end_year is None: - return {"matched": [], "ledger_only": [], "amount_mismatch": [], "voucher_only": []} + return _empty_status_rows_by_status() init_wehago_compare_db(engine) with engine.begin() as conn: cache_key = "|".join( @@ -2879,21 +3773,11 @@ def _get_cached_status_rows_by_range(engine: Any, start_year: int | None, end_ye ) now = time.time() cached = _STATUS_ROWS_CACHE.get(cache_key) - if cached and (now - float(cached.get("ts", 0))) <= _STATUS_ROWS_CACHE_TTL_SEC: + cache_ttl = float(cached.get("ttl", _STATUS_ROWS_CACHE_TTL_SEC)) if cached else _STATUS_ROWS_CACHE_TTL_SEC + if cached and (now - float(cached.get("ts", 0))) <= cache_ttl: return cached["rows_by_status"] - rows_by_status: dict[str, list[dict[str, Any]]] = { - "matched": [], - "ledger_only": [], - "amount_mismatch": [], - "voucher_only": [], - } - for year in range(start_year, end_year + 1): - parsed = _get_or_create_year_resolved_sections(conn, year) - if not parsed: - continue - for status_key in rows_by_status: - rows_by_status[status_key].extend(parsed[status_key]["rows"]) + rows_by_status, snapshot_state = _collect_cached_status_rows_by_range(conn, start_year, end_year) if start_year != end_year: section_payload = { status_key: { @@ -2908,9 +3792,14 @@ def _get_cached_status_rows_by_range(engine: Any, start_year: int | None, end_ye status_key: list(section_payload[status_key]["rows"]) for status_key in rows_by_status } + pending_years = snapshot_state["missing"] + snapshot_state["stale"] + ttl_seconds = 15 if pending_years else _STATUS_ROWS_CACHE_TTL_SEC _STATUS_ROWS_CACHE.clear() - _STATUS_ROWS_CACHE[cache_key] = {"ts": now, "rows_by_status": rows_by_status} + _STATUS_ROWS_CACHE[cache_key] = {"ts": now, "rows_by_status": rows_by_status, "ttl": ttl_seconds} + if pending_years: + enqueue_year_snapshot_rebuild(engine, pending_years) return rows_by_status + return rows_by_status def _build_voucher_section_cache_key(conn: Any, start_year: int, end_year: int) -> str: @@ -7419,54 +8308,25 @@ def get_status_detail_rows( safe_offset = max(int(offset or 0), 0) safe_limit = max(min(int(limit or 200), 500), 1) 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 + snapshot_state = _get_compare_snapshot_state(conn, start_year, end_year) + pending_years = snapshot_state["missing"] + snapshot_state["stale"] + if pending_years: + enqueue_year_snapshot_rebuild(engine, pending_years) + notice = "" + if pending_years: + year_text = ", ".join(str(year) for year in pending_years[:5]) + suffix = "..." if len(pending_years) > 5 else "" + notice = f"최신 전표 스냅샷을 갱신 중입니다. ({year_text}{suffix})" - if normalized_status in {"voucher_matched", "erp_voucher_matched", "voucher_unmatched", "voucher_recheck"}: - rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year) - voucher_sections = _get_cached_voucher_sections_by_range(engine, start_year, end_year, rows_by_status) - source_rows = voucher_sections.get(normalized_status, []) - filtered_rows = [ - group - for group in source_rows - if _filter_voucher_status_row( - group, - voucher_filter, - draft_filter, - wehago_account_filter, - erp_account_filter, - clean(wehago_amount), - clean(erp_amount), - wehago_vendor_filter, - erp_vendor_filter, - desc_filter, - ) - ] - next_offset = safe_offset + min(safe_limit, max(len(filtered_rows) - safe_offset, 0)) - shown_groups = filtered_rows[safe_offset : safe_offset + safe_limit] - return { - "columns": DETAIL_COLUMN_MAP[normalized_status], - "rows": [dict(group.get("summary", {})) for group in shown_groups], - "groups": shown_groups, - "total_count": len(filtered_rows), - "shown_count": len(shown_groups), - "offset": safe_offset, - "limit": safe_limit, - "has_more": next_offset < len(filtered_rows), - "next_offset": next_offset, - "notice": "", - "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, + 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, @@ -7476,31 +8336,44 @@ def get_status_detail_rows( wehago_vendor_filter, erp_vendor_filter, desc_filter, - boundary_excluded_filter, - safe_offset, - safe_limit, ) + ] + next_offset = safe_offset + min(safe_limit, max(len(filtered_rows) - safe_offset, 0)) + shown_groups = filtered_rows[safe_offset : safe_offset + safe_limit] + return { + "columns": DETAIL_COLUMN_MAP[normalized_status], + "rows": [dict(group.get("summary", {})) for group in shown_groups], + "groups": shown_groups, + "total_count": len(filtered_rows), + "shown_count": len(shown_groups), + "offset": safe_offset, + "limit": safe_limit, + "has_more": next_offset < len(filtered_rows), + "next_offset": next_offset, + "notice": notice, + "bank_payable_case_count": 0, + "boundary_excluded_count": 0, + } - 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, - erp_account_filter, - clean(wehago_amount), - clean(erp_amount), - wehago_vendor_filter, - erp_vendor_filter, - desc_filter, - boundary_excluded_filter, - safe_offset, - safe_limit, - ) + rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year) + payload = _build_status_detail_response_from_rows( + rows_by_status, + normalized_status, + voucher_filter, + draft_filter, + wehago_account_filter, + erp_account_filter, + clean(wehago_amount), + clean(erp_amount), + wehago_vendor_filter, + erp_vendor_filter, + desc_filter, + boundary_excluded_filter, + safe_offset, + safe_limit, + ) + payload["notice"] = notice or clean(payload.get("notice")) + return payload def get_status_field_suggestions( @@ -7566,33 +8439,13 @@ def get_status_field_suggestions( 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", []) + snapshot_state = _get_compare_snapshot_state(conn, start_year, end_year) + pending_years = snapshot_state["missing"] + snapshot_state["stale"] + if pending_years: + enqueue_year_snapshot_rebuild(engine, pending_years) + source_rows = _build_voucher_sections_from_rows_by_status( + _get_cached_status_rows_by_range(engine, start_year, end_year) + ).get(normalized_status, []) keyword_norm = normalize_text(keyword) seen: set[str] = set() all_rows = [] @@ -7844,10 +8697,8 @@ def get_erp_filtered_rows( def _warm_status_cache_worker(engine: Any, start_year: int, end_year: int, warm_key: str) -> None: try: - 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 + enqueue_year_snapshot_rebuild(engine, range(start_year, end_year + 1)) + enqueue_metric_count_rebuild(engine, start_year, end_year) finally: with _STATUS_CACHE_WARMING_LOCK: _STATUS_CACHE_WARMING.discard(warm_key) @@ -7954,6 +8805,38 @@ def get_wehago_compare_summary( dashboard.get("selected_start_year"), dashboard.get("selected_end_year"), ) + with engine.begin() as conn: + snapshot_state = _get_compare_snapshot_state( + conn, + dashboard.get("selected_start_year"), + dashboard.get("selected_end_year"), + ) + available_years = _discover_available_fiscal_years(conn) + selected_years = [ + int(year) + for year in available_years + if (dashboard.get("selected_start_year") is None or int(year) >= int(dashboard.get("selected_start_year"))) + and (dashboard.get("selected_end_year") is None or int(year) <= int(dashboard.get("selected_end_year"))) + ] + status_map = _load_snapshot_status_map(conn, selected_years) + snapshot_years = [ + _compute_year_snapshot_runtime_state(conn, year, status_map.get(year)) + for year in selected_years + ] + recent_jobs = _load_recent_compare_jobs( + conn, + dashboard.get("selected_start_year"), + dashboard.get("selected_end_year"), + limit=8, + ) + snapshot_aggregate = { + "ready": sum(1 for item in snapshot_years if item["state"] == "ready"), + "queued": sum(1 for item in snapshot_years if item["state"] == "queued"), + "running": sum(1 for item in snapshot_years if item["state"] == "running"), + "stale": sum(1 for item in snapshot_years if item["state"] == "stale"), + "failed": sum(1 for item in snapshot_years if item["state"] == "failed"), + "missing": sum(1 for item in snapshot_years if item["state"] == "missing"), + } metric_sections = [ { **section, @@ -7967,6 +8850,15 @@ def get_wehago_compare_summary( "metric_sections": metric_sections, "last_action": dashboard.get("last_action"), "pending": pending, + "snapshot_state": snapshot_state, + "snapshot_aggregate": snapshot_aggregate, + "snapshot_years": snapshot_years, + "recent_jobs": recent_jobs, + "snapshot_policy": _build_compare_snapshot_policy( + dashboard.get("selected_start_year"), + dashboard.get("selected_end_year"), + available_years, + ), }