Files
HM_project_Viewer_Board/scripts/project_export_cache_ranges.py
T

1040 lines
39 KiB
Python

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")
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)
conn.row_factory = sqlite3.Row
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()