fed3526b20
설정·스크립트·스킬·문서·큐레이션 메모리 추적. 시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)· 백업(clobbered/bak)·dream 캐시는 .gitignore로 제외. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
379 lines
14 KiB
Python
Executable File
379 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""월간 결산 — 후잉 순자산 변동을 전월 스냅샷과 비교해 메일/텔레그램으로 보고.
|
|
|
|
매월 1일 05:00 KST cron 실행 전제. 전월 스냅샷과 비교해:
|
|
- 메일: 계정별 증감 전체 (절대값 내림차순)
|
|
- 골디 텔레그램: 순자산 변동 + ±100만원 이상 top 5
|
|
|
|
Flags:
|
|
--dry-run 전송·저장 없이 본문만 stdout
|
|
--no-send 전송 생략, 스냅샷만 저장
|
|
--as-of DATE 기준일 강제 지정 (YYYY-MM-DD)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import urllib.parse
|
|
import urllib.request
|
|
from datetime import date, datetime
|
|
from pathlib import Path
|
|
from zoneinfo import ZoneInfo
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
import inbox_handler # noqa: E402
|
|
|
|
KST = ZoneInfo("Asia/Seoul")
|
|
WORKSPACE = Path("/Users/snowoyh/.openclaw/agents/budget/workspace")
|
|
STATE_FILE = WORKSPACE / "state" / "monthly_snapshots.json"
|
|
BALANCE_SCRIPT = WORKSPACE / "skills" / "whooing-sync" / "scripts" / "whooing_balance.py"
|
|
CONFIG_PATH = Path("/Users/snowoyh/.openclaw/openclaw.json")
|
|
EMAIL_RECIPIENT = "mini.snowoyh@gmail.com"
|
|
TELEGRAM_ACCOUNT = "budget" # 골디
|
|
TOP_THRESHOLD = 1_000_000
|
|
TOP_MAX = 5
|
|
|
|
|
|
def fmt_won(n: int) -> str:
|
|
sign = "+" if n > 0 else ("-" if n < 0 else "")
|
|
return f"{sign}{abs(n):,}원"
|
|
|
|
|
|
def fetch_balance(as_of: str | None) -> dict:
|
|
cmd = ["python3", str(BALANCE_SCRIPT), "--json"]
|
|
if as_of:
|
|
cmd += ["--as-of", as_of]
|
|
p = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
if p.returncode != 0:
|
|
raise RuntimeError(f"whooing_balance.py 실패: {p.stderr.strip() or p.stdout.strip()}")
|
|
return json.loads(p.stdout)
|
|
|
|
|
|
def load_snapshots() -> dict:
|
|
if STATE_FILE.exists():
|
|
try:
|
|
return json.loads(STATE_FILE.read_text())
|
|
except Exception:
|
|
pass
|
|
return {}
|
|
|
|
|
|
def save_snapshots(snaps: dict) -> None:
|
|
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
STATE_FILE.write_text(json.dumps(snaps, ensure_ascii=False, indent=2))
|
|
|
|
|
|
def section_items(section: dict, group_ko: str) -> dict[str, dict]:
|
|
"""group의 items를 account_id → {name, money}로 변환."""
|
|
items = section.get("groups", {}).get(group_ko, {}).get("items", []) or []
|
|
return {str(it["account_id"]): {"name": it["name"], "money": it["money"]} for it in items}
|
|
|
|
|
|
def section_total(section: dict, group_ko: str) -> int:
|
|
return int(section.get("groups", {}).get(group_ko, {}).get("total", 0) or 0)
|
|
|
|
|
|
def compute_section_deltas(prev: dict, curr: dict) -> dict:
|
|
"""전월/이번달 섹션 한 쌍을 받아 순자산/자산/부채 변동 + 계정별 변동 리스트 반환."""
|
|
result = {
|
|
"section_id": curr.get("section_id"),
|
|
"title": curr.get("title"),
|
|
"totals": {},
|
|
"accounts": [], # [{group, account_id, name, prev, curr, delta}]
|
|
}
|
|
|
|
for group_ko in ("자산", "부채", "자본"):
|
|
prev_total = section_total(prev, group_ko) if prev else 0
|
|
curr_total = section_total(curr, group_ko)
|
|
result["totals"][group_ko] = {
|
|
"prev": prev_total,
|
|
"curr": curr_total,
|
|
"delta": curr_total - prev_total,
|
|
}
|
|
|
|
for group_ko in ("자산", "부채"):
|
|
prev_items = section_items(prev, group_ko) if prev else {}
|
|
curr_items = section_items(curr, group_ko)
|
|
all_ids = set(prev_items) | set(curr_items)
|
|
for aid in all_ids:
|
|
p_item = prev_items.get(aid)
|
|
c_item = curr_items.get(aid)
|
|
prev_money = p_item["money"] if p_item else 0
|
|
curr_money = c_item["money"] if c_item else 0
|
|
delta = curr_money - prev_money
|
|
if delta == 0 and prev_money == 0 and curr_money == 0:
|
|
continue
|
|
name = (c_item or p_item)["name"]
|
|
result["accounts"].append({
|
|
"group": group_ko,
|
|
"account_id": aid,
|
|
"name": name,
|
|
"prev": prev_money,
|
|
"curr": curr_money,
|
|
"delta": delta,
|
|
})
|
|
|
|
return result
|
|
|
|
|
|
def format_email(report_ym: str, deltas: dict, has_prev: bool) -> tuple[str, str]:
|
|
"""메일 제목/본문 반환."""
|
|
subject = f"[월간결산] {report_ym[:4]}년 {int(report_ym[5:])}월 자산 변동"
|
|
lines: list[str] = []
|
|
|
|
if not has_prev:
|
|
lines.append(f"# {subject}\n")
|
|
lines.append("첫 스냅샷을 저장했습니다. 다음달 1일부터 전월 대비 비교가 시작됩니다.\n")
|
|
lines.append(f"## 현재 스냅샷 ({deltas['title']})\n")
|
|
for ko in ("자산", "부채", "자본"):
|
|
lines.append(f"- **{ko} 합계:** {deltas['totals'][ko]['curr']:,}원")
|
|
return subject, "\n".join(lines) + "\n"
|
|
|
|
nw = deltas["totals"]["자본"]
|
|
asset = deltas["totals"]["자산"]
|
|
liab = deltas["totals"]["부채"]
|
|
|
|
lines.append(f"# {subject}\n")
|
|
lines.append(f"섹션: {deltas['title']}\n")
|
|
lines.append("## 합계 변동\n")
|
|
lines.append(f"- **순자산:** {nw['prev']:,}원 → {nw['curr']:,}원 (**{fmt_won(nw['delta'])}**)")
|
|
lines.append(f"- 자산: {asset['prev']:,}원 → {asset['curr']:,}원 ({fmt_won(asset['delta'])})")
|
|
lines.append(f"- 부채: {liab['prev']:,}원 → {liab['curr']:,}원 ({fmt_won(liab['delta'])})")
|
|
lines.append("")
|
|
|
|
for group_ko in ("자산", "부채"):
|
|
rows = [a for a in deltas["accounts"] if a["group"] == group_ko]
|
|
rows.sort(key=lambda x: abs(x["delta"]), reverse=True)
|
|
if not rows:
|
|
continue
|
|
lines.append(f"## {group_ko} 계정별 변동\n")
|
|
for a in rows:
|
|
tag = ""
|
|
if a["prev"] == 0 and a["curr"] != 0:
|
|
tag = " · 신규"
|
|
elif a["curr"] == 0 and a["prev"] != 0:
|
|
tag = " · 청산"
|
|
lines.append(
|
|
f"- **{a['name']}**: {a['prev']:,}원 → {a['curr']:,}원 "
|
|
f"({fmt_won(a['delta'])}){tag}"
|
|
)
|
|
lines.append("")
|
|
|
|
return subject, "\n".join(lines).rstrip() + "\n"
|
|
|
|
|
|
def format_telegram(report_ym: str, deltas: dict, has_prev: bool) -> str:
|
|
month_label = f"{report_ym[:4]}년 {int(report_ym[5:])}월"
|
|
if not has_prev:
|
|
return (
|
|
f"📊 {month_label} 결산\n"
|
|
f"첫 스냅샷 저장 완료 — 다음달부터 비교 시작합니다."
|
|
)
|
|
|
|
nw = deltas["totals"]["자본"]
|
|
pct = (nw["delta"] / nw["prev"] * 100) if nw["prev"] else 0.0
|
|
pct_str = f"{pct:+.2f}%" if nw["prev"] else "N/A"
|
|
|
|
lines = [
|
|
f"📊 {month_label} 결산",
|
|
f"순자산 {nw['prev']:,} → {nw['curr']:,}원 ({fmt_won(nw['delta'])}, {pct_str})",
|
|
]
|
|
|
|
movers = [a for a in deltas["accounts"] if abs(a["delta"]) >= TOP_THRESHOLD]
|
|
movers.sort(key=lambda x: abs(x["delta"]), reverse=True)
|
|
movers = movers[:TOP_MAX]
|
|
|
|
if movers:
|
|
lines.append("")
|
|
lines.append("주요 변동:")
|
|
for a in movers:
|
|
arrow = "▲" if a["delta"] > 0 else "▼"
|
|
tag = ""
|
|
if a["prev"] == 0:
|
|
tag = " (신규)"
|
|
elif a["curr"] == 0:
|
|
tag = " (청산)"
|
|
lines.append(f"{arrow} {a['name']}{tag}: {fmt_won(a['delta'])}")
|
|
else:
|
|
lines.append("")
|
|
lines.append(f"±{TOP_THRESHOLD//10000}만원 이상 변동 없음")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def send_email(subject: str, body: str) -> None:
|
|
cmd = ["gog", "gmail", "send", "--to", EMAIL_RECIPIENT, "--subject", subject, "--body-file", "-"]
|
|
p = subprocess.run(cmd, input=body, text=True, capture_output=True, timeout=60)
|
|
if p.returncode != 0:
|
|
raise RuntimeError(f"메일 발송 실패: {p.stderr.strip() or p.stdout.strip()}")
|
|
|
|
|
|
def send_telegram(text: str) -> None:
|
|
cfg = json.loads(CONFIG_PATH.read_text())
|
|
acct = cfg["channels"]["telegram"]["accounts"][TELEGRAM_ACCOUNT]
|
|
token = acct["botToken"]
|
|
chat_ids = acct.get("allowFrom") or []
|
|
if not chat_ids:
|
|
raise RuntimeError("골디 텔레그램 allowFrom 비어있음")
|
|
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
|
for chat_id in chat_ids:
|
|
payload = {
|
|
"chat_id": chat_id,
|
|
"text": text[:4000],
|
|
"disable_web_page_preview": "true",
|
|
}
|
|
data = urllib.parse.urlencode(payload).encode()
|
|
req = urllib.request.Request(url, data=data, method="POST")
|
|
with urllib.request.urlopen(req, timeout=15) as r:
|
|
if r.status != 200:
|
|
body = r.read().decode("utf-8", errors="replace")
|
|
raise RuntimeError(f"텔레그램 HTTP {r.status}: {body[:200]}")
|
|
|
|
|
|
def format_inbox_telegram_line(summary: dict) -> str:
|
|
"""텔레그램용 인박스 한 줄. reconcile 결과만 요약, 실패 건수 있으면 표시."""
|
|
parts: list[str] = []
|
|
for r in summary.get("reconcile", {}).get("securities_balance", []):
|
|
action = r.get("action")
|
|
delta = r.get("delta") or 0
|
|
if action == "aligned":
|
|
parts.append("증권 일치")
|
|
elif action == "journaled":
|
|
sign = "차익" if delta > 0 else "차손"
|
|
parts.append(f"증권 평가{sign} {abs(delta):,}원 분개")
|
|
elif action == "noise":
|
|
parts.append(f"증권 차액 {delta:+,}원 (노이즈)")
|
|
elif action == "rejected":
|
|
parts.append(f"⚠️ 증권 차액 {delta:+,}원 거부")
|
|
elif action == "journal_failed":
|
|
parts.append("⚠️ 증권 분개 실패")
|
|
elif action == "skipped":
|
|
parts.append("증권 reconcile skip")
|
|
# rejected/journal_failed 는 위 reconcile 라인에서 이미 표시됨 — 중복 카운트 방지
|
|
reconcile_failed = sum(
|
|
1 for r in summary.get("reconcile", {}).get("securities_balance", [])
|
|
if r.get("action") in ("rejected", "journal_failed")
|
|
)
|
|
other_failed = max(0, len(summary.get("failed", [])) - reconcile_failed)
|
|
if other_failed:
|
|
parts.append(f"인박스 검증실패 {other_failed}건")
|
|
|
|
backlog = summary.get("failed_backlog", 0)
|
|
if backlog >= inbox_handler.FAILED_BACKLOG_THRESHOLD:
|
|
parts.append(f"⚠️ failed/ 적체 {backlog}건")
|
|
|
|
if not parts:
|
|
return ""
|
|
return "📨 " + " · ".join(parts)
|
|
|
|
|
|
def prev_month_key(ym: str) -> str:
|
|
y, m = int(ym[:4]), int(ym[5:])
|
|
if m == 1:
|
|
return f"{y-1:04d}-12"
|
|
return f"{y:04d}-{m-1:02d}"
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--dry-run", action="store_true", help="전송·저장 없이 본문만 출력")
|
|
ap.add_argument("--no-send", action="store_true", help="전송 생략, 스냅샷만 저장")
|
|
ap.add_argument("--as-of", help="기준일 (YYYY-MM-DD). 기본: 오늘")
|
|
args = ap.parse_args()
|
|
|
|
as_of_str = args.as_of or datetime.now(KST).strftime("%Y-%m-%d")
|
|
snapshot_ym = as_of_str[:7] # YYYY-MM (스냅샷 시점의 월)
|
|
report_ym = prev_month_key(snapshot_ym) # 결산 대상: 직전 달
|
|
|
|
# 인박스 reconcile 먼저 — securities_balance 분개가 후잉 잔액을 바꾸므로
|
|
# fetch_balance 이전에 처리해야 결산이 분개 후 스냅샷을 본다.
|
|
# --dry-run 또는 --no-send 시 webhook·텔레그램 자가알림 모두 차단 (안전 디폴트).
|
|
inbox_dry = args.dry_run or args.no_send
|
|
try:
|
|
inbox_summary = inbox_handler.process_inbox(dry_run=inbox_dry)
|
|
except Exception as e:
|
|
print(f"⚠️ 인박스 처리 예외 (결산은 계속): {e}", file=sys.stderr)
|
|
inbox_summary = {
|
|
"processed": [], "failed": [{"file": "(handler)", "reason": str(e)}],
|
|
"reconcile": {}, "gc_removed": [], "failed_backlog": 0,
|
|
}
|
|
|
|
# cron 로그용 GC·적체 진단 한 줄 (메일·텔레그램은 깨끗 유지)
|
|
n_gc = len(inbox_summary.get("gc_removed", []))
|
|
n_backlog = inbox_summary.get("failed_backlog", 0)
|
|
n_proc = len(inbox_summary.get("processed", []))
|
|
n_fail = len(inbox_summary.get("failed", []))
|
|
suffix = " [dry-run]" if inbox_dry else ""
|
|
print(
|
|
f"인박스: processed={n_proc} failed={n_fail} "
|
|
f"gc_removed={n_gc} failed_backlog={n_backlog}{suffix}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
current = fetch_balance(as_of_str)
|
|
if not current.get("sections"):
|
|
print("error: 후잉 섹션 없음", file=sys.stderr)
|
|
return 2
|
|
curr_sec = current["sections"][0] # 단일 섹션 전제
|
|
|
|
snapshots = load_snapshots()
|
|
prev_entry = snapshots.get(report_ym)
|
|
prev_sec = None
|
|
if prev_entry and prev_entry.get("sections"):
|
|
prev_sec = prev_entry["sections"][0]
|
|
|
|
deltas = compute_section_deltas(prev_sec or {}, curr_sec)
|
|
has_prev = prev_sec is not None
|
|
|
|
subject, email_body = format_email(report_ym, deltas, has_prev)
|
|
tg_body = format_telegram(report_ym, deltas, has_prev)
|
|
|
|
inbox_block = inbox_handler.format_summary(inbox_summary)
|
|
if inbox_block:
|
|
email_body = email_body.rstrip() + "\n\n" + inbox_block
|
|
# 텔레그램은 한 줄 요약만 — 자세한 건 메일에서 확인
|
|
tg_inbox_line = format_inbox_telegram_line(inbox_summary)
|
|
if tg_inbox_line:
|
|
tg_body = tg_body + "\n\n" + tg_inbox_line
|
|
|
|
if args.dry_run:
|
|
print("=" * 60)
|
|
print(f"SUBJECT: {subject}")
|
|
print("=" * 60)
|
|
print(email_body)
|
|
print("=" * 60)
|
|
print("TELEGRAM:")
|
|
print("=" * 60)
|
|
print(tg_body)
|
|
return 0
|
|
|
|
if not args.no_send:
|
|
try:
|
|
send_email(subject, email_body)
|
|
except Exception as e:
|
|
print(f"⚠️ 메일 발송 실패: {e}", file=sys.stderr)
|
|
return 1
|
|
try:
|
|
send_telegram(tg_body)
|
|
except Exception as e:
|
|
print(f"⚠️ 텔레그램 발송 실패: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
snapshots[snapshot_ym] = current
|
|
save_snapshots(snapshots)
|
|
|
|
if has_prev:
|
|
nw_delta = deltas["totals"]["자본"]["delta"]
|
|
tail = "메일+텔레그램 전송 완료" if not args.no_send else "저장만 수행"
|
|
print(f"✅ 월간결산 {report_ym}: 순자산 {fmt_won(nw_delta)}, {tail}")
|
|
else:
|
|
tail = "메일+텔레그램 전송 완료" if not args.no_send else "저장만 수행"
|
|
print(f"✅ 월간결산 {report_ym}: 첫 스냅샷 저장, {tail}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|