Initial commit: OpenClaw 워크스페이스 버전관리 시작
설정·스크립트·스킬·문서·큐레이션 메모리 추적. 시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)· 백업(clobbered/bak)·dream 캐시는 .gitignore로 제외. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# monthly-settlement
|
||||
|
||||
매월 1일에 후잉 잔액을 스냅샷으로 저장하고, 전월 스냅샷과 비교해 자산 변동을 리포트한다.
|
||||
|
||||
## When to use
|
||||
|
||||
- 매월 1일 05:00 KST cron 자동 호출 (`agent: budget`)
|
||||
- 관리자님이 "이번달 결산", "월간 결산", "자산 변동 요약" 요청할 때
|
||||
|
||||
## How
|
||||
|
||||
```bash
|
||||
python3 /Users/snowoyh/.openclaw/agents/budget/workspace/skills/monthly-settlement/scripts/monthly_settlement.py
|
||||
```
|
||||
|
||||
기본 동작:
|
||||
1. `whooing_balance.py --json` 호출해 현재 순자산/자산/부채 스냅샷 생성
|
||||
2. `state/monthly_snapshots.json` 에서 전월 스냅샷 로드
|
||||
3. 계정별 증감 계산 (신규/청산 포함)
|
||||
4. 메일 발송: `gog gmail send --to mini.snowoyh@gmail.com`
|
||||
- 제목 `[월간결산] YYYY년 M월 자산 변동`
|
||||
- 모든 계정 증감을 절대값 내림차순으로 정렬
|
||||
5. 골디 텔레그램 발송: 순자산 변동 한 줄 + ±100만원 이상 변동 top 5
|
||||
6. 이번달 스냅샷을 `monthly_snapshots.json` 에 저장
|
||||
|
||||
첫 실행(비교 대상 없음)은 스냅샷만 저장하고 "첫 결산" 안내만 발송.
|
||||
|
||||
## Flags
|
||||
|
||||
- `--dry-run` — 전송·저장 없이 메일/텔레그램 본문만 stdout 출력
|
||||
- `--no-send` — 전송 생략, 스냅샷 저장과 stdout 출력만 수행 (복구/재실행용)
|
||||
- `--as-of YYYY-MM-DD` — 기준일 강제 지정 (기본: 오늘)
|
||||
|
||||
## Output
|
||||
|
||||
마지막 한 줄 요약:
|
||||
|
||||
```
|
||||
✅ 월간결산 2026-04: 순자산 +1,234,567원, 메일+텔레그램 전송 완료
|
||||
```
|
||||
|
||||
## 데이터
|
||||
|
||||
- 스냅샷 저장: `state/monthly_snapshots.json` (키: `YYYY-MM` = 스냅샷 시점의 월)
|
||||
- 리포트 대상 월: 전월 (스냅샷 시점 기준 직전 달)
|
||||
@@ -0,0 +1,462 @@
|
||||
#!/usr/bin/env python3
|
||||
"""골디 인박스 처리기 — agents/budget/inbox/incoming/ envelope 검증·디스패치.
|
||||
|
||||
처리 흐름:
|
||||
1. incoming/*.json 정렬 스캔
|
||||
2. envelope 검증 (필수 키, schema_version, topic 등록 여부)
|
||||
3. idempotency: state/inbox_state.json 의 processed 에 message_id 있으면 skip → processed/
|
||||
4. topic 핸들러 호출 (현재 securities_balance 만)
|
||||
5. 성공 → processed/, 실패 → failed/ + 골디 텔레그램 자가 알림
|
||||
6. state 갱신 (processed 누적, 최근 1000개 유지)
|
||||
|
||||
CLI:
|
||||
python3 inbox_handler.py [--dry-run]
|
||||
--dry-run 검증만 수행. 후잉 분개 webhook·파일 이동·state 저장은 하지 않음
|
||||
|
||||
monthly_settlement.py 에서도 import 해서 process_inbox() 호출.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import traceback
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
KST = ZoneInfo('Asia/Seoul')
|
||||
ROOT = Path('/Users/snowoyh/.openclaw')
|
||||
WORKSPACE = ROOT / 'agents' / 'budget' / 'workspace'
|
||||
INBOX_DIR = ROOT / 'agents' / 'budget' / 'inbox'
|
||||
INCOMING_DIR = INBOX_DIR / 'incoming'
|
||||
PROCESSED_DIR = INBOX_DIR / 'processed'
|
||||
FAILED_DIR = INBOX_DIR / 'failed'
|
||||
STATE_FILE = WORKSPACE / 'state' / 'inbox_state.json'
|
||||
CONFIG_PATH = ROOT / 'openclaw.json'
|
||||
CREDENTIALS = ROOT / 'credentials' / 'whooing.json'
|
||||
BALANCE_SCRIPT = WORKSPACE / 'skills' / 'whooing-sync' / 'scripts' / 'whooing_balance.py'
|
||||
TELEGRAM_ACCOUNT = 'budget'
|
||||
|
||||
KNOWN_TOPICS = {'securities_balance'}
|
||||
SUPPORTED_SCHEMA = {1}
|
||||
|
||||
SECURITIES_ASSET_NAME = '증권(효원)'
|
||||
SECURITIES_GAIN_NAME = '주식평가수익'
|
||||
SECURITIES_LOSS_NAME = '주식평가손실'
|
||||
|
||||
RECONCILE_NOISE_FLOOR = 10_000 # |차액| < 1만원: 노이즈, 분개 skip
|
||||
RECONCILE_HARD_CAP = 100_000_000 # |차액| > 1억원: 분개 거부 (안전 가드)
|
||||
PAYLOAD_AMOUNT_CAP = 10_000_000_000 # 100억 초과 금액: payload 거부
|
||||
|
||||
PROCESSED_RETENTION_DAYS = 30 # processed/ 보관 기간
|
||||
FAILED_BACKLOG_THRESHOLD = 5 # failed/ 적체 alert 임계값
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def load_state() -> dict:
|
||||
if STATE_FILE.exists():
|
||||
try:
|
||||
return json.loads(STATE_FILE.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return {'processed': []}
|
||||
|
||||
|
||||
def save_state(state: dict) -> None:
|
||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
_TELEGRAM_DRY_RUN = False
|
||||
|
||||
|
||||
def send_telegram(text: str) -> bool:
|
||||
if _TELEGRAM_DRY_RUN:
|
||||
print(f'[telegram-dry-run] {text}', file=sys.stderr)
|
||||
return True
|
||||
try:
|
||||
cfg = json.loads(CONFIG_PATH.read_text())
|
||||
acct = cfg['channels']['telegram']['accounts'][TELEGRAM_ACCOUNT]
|
||||
token = acct['botToken']
|
||||
chat_ids = acct.get('allowFrom') or []
|
||||
except Exception:
|
||||
return False
|
||||
if not chat_ids:
|
||||
return False
|
||||
url = f'https://api.telegram.org/bot{token}/sendMessage'
|
||||
ok = True
|
||||
for chat_id in chat_ids:
|
||||
data = urllib.parse.urlencode({
|
||||
'chat_id': chat_id,
|
||||
'text': text[:4000],
|
||||
'disable_web_page_preview': 'true',
|
||||
}).encode()
|
||||
try:
|
||||
req = urllib.request.Request(url, data=data, method='POST')
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
if r.status != 200:
|
||||
ok = False
|
||||
except Exception:
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
|
||||
def validate_envelope(env: dict) -> None:
|
||||
required = {'message_id', 'from', 'to', 'topic', 'created_at', 'schema_version', 'payload'}
|
||||
missing = required - set(env)
|
||||
if missing:
|
||||
raise ValidationError(f"envelope 키 누락: {sorted(missing)}")
|
||||
if env['to'] != 'budget':
|
||||
raise ValidationError(f"to != 'budget': {env['to']}")
|
||||
if env['topic'] not in KNOWN_TOPICS:
|
||||
raise ValidationError(f"미등록 topic: {env['topic']}")
|
||||
if env['schema_version'] not in SUPPORTED_SCHEMA:
|
||||
raise ValidationError(f"미지원 schema_version: {env['schema_version']}")
|
||||
if not isinstance(env['payload'], dict):
|
||||
raise ValidationError('payload 가 dict 가 아님')
|
||||
|
||||
|
||||
def validate_securities_payload(payload: dict) -> None:
|
||||
for key in ('as_of', 'accounts', 'totals', 'owner_scope'):
|
||||
if key not in payload:
|
||||
raise ValidationError(f'payload 키 누락: {key}')
|
||||
|
||||
as_of = payload['as_of']
|
||||
try:
|
||||
as_of_dt = datetime.strptime(as_of, '%Y-%m-%d').date()
|
||||
except Exception:
|
||||
raise ValidationError(f'as_of 형식 오류 (YYYY-MM-DD): {as_of}')
|
||||
if as_of_dt.day not in (1, 10, 20):
|
||||
raise ValidationError(f'as_of 는 매월 1·10·20일이어야 함: {as_of}')
|
||||
|
||||
totals = payload['totals']
|
||||
for key in ('deposit', 'eval_amount', 'total'):
|
||||
if key not in totals:
|
||||
raise ValidationError(f'totals.{key} 누락')
|
||||
v = totals[key]
|
||||
if not isinstance(v, int) or v < 0:
|
||||
raise ValidationError(f'totals.{key} 비정상: {v!r}')
|
||||
if v > PAYLOAD_AMOUNT_CAP:
|
||||
raise ValidationError(f'totals.{key} 100억 초과 (가드): {v:,}')
|
||||
|
||||
if not isinstance(payload['accounts'], list) or not payload['accounts']:
|
||||
raise ValidationError('accounts 비어있음')
|
||||
sum_total = sum(int(a.get('total', 0)) for a in payload['accounts'])
|
||||
if sum_total != totals['total']:
|
||||
raise ValidationError(
|
||||
f'accounts 합계 불일치: {sum_total:,} vs totals.total={totals["total"]:,}'
|
||||
)
|
||||
|
||||
|
||||
def fetch_whooing_balance(as_of: str | None = 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 get_whooing_balance_for(name: str, current: dict) -> int | None:
|
||||
for sec in current.get('sections', []) or []:
|
||||
items = (sec.get('groups', {}).get('자산', {}) or {}).get('items', []) or []
|
||||
for it in items:
|
||||
if it.get('name') == name:
|
||||
return int(it.get('money', 0))
|
||||
return None
|
||||
|
||||
|
||||
def post_whooing(payload: dict, dry_run: bool = False) -> tuple[bool, str]:
|
||||
if dry_run:
|
||||
return True, 'dry-run'
|
||||
try:
|
||||
cred = json.loads(CREDENTIALS.read_text())
|
||||
except Exception as e:
|
||||
return False, f'credentials 읽기 실패: {e}'
|
||||
url = (cred.get('webhook_url') or '').strip()
|
||||
if not url:
|
||||
return False, 'webhook_url 비어있음'
|
||||
encoded = urllib.parse.urlencode(payload, quote_via=urllib.parse.quote).encode()
|
||||
req = urllib.request.Request(url, data=encoded, method='POST')
|
||||
req.add_header('Content-Type', 'application/x-www-form-urlencoded')
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
body = r.read().decode('utf-8', errors='replace')
|
||||
ok = (200 <= r.status < 300) and body.strip().lower().startswith('done')
|
||||
return ok, body
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def handle_securities_balance(envelope: dict, current_balance: dict, dry_run: bool = False) -> dict:
|
||||
"""차액 reconcile + 후잉 자동 분개. 반환: {action, delta, before, after, journal_ok, note, journal?}.
|
||||
action ∈ {aligned, journaled, noise, skipped, rejected, journal_failed}.
|
||||
"""
|
||||
payload = envelope['payload']
|
||||
target_total = int(payload['totals']['total'])
|
||||
current = get_whooing_balance_for(SECURITIES_ASSET_NAME, current_balance)
|
||||
base = {
|
||||
'topic': 'securities_balance',
|
||||
'as_of': payload.get('as_of'),
|
||||
'target_total': target_total,
|
||||
}
|
||||
if current is None:
|
||||
return {
|
||||
**base,
|
||||
'action': 'skipped',
|
||||
'note': f'후잉 자산에 "{SECURITIES_ASSET_NAME}" 항목 없음',
|
||||
'delta': None, 'before': None, 'after': None, 'journal_ok': None,
|
||||
}
|
||||
delta = target_total - current
|
||||
if delta == 0:
|
||||
return {**base, 'action': 'aligned', 'delta': 0,
|
||||
'before': current, 'after': current, 'journal_ok': True,
|
||||
'note': '차액 0 — 분개 불필요'}
|
||||
if abs(delta) < RECONCILE_NOISE_FLOOR:
|
||||
return {**base, 'action': 'noise', 'delta': delta,
|
||||
'before': current, 'after': current, 'journal_ok': True,
|
||||
'note': f'차액 {delta:+,}원 — 1만원 미만, 분개 skip'}
|
||||
if abs(delta) > RECONCILE_HARD_CAP:
|
||||
return {**base, 'action': 'rejected', 'delta': delta,
|
||||
'before': current, 'after': current, 'journal_ok': False,
|
||||
'note': f'차액 {delta:+,}원 — 1억원 초과, 분개 거부 (가드)'}
|
||||
|
||||
entry_date = payload['as_of'].replace('-', '')
|
||||
ym = payload['as_of'][:7]
|
||||
if delta > 0:
|
||||
item = f'{ym} 평가차익'
|
||||
left, right, money = SECURITIES_ASSET_NAME, SECURITIES_GAIN_NAME, delta
|
||||
else:
|
||||
item = f'{ym} 평가차손'
|
||||
left, right, money = SECURITIES_LOSS_NAME, SECURITIES_ASSET_NAME, -delta
|
||||
|
||||
journal = {
|
||||
'entry_date': entry_date,
|
||||
'item': item,
|
||||
'money': str(money),
|
||||
'left': left,
|
||||
'right': right,
|
||||
'memo': f'레이 inbox reconcile (msg={envelope["message_id"][:8]})',
|
||||
}
|
||||
ok, body = post_whooing(journal, dry_run=dry_run)
|
||||
return {
|
||||
**base,
|
||||
'action': 'journaled' if ok else 'journal_failed',
|
||||
'delta': delta,
|
||||
'before': current,
|
||||
'after': current + delta if ok else current,
|
||||
'journal_ok': ok,
|
||||
'note': body[:200] if not dry_run else 'dry-run',
|
||||
'journal': journal,
|
||||
}
|
||||
|
||||
|
||||
def move_to(src: Path, dest_dir: Path, dry_run: bool = False) -> None:
|
||||
if dry_run:
|
||||
return
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = dest_dir / src.name
|
||||
if target.exists():
|
||||
target = dest_dir / f'{src.stem}__{datetime.now(KST).strftime("%H%M%S")}{src.suffix}'
|
||||
shutil.move(str(src), str(target))
|
||||
|
||||
|
||||
def gc_processed(retention_days: int = PROCESSED_RETENTION_DAYS, dry_run: bool = False) -> dict:
|
||||
"""processed/ 에서 mtime 이 retention_days 초과한 *.json 삭제.
|
||||
반환: {removed: [filename, ...], kept: int}.
|
||||
"""
|
||||
if not PROCESSED_DIR.exists():
|
||||
return {'removed': [], 'kept': 0}
|
||||
cutoff = datetime.now(KST).timestamp() - retention_days * 86400
|
||||
removed: list[str] = []
|
||||
kept = 0
|
||||
for fpath in PROCESSED_DIR.glob('*.json'):
|
||||
try:
|
||||
if fpath.stat().st_mtime < cutoff:
|
||||
if not dry_run:
|
||||
fpath.unlink()
|
||||
removed.append(fpath.name)
|
||||
else:
|
||||
kept += 1
|
||||
except OSError:
|
||||
kept += 1
|
||||
return {'removed': removed, 'kept': kept}
|
||||
|
||||
|
||||
def count_failed_backlog() -> int:
|
||||
"""failed/ 의 *.json 개수. 적체 alert 판단용."""
|
||||
if not FAILED_DIR.exists():
|
||||
return 0
|
||||
return sum(1 for _ in FAILED_DIR.glob('*.json'))
|
||||
|
||||
|
||||
def process_inbox(current_balance: dict | None = None, dry_run: bool = False) -> dict:
|
||||
"""반환: {processed, failed, reconcile}. settlement 에서 호출 시 current_balance 주입.
|
||||
current_balance 가 None 이면 첫 securities_balance 처리 시 lazy fetch.
|
||||
dry_run=True 시 후잉 webhook + 텔레그램 자가 알림 모두 stdout 으로만 출력.
|
||||
"""
|
||||
global _TELEGRAM_DRY_RUN
|
||||
_TELEGRAM_DRY_RUN = dry_run
|
||||
|
||||
INCOMING_DIR.mkdir(parents=True, exist_ok=True)
|
||||
state = load_state()
|
||||
processed_set = set(state.get('processed', []))
|
||||
|
||||
summary: dict = {
|
||||
'processed': [], 'failed': [], 'reconcile': {},
|
||||
'gc_removed': [], 'failed_backlog': 0,
|
||||
}
|
||||
|
||||
# 매 호출마다 GC 한 번 + failed 적체 카운트 (incoming 비어있어도 수행)
|
||||
gc_result = gc_processed(dry_run=dry_run)
|
||||
summary['gc_removed'] = gc_result['removed']
|
||||
|
||||
files = sorted(INCOMING_DIR.glob('*.json'))
|
||||
if not files:
|
||||
summary['failed_backlog'] = count_failed_backlog()
|
||||
return summary
|
||||
|
||||
balance = current_balance
|
||||
|
||||
for fpath in files:
|
||||
try:
|
||||
env = json.loads(fpath.read_text())
|
||||
except Exception as e:
|
||||
move_to(fpath, FAILED_DIR, dry_run=dry_run)
|
||||
summary['failed'].append({'file': fpath.name, 'reason': f'JSON 파싱 실패: {e}'})
|
||||
send_telegram(f'⚠️ 골디 inbox: JSON 파싱 실패\n{fpath.name}\n{e}')
|
||||
continue
|
||||
|
||||
msg_id = env.get('message_id', '?')
|
||||
|
||||
if msg_id in processed_set:
|
||||
move_to(fpath, PROCESSED_DIR, dry_run=dry_run)
|
||||
continue
|
||||
|
||||
try:
|
||||
validate_envelope(env)
|
||||
topic = env['topic']
|
||||
|
||||
if topic == 'securities_balance':
|
||||
validate_securities_payload(env['payload'])
|
||||
if balance is None:
|
||||
balance = fetch_whooing_balance()
|
||||
result = handle_securities_balance(env, balance, dry_run=dry_run)
|
||||
summary['reconcile'].setdefault(topic, []).append(result)
|
||||
|
||||
if result['action'] in ('rejected', 'journal_failed'):
|
||||
move_to(fpath, FAILED_DIR, dry_run=dry_run)
|
||||
reason = result['note']
|
||||
summary['failed'].append({'file': fpath.name, 'reason': reason, 'message_id': msg_id})
|
||||
send_telegram(f'⚠️ 골디 inbox: 분개 {result["action"]}\n{fpath.name}\n{reason}')
|
||||
continue
|
||||
|
||||
# 분개 성공/aligned/noise/skipped 모두 processed 로 이동
|
||||
if result.get('journal_ok') and result['action'] == 'journaled' and balance is not None:
|
||||
# 후잉 잔액 in-memory 업데이트 (다음 파일 reconcile 시 정합성 위해)
|
||||
for sec in balance.get('sections', []):
|
||||
items = (sec.get('groups', {}).get('자산', {}) or {}).get('items', []) or []
|
||||
for it in items:
|
||||
if it.get('name') == SECURITIES_ASSET_NAME:
|
||||
it['money'] = result['after']
|
||||
else:
|
||||
raise ValidationError(f'핸들러 없음: {topic}')
|
||||
|
||||
move_to(fpath, PROCESSED_DIR, dry_run=dry_run)
|
||||
processed_set.add(msg_id)
|
||||
summary['processed'].append({'file': fpath.name, 'topic': topic, 'message_id': msg_id})
|
||||
|
||||
except ValidationError as e:
|
||||
move_to(fpath, FAILED_DIR, dry_run=dry_run)
|
||||
summary['failed'].append({'file': fpath.name, 'reason': str(e), 'message_id': msg_id})
|
||||
send_telegram(f'⚠️ 골디 inbox: 검증 실패\n{fpath.name}\n{e}')
|
||||
except Exception as e:
|
||||
move_to(fpath, FAILED_DIR, dry_run=dry_run)
|
||||
tb = traceback.format_exc()[-500:]
|
||||
summary['failed'].append({
|
||||
'file': fpath.name, 'reason': f'{type(e).__name__}: {e}',
|
||||
'message_id': msg_id, 'traceback': tb,
|
||||
})
|
||||
send_telegram(f'⚠️ 골디 inbox: 처리 예외\n{fpath.name}\n{type(e).__name__}: {e}')
|
||||
|
||||
if not dry_run and summary['processed']:
|
||||
new_processed = list(processed_set)[-1000:]
|
||||
state['processed'] = new_processed
|
||||
save_state(state)
|
||||
|
||||
# 처리 후 failed 적체 재카운트 (이번 사이클의 신규 failed 포함)
|
||||
summary['failed_backlog'] = count_failed_backlog()
|
||||
return summary
|
||||
|
||||
|
||||
def format_summary(summary: dict) -> str:
|
||||
"""결산 메일 본문에 추가할 마크다운. reconcile 결과 + 실패 요약 + 적체 alert."""
|
||||
backlog = summary.get('failed_backlog', 0)
|
||||
has_alert = backlog >= FAILED_BACKLOG_THRESHOLD
|
||||
has_content = (
|
||||
summary['processed'] or summary['failed'] or summary.get('reconcile') or has_alert
|
||||
)
|
||||
if not has_content:
|
||||
return ''
|
||||
|
||||
lines = ['## 인박스', '']
|
||||
|
||||
for result in summary.get('reconcile', {}).get('securities_balance', []):
|
||||
action = result.get('action')
|
||||
before = result.get('before')
|
||||
after = result.get('after')
|
||||
delta = result.get('delta')
|
||||
if action == 'aligned':
|
||||
lines.append(f'- **증권(효원):** {before:,}원 — 레이 ground truth 와 일치')
|
||||
elif action == 'journaled':
|
||||
sign = '평가차익' if (delta or 0) > 0 else '평가차손'
|
||||
lines.append(
|
||||
f'- **증권(효원):** {before:,}원 → {after:,}원 '
|
||||
f'({sign} **{abs(delta):,}원** 자동 분개)'
|
||||
)
|
||||
elif action == 'noise':
|
||||
lines.append(f'- **증권(효원):** 차액 {delta:+,}원 (1만원 미만, 분개 skip)')
|
||||
elif action == 'skipped':
|
||||
lines.append(f'- **증권(효원):** {result.get("note", "skip")}')
|
||||
elif action == 'rejected':
|
||||
lines.append(f'- **증권(효원):** ⚠️ 차액 {delta:+,}원 — 분개 거부 (안전 가드)')
|
||||
elif action == 'journal_failed':
|
||||
lines.append(f'- **증권(효원):** ⚠️ 분개 실패 — {result.get("note", "")[:120]}')
|
||||
|
||||
if summary['failed']:
|
||||
lines.append('')
|
||||
lines.append('### 이번 사이클 실패')
|
||||
for f in summary['failed']:
|
||||
lines.append(f'- `{f["file"]}` — {f["reason"]}')
|
||||
|
||||
if has_alert:
|
||||
lines.append('')
|
||||
lines.append(
|
||||
f'### ⚠️ failed/ 적체 {backlog}건 — 검토·수동 삭제 필요'
|
||||
)
|
||||
lines.append(f'- 위치: `agents/budget/inbox/failed/`')
|
||||
|
||||
return '\n'.join(lines).rstrip() + '\n'
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--dry-run', action='store_true', help='검증만 수행, 분개 webhook·파일 이동·state 저장 안 함')
|
||||
args = ap.parse_args()
|
||||
|
||||
summary = process_inbox(dry_run=args.dry_run)
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
return 0 if not summary['failed'] else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user