1041 lines
39 KiB
Python
1041 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 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()
|