Update intranet tools and voucher comparison

This commit is contained in:
b17301
2026-06-04 09:00:51 +09:00
parent 2ab74bac88
commit b93289bfa8
55 changed files with 32809 additions and 1063 deletions
+427
View File
@@ -0,0 +1,427 @@
from __future__ import annotations
import json
import re
import sqlite3
from collections import Counter, defaultdict
from datetime import datetime
from pathlib import Path
from typing import Any
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill
from openpyxl.utils import get_column_letter
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from runtime_config import DB_PATH # noqa: E402
EXPORT_DIR = ROOT / "static" / "exports" / "wehago_compare"
YEAR = 2025
WEHAGO_STATUSES = ("voucher_matched", "voucher_unmatched", "voucher_recheck")
def clean(value: Any) -> str:
return "" if value is None else str(value).strip()
def key_from_display(year: int, ledger_date: str, voucher_no: str) -> str:
voucher = clean(voucher_no)
if re.fullmatch(r"\d{8}-\d{5}", voucher):
return voucher
match = re.fullmatch(r"(\d{4})-(\d{2})-(\d{5})", voucher)
if match:
return f"{match.group(1)}{match.group(2)}-{match.group(3)}"
date_text = clean(ledger_date)
date_match = re.search(r"(\d{1,2})[-./](\d{1,2})", date_text)
voucher_digits = re.sub(r"\D+", "", voucher)
if date_match and voucher_digits:
month = int(date_match.group(1))
day = int(date_match.group(2))
return f"{int(year):04d}{month:02d}{day:02d}-{int(voucher_digits):05d}"
return ""
def latest_projection_signature(cur: sqlite3.Cursor) -> str:
row = cur.execute(
"""
SELECT payload_json
FROM wehago_action_history
WHERE action_type = 'auto_recheck_promote'
ORDER BY id DESC
LIMIT 1
"""
).fetchone()
if row:
try:
payload = json.loads(row[0] or "{}")
signature = clean(payload.get("signature"))
if signature:
exists = cur.execute(
"""
SELECT 1
FROM wehago_compare_query_groups
WHERE start_year = ? AND end_year = ? AND signature = ?
LIMIT 1
""",
(YEAR, YEAR, signature),
).fetchone()
if exists:
return signature
except Exception:
pass
row = cur.execute(
"""
SELECT signature, MAX(updated_at) AS max_updated_at
FROM wehago_compare_query_groups
WHERE start_year = ? AND end_year = ? AND signature LIKE 'compare-query-v5|%'
GROUP BY signature
ORDER BY max_updated_at DESC
LIMIT 1
""",
(YEAR, YEAR),
).fetchone()
return clean(row[0]) if row else ""
def append_rows(ws, rows: list[list[Any]]) -> None:
for row in rows:
ws.append(row)
def style_sheet(ws) -> None:
header_fill = PatternFill("solid", fgColor="D9EAF7")
for cell in ws[1]:
cell.font = Font(bold=True)
cell.fill = header_fill
ws.freeze_panes = "A2"
ws.auto_filter.ref = ws.dimensions
for col_idx, column_cells in enumerate(ws.columns, start=1):
max_len = 10
for cell in column_cells:
max_len = max(max_len, min(len(clean(cell.value)), 80))
ws.column_dimensions[get_column_letter(col_idx)].width = max_len + 2
def main() -> None:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
signature = latest_projection_signature(cur)
if not signature:
raise RuntimeError("compare query projection signature not found")
db_rows = cur.execute(
"""
SELECT fiscal_year, voucher_no, status, voucher_row_count, ledger_row_count,
voucher_debit, voucher_credit, ledger_debit, ledger_credit,
voucher_accounts, ledger_accounts, voucher_vendors, ledger_vendors, notes
FROM wehago_comparison_results
WHERE fiscal_year = ?
AND status <> 'voucher_only'
ORDER BY voucher_no
""",
(YEAR,),
).fetchall()
db_by_key = {clean(row["voucher_no"]): dict(row) for row in db_rows if clean(row["voucher_no"])}
comparison_total = cur.execute(
"""
SELECT COUNT(*) AS count_all,
SUM(CASE WHEN status = 'voucher_only' THEN 1 ELSE 0 END) AS voucher_only_count
FROM wehago_comparison_results
WHERE fiscal_year = ?
""",
(YEAR,),
).fetchone()
ledger_source = cur.execute(
"""
SELECT COUNT(*) AS row_count,
COUNT(DISTINCT fiscal_year || '|' || compare_voucher_no) AS voucher_count
FROM wehago_ledger_rows
WHERE fiscal_year = ? AND COALESCE(compare_voucher_no, '') <> ''
""",
(YEAR,),
).fetchone()
voucher_source = cur.execute(
"""
SELECT COUNT(*) AS row_count,
COUNT(DISTINCT fiscal_year || '|' || compare_voucher_no) AS voucher_count
FROM wehago_voucher_rows
WHERE fiscal_year = ? AND COALESCE(compare_voucher_no, '') <> ''
""",
(YEAR,),
).fetchone()
ui_rows = cur.execute(
f"""
SELECT status_key, 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
FROM wehago_compare_query_groups
WHERE start_year = ? AND end_year = ? AND signature = ?
AND status_key IN ({','.join('?' for _ in WEHAGO_STATUSES)})
ORDER BY status_key, group_index
""",
(YEAR, YEAR, signature, *WEHAGO_STATUSES),
).fetchall()
ui_group_records: list[dict[str, Any]] = []
for row in ui_rows:
record = dict(row)
record["compare_key"] = key_from_display(record["fiscal_year"], record["ledger_date"], record["voucher_no"])
ui_group_records.append(record)
section_counts = Counter(record["status_key"] for record in ui_group_records)
ui_by_key: dict[str, list[dict[str, Any]]] = defaultdict(list)
bad_key_records: list[dict[str, Any]] = []
for record in ui_group_records:
key = clean(record.get("compare_key"))
if key:
ui_by_key[key].append(record)
else:
bad_key_records.append(record)
db_keys = set(db_by_key)
ui_keys = set(ui_by_key)
duplicate_ui = {key: records for key, records in ui_by_key.items() if len(records) > 1}
ui_not_in_db = {key: records for key, records in ui_by_key.items() if key not in db_keys}
db_not_in_ui = {key: db_by_key[key] for key in sorted(db_keys - ui_keys)}
section_sum = sum(section_counts.get(status, 0) for status in WEHAGO_STATUSES)
db_count = len(db_by_key)
ui_unique_count = len(ui_keys)
duplicate_extra_count = section_sum - ui_unique_count
wb = Workbook()
ws = wb.active
ws.title = "요약"
append_rows(
ws,
[
["항목", "", "설명"],
["분석 기준 연도", YEAR, ""],
["사용 projection signature", signature, "화면 WEHAGO/Recheck 섹션에 사용된 최신 projection"],
["DB WEHAGO 전표 수", db_count, "wehago_comparison_results의 fiscal_year+voucher_no 기준"],
["DB 비교결과 전체 전표 수", int(comparison_total["count_all"] or 0), "ERP-only(voucher_only)를 포함한 전체 비교 결과"],
["DB 비교결과 중 ERP-only 수", int(comparison_total["voucher_only_count"] or 0), "WEHAGO 전표 수 비교에서는 제외"],
["DB WEHAGO 원장 전표 수", int(ledger_source["voucher_count"] or 0), "wehago_ledger_rows의 fiscal_year+compare_voucher_no distinct"],
["DB WEHAGO 원장 행 수", int(ledger_source["row_count"] or 0), "wehago_ledger_rows 행 수"],
["보조: wehago_voucher_rows 전표 수", int(voucher_source["voucher_count"] or 0), "ERP/증빙성 원천 행으로 보이는 테이블의 distinct 전표 수"],
["WEHAGO", section_counts.get("voucher_matched", 0), "화면 WEHAGO 그룹 수"],
["WEHAGO Unmatched", section_counts.get("voucher_unmatched", 0), "화면 WEHAGO Unmatched 그룹 수"],
["WEHAGO Recheck", section_counts.get("voucher_recheck", 0), "화면 WEHAGO Recheck 그룹 수"],
["화면 3개 섹션 합계", section_sum, "WEHAGO + WEHAGO Unmatched + WEHAGO Recheck"],
["차이(화면 합계 - DB WEHAGO)", section_sum - db_count, "양수면 화면 그룹 집계가 DB 전표 수보다 많음"],
["화면 고유 WEHAGO 전표 수", ui_unique_count, "ledger_date+voucher_no를 YYYYMMDD-전표번호로 환산한 distinct"],
["차이(화면 고유 전표 - DB WEHAGO)", ui_unique_count - db_count, "전표번호 기준 순수 누락/추가 차이"],
["중복 그룹 초과분", duplicate_extra_count, "같은 WEHAGO 전표가 여러 그룹으로 나뉘어 합계에 중복 반영된 수"],
["키 생성 불가 그룹", len(bad_key_records), "ledger_date/voucher_no 조합으로 DB 전표번호를 만들 수 없는 그룹"],
],
)
style_sheet(ws)
ws = wb.create_sheet("차이 사유")
reason_rows = [
["사유", "건수", "해석"],
[
"같은 WEHAGO 전표가 화면에서 복수 그룹으로 집계됨",
duplicate_extra_count,
"전표 하나가 복수 ERP 가전표/후보/라인 그룹으로 분리되면 화면 섹션 합계는 DB 전표 수보다 커집니다.",
],
[
"화면에는 있으나 DB comparison_results 전표번호와 직접 대응되지 않음",
len(ui_not_in_db),
"표시 전표번호를 YYYYMMDD-전표번호로 환산해도 DB 전표번호 집합에 없는 경우입니다.",
],
[
"DB에는 있으나 화면 3개 WEHAGO 섹션에 없음",
len(db_not_in_ui),
"DB 비교 결과에는 있으나 현재 projection의 WEHAGO/Unmatched/Recheck 그룹에는 없는 경우입니다.",
],
[
"전표번호 키 생성 불가",
len(bad_key_records),
"화면 그룹의 일자 또는 전표번호가 비어 있거나 형식이 달라 비교 키를 만들지 못한 경우입니다.",
],
]
append_rows(ws, reason_rows)
style_sheet(ws)
ws = wb.create_sheet("중복 그룹 상세")
append_rows(
ws,
[[
"compare_key",
"그룹 수",
"status 목록",
"전표 표시값",
"일자 목록",
"가전표번호/ERP 전표",
"WEHAGO 계정",
"ERP 계정",
"검토 사유",
]],
)
for key, records in sorted(duplicate_ui.items(), key=lambda item: (-len(item[1]), item[0])):
append_rows(
ws,
[[
key,
len(records),
", ".join(sorted({clean(r.get("status_key")) for r in records})),
", ".join(sorted({clean(r.get("voucher_no")) for r in records if clean(r.get("voucher_no"))}))[:300],
", ".join(sorted({clean(r.get("ledger_date")) for r in records if clean(r.get("ledger_date"))}))[:300],
", ".join(clean(r.get("draft_no")) for r in records if clean(r.get("draft_no")))[:1000],
" | ".join(clean(r.get("ledger_accounts")) for r in records if clean(r.get("ledger_accounts")))[:1000],
" | ".join(clean(r.get("voucher_accounts")) for r in records if clean(r.get("voucher_accounts")))[:1000],
" | ".join(clean(r.get("review_reason")) for r in records if clean(r.get("review_reason")))[:1000],
]],
)
style_sheet(ws)
ws = wb.create_sheet("화면만 있음")
append_rows(
ws,
[[
"compare_key",
"그룹 수",
"status 목록",
"일자 목록",
"전표 표시값",
"가전표번호/ERP 전표",
"WEHAGO 계정",
"ERP 계정",
"검토 사유",
]],
)
for key, records in sorted(ui_not_in_db.items()):
append_rows(
ws,
[[
key,
len(records),
", ".join(sorted({clean(r.get("status_key")) for r in records})),
", ".join(sorted({clean(r.get("ledger_date")) for r in records if clean(r.get("ledger_date"))}))[:300],
", ".join(sorted({clean(r.get("voucher_no")) for r in records if clean(r.get("voucher_no"))}))[:300],
", ".join(clean(r.get("draft_no")) for r in records if clean(r.get("draft_no")))[:1000],
" | ".join(clean(r.get("ledger_accounts")) for r in records if clean(r.get("ledger_accounts")))[:1000],
" | ".join(clean(r.get("voucher_accounts")) for r in records if clean(r.get("voucher_accounts")))[:1000],
" | ".join(clean(r.get("review_reason")) for r in records if clean(r.get("review_reason")))[:1000],
]],
)
style_sheet(ws)
ws = wb.create_sheet("DB만 있음")
append_rows(
ws,
[[
"compare_key",
"DB status",
"voucher_row_count",
"ledger_row_count",
"WEHAGO 차변",
"WEHAGO 대변",
"ERP 차변",
"ERP 대변",
"WEHAGO 계정",
"ERP 계정",
"비고",
]],
)
for key, row in db_not_in_ui.items():
append_rows(
ws,
[[
key,
clean(row.get("status")),
row.get("ledger_row_count"),
row.get("voucher_row_count"),
row.get("ledger_debit"),
row.get("ledger_credit"),
row.get("voucher_debit"),
row.get("voucher_credit"),
clean(row.get("ledger_accounts")),
clean(row.get("voucher_accounts")),
clean(row.get("notes")),
]],
)
style_sheet(ws)
ws = wb.create_sheet("섹션 원자료")
append_rows(
ws,
[[
"status",
"compare_key",
"fiscal_year",
"ledger_date",
"voucher_no",
"draft_no",
"ledger_row_count",
"voucher_row_count",
"WEHAGO 차변",
"WEHAGO 대변",
"ERP 차변",
"ERP 대변",
"WEHAGO 계정",
"ERP 계정",
"WEHAGO 거래처",
"ERP 거래처",
"review_reason",
]],
)
for record in ui_group_records:
append_rows(
ws,
[[
record.get("status_key"),
record.get("compare_key"),
record.get("fiscal_year"),
record.get("ledger_date"),
record.get("voucher_no"),
record.get("draft_no"),
record.get("ledger_row_count"),
record.get("voucher_row_count"),
record.get("ledger_debit"),
record.get("ledger_credit"),
record.get("voucher_debit"),
record.get("voucher_credit"),
record.get("ledger_accounts"),
record.get("voucher_accounts"),
record.get("ledger_vendors"),
record.get("voucher_vendors"),
record.get("review_reason"),
]],
)
style_sheet(ws)
EXPORT_DIR.mkdir(parents=True, exist_ok=True)
file_name = f"wehago_voucher_coverage_{YEAR}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
output_path = EXPORT_DIR / file_name
wb.save(output_path)
conn.close()
print(
json.dumps(
{
"file": str(output_path),
"download_url": f"/static/exports/wehago_compare/{file_name}",
"db_count": db_count,
"section_sum": section_sum,
"diff": section_sum - db_count,
"ui_unique_count": ui_unique_count,
"unique_diff": ui_unique_count - db_count,
"duplicate_extra_count": duplicate_extra_count,
"ui_not_in_db": len(ui_not_in_db),
"db_not_in_ui": len(db_not_in_ui),
},
ensure_ascii=False,
)
)
if __name__ == "__main__":
main()
@@ -0,0 +1,506 @@
from __future__ import annotations
import argparse
import hashlib
import json
import sqlite3
import sys
from collections import defaultdict
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from runtime_config import DB_PATH # noqa: E402
from scripts.project_export_cache_ranges import ( # noqa: E402
current_ready_export_signature,
latest_export_signature,
project_range,
projection_signature,
)
from wehago_compare import ( # noqa: E402
QUERY_PROJECTION_VERSION,
_parse_row_date_with_year,
_raw_erp_entry_date_values,
_raw_erp_trace_prefilter,
_raw_erp_trace_prefilter_score,
_raw_erp_trace_score,
clean,
parse_amount,
)
TRACE_LOGIC_VERSION = "raw-erp-trace-candidate-v1"
SOURCE_STATUSES = ("voucher_unmatched", "voucher_recheck")
def _connect(db_path: Path = DB_PATH) -> sqlite3.Connection:
conn = sqlite3.connect(db_path, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout = 30000")
return conn
def _ensure_schema(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS wehago_raw_erp_trace_candidate_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
candidate_key TEXT NOT NULL DEFAULT '',
fiscal_year INTEGER NOT NULL,
source_mode TEXT NOT NULL DEFAULT '',
source_signature TEXT NOT NULL DEFAULT '',
logic_version TEXT NOT NULL DEFAULT '',
status_key TEXT NOT NULL DEFAULT '',
group_index INTEGER NOT NULL DEFAULT 0,
row_index INTEGER NOT NULL DEFAULT 0,
ledger_date TEXT NOT NULL DEFAULT '',
voucher_no TEXT NOT NULL DEFAULT '',
ledger_account_name TEXT NOT NULL DEFAULT '',
ledger_vendor TEXT NOT NULL DEFAULT '',
ledger_desc TEXT NOT NULL DEFAULT '',
ledger_amount REAL NOT NULL DEFAULT 0,
erp_draft_no TEXT NOT NULL DEFAULT '',
erp_confirmed_no TEXT NOT NULL DEFAULT '',
erp_account_name TEXT NOT NULL DEFAULT '',
erp_vendor TEXT NOT NULL DEFAULT '',
erp_desc TEXT NOT NULL DEFAULT '',
erp_amount REAL NOT NULL DEFAULT 0,
amount_field TEXT NOT NULL DEFAULT '',
score REAL NOT NULL DEFAULT 0,
matched_case TEXT NOT NULL DEFAULT '',
review_reason TEXT NOT NULL DEFAULT '',
candidate_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
columns = {
str(row["name"] or "")
for row in conn.execute("PRAGMA table_info(wehago_raw_erp_trace_candidate_cache)").fetchall()
}
if "candidate_key" not in columns:
conn.execute(
"ALTER TABLE wehago_raw_erp_trace_candidate_cache ADD COLUMN candidate_key TEXT NOT NULL DEFAULT ''"
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_wehago_raw_trace_candidate_source
ON wehago_raw_erp_trace_candidate_cache(
fiscal_year, source_mode, source_signature, logic_version, status_key, group_index
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_wehago_raw_trace_candidate_voucher
ON wehago_raw_erp_trace_candidate_cache(fiscal_year, ledger_date, voucher_no)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_wehago_raw_trace_candidate_score
ON wehago_raw_erp_trace_candidate_cache(fiscal_year, score DESC)
"""
)
conn.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_wehago_raw_trace_candidate_key
ON wehago_raw_erp_trace_candidate_cache(candidate_key)
WHERE candidate_key <> ''
"""
)
conn.commit()
def _source_signature(conn: sqlite3.Connection, year: int, source_mode: str) -> str:
if source_mode == "current":
yearly_signature = current_ready_export_signature(conn, year)
return projection_signature({year: yearly_signature}, year, year, allow_stale=False)
if source_mode == "stale-diagnostic":
yearly_signature = latest_export_signature(conn, year, allow_stale=True)
return projection_signature({year: yearly_signature}, year, year, allow_stale=True)
raise ValueError(f"Unknown source mode: {source_mode}")
def _ensure_projection(conn: sqlite3.Connection, year: int, source_mode: str) -> str:
signature = _source_signature(conn, year, source_mode)
exists = conn.execute(
"""
SELECT 1
FROM wehago_compare_query_groups
WHERE start_year = ?
AND end_year = ?
AND signature = ?
LIMIT 1
""",
(year, year, signature),
).fetchone()
if exists is not None:
return signature
project_range(conn, year, year, allow_stale=(source_mode == "stale-diagnostic"))
return signature
def _entry_amount_index(conn: sqlite3.Connection, year: int) -> dict[float, list[dict[str, Any]]]:
rows = conn.execute(
"""
SELECT fiscal_year, proof_date, confirmed_no, draft_no, account_code, account_name,
debit_supply, debit_tax, credit_supply, credit_tax,
support_dept_name, cost_dept_name, desc1, desc2, vendor_name, management_item
FROM wehago_voucher_rows
WHERE fiscal_year = ?
""",
(year,),
)
indexed: dict[float, list[dict[str, Any]]] = defaultdict(list)
fields = (
("debit_supply", "debit", False),
("credit_supply", "credit", False),
("debit_tax", "debit", True),
("credit_tax", "credit", True),
)
for row in rows:
raw = dict(row)
for field, side, tax_evidence in fields:
amount = parse_amount(raw.get(field))
if abs(amount) < 0.5:
continue
entry = dict(raw)
entry["_raw_amount_field"] = field
entry["_raw_amount_side"] = side
entry["_raw_amount_value"] = amount
entry["_raw_tax_evidence"] = tax_evidence
entry["_raw_entry_dates"] = tuple(_raw_erp_entry_date_values(entry))
indexed[round(abs(amount), 2)].append(entry)
return dict(indexed)
def _group_filters(args: argparse.Namespace) -> tuple[str, list[Any]]:
filters: list[str] = []
params: list[Any] = []
if args.voucher_no:
placeholders = ",".join("?" for _ in args.voucher_no)
filters.append(f"g.voucher_no IN ({placeholders})")
params.extend(args.voucher_no)
if args.ledger_date:
placeholders = ",".join("?" for _ in args.ledger_date)
filters.append(f"g.ledger_date IN ({placeholders})")
params.extend(args.ledger_date)
if not filters:
return "", []
return " AND " + " AND ".join(filters), params
def _load_source_groups(
conn: sqlite3.Connection,
year: int,
signature: str,
args: argparse.Namespace,
) -> list[dict[str, Any]]:
filter_sql, filter_params = _group_filters(args)
limit_sql = " LIMIT ?" if args.limit_groups else ""
offset_sql = " OFFSET ?" if args.limit_groups and args.group_offset else ""
params: list[Any] = [year, year, signature, *SOURCE_STATUSES, *filter_params]
if args.limit_groups:
params.append(int(args.limit_groups))
if args.group_offset:
params.append(int(args.group_offset))
groups = conn.execute(
f"""
SELECT g.status_key, g.group_index, g.fiscal_year, g.ledger_date, g.voucher_no, g.draft_no
FROM wehago_compare_query_groups AS g
WHERE g.start_year = ?
AND g.end_year = ?
AND g.signature = ?
AND g.status_key IN ({','.join('?' for _ in SOURCE_STATUSES)})
{filter_sql}
ORDER BY g.status_key ASC, g.group_index ASC
{limit_sql}
{offset_sql}
""",
params,
).fetchall()
if not groups:
return []
result: list[dict[str, Any]] = []
for group_row in groups:
row_items = 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 ASC
""",
(year, year, signature, group_row["status_key"], group_row["group_index"]),
).fetchall()
rows = []
for row in row_items:
data = dict(row)
data.setdefault("group_voucher_no", group_row["voucher_no"])
data.setdefault("group_draft_no", group_row["draft_no"])
if not clean(data.get("voucher_no")):
data["voucher_no"] = group_row["voucher_no"]
if not clean(data.get("draft_no")):
data["draft_no"] = group_row["draft_no"]
rows.append(data)
result.append({"group": dict(group_row), "rows": rows})
return result
def _ledger_amount(row: dict[str, Any]) -> float:
return round(max(abs(parse_amount(row.get("ledger_debit"))), abs(parse_amount(row.get("ledger_credit")))), 2)
def _entries_in_date_window(
ledger_row: dict[str, Any],
entries: list[dict[str, Any]],
window_days: int,
) -> list[dict[str, Any]]:
if window_days < 0:
return entries
ledger_dates = [
value
for value in (
_parse_row_date_with_year(ledger_row, "ledger_date"),
_parse_row_date_with_year(ledger_row, "proof_date"),
)
if value
]
if not ledger_dates:
return entries
filtered: list[dict[str, Any]] = []
for entry in entries:
entry_dates = entry.get("_raw_entry_dates") or ()
if any(abs((ledger_date - entry_date).days) <= window_days for ledger_date in ledger_dates for entry_date in entry_dates):
filtered.append(entry)
return filtered
def _matched_case(score: float, candidate: dict[str, Any]) -> str:
existing = clean(candidate.get("matched_case"))
if existing:
return existing
if score >= 105:
return "RAW_ERP_HIGH_CONFIDENCE_TRACE_CANDIDATE"
return "RAW_ERP_SOURCE_TRACE_CANDIDATE"
def _candidate_identity(
year: int,
source_mode: str,
source_signature: str,
logic_version: str,
status_key: str,
group_index: int,
row_index: int,
candidate: dict[str, Any],
) -> str:
raw = "|".join(
clean(part)
for part in (
year,
source_mode,
source_signature,
logic_version,
status_key,
group_index,
row_index,
candidate.get("draft_no"),
candidate.get("voucher_confirmed_no"),
candidate.get("voucher_account_name"),
candidate.get("voucher_debit"),
candidate.get("voucher_credit"),
)
)
return hashlib.sha1(raw.encode("utf-8")).hexdigest()
def _insert_candidates(
conn: sqlite3.Connection,
year: int,
source_mode: str,
source_signature: str,
rows: list[dict[str, Any]],
) -> None:
conn.executemany(
"""
INSERT OR REPLACE INTO wehago_raw_erp_trace_candidate_cache (
candidate_key, fiscal_year, source_mode, source_signature, logic_version, status_key,
group_index, row_index, ledger_date, voucher_no,
ledger_account_name, ledger_vendor, ledger_desc, ledger_amount,
erp_draft_no, erp_confirmed_no, erp_account_name, erp_vendor, erp_desc,
erp_amount, amount_field, score, matched_case, review_reason, candidate_json
)
VALUES (
:candidate_key, :fiscal_year, :source_mode, :source_signature, :logic_version, :status_key,
:group_index, :row_index, :ledger_date, :voucher_no,
:ledger_account_name, :ledger_vendor, :ledger_desc, :ledger_amount,
:erp_draft_no, :erp_confirmed_no, :erp_account_name, :erp_vendor, :erp_desc,
:erp_amount, :amount_field, :score, :matched_case, :review_reason, :candidate_json
)
""",
rows,
)
conn.commit()
def build_candidates(args: argparse.Namespace) -> dict[str, Any]:
conn = _connect(args.db)
try:
_ensure_schema(conn)
source_signature = _ensure_projection(conn, args.year, args.source)
if args.reset:
conn.execute(
"""
DELETE FROM wehago_raw_erp_trace_candidate_cache
WHERE fiscal_year = ?
AND source_mode = ?
AND source_signature = ?
AND logic_version = ?
""",
(args.year, args.source, source_signature, TRACE_LOGIC_VERSION),
)
conn.commit()
amount_index = _entry_amount_index(conn, args.year)
source_groups = _load_source_groups(conn, args.year, source_signature, args)
candidates: list[dict[str, Any]] = []
scored_rows = 0
skipped_common_amounts = 0
for source_group in source_groups:
group_meta = source_group["group"]
status_key = clean(group_meta.get("status_key"))
group_index = int(group_meta.get("group_index") or 0)
for ledger_row in source_group["rows"]:
amount = _ledger_amount(ledger_row)
if amount <= 0:
continue
entries = list(amount_index.get(amount, []) or [])
if not entries:
continue
if len(entries) > args.date_prefilter_threshold:
date_entries = _entries_in_date_window(ledger_row, entries, args.date_window_days)
if date_entries:
entries = date_entries
if len(entries) > args.prefilter_threshold:
entries = [entry for entry in entries if _raw_erp_trace_prefilter(ledger_row, entry)]
skipped_common_amounts += 1
if len(entries) > args.max_candidates_per_row:
ranked = [
(_raw_erp_trace_prefilter_score(ledger_row, entry), entry)
for entry in entries
]
ranked = [item for item in ranked if item[0] > 0]
ranked.sort(key=lambda item: item[0], reverse=True)
entries = [entry for _score, entry in ranked[: args.max_candidates_per_row]]
row_candidates: list[tuple[float, dict[str, Any], dict[str, Any]]] = []
for entry in entries:
score, candidate = _raw_erp_trace_score(ledger_row, entry)
scored_rows += 1
if score < args.min_score:
continue
row_candidates.append((score, entry, candidate))
row_candidates.sort(key=lambda item: item[0], reverse=True)
for score, entry, candidate in row_candidates[: args.top_per_row]:
row_index = int(ledger_row.get("row_index") or 0)
matched_case = _matched_case(score, candidate)
payload = {
"candidate_id": _candidate_identity(
args.year,
args.source,
source_signature,
TRACE_LOGIC_VERSION,
status_key,
group_index,
row_index,
candidate,
),
"ledger_row": ledger_row,
"erp_entry": entry,
"candidate_row": candidate,
}
candidates.append(
{
"candidate_key": payload["candidate_id"],
"fiscal_year": args.year,
"source_mode": args.source,
"source_signature": source_signature,
"logic_version": TRACE_LOGIC_VERSION,
"status_key": status_key,
"group_index": group_index,
"row_index": row_index,
"ledger_date": clean(ledger_row.get("ledger_date")),
"voucher_no": clean(ledger_row.get("voucher_no") or group_meta.get("voucher_no")),
"ledger_account_name": clean(ledger_row.get("ledger_account_name")),
"ledger_vendor": clean(ledger_row.get("ledger_vendor")),
"ledger_desc": clean(ledger_row.get("ledger_desc")),
"ledger_amount": amount,
"erp_draft_no": clean(candidate.get("draft_no")),
"erp_confirmed_no": clean(candidate.get("voucher_confirmed_no")),
"erp_account_name": clean(candidate.get("voucher_account_name")),
"erp_vendor": clean(candidate.get("voucher_vendor")),
"erp_desc": clean(candidate.get("voucher_desc")),
"erp_amount": max(
abs(parse_amount(candidate.get("voucher_debit"))),
abs(parse_amount(candidate.get("voucher_credit"))),
),
"amount_field": clean(entry.get("_raw_amount_field")),
"score": float(score),
"matched_case": matched_case,
"review_reason": matched_case,
"candidate_json": json.dumps(payload, ensure_ascii=False, default=str),
}
)
if candidates:
_insert_candidates(conn, args.year, args.source, source_signature, candidates)
return {
"year": args.year,
"source_mode": args.source,
"source_signature": source_signature,
"group_offset": args.group_offset,
"limit_groups": args.limit_groups,
"groups": len(source_groups),
"amount_buckets": len(amount_index),
"scored_rows": scored_rows,
"common_amount_prefilters": skipped_common_amounts,
"inserted_candidates": len(candidates),
}
finally:
conn.close()
def main() -> None:
parser = argparse.ArgumentParser(description="Build bounded raw ERP trace candidate cache for voucher compare review.")
parser.add_argument("--year", type=int, required=True)
parser.add_argument(
"--source",
choices=("current", "stale-diagnostic"),
default="current",
help="current는 현재 로직 ready projection만 사용합니다. stale-diagnostic은 진단용 후보 산출에만 사용하세요.",
)
parser.add_argument("--db", type=Path, default=DB_PATH)
parser.add_argument("--reset", action="store_true")
parser.add_argument("--limit-groups", type=int, default=0)
parser.add_argument("--group-offset", type=int, default=0)
parser.add_argument("--ledger-date", action="append", default=[])
parser.add_argument("--voucher-no", action="append", default=[])
parser.add_argument("--min-score", type=float, default=70)
parser.add_argument("--top-per-row", type=int, default=5)
parser.add_argument("--max-candidates-per-row", type=int, default=30)
parser.add_argument("--date-prefilter-threshold", type=int, default=30)
parser.add_argument("--date-window-days", type=int, default=62)
parser.add_argument("--prefilter-threshold", type=int, default=30)
args = parser.parse_args()
print(json.dumps(build_candidates(args), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
import argparse
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from main import engine, hash_password, init_db # noqa: E402
from sqlalchemy import text # noqa: E402
def main() -> int:
parser = argparse.ArgumentParser(description="Create or update an intranet app user.")
parser.add_argument("username")
parser.add_argument("--password", required=True)
parser.add_argument("--display-name", default="")
parser.add_argument("--role", choices=["admin", "viewer"], default="viewer")
parser.add_argument("--inactive", action="store_true")
args = parser.parse_args()
init_db()
with engine.begin() as conn:
conn.execute(
text(
"""
INSERT INTO app_users (
username, password_hash, display_name, is_active, is_admin, created_at, updated_at
) VALUES (
:username, :password_hash, :display_name, :is_active, :is_admin,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
ON CONFLICT(username) DO UPDATE SET
password_hash = excluded.password_hash,
display_name = excluded.display_name,
is_active = excluded.is_active,
is_admin = excluded.is_admin,
updated_at = CURRENT_TIMESTAMP
"""
),
{
"username": args.username,
"password_hash": hash_password(args.password),
"display_name": args.display_name or args.username,
"is_active": 0 if args.inactive else 1,
"is_admin": 1 if args.role == "admin" else 0,
},
)
user_id = conn.execute(
text("SELECT id FROM app_users WHERE username = :username"),
{"username": args.username},
).scalar_one()
role_id = conn.execute(
text("SELECT id FROM app_roles WHERE role_key = :role_key"),
{"role_key": args.role},
).scalar_one()
conn.execute(text("DELETE FROM app_user_roles WHERE user_id = :user_id"), {"user_id": user_id})
conn.execute(
text(
"""
INSERT OR IGNORE INTO app_user_roles (user_id, role_id, created_at)
VALUES (:user_id, :role_id, CURRENT_TIMESTAMP)
"""
),
{"user_id": user_id, "role_id": role_id},
)
print(f"ok: {args.username} ({args.role})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+397
View File
@@ -0,0 +1,397 @@
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from xml.sax.saxutils import escape
import math
import struct
import zlib
import zipfile
BASE_DIR = Path(__file__).resolve().parent.parent
REPORT_DIR = BASE_DIR / "reports"
OUTPUT_PATH = REPORT_DIR / "my-intranet-app_architecture_report_20260522_v3.docx"
PAGE_WIDTH = 11906
MARGIN = 620
CONTENT_WIDTH = PAGE_WIDTH - (MARGIN * 2)
def esc(value: object) -> str:
return escape(str(value), {'"': "&quot;"})
def run(text: str, *, bold: bool = False, size: int | None = None, font: str | None = None) -> str:
props: list[str] = []
if bold:
props.append("<w:b/>")
if size:
props.append(f'<w:sz w:val="{size}"/><w:szCs w:val="{size}"/>')
if font:
props.append(f'<w:rFonts w:ascii="{font}" w:hAnsi="{font}" w:eastAsia="{font}" w:cs="{font}"/>')
rpr = f"<w:rPr>{''.join(props)}</w:rPr>" if props else ""
preserve = ' xml:space="preserve"' if text[:1].isspace() or text[-1:].isspace() else ""
return f"<w:r>{rpr}<w:t{preserve}>{esc(text)}</w:t></w:r>"
def para(text: str = "", *, style: str | None = None, bold: bool = False, size: int | None = None, font: str | None = None, after: int | None = None) -> str:
pprops: list[str] = []
if style:
pprops.append(f'<w:pStyle w:val="{style}"/>')
if after is not None:
pprops.append(f'<w:spacing w:after="{after}"/>')
ppr = f"<w:pPr>{''.join(pprops)}</w:pPr>" if pprops else ""
return f"<w:p>{ppr}{run(text, bold=bold, size=size, font=font)}</w:p>"
def bullet(text: str) -> str:
return para("" + text, after=60)
def table(headers: list[str], rows: list[list[str]], widths: list[int] | None = None) -> str:
if widths is None:
widths = [CONTENT_WIDTH // len(headers)] * len(headers)
grid = "".join(f'<w:gridCol w:w="{w}"/>' for w in widths)
def cell(text: str, width: int, header: bool = False) -> str:
fill = '<w:shd w:fill="E8EEF7"/>' if header else ""
props = (
f'<w:tcPr><w:tcW w:w="{width}" w:type="dxa"/>{fill}'
'<w:tcMar><w:top w:w="30" w:type="dxa"/><w:left w:w="45" w:type="dxa"/>'
'<w:bottom w:w="30" w:type="dxa"/><w:right w:w="45" w:type="dxa"/></w:tcMar></w:tcPr>'
)
return f"<w:tc>{props}{para(text, bold=header, size=16, after=0)}</w:tc>"
rows_xml = ["<w:tr>" + "".join(cell(h, widths[i], True) for i, h in enumerate(headers)) + "</w:tr>"]
rows_xml.extend(
"<w:tr>" + "".join(cell(c, widths[i]) for i, c in enumerate(row)) + "</w:tr>"
for row in rows
)
return (
'<w:tbl><w:tblPr><w:tblStyle w:val="TableGrid"/>'
f'<w:tblW w:w="{CONTENT_WIDTH}" w:type="dxa"/><w:tblLayout w:type="fixed"/>'
"</w:tblPr>"
f"<w:tblGrid>{grid}</w:tblGrid>{''.join(rows_xml)}</w:tbl>"
)
class PngCanvas:
def __init__(self, width: int, height: int, bg: tuple[int, int, int] = (255, 255, 255)):
self.width = width
self.height = height
self.px = bytearray(bg * width * height)
def set(self, x: int, y: int, color: tuple[int, int, int]) -> None:
if 0 <= x < self.width and 0 <= y < self.height:
i = (y * self.width + x) * 3
self.px[i : i + 3] = bytes(color)
def rect(self, x: int, y: int, w: int, h: int, fill: tuple[int, int, int], border: tuple[int, int, int], bw: int = 3) -> None:
for yy in range(y, y + h):
for xx in range(x, x + w):
if x <= xx < x + w and y <= yy < y + h:
self.set(xx, yy, fill)
for n in range(bw):
self.line(x + n, y + n, x + w - 1 - n, y + n, border)
self.line(x + n, y + h - 1 - n, x + w - 1 - n, y + h - 1 - n, border)
self.line(x + n, y + n, x + n, y + h - 1 - n, border)
self.line(x + w - 1 - n, y + n, x + w - 1 - n, y + h - 1 - n, border)
def line(self, x1: int, y1: int, x2: int, y2: int, color: tuple[int, int, int], width: int = 3) -> None:
dx = abs(x2 - x1)
dy = -abs(y2 - y1)
sx = 1 if x1 < x2 else -1
sy = 1 if y1 < y2 else -1
err = dx + dy
x, y = x1, y1
while True:
r = width // 2
for yy in range(y - r, y + r + 1):
for xx in range(x - r, x + r + 1):
self.set(xx, yy, color)
if x == x2 and y == y2:
break
e2 = 2 * err
if e2 >= dy:
err += dy
x += sx
if e2 <= dx:
err += dx
y += sy
def arrow(self, x1: int, y1: int, x2: int, y2: int, color: tuple[int, int, int] = (75, 88, 99)) -> None:
self.line(x1, y1, x2, y2, color, 4)
ang = math.atan2(y2 - y1, x2 - x1)
for a in (ang + 2.55, ang - 2.55):
self.line(x2, y2, int(x2 + 22 * math.cos(a)), int(y2 + 22 * math.sin(a)), color, 4)
def digit(self, x: int, y: int, digit: str, color: tuple[int, int, int] = (20, 39, 54), scale: int = 8) -> None:
glyphs = {
"0": ["111", "101", "101", "101", "111"],
"1": ["010", "110", "010", "010", "111"],
"2": ["111", "001", "111", "100", "111"],
"3": ["111", "001", "111", "001", "111"],
"4": ["101", "101", "111", "001", "001"],
"5": ["111", "100", "111", "001", "111"],
"6": ["111", "100", "111", "101", "111"],
"7": ["111", "001", "010", "010", "010"],
"8": ["111", "101", "111", "101", "111"],
"9": ["111", "101", "111", "001", "111"],
}[digit]
for gy, row in enumerate(glyphs):
for gx, v in enumerate(row):
if v == "1":
self.rect(x + gx * scale, y + gy * scale, scale - 1, scale - 1, color, color, 1)
def number_badge(self, x: int, y: int, n: int) -> None:
self.rect(x - 28, y - 28, 56, 56, (255, 255, 255), (47, 111, 163), 4)
self.digit(x - 12, y - 18, str(n), scale=8)
def png(self) -> bytes:
rows = bytearray()
stride = self.width * 3
for y in range(self.height):
rows.append(0)
rows.extend(self.px[y * stride : (y + 1) * stride])
def chunk(kind: bytes, data: bytes) -> bytes:
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
return (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", self.width, self.height, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(bytes(rows), 9))
+ chunk(b"IEND", b"")
)
def architecture_png() -> bytes:
c = PngCanvas(1200, 640, (252, 254, 255))
blue, green, gold, purple, gray = (232, 242, 252), (237, 248, 237), (255, 248, 230), (246, 239, 250), (78, 91, 104)
c.rect(55, 260, 170, 90, blue, (47, 111, 163)); c.number_badge(140, 305, 1)
c.rect(310, 215, 245, 180, green, (63, 143, 77)); c.number_badge(432, 305, 2)
c.rect(650, 45, 230, 90, gold, (176, 122, 26)); c.number_badge(765, 90, 3)
c.rect(650, 185, 230, 105, blue, (47, 111, 163)); c.number_badge(765, 238, 4)
c.rect(650, 340, 230, 105, purple, (127, 85, 160)); c.number_badge(765, 393, 5)
c.rect(650, 500, 230, 90, gold, (176, 122, 26)); c.number_badge(765, 545, 6)
c.rect(970, 230, 175, 145, green, (63, 143, 77)); c.number_badge(1058, 303, 7)
c.arrow(225, 305, 310, 305, gray); c.arrow(555, 270, 650, 92, gray); c.arrow(555, 305, 650, 238, gray)
c.arrow(555, 335, 650, 393, gray); c.arrow(555, 375, 650, 545, gray); c.arrow(880, 238, 970, 285, gray); c.arrow(880, 393, 970, 325, gray)
return c.png()
def flow_png() -> bytes:
c = PngCanvas(1200, 640, (255, 255, 255))
colors = [(232, 242, 252), (237, 248, 237), (255, 248, 230), (246, 239, 250), (238, 242, 246)]
border = [(47, 111, 163), (63, 143, 77), (176, 122, 26), (127, 85, 160), (90, 105, 120)]
boxes = [(60, 90, 190, 105), (330, 90, 190, 105), (600, 90, 190, 105), (870, 90, 190, 105),
(330, 350, 190, 105), (600, 350, 190, 105), (870, 350, 190, 105)]
for i, (x, y, w, h) in enumerate(boxes, start=1):
c.rect(x, y, w, h, colors[(i - 1) % len(colors)], border[(i - 1) % len(border)])
c.number_badge(x + w // 2, y + h // 2, i)
gray = (78, 91, 104)
c.arrow(250, 142, 330, 142, gray); c.arrow(520, 142, 600, 142, gray); c.arrow(790, 142, 870, 142, gray)
c.arrow(965, 195, 965, 350, gray); c.arrow(870, 402, 790, 402, gray); c.arrow(600, 402, 520, 402, gray)
c.arrow(425, 350, 425, 195, gray)
return c.png()
def image_paragraph(rel_id: str, width_emu: int = 6_950_000, height_emu: int = 3_700_000) -> str:
return f"""
<w:p><w:pPr><w:spacing w:after="100"/></w:pPr><w:r><w:drawing>
<wp:inline distT="0" distB="0" distL="0" distR="0" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing">
<wp:extent cx="{width_emu}" cy="{height_emu}"/><wp:docPr id="{rel_id[3:]}" name="{rel_id}.png"/>
<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
<pic:nvPicPr><pic:cNvPr id="0" name="{rel_id}.png"/><pic:cNvPicPr/></pic:nvPicPr>
<pic:blipFill><a:blip r:embed="{rel_id}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>
<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="{width_emu}" cy="{height_emu}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>
</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>
"""
def body_xml() -> str:
now = datetime.now().strftime("%Y-%m-%d %H:%M")
p: list[str] = []
p.append(para("my-intranet-app 아키텍처 분석 보고서", style="Title"))
p.append(para(f"작성일: {now} / 기준 경로: {BASE_DIR}", style="Subtitle"))
p.append(para("본 보고서의 주 목적은 현재 my-intranet-app의 아키텍처, 데이터 흐름, 주요 결합도와 개선 포인트를 분석하는 것입니다. WSL 제로 세팅 시 보존 범위는 마지막 운영 고려사항으로 덧붙였습니다."))
p.append(para("1. 분석 결론", style="Heading1"))
p.append(bullet("현재 시스템은 FastAPI 단일 애플리케이션 안에 화면 렌더링, JSON API, SQLite 접근, 캐시, 백그라운드 작업, 외부 ERP/WEHAGO 연동이 결합된 내부 업무 앱입니다."))
p.append(bullet("업무 데이터의 중심은 SQLite data.db이며, 프로젝트/회계/전표비교/캐시/작업 이력이 같은 DB에 함께 저장됩니다."))
p.append(bullet("구조 안정성 측면의 가장 큰 리스크는 main.py의 과도한 책임 집중과 대용량 SQLite 파일에 운영 데이터와 캐시가 공존하는 점입니다."))
p.append(bullet("속도는 런타임 캐시, system_page_cache, WEHAGO query/export projection, background job으로 보완하고 있으나, 장기적으로는 DB 슬림화와 모듈 분리가 필요합니다."))
p.append(para("2. 아키텍처 개요 이미지", style="Heading1"))
p.append(image_paragraph("rId10"))
p.append(table(
["번호", "구성요소", "설명"],
[
["1", "Browser/UI", "Jinja2로 내려받은 HTML과 브라우저 fetch API가 화면 갱신을 담당"],
["2", "FastAPI Runtime(main.py)", "라우트, HTML 렌더링, JSON API, DB 초기화, 캐시, 작업 큐 진입점"],
["3", "Templates", "dashboard/projects/process_cost/wehago_compare 등 서버 렌더링 화면"],
["4", "SQLite data.db", "업무 데이터, 설정, 캐시, 작업 상태의 중심 저장소"],
["5", "WEHAGO Compare", "전표 비교 전문 로직. 파일 정규화, 매칭, 리뷰, 추천, export"],
["6", "Hanmac External", "pymysql 기반 외부 ERP DB 조회 및 집계"],
["7", "Workers/Jobs", "캐시 재생성, snapshot, export, 유지보수 작업"],
],
[750, 2500, CONTENT_WIDTH - 3250],
))
p.append(para("3. 런타임/기술 스택", style="Heading1"))
p.append(table(
["영역", "현재 구성", "역할/관찰"],
[
["Web", "FastAPI, Uvicorn", "ASGI 기반 단일 서버. HTML과 JSON API를 같은 앱에서 제공"],
["UI", "Jinja2, CSS, Vanilla JS", "템플릿별 inline CSS/JS가 많고 fetch 기반 동적 로딩을 사용"],
["DB", "SQLite, SQLAlchemy", "로컬 data.db를 중심으로 업무/캐시/작업 데이터 저장"],
["Excel", "openpyxl", "업로드 파일 파싱, WEHAGO 상태별 xlsx 내보내기"],
["External DB", "pymysql", "Hanmac 외부 MySQL 접속/preview/aggregate"],
["DB Browser", "Datasette", "/db 및 /db-browser에서 내부 DB 조회 지원"],
],
[1400, 2600, CONTENT_WIDTH - 4000],
))
p.append(para("4. 코드 구조 분석", style="Heading1"))
p.append(table(
["파일/디렉터리", "아키텍처상 책임", "개선 관점"],
[
["main.py", "FastAPI app 생성, 라우트, DB 초기화, 화면별 bootstrap, 저장 API, system job, Hanmac 연동", "routers/services/repositories/jobs로 단계 분리 필요"],
["wehago_compare.py", "WEHAGO/ERP 전표 비교 도메인. 매칭, 리뷰, 추천, projection/cache/export", "비교 도메인으로 분리된 점은 좋으나 내부 함수가 매우 크고 캐시 책임도 함께 큼"],
["templates/*.html", "서버 렌더링 화면과 화면별 대형 JS/CSS", "공통 fetch/job polling/table rendering을 static 모듈로 분리 가능"],
["scripts/", "서버 실행, WEHAGO 수집/검증/보정, Windows portproxy", "운영 자동화와 일회성 보정 스크립트 구분 필요"],
["data.db", "업무 데이터와 캐시/작업 이력 저장", "운영 데이터와 재생성 캐시 분리 또는 보존 정책 필요"],
["backups/", "수동/시점 백업", "복구 가치 기준으로 최신/중요 백업만 관리 권장"],
],
[2000, 4300, CONTENT_WIDTH - 6300],
))
p.append(para("5. 주요 화면/API 경계", style="Heading1"))
p.append(table(
["화면/도메인", "대표 라우트", "핵심 데이터 흐름"],
[
["Dashboard", "/, /bootstrap-data, /dashboard/api/rebuild-cache", "transactions/project 집계 -> bootstrap/cache -> 차트/KPI"],
["Projects", "/projects, /projects/bootstrap-data, /projects/save-json", "project_* 조회/저장 -> 미계약/관련 프로젝트/비교 상세 API"],
["Process Cost", "/process-cost, /process-cost/bootstrap-data", "Hanmac/WEHAGO 소스 선택 -> 프로젝트별 수익/비용/진척/비율 계산"],
["Annual Summary", "/annual-summary, /annual-summary/bootstrap-data", "연도/월별 회계 집계 -> 차트 데이터"],
["WEHAGO Compare", "/wehago-compare/api/*", "원천 rows -> 비교 결과 -> 상태별 상세 -> 리뷰/매칭/export"],
["Hanmac Browser", "/hanmac-browser/api/*", "외부 MySQL 조회 -> preview/aggregate cache -> CSV export"],
["System Jobs", "/api/system-jobs/*", "무거운 cache rebuild/export 작업 생성 및 진행률 조회"],
],
[1900, 3300, CONTENT_WIDTH - 5200],
))
p.append(para("6. 데이터 흐름 이미지", style="Heading1"))
p.append(image_paragraph("rId11"))
p.append(table(
["번호", "흐름 단계", "설명"],
[
["1", "원천 데이터", "Excel 업로드, WEHAGO_DB 파일, Hanmac 외부 DB, 사용자 입력"],
["2", "수집/정규화", "main.py와 wehago_compare.py에서 날짜/금액/전표번호/프로젝트코드 정규화"],
["3", "영속 저장", "transactions, project_*, wehago_* 테이블에 저장"],
["4", "집계/비교 계산", "프로젝트 원가, 연도 집계, 전표 매칭, 상태별 metric 계산"],
["5", "캐시/작업", "system_page_cache, wehago query cache, background jobs로 무거운 조회 완화"],
["6", "API 응답", "bootstrap-data 및 상세 JSON API로 화면에 전달"],
["7", "화면 표시/export", "Jinja2 화면, fetch 갱신, xlsx/csv 다운로드"],
],
[750, 2200, CONTENT_WIDTH - 2950],
))
p.append(para("7. 데이터 아키텍처", style="Heading1"))
p.append(table(
["테이블 그룹", "대표 테이블", "아키텍처 의미"],
[
["업무 원장", "transactions", "회계 전표/거래 행의 중심 원천"],
["프로젝트", "project_basic_info, project_status, project_contract_info, project_billing_entries, project_collection_entries", "프로젝트 기본/계약/청구/수금/상태"],
["프로젝트 분석", "project_exec_budget_entries, project_actual_input_entries, project_task_plan_entries, project_analysis_settings", "원가/투입/계획/분석 설정"],
["WEHAGO 비교 원천", "wehago_source_files, wehago_voucher_rows, wehago_ledger_rows", "ERP 전표와 WEHAGO 원장 정규화 데이터"],
["WEHAGO 비교 결과", "wehago_comparison_results, wehago_recheck_reviews, wehago_manual_pair_matches", "비교 결과와 사용자가 만든 검토/매칭 상태"],
["캐시/작업", "system_page_cache, system_jobs, wehago_*_cache, hanmac_*_cache", "속도 보완용. 일부는 재생성 가능"],
["설정/운영", "app_option_items, app_keyword_rules, hanmac_holidays, db_backup_history", "분류 규칙, 옵션, 휴일, 백업 이력"],
],
[1800, 4300, CONTENT_WIDTH - 6100],
))
p.append(para("8. 구조 안정성/속도 개선 포인트", style="Heading1"))
p.append(table(
["개선 영역", "현재 리스크", "권장 방향"],
[
["main.py 책임 분리", "라우트/DB/worker/비즈니스 로직 집중", "도메인별 router, service, repository, job 모듈로 점진 분리"],
["DB 관리", "14GB 수준 SQLite에 운영 데이터와 캐시 공존", "캐시 보존 정책, VACUUM/ANALYZE, cache DB 분리 검토"],
["WEHAGO 비교", "projection/cache가 많고 상태별 경로가 복잡", "상태별 query path 정리, 캐시 키 문서화, 재계산 CLI 표준화"],
["Frontend", "템플릿별 inline JS/CSS가 큼", "공통 fetch/polling/render 유틸을 static JS/CSS로 이동"],
["작업 큐", "DB 테이블 기반 작업 상태와 런타임 worker 결합", "작업 타입/상태 전이 규칙 문서화 및 stale job 정리 강화"],
["테스트", "구조 변경 후 회귀 확인 경로 부족", "핵심 bootstrap API와 저장 API smoke test 추가"],
],
[1800, 3300, CONTENT_WIDTH - 5100],
))
p.append(para("9. WSL 제로 세팅 시 보존 범위(부가 운영 고려사항)", style="Heading1"))
p.append(para("이 절은 이관 방법 보고서가 아니라, 현재 아키텍처를 보존 가능한 상태로 유지하려면 어떤 정보를 어느 수준까지 관리해야 하는지에 대한 부가 판단입니다."))
p.append(table(
["대상", "보존 수준", "이유"],
[
["코드", "필수", "main.py, wehago_compare.py, templates, scripts, requirements는 앱 동작의 본체"],
["SQLite DB", "필수", "업무 데이터와 사용자 검토/매칭/설정이 data.db에 존재. 코드만으로 복구 불가"],
["WAL/SHM", "조건부 필수", "실행 중 복사라면 data.db-wal 변경분 누락 위험. 서버 중지 또는 checkpoint/backup 필요"],
["DB dump", "필수 또는 강력 권장", "새 환경 복원 검증용. 현재 dump.sql은 0 bytes라 유효하지 않음"],
["원천 Excel/WEHAGO_DB", "강력 권장", "재검증/재처리/비교 로직 개선 시 기준 자료"],
["사용자 검토/수동매칭", "필수", "wehago_recheck_reviews, wehago_manual_pair_matches 등은 재생성 어려움"],
["캐시 테이블", "선택", "속도에는 도움되지만 구조 개선 후 재생성 가능. DB 슬림화 대상"],
["backups", "선별", "최신 정상본과 구조 변경 직전본 위주로 보존"],
[".venv/__pycache__", "불필요", "새 WSL에서 재생성"],
],
[2200, 1600, CONTENT_WIDTH - 3800],
))
p.append(para("10. 최종 권고", style="Heading1"))
p.append(bullet("아키텍처 개선의 1순위는 기능 추가보다 책임 분리와 DB/캐시 관리 기준 정립입니다."))
p.append(bullet("WSL 제로 세팅을 하더라도 목표는 '코드 이관'이 아니라 '동일 업무 상태를 복원 가능한 형태로 보존'하는 것입니다."))
p.append(bullet("구조 개선 시작 전에는 유효한 SQLite 백업/dump를 새로 만들고, 원천파일과 사용자 검토 데이터의 보존 여부를 반드시 확인해야 합니다."))
p.append(para("Appendix. 관찰된 현재 상태", style="Heading1"))
p.append(bullet("data.db 약 14GB, data.db-wal 약 183MB, dump.sql 0 bytes 상태를 확인했습니다."))
p.append(bullet("현재 git working tree에는 기존 수정 파일과 미추적 파일이 존재합니다. 구조 변경 전 기준점을 별도로 고정하는 것이 좋습니다."))
p.append(bullet("검토 파일: requirements.txt, main.py, wehago_compare.py, templates/*.html, data.db sqlite_master schema."))
sect = f'<w:sectPr><w:pgSz w:w="{PAGE_WIDTH}" w:h="16838"/><w:pgMar w:top="720" w:right="{MARGIN}" w:bottom="720" w:left="{MARGIN}" w:header="360" w:footer="360" w:gutter="0"/></w:sectPr>'
return "".join(p) + sect
def styles_xml() -> str:
return """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/><w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial" w:eastAsia="Malgun Gothic"/><w:sz w:val="20"/></w:rPr><w:pPr><w:spacing w:after="90" w:line="246" w:lineRule="auto"/></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:qFormat/><w:rPr><w:b/><w:sz w:val="34"/></w:rPr><w:pPr><w:spacing w:after="200"/></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="Subtitle"><w:name w:val="Subtitle"/><w:basedOn w:val="Normal"/><w:qFormat/><w:rPr><w:color w:val="5B6770"/><w:sz w:val="18"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:qFormat/><w:rPr><w:b/><w:color w:val="1F4E79"/><w:sz w:val="25"/></w:rPr><w:pPr><w:spacing w:before="250" w:after="100"/><w:outlineLvl w:val="0"/></w:pPr></w:style>
<w:style w:type="table" w:styleId="TableGrid"><w:name w:val="Table Grid"/><w:basedOn w:val="TableNormal"/><w:qFormat/><w:tblPr><w:tblBorders><w:top w:val="single" w:sz="4" w:color="AAB7C4"/><w:left w:val="single" w:sz="4" w:color="AAB7C4"/><w:bottom w:val="single" w:sz="4" w:color="AAB7C4"/><w:right w:val="single" w:sz="4" w:color="AAB7C4"/><w:insideH w:val="single" w:sz="4" w:color="D5DDE6"/><w:insideV w:val="single" w:sz="4" w:color="D5DDE6"/></w:tblBorders></w:tblPr></w:style>
</w:styles>"""
def write_docx() -> None:
created = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
document = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>{body_xml()}</w:body></w:document>"""
files = {
"[Content_Types].xml": """<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>""",
"_rels/.rels": """<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/></Relationships>""",
"word/_rels/document.xml.rels": """<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/><Relationship Id="rId10" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/architecture.png"/><Relationship Id="rId11" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/dataflow.png"/></Relationships>""",
"word/document.xml": document,
"word/styles.xml": styles_xml(),
"word/settings.xml": """<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:zoom w:percent="100"/></w:settings>""",
"word/media/architecture.png": architecture_png(),
"word/media/dataflow.png": flow_png(),
"docProps/core.xml": f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>my-intranet-app 아키텍처 분석 보고서</dc:title><dc:creator>Codex</dc:creator><cp:lastModifiedBy>Codex</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">{created}</dcterms:created><dcterms:modified xsi:type="dcterms:W3CDTF">{created}</dcterms:modified></cp:coreProperties>""",
"docProps/app.xml": """<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>Codex OOXML Generator</Application></Properties>""",
}
REPORT_DIR.mkdir(exist_ok=True)
with zipfile.ZipFile(OUTPUT_PATH, "w", compression=zipfile.ZIP_DEFLATED) as docx:
for name, content in files.items():
docx.writestr(name, content)
if __name__ == "__main__":
write_docx()
print(OUTPUT_PATH)
+6
View File
@@ -0,0 +1,6 @@
$ErrorActionPreference = "Stop"
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
Set-Location -LiteralPath $projectRoot
docker compose -f compose.yaml -f compose.dev.yaml down
+65
View File
@@ -0,0 +1,65 @@
param(
[switch]$Build
)
$ErrorActionPreference = "Stop"
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
Set-Location -LiteralPath $projectRoot
$imageInputFiles = @(
"Dockerfile",
"requirements.txt",
".dockerignore",
"compose.yaml",
"compose.dev.yaml"
)
$fingerprintLines = foreach ($relativePath in $imageInputFiles) {
$filePath = Join-Path $projectRoot $relativePath
if (Test-Path -LiteralPath $filePath) {
"$relativePath=$((Get-FileHash -Algorithm SHA256 -LiteralPath $filePath).Hash)"
} else {
"$relativePath=MISSING"
}
}
$fingerprintText = $fingerprintLines -join "`n"
$hashAlgorithm = [System.Security.Cryptography.SHA256]::Create()
try {
$fingerprintBytes = [System.Text.Encoding]::UTF8.GetBytes($fingerprintText)
$currentFingerprint = ([System.BitConverter]::ToString($hashAlgorithm.ComputeHash($fingerprintBytes))).Replace("-", "").ToLowerInvariant()
} finally {
$hashAlgorithm.Dispose()
}
$stateDir = Join-Path $projectRoot ".dev-state"
$stateFile = Join-Path $stateDir "docker-image-inputs.sha256"
$previousFingerprint = if (Test-Path -LiteralPath $stateFile) {
(Get-Content -LiteralPath $stateFile -Raw).Trim()
} else {
""
}
$inputsChanged = -not $previousFingerprint -or $previousFingerprint -ne $currentFingerprint
$shouldBuild = $Build -or $inputsChanged
$composeArgs = @("-f", "compose.yaml", "-f", "compose.dev.yaml", "up", "-d")
if ($shouldBuild) {
$composeArgs += "--build"
}
if ($Build) {
Write-Host "Image rebuild requested explicitly with -Build."
} elseif ($inputsChanged) {
Write-Host "Docker image inputs changed or were not recorded yet; rebuilding automatically."
} else {
Write-Host "Docker image inputs are unchanged; starting without rebuild."
}
docker compose @composeArgs
New-Item -ItemType Directory -Force -Path $stateDir | Out-Null
Set-Content -LiteralPath $stateFile -Value $currentFingerprint -NoNewline
docker compose -f compose.yaml -f compose.dev.yaml ps
Write-Host ""
Write-Host "Development server: http://127.0.0.1:8010"
Write-Host "Source edits reload automatically."
Write-Host "Dependency and Docker image-setting changes are detected and rebuilt automatically."
Write-Host "Use -Build only when you intentionally want to force a clean image check."
+1 -1
View File
@@ -24,9 +24,9 @@ from wehago_compare import (
rebuild_comparison_results,
upsert_source_file,
)
from runtime_config import DB_PATH
DB_PATH = BASE_DIR / "data.db"
ENGINE = create_engine(f"sqlite:///{DB_PATH}", connect_args={"check_same_thread": False})
LEDGER_FILES = {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,901 @@
from __future__ import annotations
import sqlite3
import sys
import re
from datetime import datetime
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from runtime_config import DB_PATH
from wehago_compare import (
QUERY_PROJECTION_VERSION,
_account_base_names_compatible,
_contained_core_desc_match,
_erp_section_identity,
_is_obvious_recheck_group,
_recheck_group_rank_key,
_section_vat_exception_capacity,
_short_core_desc_fuzzy_match,
_same_or_similar_desc,
_wehago_section_identity,
clean,
)
TARGET_START_YEAR = 2025
TARGET_END_YEAR = 2025
GROUP_COLUMNS = (
"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",
)
ROW_COLUMNS = (
"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",
)
def _dict(row: sqlite3.Row) -> dict[str, Any]:
return {key: row[key] for key in row.keys()}
def _amount_key(value: Any) -> str:
try:
amount = float(str(value or "0").replace(",", ""))
except Exception:
amount = 0.0
if abs(amount - round(amount)) < 0.0001:
return str(int(round(amount)))
return f"{amount:.2f}".rstrip("0").rstrip(".")
def _parse_amount(value: Any) -> float:
try:
return float(str(value or "0").replace(",", ""))
except Exception:
return 0.0
def _has_ledger_value(row: dict[str, Any]) -> bool:
return bool(clean(row.get("ledger_account_name"))) and (
abs(_parse_amount(row.get("ledger_debit"))) > 0.0001
or abs(_parse_amount(row.get("ledger_credit"))) > 0.0001
)
def _has_voucher_value(row: dict[str, Any]) -> bool:
return bool(clean(row.get("voucher_account_name"))) and (
abs(_parse_amount(row.get("voucher_debit"))) > 0.0001
or abs(_parse_amount(row.get("voucher_credit"))) > 0.0001
)
def _same_side_amount_match(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -> bool:
return (
abs(_parse_amount(ledger_row.get("ledger_debit")) - _parse_amount(voucher_row.get("voucher_debit"))) < 0.5
and abs(_parse_amount(ledger_row.get("ledger_credit")) - _parse_amount(voucher_row.get("voucher_credit"))) < 0.5
and (
abs(_parse_amount(ledger_row.get("ledger_debit"))) > 0.0001
or abs(_parse_amount(ledger_row.get("ledger_credit"))) > 0.0001
)
)
def _desc_core_match(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -> bool:
probe = {
"ledger_desc": ledger_row.get("ledger_desc"),
"voucher_desc": voucher_row.get("voucher_desc"),
}
matched = (
_same_or_similar_desc(probe)
or _contained_core_desc_match(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc"))
or _short_core_desc_fuzzy_match(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc"))
)
if matched:
return True
stop_words = {
"관련",
"전표",
"처리",
"정산",
"금액",
"비용",
"지급",
"입금",
"출금",
"매입",
"매출",
"급여",
"제경비",
}
def tokens(value: Any) -> set[str]:
found: set[str] = set()
for token in re.split(r"[^0-9A-Za-z가-힣]+", clean(value)):
token = token.strip()
if len(token) < 2 or token in stop_words or re.fullmatch(r"\d+월?", token):
continue
found.add(token)
return found
shared = tokens(ledger_row.get("ledger_desc")) & tokens(voucher_row.get("voucher_desc"))
return len(shared) >= 2 or any(len(token) >= 3 for token in shared)
def _merge_internal_recheck_pairs(group: dict[str, Any]) -> dict[str, Any]:
rows = [dict(row) for row in group.get("rows") or []]
ledger_only = [row for row in rows if _has_ledger_value(row) and not _has_voucher_value(row)]
voucher_only = [row for row in rows if _has_voucher_value(row) and not _has_ledger_value(row)]
if not ledger_only or not voucher_only:
return group
used_ledger: set[int] = set()
used_voucher: set[int] = set()
merged_rows: list[dict[str, Any]] = []
candidates: list[tuple[float, int, int]] = []
for ledger_index, ledger_row in enumerate(ledger_only):
for voucher_index, voucher_row in enumerate(voucher_only):
if not _same_side_amount_match(ledger_row, voucher_row):
continue
if not _account_base_names_compatible(ledger_row.get("ledger_account_name"), voucher_row.get("voucher_account_name")):
continue
if not _desc_core_match(ledger_row, voucher_row):
continue
amount = max(abs(_parse_amount(ledger_row.get("ledger_debit"))), abs(_parse_amount(ledger_row.get("ledger_credit"))))
candidates.append((amount, ledger_index, voucher_index))
for _amount, ledger_index, voucher_index in sorted(candidates, reverse=True):
if ledger_index in used_ledger or voucher_index in used_voucher:
continue
ledger_row = ledger_only[ledger_index]
voucher_row = voucher_only[voucher_index]
merged = dict(ledger_row)
for field in (
"proof_date",
"draft_no",
"voucher_account_name",
"voucher_vendor",
"voucher_debit",
"voucher_credit",
"voucher_desc",
"voucher_row_key",
"match_identity_key",
):
merged[field] = voucher_row.get(field, "")
merged["review_reason"] = "RECHECK_INTERNAL_CORE_MATCH"
merged_rows.append(merged)
used_ledger.add(ledger_index)
used_voucher.add(voucher_index)
if not merged_rows:
return group
remaining_rows: list[dict[str, Any]] = []
ledger_ids = {id(row): index for index, row in enumerate(ledger_only)}
voucher_ids = {id(row): index for index, row in enumerate(voucher_only)}
for row in rows:
if _has_ledger_value(row) and not _has_voucher_value(row):
index = ledger_ids.get(id(row))
if index is not None and index in used_ledger:
continue
if _has_voucher_value(row) and not _has_ledger_value(row):
index = voucher_ids.get(id(row))
if index is not None and index in used_voucher:
continue
remaining_rows.append(row)
return {**group, "rows": merged_rows + remaining_rows}
def _group_manual_review_key(group: dict[str, Any]) -> str:
summary = group.get("summary") or {}
return "|".join(
[
"manual-recheck",
clean(summary.get("fiscal_year")),
clean(summary.get("ledger_date")),
clean(summary.get("voucher_no")),
clean(summary.get("draft_no")),
"",
"",
_amount_key(summary.get("ledger_debit")),
_amount_key(summary.get("ledger_credit")),
_amount_key(summary.get("voucher_debit")),
_amount_key(summary.get("voucher_credit")),
]
)
def _row_change_key(row: dict[str, Any], change_type: str) -> str:
explicit_key = clean(row.get("review_key")) or clean(row.get("match_identity_key"))
if explicit_key:
return f"{change_type}:{explicit_key}"
return "|".join(
[
change_type,
clean(row.get("fiscal_year")),
clean(row.get("ledger_date")),
clean(row.get("voucher_no")),
clean(row.get("draft_no")),
clean(row.get("ledger_account_name")),
clean(row.get("voucher_account_name")),
_amount_key(row.get("ledger_debit")),
_amount_key(row.get("ledger_credit")),
_amount_key(row.get("voucher_debit")),
_amount_key(row.get("voucher_credit")),
clean(row.get("ledger_desc")),
clean(row.get("voucher_desc")),
]
)
def _ensure_change_table(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS wehago_recheck_row_changes (
change_key TEXT PRIMARY KEY,
change_type TEXT NOT NULL DEFAULT 'match',
fiscal_year INTEGER,
voucher_no TEXT NOT NULL DEFAULT '',
draft_no TEXT NOT NULL DEFAULT '',
ledger_date TEXT NOT NULL DEFAULT '',
proof_date TEXT NOT NULL DEFAULT '',
ledger_account_name TEXT NOT NULL DEFAULT '',
voucher_account_name TEXT NOT NULL DEFAULT '',
ledger_debit REAL NOT NULL DEFAULT 0,
ledger_credit REAL NOT NULL DEFAULT 0,
voucher_debit REAL NOT NULL DEFAULT 0,
voucher_credit REAL NOT NULL DEFAULT 0,
ledger_desc TEXT NOT NULL DEFAULT '',
voucher_desc TEXT NOT NULL DEFAULT '',
changed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
def _load_manual_change_keys(conn: sqlite3.Connection) -> tuple[set[str], set[str], set[str]]:
_ensure_change_table(conn)
review_keys = {
str(row[0])
for row in conn.execute(
"""
SELECT review_key
FROM wehago_recheck_reviews
WHERE fiscal_year BETWEEN ? AND ?
""",
(TARGET_START_YEAR, TARGET_END_YEAR),
).fetchall()
}
match_change_keys = {
str(row[0])
for row in conn.execute(
"""
SELECT change_key
FROM wehago_recheck_row_changes
WHERE fiscal_year BETWEEN ? AND ?
AND change_type = 'match'
""",
(TARGET_START_YEAR, TARGET_END_YEAR),
).fetchall()
}
split_change_keys = {
str(row[0])
for row in conn.execute(
"""
SELECT change_key
FROM wehago_recheck_row_changes
WHERE fiscal_year BETWEEN ? AND ?
AND change_type = 'split'
""",
(TARGET_START_YEAR, TARGET_END_YEAR),
).fetchall()
}
return review_keys, match_change_keys, split_change_keys
def _snapshot_signature(conn: sqlite3.Connection) -> str:
row = conn.execute(
"""
SELECT snapshot_signature
FROM wehago_snapshot_status
WHERE fiscal_year = ?
AND state = 'ready'
LIMIT 1
""",
(TARGET_START_YEAR,),
).fetchone()
if row is not None and str(row["snapshot_signature"] or ""):
signature = str(row["snapshot_signature"])
exists = conn.execute(
"""
SELECT 1
FROM wehago_compare_export_row_cache
WHERE fiscal_year BETWEEN ? AND ?
AND snapshot_signature = ?
LIMIT 1
""",
(TARGET_START_YEAR, TARGET_END_YEAR, signature),
).fetchone()
if exists:
return signature
row = conn.execute(
"""
SELECT snapshot_signature, COUNT(*) AS row_count, MAX(rowid) AS max_rowid
FROM wehago_compare_export_row_cache
WHERE fiscal_year BETWEEN ? AND ?
GROUP BY snapshot_signature
ORDER BY
CASE WHEN snapshot_signature LIKE 'voucher-summary-v7|recheck-v20%' THEN 0 ELSE 1 END ASC,
row_count DESC,
max_rowid DESC
LIMIT 1
""",
(TARGET_START_YEAR, TARGET_END_YEAR),
).fetchone()
if row is None or not str(row["snapshot_signature"] or ""):
raise RuntimeError("No snapshot/export signature was found.")
return str(row["snapshot_signature"])
def _latest_query_projection_signature(conn: sqlite3.Connection) -> str | None:
row = conn.execute(
"""
SELECT signature, COUNT(DISTINCT status_key) AS status_count, MAX(updated_at) AS updated_at
FROM wehago_compare_query_groups
WHERE start_year = ?
AND end_year = ?
AND signature LIKE ?
AND signature NOT LIKE '%|snapshot-recheck-promote|%'
GROUP BY signature
HAVING status_count >= 5
ORDER BY updated_at DESC
LIMIT 1
""",
(TARGET_START_YEAR, TARGET_END_YEAR, f"{QUERY_PROJECTION_VERSION}|%"),
).fetchone()
return str(row["signature"] or "") if row is not None else None
def _load_groups_from_query_projection(conn: sqlite3.Connection, signature: str) -> dict[str, list[dict[str, Any]]]:
group_rows = conn.execute(
"""
SELECT *
FROM wehago_compare_query_groups
WHERE start_year = ?
AND end_year = ?
AND signature = ?
ORDER BY status_key, group_index
""",
(TARGET_START_YEAR, TARGET_END_YEAR, signature),
).fetchall()
detail_rows = conn.execute(
"""
SELECT *
FROM wehago_compare_query_rows
WHERE start_year = ?
AND end_year = ?
AND signature = ?
ORDER BY status_key, group_index, row_index
""",
(TARGET_START_YEAR, TARGET_END_YEAR, signature),
).fetchall()
rows_by_key: dict[tuple[str, int], list[dict[str, Any]]] = {}
for row in detail_rows:
payload = _dict(row)
rows_by_key.setdefault((str(payload["status_key"]), int(payload["group_index"])), []).append(payload)
groups: dict[str, list[dict[str, Any]]] = {}
for row in group_rows:
summary = _dict(row)
status_key = str(summary["status_key"])
group_index = int(summary["group_index"])
groups.setdefault(status_key, []).append(
{
"summary": summary,
"rows": rows_by_key.get((status_key, group_index), []),
"source_group_index": group_index,
}
)
return groups
def _source_signature(conn: sqlite3.Connection) -> str:
row = conn.execute(
"""
SELECT signature, COUNT(DISTINCT status_key) AS status_count, COUNT(*) AS group_count
FROM wehago_compare_query_groups
WHERE fiscal_year BETWEEN ? AND ?
AND signature NOT LIKE ?
GROUP BY signature
HAVING status_count >= 5
ORDER BY group_count DESC
LIMIT 1
""",
(TARGET_START_YEAR, TARGET_END_YEAR, f"{QUERY_PROJECTION_VERSION}|%"),
).fetchone()
if row is None:
raise RuntimeError("No source query projection with voucher status groups was found.")
return str(row["signature"])
def _matched_source_signature(conn: sqlite3.Connection) -> str:
row = conn.execute(
"""
SELECT signature, COUNT(*) AS group_count
FROM wehago_compare_query_groups
WHERE fiscal_year BETWEEN ? AND ?
AND status_key = 'voucher_matched'
AND signature NOT LIKE ?
GROUP BY signature
ORDER BY group_count DESC
LIMIT 1
""",
(TARGET_START_YEAR, TARGET_END_YEAR, f"{QUERY_PROJECTION_VERSION}|%"),
).fetchone()
if row is None:
raise RuntimeError("No matched source query projection was found.")
return str(row["signature"])
def _load_groups(
conn: sqlite3.Connection,
signature: str,
statuses: tuple[str, ...] | None = None,
) -> dict[str, list[dict[str, Any]]]:
status_filter = ""
params: list[Any] = [signature, TARGET_START_YEAR, TARGET_END_YEAR]
if statuses:
status_filter = f" AND status_key IN ({', '.join('?' for _ in statuses)})"
params.extend(statuses)
groups: dict[str, list[dict[str, Any]]] = {}
group_rows = conn.execute(
f"""
SELECT *
FROM wehago_compare_query_groups
WHERE signature = ?
AND fiscal_year BETWEEN ? AND ?
{status_filter}
ORDER BY status_key, group_index
""",
params,
).fetchall()
detail_rows = conn.execute(
f"""
SELECT *
FROM wehago_compare_query_rows
WHERE signature = ?
AND fiscal_year BETWEEN ? AND ?
{status_filter}
ORDER BY status_key, group_index, row_index
""",
params,
).fetchall()
rows_by_key: dict[tuple[str, int], list[dict[str, Any]]] = {}
for row in detail_rows:
item = _dict(row)
rows_by_key.setdefault((str(item["status_key"]), int(item["group_index"])), []).append(item)
for row in group_rows:
summary = _dict(row)
status_key = str(summary["status_key"])
group_index = int(summary["group_index"])
groups.setdefault(status_key, []).append(
{
"summary": summary,
"rows": rows_by_key.get((status_key, group_index), []),
"source_group_index": group_index,
}
)
return groups
def _load_groups_from_export_cache(conn: sqlite3.Connection, snapshot_signature: str) -> dict[str, list[dict[str, Any]]]:
export_rows = conn.execute(
"""
SELECT *
FROM wehago_compare_export_row_cache
WHERE fiscal_year BETWEEN ? AND ?
AND snapshot_signature = ?
ORDER BY status_key, group_sort, row_sort
""",
(TARGET_START_YEAR, TARGET_END_YEAR, snapshot_signature),
).fetchall()
grouped_rows: dict[tuple[str, int], list[dict[str, Any]]] = {}
for row in export_rows:
item = _dict(row)
grouped_rows.setdefault((str(item["status_key"]), int(item["group_sort"])), []).append(item)
groups: dict[str, list[dict[str, Any]]] = {}
for (status_key, group_index), rows in grouped_rows.items():
first = rows[0]
detail_rows: list[dict[str, Any]] = []
ledger_accounts: list[str] = []
voucher_accounts: list[str] = []
ledger_vendors: list[str] = []
voucher_vendors: list[str] = []
def append_unique(target: list[str], value: Any) -> None:
text_value = clean(value)
if text_value and text_value not in target:
target.append(text_value)
ledger_row_count = 0
voucher_row_count = 0
for row_index, row in enumerate(rows):
detail = {
"fiscal_year": int(row.get("fiscal_year") or 0),
"status_label": "Matched" if status_key in {"voucher_matched", "erp_voucher_matched"} else "Recheck" if status_key == "voucher_recheck" else "Unmatched",
"ledger_date": clean(row.get("ledger_date")),
"proof_date": "",
"voucher_no": clean(row.get("voucher_no")),
"draft_no": clean(row.get("draft_no")),
"ledger_account_name": clean(row.get("ledger_account_name")),
"voucher_account_name": clean(row.get("voucher_account_name")),
"ledger_vendor": clean(row.get("ledger_vendor")),
"voucher_vendor": clean(row.get("voucher_vendor")),
"ledger_debit": float(row.get("ledger_debit") or 0),
"ledger_credit": float(row.get("ledger_credit") or 0),
"voucher_debit": float(row.get("voucher_debit") or 0),
"voucher_credit": float(row.get("voucher_credit") or 0),
"ledger_desc": clean(row.get("ledger_desc")),
"voucher_desc": clean(row.get("voucher_desc")),
"review_reason": "SNAPSHOT_EXPORT_CACHE",
"matched_case": "",
"ledger_row_key": "",
"voucher_row_key": "",
"match_identity_key": "",
"row_index": row_index,
}
if detail["ledger_account_name"] or detail["ledger_desc"]:
ledger_row_count += 1
if detail["voucher_account_name"] or detail["voucher_desc"]:
voucher_row_count += 1
append_unique(ledger_accounts, detail["ledger_account_name"])
append_unique(voucher_accounts, detail["voucher_account_name"])
append_unique(ledger_vendors, detail["ledger_vendor"])
append_unique(voucher_vendors, detail["voucher_vendor"])
detail_rows.append(detail)
summary = {
"fiscal_year": int(first.get("fiscal_year") or 0),
"status_label": detail_rows[0]["status_label"] if detail_rows else "",
"ledger_date": clean(first.get("ledger_date")),
"proof_date": "",
"voucher_no": clean(first.get("group_voucher_no")) or clean(first.get("voucher_no")),
"draft_no": clean(first.get("group_draft_no")) or clean(first.get("draft_no")),
"ledger_row_count": ledger_row_count,
"voucher_row_count": voucher_row_count,
"ledger_debit": float(first.get("group_ledger_debit") or 0),
"ledger_credit": float(first.get("group_ledger_credit") or 0),
"voucher_debit": float(first.get("group_voucher_debit") or 0),
"voucher_credit": float(first.get("group_voucher_credit") or 0),
"ledger_accounts": clean(first.get("group_ledger_accounts")) or ", ".join(ledger_accounts),
"voucher_accounts": clean(first.get("group_voucher_accounts")) or ", ".join(voucher_accounts),
"ledger_vendors": clean(first.get("group_ledger_vendors")) or ", ".join(ledger_vendors),
"voucher_vendors": clean(first.get("group_voucher_vendors")) or ", ".join(voucher_vendors),
"review_reason": "SNAPSHOT_EXPORT_CACHE",
"search_text": "",
}
summary["search_text"] = _search_text(summary, detail_rows)
groups.setdefault(status_key, []).append(
{
"summary": summary,
"rows": detail_rows,
"source_group_index": group_index,
}
)
return groups
def _seed_used_identities(groups: dict[str, list[dict[str, Any]]]) -> tuple[dict[str, int], dict[str, int]]:
used_wehago: dict[str, int] = {}
used_erp: dict[str, int] = {}
for group in groups.get("voucher_matched", []):
identity = _wehago_section_identity(group)
if identity:
used_wehago[identity] = used_wehago.get(identity, 0) + 1
for group in groups.get("erp_voucher_matched", []):
identity = _erp_section_identity(group)
if identity:
used_erp[identity] = used_erp.get(identity, 0) + 1
return used_wehago, used_erp
def _group_has_manual_match(
group: dict[str, Any],
review_keys: set[str],
match_change_keys: set[str],
) -> bool:
if _group_manual_review_key(group) in review_keys:
return True
for row in group.get("rows") or []:
if _row_change_key(row, "match") in match_change_keys:
return True
return False
def _select_promotions(
groups: dict[str, list[dict[str, Any]]],
review_keys: set[str],
match_change_keys: set[str],
) -> set[int]:
used_wehago, _used_erp = _seed_used_identities(groups)
candidates: list[dict[str, Any]] = []
for group in groups.get("voucher_recheck", []):
manual_match = _group_has_manual_match(group, review_keys, match_change_keys)
if not manual_match and not _is_obvious_recheck_group(group):
continue
wehago_identity = _wehago_section_identity(group)
erp_identity = _erp_section_identity(group)
if not wehago_identity or not erp_identity:
continue
candidates.append(
{
"group": group,
"wehago_identity": wehago_identity,
"erp_identity": erp_identity,
"wehago_capacity": _section_vat_exception_capacity(group, side="wehago"),
"rank": (1 if manual_match else 0, *_recheck_group_rank_key(group)),
"manual_match": manual_match,
}
)
candidates.sort(key=lambda item: item["rank"], reverse=True)
promoted: set[int] = set()
for candidate in candidates:
wehago_identity = candidate["wehago_identity"]
erp_identity = candidate["erp_identity"]
wehago_capacity = int(candidate["wehago_capacity"] or 1)
if used_wehago.get(wehago_identity, 0) >= wehago_capacity:
continue
promoted.add(id(candidate["group"]))
used_wehago[wehago_identity] = used_wehago.get(wehago_identity, 0) + 1
return promoted
def _search_text(summary: dict[str, Any], rows: list[dict[str, Any]]) -> str:
parts = [
summary.get("voucher_no"),
summary.get("draft_no"),
summary.get("ledger_accounts"),
summary.get("voucher_accounts"),
summary.get("ledger_vendors"),
summary.get("voucher_vendors"),
summary.get("review_reason"),
]
for row in rows[:20]:
parts.extend([row.get("ledger_desc"), row.get("voucher_desc")])
return " ".join(clean(part) for part in parts if clean(part))
def _insert_group(
conn: sqlite3.Connection,
*,
signature: str,
status_key: str,
group_index: int,
group: dict[str, Any],
promoted: bool = False,
split_change_keys: set[str] | None = None,
omit_split_rows: bool = False,
) -> None:
summary = dict(group["summary"])
rows = [dict(row) for row in group.get("rows") or []]
split_change_keys = split_change_keys or set()
if promoted and omit_split_rows:
rows = [row for row in rows if _row_change_key(row, "split") not in split_change_keys]
summary.update(
{
"start_year": TARGET_START_YEAR,
"end_year": TARGET_END_YEAR,
"status_key": status_key,
"signature": signature,
"group_index": group_index,
}
)
if promoted:
summary["review_reason"] = clean(summary.get("review_reason")) or "RECHECK_PROMOTED_BY_USER_RULE"
summary["search_text"] = _search_text(summary, rows)
values = [summary.get(column, "") for column in GROUP_COLUMNS]
conn.execute(
f"""
INSERT INTO wehago_compare_query_groups ({', '.join(GROUP_COLUMNS)}, created_at, updated_at)
VALUES ({', '.join('?' for _ in GROUP_COLUMNS)}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
values,
)
for row_index, row in enumerate(rows):
split_row = promoted and _row_change_key(row, "split") in split_change_keys
row.update(
{
"start_year": TARGET_START_YEAR,
"end_year": TARGET_END_YEAR,
"status_key": status_key,
"signature": signature,
"group_index": group_index,
"row_index": row_index,
}
)
if promoted:
if split_row:
row["status_label"] = "Unmatched"
row["review_reason"] = "MANUAL_SPLIT_FROM_RECHECK"
row["proof_date"] = ""
row["draft_no"] = ""
row["voucher_account_name"] = ""
row["voucher_vendor"] = ""
row["voucher_debit"] = 0
row["voucher_credit"] = 0
row["voucher_desc"] = ""
row["voucher_row_key"] = ""
row["match_identity_key"] = ""
else:
row["status_label"] = "Matched"
row["review_reason"] = clean(row.get("review_reason")) or "RECHECK_PROMOTED_BY_USER_RULE"
values = [row.get(column, "") for column in ROW_COLUMNS]
conn.execute(
f"""
INSERT INTO wehago_compare_query_rows ({', '.join(ROW_COLUMNS)}, created_at, updated_at)
VALUES ({', '.join('?' for _ in ROW_COLUMNS)}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
values,
)
def main() -> None:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
source_signature = _latest_query_projection_signature(conn)
source_kind = "query_projection"
if not source_signature:
source_signature = _snapshot_signature(conn)
source_kind = "export_cache"
target_signature = f"{QUERY_PROJECTION_VERSION}|snapshot-recheck-promote|{source_signature}"
groups = (
_load_groups_from_query_projection(conn, source_signature)
if source_kind == "query_projection"
else _load_groups_from_export_cache(conn, source_signature)
)
groups["voucher_recheck"] = [
_merge_internal_recheck_pairs(group)
for group in groups.get("voucher_recheck", [])
]
review_keys, match_change_keys, split_change_keys = _load_manual_change_keys(conn)
promoted_ids = _select_promotions(groups, review_keys, match_change_keys)
conn.execute("BEGIN")
try:
conn.execute(
"DELETE FROM wehago_compare_query_rows WHERE start_year = ? AND end_year = ? AND signature = ?",
(TARGET_START_YEAR, TARGET_END_YEAR, target_signature),
)
conn.execute(
"DELETE FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ?",
(TARGET_START_YEAR, TARGET_END_YEAR, target_signature),
)
max_group_index = {
status_key: max([int(group["source_group_index"]) for group in status_groups] or [0])
for status_key, status_groups in groups.items()
}
for status_key, status_groups in groups.items():
for group in status_groups:
if status_key == "voucher_recheck" and id(group) in promoted_ids:
continue
_insert_group(
conn,
signature=target_signature,
status_key=status_key,
group_index=int(group["source_group_index"]),
group=group,
)
for group in groups.get("voucher_recheck", []):
if id(group) not in promoted_ids:
continue
max_group_index["voucher_matched"] = max_group_index.get("voucher_matched", 0) + 1
_insert_group(
conn,
signature=target_signature,
status_key="voucher_matched",
group_index=max_group_index["voucher_matched"],
group=group,
promoted=True,
split_change_keys=split_change_keys,
)
max_group_index["erp_voucher_matched"] = max_group_index.get("erp_voucher_matched", 0) + 1
_insert_group(
conn,
signature=target_signature,
status_key="erp_voucher_matched",
group_index=max_group_index["erp_voucher_matched"],
group=group,
promoted=True,
split_change_keys=split_change_keys,
omit_split_rows=True,
)
conn.execute(
"""
INSERT INTO wehago_action_history (action_type, payload_json, created_at)
VALUES ('auto_recheck_promote', ?, CURRENT_TIMESTAMP)
""",
(
f'{{"count": {len(promoted_ids)}, "start_year": {TARGET_START_YEAR}, '
f'"manual_review_keys": {len(review_keys)}, "match_change_keys": {len(match_change_keys)}, '
f'"split_change_keys": {len(split_change_keys)}, '
f'"source_kind": "{source_kind}", '
f'"end_year": {TARGET_END_YEAR}, "signature": "{target_signature}", '
f'"created_at": "{datetime.now().isoformat(timespec="seconds")}"}}',
),
)
conn.commit()
except Exception:
conn.rollback()
raise
counts = conn.execute(
"""
SELECT status_key, COUNT(*)
FROM wehago_compare_query_groups
WHERE signature = ?
AND fiscal_year BETWEEN ? AND ?
GROUP BY status_key
ORDER BY status_key
""",
(target_signature, TARGET_START_YEAR, TARGET_END_YEAR),
).fetchall()
print(
{
"source_signature": source_signature,
"source_kind": source_kind,
"target_signature": target_signature,
"promoted": len(promoted_ids),
"counts": {str(row[0]): int(row[1]) for row in counts},
}
)
if __name__ == "__main__":
main()
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
import argparse
import fcntl
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from main import engine
from wehago_compare import (
_build_db_state_signature,
_get_fast_year_export_row_cache_signature,
_load_snapshot_status_map,
_project_year_export_row_cache_from_latest_resolved,
_rebuild_compare_query_projection,
_refresh_year_resolved_sections,
_status_row_counts_from_sections,
_upsert_snapshot_status,
)
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 main() -> None:
parser = argparse.ArgumentParser(description="Rebuild WEHAGO compare snapshots and query projections.")
parser.add_argument("--years", nargs="*", type=int, default=[])
parser.add_argument("--fast-years", nargs="*", type=int, default=[])
parser.add_argument("--ranges", nargs="*", default=[])
args = parser.parse_args()
lock_path = Path("/tmp/wehago_compare_compute.lock")
lock_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open("w") as lock_file:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
for year in sorted({int(year) for year in args.fast_years if int(year or 0) > 0}):
signature = _build_db_state_signature(conn, year, year)
print({"step": "fast_year_projection_start", "year": year, "signature": signature}, flush=True)
projected = _project_year_export_row_cache_from_latest_resolved(conn, year, signature)
if not projected:
print({"step": "fast_year_projection_miss", "year": year}, flush=True)
_refresh_year_resolved_sections(conn, year)
else:
_upsert_snapshot_status(
conn,
year,
signature=signature,
state="ready",
row_counts={},
built_now=True,
)
selected_signature = _get_fast_year_export_row_cache_signature(conn, year)
print(
{
"step": "fast_year_projection_done",
"year": year,
"selected_signature": selected_signature,
},
flush=True,
)
for year in sorted({int(year) for year in args.years if int(year or 0) > 0}):
signature = _build_db_state_signature(conn, year, year)
print({"step": "year_snapshot_start", "year": year, "signature": signature}, flush=True)
_refresh_year_resolved_sections(conn, year)
print({"step": "year_snapshot_done", "year": year}, flush=True)
for start_year, end_year in [parse_range(item) for item in args.ranges]:
print({"step": "projection_start", "start_year": start_year, "end_year": end_year}, flush=True)
counts, snapshot_state = _rebuild_compare_query_projection(engine, conn, start_year, end_year)
print(
{
"step": "projection_done",
"start_year": start_year,
"end_year": end_year,
"counts": counts,
"snapshot_state": snapshot_state,
},
flush=True,
)
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -35,7 +35,7 @@ if ss -ltn "( sport = :${PORT} )" | tail -n +2 | grep -q .; then
exit 1
fi
export INTRANET_AUTO_RELOAD="${INTRANET_AUTO_RELOAD:-1}"
export INTRANET_AUTO_RELOAD="${INTRANET_AUTO_RELOAD:-0}"
export INTRANET_PORT="$PORT"
echo "서버를 시작합니다: http://127.0.0.1:${PORT} (auto_reload=${INTRANET_AUTO_RELOAD})"
echo "예기치 않게 종료되면 2초 후 자동 재시작합니다."
+36 -1
View File
@@ -81,6 +81,31 @@ function Get-WslListeningPorts {
return $portMap.Values | Sort-Object Port
}
function Get-DockerPublishedPorts {
$publishedPorts = New-Object System.Collections.Generic.HashSet[int]
try {
$raw = docker ps --format "{{.Ports}}" 2>$null
if (-not $raw) {
return $publishedPorts
}
foreach ($line in ($raw -split "`r?`n")) {
if ([string]::IsNullOrWhiteSpace($line)) {
continue
}
foreach ($match in [regex]::Matches($line, "(?:0\.0\.0\.0|\[::\]):(?<port>\d+)->")) {
$port = 0
if ([int]::TryParse($match.Groups["port"].Value, [ref]$port)) {
[void]$publishedPorts.Add($port)
}
}
}
} catch {
return $publishedPorts
}
return $publishedPorts
}
if ([string]::IsNullOrWhiteSpace($WslIp)) {
$detected = wsl.exe hostname -I 2>$null
if (-not $detected) {
@@ -110,6 +135,10 @@ $listenAddresses = $listenAddresses | Where-Object { -not [string]::IsNullOrWhit
Write-Host "Setting Windows portproxy for all active WSL TCP ports..." -ForegroundColor Cyan
Write-Host "WSL IP: $WslIp"
$portEntries = @(Get-WslListeningPorts)
$dockerPublishedPorts = @(Get-DockerPublishedPorts)
if ($dockerPublishedPorts.Count -gt 0) {
Write-Host "Docker-published Windows ports: $((@($dockerPublishedPorts) | Sort-Object) -join ', ')" -ForegroundColor Cyan
}
$knownPortEntries = @()
foreach ($knownPort in ($knownPorts.Keys | Sort-Object)) {
@@ -162,7 +191,13 @@ foreach ($entry in $reachableEntries) {
foreach ($address in $listenAddresses) {
netsh interface portproxy delete v4tov4 listenport=$port listenaddress=$address | Out-Null
netsh interface portproxy add v4tov4 listenport=$port listenaddress=$address connectport=$port connectaddress=$WslIp
if ($dockerPublishedPorts -notcontains $port) {
netsh interface portproxy add v4tov4 listenport=$port listenaddress=$address connectport=$port connectaddress=$WslIp
}
}
if ($dockerPublishedPorts -contains $port) {
Write-Host " Docker already publishes this port on Windows; stale portproxy entries were removed and only firewall is refreshed." -ForegroundColor Yellow
}
$existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
+568
View File
@@ -0,0 +1,568 @@
from __future__ import annotations
import argparse
import json
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from runtime_config import ( # noqa: E402
BACKUP_DIR,
DB_PATH,
WAL_BLOCK_HEAVY_BYTES,
WAL_WARN_BYTES,
ensure_runtime_directories,
sqlite_runtime_status,
)
CACHE_TABLES = (
"wehago_compare_export_row_cache",
"wehago_compare_query_page_cache",
"wehago_compare_query_groups",
"wehago_compare_query_rows",
"wehago_metric_count_cache",
"wehago_pair_recommend_cache",
"wehago_raw_erp_trace_candidate_cache",
"wehago_result_row_cache",
"wehago_summary_range_cache",
)
SOURCE_TABLES = ("wehago_voucher_rows", "wehago_ledger_rows")
def _size(path: Path) -> int:
return path.stat().st_size if path.exists() else 0
def _table_counts(conn: sqlite3.Connection, tables: tuple[str, ...]) -> dict[str, int | None]:
existing = {
str(row[0])
for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall()
}
counts: dict[str, int | None] = {}
for table in tables:
if table not in existing:
counts[table] = None
continue
counts[table] = int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
return counts
def _table_storage(conn: sqlite3.Connection, tables: tuple[str, ...]) -> dict[str, dict[str, int | None]]:
existing = {
str(row[0])
for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall()
}
has_dbstat = True
try:
conn.execute("SELECT 1 FROM dbstat LIMIT 1").fetchone()
except sqlite3.DatabaseError:
has_dbstat = False
page_size = int(conn.execute("PRAGMA page_size").fetchone()[0])
storage: dict[str, dict[str, int | None]] = {}
for table in tables:
if table not in existing:
storage[table] = {"rows": None, "bytes": None}
continue
rows = int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
bytes_used: int | None = None
if has_dbstat:
try:
stat = conn.execute(
"SELECT SUM(pgsize) FROM dbstat WHERE name = ?",
(table,),
).fetchone()
bytes_used = int(stat[0] or 0)
except sqlite3.DatabaseError:
bytes_used = None
storage[table] = {"rows": rows, "bytes": bytes_used if has_dbstat else None}
if not has_dbstat:
for table in storage.values():
table["estimated_page_size"] = page_size
return storage
def _table_exists(conn: sqlite3.Connection, table: str) -> bool:
return bool(
conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
(table,),
).fetchone()
)
def cache_retention_report(limit: int) -> dict[str, Any]:
conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True, timeout=30)
try:
if not _table_exists(conn, "wehago_compare_export_row_cache"):
return {"export_row_cache": {"exists": False}}
signatures = [
{
"fiscal_year": int(row[0]),
"snapshot_signature": str(row[1]),
"rows": int(row[2]),
}
for row in conn.execute(
"""
SELECT fiscal_year, snapshot_signature, COUNT(*) AS rows
FROM wehago_compare_export_row_cache
GROUP BY fiscal_year, snapshot_signature
ORDER BY fiscal_year DESC, rows DESC
LIMIT ?
""",
(limit,),
).fetchall()
]
orphan_rows = 0
if _table_exists(conn, "wehago_snapshot_status"):
orphan_rows = int(
conn.execute(
"""
SELECT COUNT(*)
FROM wehago_compare_export_row_cache c
LEFT JOIN wehago_snapshot_status s
ON s.fiscal_year = c.fiscal_year
AND s.snapshot_signature = c.snapshot_signature
AND s.state = 'ready'
WHERE s.fiscal_year IS NULL
"""
).fetchone()[0]
)
return {
"export_row_cache": {
"exists": True,
"signature_groups_sample": signatures,
"orphan_rows_not_matching_ready_snapshot": orphan_rows,
}
}
finally:
conn.close()
def prune_orphan_export_cache(dry_run: bool, acknowledged: bool) -> dict[str, Any]:
if not dry_run and not acknowledged:
raise RuntimeError(
"실제 삭제는 `--ack-delete-rebuildable-cache`를 함께 지정해야 합니다. "
"삭제 대상은 현재 ready 스냅샷 서명과 맞지 않는 재생성 가능 export-row 캐시입니다."
)
conn = sqlite3.connect(DB_PATH, timeout=30)
try:
if not _table_exists(conn, "wehago_compare_export_row_cache") or not _table_exists(
conn,
"wehago_snapshot_status",
):
return {"dry_run": dry_run, "candidate_rows": 0, "deleted_rows": 0}
candidate_rows = int(
conn.execute(
"""
SELECT COUNT(*)
FROM wehago_compare_export_row_cache c
LEFT JOIN wehago_snapshot_status s
ON s.fiscal_year = c.fiscal_year
AND s.snapshot_signature = c.snapshot_signature
AND s.state = 'ready'
WHERE s.fiscal_year IS NULL
"""
).fetchone()[0]
)
deleted_rows = 0
if not dry_run and candidate_rows:
conn.execute(
"""
DELETE FROM wehago_compare_export_row_cache
WHERE NOT EXISTS (
SELECT 1
FROM wehago_snapshot_status s
WHERE s.fiscal_year = wehago_compare_export_row_cache.fiscal_year
AND s.snapshot_signature = wehago_compare_export_row_cache.snapshot_signature
AND s.state = 'ready'
)
"""
)
deleted_rows = conn.total_changes
conn.commit()
return {"dry_run": dry_run, "candidate_rows": candidate_rows, "deleted_rows": deleted_rows}
finally:
conn.close()
def query_projection_retention_report(keep: int) -> dict[str, Any]:
conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True, timeout=30)
conn.row_factory = sqlite3.Row
try:
if not _table_exists(conn, "wehago_compare_query_groups"):
return {"query_projection_cache": {"exists": False}}
groups = conn.execute(
"""
SELECT start_year, end_year, signature,
COUNT(*) AS group_rows,
MAX(updated_at) AS max_updated_at
FROM wehago_compare_query_groups
GROUP BY start_year, end_year, signature
ORDER BY start_year DESC, end_year DESC, max_updated_at DESC
"""
).fetchall()
keep = max(1, int(keep))
by_scope: dict[tuple[int, int], list[sqlite3.Row]] = {}
for row in groups:
by_scope.setdefault((int(row["start_year"]), int(row["end_year"])), []).append(row)
obsolete: list[sqlite3.Row] = []
retained: list[dict[str, Any]] = []
for scope, rows in by_scope.items():
for idx, row in enumerate(rows):
item = {
"start_year": int(row["start_year"]),
"end_year": int(row["end_year"]),
"signature": str(row["signature"] or ""),
"group_rows": int(row["group_rows"] or 0),
"max_updated_at": str(row["max_updated_at"] or ""),
"retained": idx < keep,
}
if idx < keep:
retained.append(item)
else:
obsolete.append(row)
obsolete_group_rows = sum(int(row["group_rows"] or 0) for row in obsolete)
obsolete_query_rows = 0
obsolete_page_rows = 0
for row in obsolete:
params = (
int(row["start_year"]),
int(row["end_year"]),
str(row["signature"] or ""),
)
if _table_exists(conn, "wehago_compare_query_rows"):
obsolete_query_rows += int(
conn.execute(
"""
SELECT COUNT(*)
FROM wehago_compare_query_rows
WHERE start_year = ? AND end_year = ? AND signature = ?
""",
params,
).fetchone()[0]
)
if _table_exists(conn, "wehago_compare_query_page_cache"):
obsolete_page_rows += int(
conn.execute(
"""
SELECT COUNT(*)
FROM wehago_compare_query_page_cache
WHERE start_year = ? AND end_year = ? AND signature = ?
""",
params,
).fetchone()[0]
)
return {
"query_projection_cache": {
"exists": True,
"keep_per_range": keep,
"range_signature_count": len(groups),
"obsolete_signature_count": len(obsolete),
"obsolete_group_rows": obsolete_group_rows,
"obsolete_query_rows": obsolete_query_rows,
"obsolete_page_rows": obsolete_page_rows,
"retained_sample": retained[:20],
}
}
finally:
conn.close()
def prune_old_query_projections(dry_run: bool, acknowledged: bool, keep: int) -> dict[str, Any]:
if not dry_run and not acknowledged:
raise RuntimeError(
"실제 삭제는 `--ack-delete-rebuildable-cache`를 함께 지정해야 합니다. "
"삭제 대상은 범위별 최신 N개를 제외한 재생성 가능 query projection 캐시입니다."
)
conn = sqlite3.connect(DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
try:
if not _table_exists(conn, "wehago_compare_query_groups"):
return {"dry_run": dry_run, "candidate_group_rows": 0, "candidate_query_rows": 0, "candidate_page_rows": 0}
keep = max(1, int(keep))
rows = conn.execute(
"""
SELECT start_year, end_year, signature, MAX(updated_at) AS max_updated_at
FROM wehago_compare_query_groups
GROUP BY start_year, end_year, signature
ORDER BY start_year, end_year, max_updated_at DESC
"""
).fetchall()
by_scope: dict[tuple[int, int], list[sqlite3.Row]] = {}
for row in rows:
by_scope.setdefault((int(row["start_year"]), int(row["end_year"])), []).append(row)
obsolete = [row for scope_rows in by_scope.values() for row in scope_rows[keep:]]
candidate_group_rows = 0
candidate_query_rows = 0
candidate_page_rows = 0
deleted_rows = 0
for row in obsolete:
params = (
int(row["start_year"]),
int(row["end_year"]),
str(row["signature"] or ""),
)
candidate_group_rows += int(
conn.execute(
"""
SELECT COUNT(*)
FROM wehago_compare_query_groups
WHERE start_year = ? AND end_year = ? AND signature = ?
""",
params,
).fetchone()[0]
)
if _table_exists(conn, "wehago_compare_query_rows"):
candidate_query_rows += int(
conn.execute(
"""
SELECT COUNT(*)
FROM wehago_compare_query_rows
WHERE start_year = ? AND end_year = ? AND signature = ?
""",
params,
).fetchone()[0]
)
if _table_exists(conn, "wehago_compare_query_page_cache"):
candidate_page_rows += int(
conn.execute(
"""
SELECT COUNT(*)
FROM wehago_compare_query_page_cache
WHERE start_year = ? AND end_year = ? AND signature = ?
""",
params,
).fetchone()[0]
)
if not dry_run:
for table in (
"wehago_compare_query_page_cache",
"wehago_compare_query_rows",
"wehago_compare_query_groups",
):
if not _table_exists(conn, table):
continue
before = conn.total_changes
conn.execute(
f"""
DELETE FROM {table}
WHERE start_year = ? AND end_year = ? AND signature = ?
""",
params,
)
deleted_rows += conn.total_changes - before
conn.commit()
return {
"dry_run": dry_run,
"keep_per_range": keep,
"obsolete_signature_count": len(obsolete),
"candidate_group_rows": candidate_group_rows,
"candidate_query_rows": candidate_query_rows,
"candidate_page_rows": candidate_page_rows,
"deleted_rows": deleted_rows,
}
finally:
conn.close()
def status(include_counts: bool) -> dict[str, Any]:
path = DB_PATH
payload: dict[str, Any] = {
"database_path": str(path),
"database_exists": path.exists(),
"files": {
"database_bytes": _size(path),
"wal_bytes": _size(path.with_name(path.name + "-wal")),
"shm_bytes": _size(path.with_name(path.name + "-shm")),
},
"runtime": sqlite_runtime_status(),
"thresholds": {
"wal_warn_bytes": WAL_WARN_BYTES,
"wal_block_heavy_bytes": WAL_BLOCK_HEAVY_BYTES,
},
}
if not path.exists():
return payload
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=5)
try:
payload["database"] = {
"journal_mode": str(conn.execute("PRAGMA journal_mode").fetchone()[0]),
"page_size": int(conn.execute("PRAGMA page_size").fetchone()[0]),
"page_count": int(conn.execute("PRAGMA page_count").fetchone()[0]),
"freelist_count": int(conn.execute("PRAGMA freelist_count").fetchone()[0]),
"wal_autocheckpoint": int(conn.execute("PRAGMA wal_autocheckpoint").fetchone()[0]),
}
if include_counts:
payload["source_table_counts"] = _table_counts(conn, SOURCE_TABLES)
payload["cache_table_counts"] = _table_counts(conn, CACHE_TABLES)
payload["cache_table_storage"] = _table_storage(conn, CACHE_TABLES)
finally:
conn.close()
wal_bytes = int(payload["files"]["wal_bytes"])
warnings: list[str] = []
if not payload["runtime"]["meets_minimum_safe_version"]:
warnings.append(
"SQLite 런타임이 3.51.3 미만입니다. WAL DB를 운영하기 전에 안전 런타임으로 교체하세요."
)
if wal_bytes >= WAL_BLOCK_HEAVY_BYTES:
warnings.append("WAL이 중단 기준을 넘었습니다. 신규 대량 캐시 재생성을 보류하고 유지보수 창을 확보하세요.")
elif wal_bytes >= WAL_WARN_BYTES:
warnings.append("WAL이 경고 기준을 넘었습니다. 긴 조회/쓰기 작업과 checkpoint 상태를 점검하세요.")
payload["warnings"] = warnings
return payload
def backup(output: Path | None, verify: str) -> dict[str, Any]:
if not DB_PATH.exists():
raise FileNotFoundError(DB_PATH)
ensure_runtime_directories()
output = output or BACKUP_DIR / f"data-migration-{datetime.now():%Y%m%d-%H%M%S}.sqlite3"
output.parent.mkdir(parents=True, exist_ok=True)
temp_path = output.with_name("." + output.name + ".tmp")
source_conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True, timeout=30)
target_conn = sqlite3.connect(temp_path)
last_reported_percent = -10
def report_progress(_status: int, remaining: int, total: int) -> None:
nonlocal last_reported_percent
percent = int((total - remaining) * 100 / total) if total else 100
reported_percent = min(100, (percent // 10) * 10)
if reported_percent > last_reported_percent:
print(f"backup copy progress: {reported_percent}%", file=sys.stderr, flush=True)
last_reported_percent = reported_percent
try:
source_conn.backup(target_conn, pages=8192, progress=report_progress)
check = "skipped"
if verify == "smoke":
target_conn.execute("PRAGMA schema_version").fetchone()
target_conn.execute("SELECT COUNT(*) FROM sqlite_master").fetchone()
check = "open-and-schema-readable"
elif verify != "none":
pragma = "integrity_check" if verify == "full" else "quick_check"
print(f"backup verification started: {pragma}", file=sys.stderr, flush=True)
check = str(target_conn.execute(f"PRAGMA {pragma}").fetchone()[0])
if check.lower() != "ok":
raise RuntimeError(f"백업 {pragma} 실패: {check}")
finally:
target_conn.close()
source_conn.close()
temp_path.replace(output)
return {"backup_path": str(output), "backup_bytes": _size(output), "verification": verify, "check_result": check}
def checkpoint(mode: str, acknowledged: bool) -> dict[str, Any]:
if not acknowledged:
raise RuntimeError(
"checkpoint는 운영 서버와 대량 작업을 중단한 유지보수 창에서만 실행하세요. "
"`--ack-maintenance-window`를 함께 지정해야 합니다."
)
conn = sqlite3.connect(DB_PATH, timeout=30)
try:
before = _size(DB_PATH.with_name(DB_PATH.name + "-wal"))
result = conn.execute(f"PRAGMA wal_checkpoint({mode.upper()})").fetchone()
after = _size(DB_PATH.with_name(DB_PATH.name + "-wal"))
finally:
conn.close()
return {"mode": mode, "checkpoint_result": list(result or ()), "wal_bytes_before": before, "wal_bytes_after": after}
def prepare_runtime_layout(root: Path) -> dict[str, str]:
paths = {
"db": root / "db",
"cache": root / "cache",
"backups": root / "backups",
"exports": root / "exports",
}
for path in paths.values():
path.mkdir(parents=True, exist_ok=True)
env_file = root / "runtime.env.example"
env_file.write_text(
"\n".join(
[
f"INTRANET_DB_PATH={paths['db'] / 'data.db'}",
f"INTRANET_BACKUP_DIR={paths['backups']}",
f"INTRANET_CACHE_ROOT={paths['cache']}",
f"INTRANET_COMPARE_EXPORT_DIR={paths['exports'] / 'wehago_compare'}",
"INTRANET_REQUIRE_SAFE_SQLITE=1",
f"WEHAGO_SOURCE_ROOT={Path.home() / 'WEHAGO_DB'}",
"",
]
),
encoding="utf-8",
)
return {key: str(path) for key, path in paths.items()} | {"env_example": str(env_file)}
def main() -> None:
parser = argparse.ArgumentParser(description="SQLite/WAL runtime inspection and maintenance helpers.")
subparsers = parser.add_subparsers(dest="command", required=True)
status_parser = subparsers.add_parser("status")
status_parser.add_argument("--include-counts", action="store_true")
backup_parser = subparsers.add_parser("backup")
backup_parser.add_argument("--output", type=Path)
backup_parser.add_argument(
"--verify",
choices=("smoke", "quick", "full", "none"),
default="smoke",
help="smoke is suitable for container trials; use quick/full during a maintenance window.",
)
checkpoint_parser = subparsers.add_parser("checkpoint")
checkpoint_parser.add_argument("--mode", choices=("passive", "full", "restart", "truncate"), default="passive")
checkpoint_parser.add_argument("--ack-maintenance-window", action="store_true")
layout_parser = subparsers.add_parser("prepare-layout")
layout_parser.add_argument("--root", type=Path, default=Path.home() / "intranet-runtime")
report_parser = subparsers.add_parser("cache-retention-report")
report_parser.add_argument("--limit", type=int, default=20)
prune_parser = subparsers.add_parser("prune-orphan-export-cache")
prune_parser.add_argument("--execute", action="store_true")
prune_parser.add_argument("--ack-delete-rebuildable-cache", action="store_true")
query_report_parser = subparsers.add_parser("query-retention-report")
query_report_parser.add_argument("--keep", type=int, default=2)
query_prune_parser = subparsers.add_parser("prune-old-query-projections")
query_prune_parser.add_argument("--keep", type=int, default=2)
query_prune_parser.add_argument("--execute", action="store_true")
query_prune_parser.add_argument("--ack-delete-rebuildable-cache", action="store_true")
args = parser.parse_args()
if args.command == "status":
result = status(args.include_counts)
elif args.command == "backup":
result = backup(args.output, args.verify)
elif args.command == "checkpoint":
result = checkpoint(args.mode, args.ack_maintenance_window)
elif args.command == "cache-retention-report":
result = cache_retention_report(args.limit)
elif args.command == "prune-orphan-export-cache":
result = prune_orphan_export_cache(
dry_run=not args.execute,
acknowledged=args.ack_delete_rebuildable_cache,
)
elif args.command == "query-retention-report":
result = query_projection_retention_report(args.keep)
elif args.command == "prune-old-query-projections":
result = prune_old_query_projections(
dry_run=not args.execute,
acknowledged=args.ack_delete_rebuildable_cache,
keep=args.keep,
)
else:
result = prepare_runtime_layout(args.root)
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()