from __future__ import annotations import argparse import hashlib import re import sqlite3 from collections import defaultdict from pathlib import Path from typing import Any import sys sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from runtime_config import DB_PATH from wehago_compare import ( QUERY_PROJECTION_VERSION, _apply_wehago_cancel_reissue_recheck, _is_wehago_excepted_voucher_group, _move_wehago_confirmed_reversal_pairs_to_excepted, _move_wehago_offset_tax_invoice_groups_to_excepted, clean, parse_amount, ) STATUSES = ( "voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched", "voucher_recheck", "voucher_excepted", ) STANDARD_STATUSES = ("matched", "ledger_only", "voucher_only", "amount_mismatch") def parse_range(value: str) -> tuple[int, int]: raw = str(value or "").strip() if "-" not in raw: year = int(raw) return year, year left, right = raw.split("-", 1) start_year = int(left) end_year = int(right) if start_year > end_year: start_year, end_year = end_year, start_year return start_year, end_year def current_ready_export_signature(conn: sqlite3.Connection, year: int) -> str: status_row = conn.execute( """ SELECT snapshot_signature FROM wehago_snapshot_status WHERE fiscal_year = ? AND state = 'ready' AND COALESCE(snapshot_signature, '') <> '' LIMIT 1 """, (year,), ).fetchone() if status_row is not None: ready_signature = str(status_row["snapshot_signature"] or "") exists = conn.execute( """ SELECT 1 FROM wehago_compare_export_row_cache WHERE fiscal_year = ? AND snapshot_signature = ? LIMIT 1 """, (year, ready_signature), ).fetchone() if exists is not None: return ready_signature raise RuntimeError(f"{year}년 현재 로직 전표 행 캐시가 아직 준비되지 않았습니다.") def latest_export_signature(conn: sqlite3.Connection, year: int, *, allow_stale: bool = False) -> str: if not allow_stale: return current_ready_export_signature(conn, year) row = conn.execute( """ SELECT snapshot_signature, COUNT(*) AS row_count, MAX(rowid) AS max_rowid FROM wehago_compare_export_row_cache WHERE fiscal_year = ? AND COALESCE(snapshot_signature, '') <> '' GROUP BY snapshot_signature ORDER BY CASE WHEN snapshot_signature LIKE 'voucher-summary-v8|recheck-v21%' THEN 0 WHEN snapshot_signature LIKE 'voucher-summary-v7|recheck-v20%' THEN 1 WHEN snapshot_signature LIKE 'voucher-summary-v7|recheck-v19%' THEN 2 WHEN snapshot_signature LIKE 'voucher-summary-v7|recheck-v18%' THEN 3 WHEN snapshot_signature LIKE 'voucher-summary-v7|recheck-v17%' THEN 4 WHEN snapshot_signature LIKE '%recheck-v20%' THEN 5 WHEN snapshot_signature LIKE '%recheck-v19%' THEN 6 WHEN snapshot_signature LIKE '%recheck-v18%' THEN 7 WHEN snapshot_signature LIKE '%recheck-v17%' THEN 8 ELSE 9 END ASC, row_count DESC, max_rowid DESC LIMIT 1 """, (year,), ).fetchone() if row is None: raise RuntimeError(f"No export cache found for {year}.") return str(row["snapshot_signature"] or "") def projection_signature( signatures: dict[int, str], start_year: int, end_year: int, *, allow_stale: bool = False, context_signatures: dict[int, str] | None = None, ) -> str: raw = "|".join(f"{year}:{signatures[year]}" for year in sorted(signatures)) if context_signatures: context_raw = "|".join(f"{year}:{context_signatures[year]}" for year in sorted(context_signatures)) raw = f"{raw}|context:{context_raw}" digest = hashlib.sha1(raw.encode("utf-8")).hexdigest() mode = "export-cache-stale" if allow_stale else "export-cache-current" return f"{QUERY_PROJECTION_VERSION}|{mode}|{start_year}-{end_year}|{digest}" def _context_year_signatures( conn: sqlite3.Connection, start_year: int, end_year: int, *, allow_stale: bool, ) -> dict[int, str]: signatures: dict[int, str] = {} for year in range(start_year - 1, end_year + 2): try: signatures[year] = latest_export_signature(conn, year, allow_stale=allow_stale) except Exception: continue return signatures def group_key(row: sqlite3.Row) -> tuple[int, str, int]: return int(row["fiscal_year"] or 0), str(row["status_key"] or ""), int(row["group_sort"] or 0) def row_to_dict(row: sqlite3.Row) -> dict[str, Any]: return {key: row[key] for key in row.keys()} def _projection_source_table( conn: sqlite3.Connection, start_year: int, end_year: int, signatures: dict[int, str], *, source_start_year: int | None = None, source_end_year: int | None = None, ) -> None: conn.execute("DROP TABLE IF EXISTS temp._wehago_projection_signatures") conn.execute("DROP TABLE IF EXISTS temp._wehago_projection_source") conn.execute( """ CREATE TEMP TABLE _wehago_projection_signatures ( fiscal_year INTEGER PRIMARY KEY, snapshot_signature TEXT NOT NULL ) """ ) conn.executemany( """ INSERT INTO _wehago_projection_signatures (fiscal_year, snapshot_signature) VALUES (?, ?) """, [(year, signature) for year, signature in sorted(signatures.items())], ) conn.execute( f""" CREATE TEMP TABLE _wehago_projection_source AS SELECT c.* FROM wehago_compare_export_row_cache AS c JOIN _wehago_projection_signatures AS s ON s.fiscal_year = c.fiscal_year AND s.snapshot_signature = c.snapshot_signature WHERE c.fiscal_year BETWEEN ? AND ? AND c.status_key IN ({','.join('?' for _ in STATUSES)}) """, (source_start_year or start_year, source_end_year or end_year, *STATUSES), ) conn.execute( """ CREATE INDEX _idx_wehago_projection_source_group ON _wehago_projection_source(status_key, fiscal_year, group_sort, row_sort) """ ) def _prune_old_projection_signatures( conn: sqlite3.Connection, start_year: int, end_year: int, signature: str, *, keep: int = 3, ) -> None: obsolete_rows = conn.execute( """ SELECT signature FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature LIKE ? AND signature <> ? GROUP BY signature ORDER BY MAX(updated_at) DESC LIMIT -1 OFFSET ? """, (start_year, end_year, f"{QUERY_PROJECTION_VERSION}|%", signature, max(0, keep - 1)), ).fetchall() obsolete_signatures = [str(row["signature"] or "") for row in obsolete_rows if str(row["signature"] or "")] if not obsolete_signatures: return placeholders = ",".join("?" for _ in obsolete_signatures) params = (start_year, end_year, *obsolete_signatures) conn.execute( f""" DELETE FROM wehago_compare_query_rows WHERE start_year = ? AND end_year = ? AND signature IN ({placeholders}) """, params, ) conn.execute( f""" DELETE FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature IN ({placeholders}) """, params, ) def _projection_group_identity(group: dict[str, Any]) -> tuple[Any, ...]: summary = group.get("summary") or group return ( int(summary.get("fiscal_year") or 0), clean(summary.get("ledger_date")), clean(summary.get("voucher_no")), int(summary.get("group_index") or 0), ) def _projection_wehago_identity(group: dict[str, Any]) -> str: summary = group.get("summary") or group fiscal_year = int(summary.get("fiscal_year") or 0) date_digits = re.findall(r"\d+", clean(summary.get("ledger_date"))) if len(date_digits) >= 3: year, month, day = int(date_digits[-3]), int(date_digits[-2]), int(date_digits[-1]) elif len(date_digits) >= 2 and fiscal_year: year, month, day = fiscal_year, int(date_digits[-2]), int(date_digits[-1]) else: return "" voucher_digits = re.sub(r"\D", "", clean(summary.get("voucher_no"))) if not voucher_digits: return "" return f"{year:04d}{month:02d}{day:02d}-{int(voucher_digits):05d}" def _append_reason(existing: Any, reason: str) -> str: existing_text = clean(existing) reason_text = clean(reason) if not reason_text: return existing_text parts = [part.strip() for part in existing_text.split("/") if part.strip()] if reason_text not in parts: parts.append(reason_text) return " / ".join(parts) def _load_projection_group( conn: sqlite3.Connection, start_year: int, end_year: int, signature: str, status_key: str, group_index: int, ) -> dict[str, Any]: summary = dict( conn.execute( """ SELECT * FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ? AND status_key = ? AND group_index = ? """, (start_year, end_year, signature, status_key, group_index), ).fetchone() ) rows = [ dict(row) for row in conn.execute( """ SELECT * FROM wehago_compare_query_rows WHERE start_year = ? AND end_year = ? AND signature = ? AND status_key = ? AND group_index = ? ORDER BY row_index """, (start_year, end_year, signature, status_key, group_index), ).fetchall() ] return {"summary": summary, "rows": rows} def _move_projection_group_to_excepted( conn: sqlite3.Connection, start_year: int, end_year: int, signature: str, old_group_index: int, new_group_index: int, reason: str, old_status_key: str = "voucher_unmatched", ) -> None: row = conn.execute( """ SELECT review_reason FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ? AND status_key = ? AND group_index = ? """, (start_year, end_year, signature, old_status_key, old_group_index), ).fetchone() if row is None: return review_reason = _append_reason(row["review_reason"], reason) conn.execute( """ UPDATE wehago_compare_query_groups SET status_key = 'voucher_excepted', group_index = ?, review_reason = ?, search_text = search_text || ' ' || ?, updated_at = CURRENT_TIMESTAMP WHERE start_year = ? AND end_year = ? AND signature = ? AND status_key = ? AND group_index = ? """, (new_group_index, review_reason, reason, start_year, end_year, signature, old_status_key, old_group_index), ) conn.execute( """ UPDATE wehago_compare_query_rows SET status_key = 'voucher_excepted', group_index = ?, status_label = 'Excepted', review_reason = CASE WHEN COALESCE(review_reason, '') = '' THEN ? WHEN INSTR(review_reason, ?) > 0 THEN review_reason ELSE review_reason || ' / ' || ? END, updated_at = CURRENT_TIMESTAMP WHERE start_year = ? AND end_year = ? AND signature = ? AND status_key = ? AND group_index = ? """, (new_group_index, reason, reason, reason, start_year, end_year, signature, old_status_key, old_group_index), ) def _move_projection_group_to_recheck( conn: sqlite3.Connection, start_year: int, end_year: int, signature: str, old_status_key: str, old_group_index: int, new_group_index: int, reason: str, ) -> None: row = conn.execute( """ SELECT review_reason FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ? AND status_key = ? AND group_index = ? """, (start_year, end_year, signature, old_status_key, old_group_index), ).fetchone() if row is None: return review_reason = _append_reason(row["review_reason"], reason) conn.execute( """ UPDATE wehago_compare_query_groups SET status_key = 'voucher_recheck', group_index = ?, review_reason = ?, search_text = search_text || ' ' || ?, updated_at = CURRENT_TIMESTAMP WHERE start_year = ? AND end_year = ? AND signature = ? AND status_key = ? AND group_index = ? """, (new_group_index, review_reason, reason, start_year, end_year, signature, old_status_key, old_group_index), ) conn.execute( """ UPDATE wehago_compare_query_rows SET status_key = 'voucher_recheck', group_index = ?, status_label = 'Recheck', review_reason = CASE WHEN COALESCE(review_reason, '') = '' THEN ? WHEN INSTR(review_reason, ?) > 0 THEN review_reason ELSE review_reason || ' / ' || ? END, updated_at = CURRENT_TIMESTAMP WHERE start_year = ? AND end_year = ? AND signature = ? AND status_key = ? AND group_index = ? """, (new_group_index, reason, reason, reason, start_year, end_year, signature, old_status_key, old_group_index), ) def _load_projection_context_groups_from_source( conn: sqlite3.Connection, start_year: int, end_year: int, ) -> dict[str, list[dict[str, Any]]]: source_rows = conn.execute( f""" SELECT * FROM _wehago_projection_source WHERE fiscal_year NOT BETWEEN ? AND ? AND status_key IN ({','.join('?' for _ in ('voucher_matched', 'voucher_unmatched', 'voucher_recheck'))}) ORDER BY status_key, fiscal_year, group_sort, row_sort """, (start_year, end_year, "voucher_matched", "voucher_unmatched", "voucher_recheck"), ).fetchall() grouped: dict[tuple[str, int, int], list[sqlite3.Row]] = defaultdict(list) for row in source_rows: grouped[(clean(row["status_key"]), int(row["fiscal_year"] or 0), int(row["group_sort"] or 0))].append(row) groups_by_status: dict[str, list[dict[str, Any]]] = { "voucher_matched": [], "voucher_unmatched": [], "voucher_recheck": [], } for (status_key, fiscal_year, group_sort), rows in grouped.items(): if status_key not in groups_by_status: continue ledger_dates = [clean(row["ledger_date"]) for row in rows if clean(row["ledger_date"])] summary = { "status_key": status_key, "group_index": group_sort, "projection_context_only": "1", "fiscal_year": fiscal_year, "ledger_date": min(ledger_dates) if ledger_dates else "", "proof_date": "", "voucher_no": clean(next((row["group_voucher_no"] for row in rows if clean(row["group_voucher_no"])), "")) or clean(next((row["voucher_no"] for row in rows if clean(row["voucher_no"])), "")), "draft_no": clean(next((row["group_draft_no"] for row in rows if clean(row["group_draft_no"])), "")) or clean(next((row["draft_no"] for row in rows if clean(row["draft_no"])), "")), "ledger_debit": max(parse_amount(row["group_ledger_debit"]) for row in rows), "ledger_credit": max(parse_amount(row["group_ledger_credit"]) for row in rows), "voucher_debit": max(parse_amount(row["group_voucher_debit"]) for row in rows), "voucher_credit": max(parse_amount(row["group_voucher_credit"]) for row in rows), "ledger_accounts": clean(next((row["group_ledger_accounts"] for row in rows if clean(row["group_ledger_accounts"])), "")), "voucher_accounts": clean(next((row["group_voucher_accounts"] for row in rows if clean(row["group_voucher_accounts"])), "")), "ledger_vendors": clean(next((row["group_ledger_vendors"] for row in rows if clean(row["group_ledger_vendors"])), "")), "voucher_vendors": clean(next((row["group_voucher_vendors"] for row in rows if clean(row["group_voucher_vendors"])), "")), "review_reason": "EXPORT_CACHE_CONTEXT", } group_rows = [] for row_index, row in enumerate(rows): group_rows.append( { "status_key": status_key, "row_index": row_index, "fiscal_year": fiscal_year, "status_label": "Matched" if status_key == "voucher_matched" else "Recheck" if status_key == "voucher_recheck" else "Unmatched", "ledger_date": clean(row["ledger_date"]), "proof_date": "", "voucher_no": clean(row["voucher_no"]) or clean(row["group_voucher_no"]), "draft_no": clean(row["draft_no"]) or clean(row["group_draft_no"]), "ledger_account_name": clean(row["ledger_account_name"]), "voucher_account_name": clean(row["voucher_account_name"]), "ledger_vendor": clean(row["ledger_vendor"]), "voucher_vendor": clean(row["voucher_vendor"]), "ledger_debit": parse_amount(row["ledger_debit"]), "ledger_credit": parse_amount(row["ledger_credit"]), "voucher_debit": parse_amount(row["voucher_debit"]), "voucher_credit": parse_amount(row["voucher_credit"]), "ledger_desc": clean(row["ledger_desc"]), "voucher_desc": clean(row["voucher_desc"]), "review_reason": "EXPORT_CACHE_CONTEXT", } ) groups_by_status[status_key].append({"summary": summary, "rows": group_rows}) return groups_by_status def _apply_projection_cancel_reissue_recheck(conn: sqlite3.Connection, start_year: int, end_year: int, signature: str) -> None: statuses = ("voucher_matched", "voucher_unmatched", "voucher_recheck") groups_by_status: dict[str, list[dict[str, Any]]] = {} for status_key in statuses: rows = conn.execute( """ SELECT group_index FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ? AND status_key = ? ORDER BY group_index """, (start_year, end_year, signature, status_key), ).fetchall() groups_by_status[status_key] = [ _load_projection_group(conn, start_year, end_year, signature, status_key, int(row["group_index"] or 0)) for row in rows ] context_groups = _load_projection_context_groups_from_source(conn, start_year, end_year) for status_key, groups in context_groups.items(): groups_by_status.setdefault(status_key, []) groups_by_status[status_key].extend(groups) if not groups_by_status.get("voucher_matched"): return rechecked = _apply_wehago_cancel_reissue_recheck( {status_key: [dict(group, rows=list(group.get("rows") or [])) for group in groups] for status_key, groups in groups_by_status.items()} ) new_recheck_groups = rechecked.get("voucher_recheck") or [] reasons = ( "MATCHED_CANCEL_TARGET_RECHECK", "CANCEL_TARGET_ALREADY_MATCHED_RECHECK", "CANCEL_REISSUE_RETARGET_RECHECK", ) to_move: list[tuple[str, int, str]] = [] for group in new_recheck_groups: summary = group.get("summary") or {} reason_text = clean(summary.get("review_reason")) reason = next((item for item in reasons if item in reason_text), "") if not reason: continue if clean(summary.get("projection_context_only")): continue old_status_key = clean(summary.get("status_key")) or "voucher_recheck" old_group_index = int(summary.get("group_index") or 0) if old_status_key == "voucher_recheck": _move_projection_group_to_recheck( conn, start_year, end_year, signature, old_status_key, old_group_index, old_group_index, reason, ) continue to_move.append((old_status_key, old_group_index, reason)) if not to_move: return max_recheck = conn.execute( """ SELECT COALESCE(MAX(group_index), 0) FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ? AND status_key = 'voucher_recheck' """, (start_year, end_year, signature), ).fetchone()[0] next_group_index = int(max_recheck or 0) + 1 for old_status_key, old_group_index, reason in to_move: _move_projection_group_to_recheck( conn, start_year, end_year, signature, old_status_key, old_group_index, next_group_index, reason, ) next_group_index += 1 def _apply_projection_confirmed_reversal_pairs(conn: sqlite3.Connection, start_year: int, end_year: int, signature: str) -> None: statuses = ("voucher_matched", "voucher_unmatched", "voucher_recheck") sections: dict[str, list[dict[str, Any]]] = {} for status_key in statuses: rows = conn.execute( """ SELECT group_index FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ? AND status_key = ? ORDER BY group_index """, (start_year, end_year, signature, status_key), ).fetchall() sections[status_key] = [ _load_projection_group(conn, start_year, end_year, signature, status_key, int(row["group_index"] or 0)) for row in rows ] moved = _move_wehago_confirmed_reversal_pairs_to_excepted({**sections, "voucher_excepted": []}) selected = [ group for group in moved.get("voucher_excepted", []) or [] if "WEHAGO_EXCEPTED_CONFIRMED_REVERSAL_PAIR" in clean((group.get("summary") or {}).get("review_reason")) ] if not selected: return max_excepted = conn.execute( """ SELECT COALESCE(MAX(group_index), 0) FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ? AND status_key = 'voucher_excepted' """, (start_year, end_year, signature), ).fetchone()[0] next_group_index = int(max_excepted or 0) + 1 for group in selected: summary = group.get("summary") or {} _move_projection_group_to_excepted( conn, start_year, end_year, signature, int(summary.get("group_index") or 0), next_group_index, "WEHAGO_EXCEPTED_CONFIRMED_REVERSAL_PAIR", old_status_key=clean(summary.get("status_key")), ) next_group_index += 1 def _apply_projection_manual_offset_excepted(conn: sqlite3.Connection, start_year: int, end_year: int, signature: str) -> None: conn.execute( """ CREATE TABLE IF NOT EXISTS wehago_manual_offset_excepted ( pair_key TEXT PRIMARY KEY, left_identity TEXT NOT NULL, right_identity TEXT NOT NULL, start_year INTEGER NOT NULL, end_year INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """ ) identities: set[str] = set() for row in conn.execute( """ SELECT left_identity, right_identity FROM wehago_manual_offset_excepted WHERE start_year <= ? AND end_year >= ? """, (end_year, start_year), ).fetchall(): identities.update(filter(None, (clean(row["left_identity"]), clean(row["right_identity"])))) if not identities: return candidates: list[tuple[str, int]] = [] for status_key in ("voucher_matched", "voucher_unmatched", "voucher_recheck"): rows = conn.execute( """ SELECT group_index FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ? AND status_key = ? ORDER BY group_index """, (start_year, end_year, signature, status_key), ).fetchall() for row in rows: group_index = int(row["group_index"] or 0) group = _load_projection_group(conn, start_year, end_year, signature, status_key, group_index) if _projection_wehago_identity(group) in identities: candidates.append((status_key, group_index)) if not candidates: return max_excepted = conn.execute( """ SELECT COALESCE(MAX(group_index), 0) FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ? AND status_key = 'voucher_excepted' """, (start_year, end_year, signature), ).fetchone()[0] next_group_index = int(max_excepted or 0) + 1 for status_key, group_index in candidates: _move_projection_group_to_excepted( conn, start_year, end_year, signature, group_index, next_group_index, "MANUAL_OFFSET_PAIR_EXCEPTED", old_status_key=status_key, ) next_group_index += 1 def _apply_projection_excepted_rules(conn: sqlite3.Connection, start_year: int, end_year: int, signature: str) -> None: summary_rows = conn.execute( """ SELECT group_index FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ? AND status_key = 'voucher_unmatched' ORDER BY group_index """, (start_year, end_year, signature), ).fetchall() if not summary_rows: return groups = [ _load_projection_group(conn, start_year, end_year, signature, "voucher_unmatched", int(row["group_index"] or 0)) for row in summary_rows ] move_reasons: dict[tuple[Any, ...], str] = {} for group in groups: is_excepted, reason = _is_wehago_excepted_voucher_group(group) if is_excepted: move_reasons[_projection_group_identity(group)] = reason offset_sections = _move_wehago_offset_tax_invoice_groups_to_excepted( {"voucher_unmatched": groups, "voucher_excepted": []} ) for group in offset_sections.get("voucher_excepted", []) or []: reason = "WEHAGO_EXCEPTED_OFFSET_TAX_INVOICE_CANCEL" move_reasons.setdefault(_projection_group_identity(group), reason) if not move_reasons: return max_excepted = conn.execute( """ SELECT COALESCE(MAX(group_index), 0) FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ? AND status_key = 'voucher_excepted' """, (start_year, end_year, signature), ).fetchone()[0] next_group_index = int(max_excepted or 0) + 1 for group in groups: identity = _projection_group_identity(group) reason = move_reasons.get(identity) if not reason: continue old_group_index = int((group.get("summary") or {}).get("group_index") or 0) _move_projection_group_to_excepted( conn, start_year, end_year, signature, old_group_index, next_group_index, reason, ) next_group_index += 1 def project_range( conn: sqlite3.Connection, start_year: int, end_year: int, *, allow_stale: bool = False, prune_old: bool = False, ) -> dict[str, int]: signatures = {year: latest_export_signature(conn, year, allow_stale=allow_stale) for year in range(start_year, end_year + 1)} context_signatures = _context_year_signatures(conn, start_year, end_year, allow_stale=allow_stale) signature = projection_signature( signatures, start_year, end_year, allow_stale=allow_stale, context_signatures=context_signatures, ) conn.execute("BEGIN IMMEDIATE") try: _projection_source_table( conn, start_year, end_year, context_signatures or signatures, source_start_year=start_year - 1, source_end_year=end_year + 1, ) conn.execute( "DELETE FROM wehago_compare_query_rows WHERE start_year = ? AND end_year = ? AND signature = ?", (start_year, end_year, signature), ) conn.execute( "DELETE FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ?", (start_year, end_year, signature), ) conn.execute( """ INSERT INTO wehago_compare_query_groups ( start_year, end_year, status_key, signature, group_index, fiscal_year, ledger_date, proof_date, voucher_no, draft_no, ledger_row_count, voucher_row_count, ledger_debit, ledger_credit, voucher_debit, voucher_credit, ledger_accounts, voucher_accounts, ledger_vendors, voucher_vendors, review_reason, search_text, created_at, updated_at ) WITH grouped AS ( SELECT status_key, fiscal_year, group_sort, MIN(COALESCE(ledger_date, '')) AS ledger_date, COALESCE(MAX(NULLIF(group_voucher_no, '')), MAX(NULLIF(voucher_no, '')), '') AS voucher_no, COALESCE(MAX(NULLIF(group_draft_no, '')), MAX(NULLIF(draft_no, '')), '') AS draft_no, SUM(CASE WHEN TRIM(COALESCE(ledger_account_name, '') || COALESCE(ledger_desc, '')) <> '' THEN 1 ELSE 0 END) AS ledger_row_count, SUM(CASE WHEN TRIM(COALESCE(voucher_account_name, '') || COALESCE(voucher_desc, '')) <> '' THEN 1 ELSE 0 END) AS voucher_row_count, MAX(COALESCE(group_ledger_debit, 0)) AS ledger_debit, MAX(COALESCE(group_ledger_credit, 0)) AS ledger_credit, MAX(COALESCE(group_voucher_debit, 0)) AS voucher_debit, MAX(COALESCE(group_voucher_credit, 0)) AS voucher_credit, MAX(COALESCE(group_ledger_accounts, '')) AS ledger_accounts, MAX(COALESCE(group_voucher_accounts, '')) AS voucher_accounts, MAX(COALESCE(group_ledger_vendors, '')) AS ledger_vendors, MAX(COALESCE(group_voucher_vendors, '')) AS voucher_vendors, GROUP_CONCAT(COALESCE(ledger_desc, ''), ' ') AS ledger_descs, GROUP_CONCAT(COALESCE(voucher_desc, ''), ' ') AS voucher_descs FROM _wehago_projection_source WHERE fiscal_year BETWEEN ? AND ? GROUP BY status_key, fiscal_year, group_sort ), ordered AS ( SELECT ROW_NUMBER() OVER (PARTITION BY status_key ORDER BY fiscal_year ASC, status_key ASC, group_sort ASC) AS group_index, * FROM grouped ) SELECT ?, ?, status_key, ?, group_index, fiscal_year, ledger_date, '', voucher_no, draft_no, ledger_row_count, voucher_row_count, ledger_debit, ledger_credit, voucher_debit, voucher_credit, ledger_accounts, voucher_accounts, ledger_vendors, voucher_vendors, 'EXPORT_CACHE_V20', TRIM( voucher_no || ' ' || draft_no || ' ' || ledger_accounts || ' ' || voucher_accounts || ' ' || ledger_vendors || ' ' || voucher_vendors || ' ' || COALESCE(ledger_descs, '') || ' ' || COALESCE(voucher_descs, '') ), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP FROM ordered ORDER BY status_key ASC, group_index ASC """, (start_year, end_year, start_year, end_year, signature), ) conn.execute( """ INSERT INTO wehago_compare_query_rows ( start_year, end_year, status_key, signature, group_index, row_index, fiscal_year, status_label, ledger_date, proof_date, voucher_no, draft_no, ledger_account_name, voucher_account_name, ledger_vendor, voucher_vendor, ledger_debit, ledger_credit, voucher_debit, voucher_credit, ledger_desc, voucher_desc, review_reason, matched_case, ledger_row_key, voucher_row_key, match_identity_key, created_at, updated_at ) WITH group_order AS ( SELECT status_key, fiscal_year, group_sort, ROW_NUMBER() OVER (PARTITION BY status_key ORDER BY fiscal_year ASC, status_key ASC, group_sort ASC) AS group_index FROM ( SELECT DISTINCT status_key, fiscal_year, group_sort FROM _wehago_projection_source WHERE fiscal_year BETWEEN ? AND ? ) ), ordered_rows AS ( SELECT c.*, g.group_index, ROW_NUMBER() OVER ( PARTITION BY c.status_key, c.fiscal_year, c.group_sort ORDER BY c.row_sort ASC ) - 1 AS projected_row_index FROM _wehago_projection_source AS c JOIN group_order AS g ON g.status_key = c.status_key AND g.fiscal_year = c.fiscal_year AND g.group_sort = c.group_sort WHERE c.fiscal_year BETWEEN ? AND ? ) SELECT ?, ?, status_key, ?, group_index, projected_row_index, fiscal_year, CASE WHEN status_key IN ('voucher_matched', 'erp_voucher_matched') THEN 'Matched' WHEN status_key = 'voucher_recheck' THEN 'Recheck' ELSE 'Unmatched' END, COALESCE(ledger_date, ''), '', COALESCE(NULLIF(voucher_no, ''), group_voucher_no, ''), COALESCE(NULLIF(draft_no, ''), group_draft_no, ''), COALESCE(ledger_account_name, ''), COALESCE(voucher_account_name, ''), COALESCE(ledger_vendor, ''), COALESCE(voucher_vendor, ''), COALESCE(ledger_debit, 0), COALESCE(ledger_credit, 0), COALESCE(voucher_debit, 0), COALESCE(voucher_credit, 0), COALESCE(ledger_desc, ''), COALESCE(voucher_desc, ''), 'EXPORT_CACHE_V20', '', '', '', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP FROM ordered_rows ORDER BY status_key ASC, group_index ASC, projected_row_index ASC """, (start_year, end_year, start_year, end_year, start_year, end_year, signature), ) conn.execute( """ DELETE FROM wehago_summary_range_cache WHERE start_year = ? AND end_year = ? """, (start_year, end_year), ) _apply_projection_confirmed_reversal_pairs(conn, start_year, end_year, signature) _apply_projection_manual_offset_excepted(conn, start_year, end_year, signature) _apply_projection_cancel_reissue_recheck(conn, start_year, end_year, signature) _apply_projection_excepted_rules(conn, start_year, end_year, signature) counters = {status: 0 for status in STATUSES} for status_key, row_count in conn.execute( """ SELECT status_key, COUNT(*) FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ? GROUP BY status_key """, (start_year, end_year, signature), ).fetchall(): counters[str(status_key or "")] = int(row_count or 0) for status_key, row_count in conn.execute( f""" SELECT status, COUNT(*) FROM wehago_comparison_results WHERE fiscal_year BETWEEN ? AND ? AND status IN ({','.join('?' for _ in STANDARD_STATUSES)}) GROUP BY status """, (start_year, end_year, *STANDARD_STATUSES), ).fetchall(): counters[str(status_key or "")] = int(row_count or 0) if prune_old: _prune_old_projection_signatures(conn, start_year, end_year, signature) conn.commit() except Exception: conn.rollback() raise finally: conn.execute("DROP TABLE IF EXISTS temp._wehago_projection_source") conn.execute("DROP TABLE IF EXISTS temp._wehago_projection_signatures") return counters def main() -> None: parser = argparse.ArgumentParser(description="Project query rows/groups from recheck-v20 export row cache.") parser.add_argument("ranges", nargs="+") parser.add_argument( "--allow-stale", action="store_true", help="현재 로직 ready 캐시가 없어도 최신 export row cache를 사용합니다. 새 로직 검증용 기본 경로에서는 사용하지 마세요.", ) parser.add_argument( "--prune-old", action="store_true", help="같은 기간의 오래된 query projection을 함께 정리합니다. 대용량 DB에서는 별도 유지보수 시간에 실행하세요.", ) args = parser.parse_args() conn = sqlite3.connect(DB_PATH, timeout=120) conn.row_factory = sqlite3.Row conn.execute("PRAGMA busy_timeout = 120000") for item in args.ranges: start_year, end_year = parse_range(item) counts = project_range(conn, start_year, end_year, allow_stale=args.allow_stale, prune_old=args.prune_old) print({"range": f"{start_year}-{end_year}", "counts": counts}, flush=True) conn.close() if __name__ == "__main__": main()