"""expense_db PostgreSQL 저장소. JSON 저장소(`ExpenseStore`)와 같은 인터페이스를 제공하여, 라우터 코드를 바꾸지 않고도 교체할 수 있다. - 드라이버: psycopg 3 (`psycopg[binary,pool]`) - 연결 정보: 환경변수 `EXPENSE_DB_URL` (예: postgresql://user:pwd@host:5432/expense_db) - 스키마: `scripts/sql/expense_db_init.sql` 로 사전 초기화한다. 본 클래스는 앱 부팅 시 `CREATE TABLE IF NOT EXISTS`로 보강만 한다. """ from __future__ import annotations import uuid from datetime import date, datetime, timezone from typing import Any from psycopg.rows import dict_row from psycopg_pool import ConnectionPool from .store import CATEGORIES, METHODS, STATUSES _DDL = """ CREATE TABLE IF NOT EXISTS expense_items ( id TEXT PRIMARY KEY, owner TEXT NOT NULL, spent_at DATE NOT NULL, category TEXT NOT NULL, method TEXT NOT NULL, merchant TEXT NOT NULL DEFAULT '', amount BIGINT NOT NULL DEFAULT 0 CHECK (amount >= 0), memo TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT '작성중', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_expense_owner_spent ON expense_items (owner, spent_at DESC); CREATE INDEX IF NOT EXISTS idx_expense_status ON expense_items (status); """ class ExpenseDBStore: """`ExpenseStore` 와 동일한 메서드 시그니처.""" def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5): self._pool = ConnectionPool( conninfo=dsn, min_size=min_size, max_size=max_size, kwargs={"row_factory": dict_row, "autocommit": True}, open=True, ) self._ensure_schema() def close(self) -> None: self._pool.close() def _ensure_schema(self) -> None: with self._pool.connection() as conn: conn.execute(_DDL) # ── 조회 ── def list_for(self, email: str) -> list[dict[str, Any]]: email = email.lower().strip() with self._pool.connection() as conn: rows = conn.execute( "SELECT * FROM expense_items WHERE owner = %s " "ORDER BY spent_at DESC, created_at DESC", (email,), ).fetchall() return [self._serialize(r) for r in rows] def list_all(self) -> list[dict[str, Any]]: with self._pool.connection() as conn: rows = conn.execute( "SELECT * FROM expense_items ORDER BY created_at DESC" ).fetchall() return [self._serialize(r) for r in rows] def get(self, *, item_id: str, owner: str) -> dict[str, Any] | None: owner = owner.lower().strip() with self._pool.connection() as conn: row = conn.execute( "SELECT * FROM expense_items WHERE id = %s AND owner = %s", (item_id, owner), ).fetchone() return self._serialize(row) if row else None # ── 변경 ── def create(self, *, owner: str, payload: dict[str, Any]) -> dict[str, Any]: owner = owner.lower().strip() norm = self._normalize(payload) if not norm["spent_at"]: raise ValueError("spent_at 필수") status = (payload.get("status") or "작성중").strip() item_id = uuid.uuid4().hex[:12] with self._pool.connection() as conn: row = conn.execute( """ INSERT INTO expense_items (id, owner, spent_at, category, method, merchant, amount, memo, status) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING * """, ( item_id, owner, norm["spent_at"], norm["category"], norm["method"], norm["merchant"], norm["amount"], norm["memo"], status, ), ).fetchone() return self._serialize(row) def update( self, *, item_id: str, owner: str, payload: dict[str, Any] ) -> dict[str, Any]: owner = owner.lower().strip() norm = self._normalize(payload) status = payload.get("status") with self._pool.connection() as conn: if status: row = conn.execute( """ UPDATE expense_items SET spent_at = %s, category = %s, method = %s, merchant = %s, amount = %s, memo = %s, status = %s WHERE id = %s AND owner = %s RETURNING * """, ( norm["spent_at"] or None, norm["category"], norm["method"], norm["merchant"], norm["amount"], norm["memo"], status, item_id, owner, ), ).fetchone() else: row = conn.execute( """ UPDATE expense_items SET spent_at = %s, category = %s, method = %s, merchant = %s, amount = %s, memo = %s WHERE id = %s AND owner = %s RETURNING * """, ( norm["spent_at"] or None, norm["category"], norm["method"], norm["merchant"], norm["amount"], norm["memo"], item_id, owner, ), ).fetchone() if not row: raise KeyError(item_id) return self._serialize(row) def delete(self, *, item_id: str, owner: str) -> None: owner = owner.lower().strip() with self._pool.connection() as conn: cur = conn.execute( "DELETE FROM expense_items WHERE id = %s AND owner = %s", (item_id, owner), ) if cur.rowcount == 0: raise KeyError(item_id) # ── 요약 ── def summary_for(self, email: str) -> dict[str, Any]: email = email.lower().strip() with self._pool.connection() as conn: head = conn.execute( "SELECT COUNT(*) AS count, COALESCE(SUM(amount), 0) AS total " "FROM expense_items WHERE owner = %s", (email,), ).fetchone() status_rows = conn.execute( "SELECT status, COUNT(*) AS c FROM expense_items " "WHERE owner = %s GROUP BY status", (email,), ).fetchall() cat_rows = conn.execute( "SELECT category, COALESCE(SUM(amount), 0) AS s " "FROM expense_items WHERE owner = %s GROUP BY category", (email,), ).fetchall() by_status = {s: 0 for s in STATUSES} for r in status_rows: by_status[r["status"]] = int(r["c"]) by_category = {c: 0 for c in CATEGORIES} for r in cat_rows: by_category[r["category"]] = int(r["s"]) return { "count": int(head["count"]) if head else 0, "total": int(head["total"]) if head else 0, "by_status": by_status, "by_category": by_category, } # ── helpers ── @staticmethod def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None: if row is None: return None out = dict(row) if isinstance(out.get("spent_at"), date): out["spent_at"] = out["spent_at"].isoformat() for k in ("created_at", "updated_at"): v = out.get(k) if isinstance(v, datetime): out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds") out["amount"] = int(out.get("amount", 0)) return out @staticmethod def _normalize( payload: dict[str, Any], base: dict[str, Any] | None = None ) -> dict[str, Any]: b = dict(base or {}) b["spent_at"] = str(payload.get("spent_at") or b.get("spent_at") or "").strip() category = str(payload.get("category") or b.get("category") or "기타").strip() method = str(payload.get("method") or b.get("method") or "법인카드").strip() b["category"] = category if category in CATEGORIES else "기타" b["method"] = method if method in METHODS else "법인카드" b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip() try: b["amount"] = max(0, int(payload.get("amount") or b.get("amount") or 0)) except (TypeError, ValueError): b["amount"] = 0 b["memo"] = str(payload.get("memo") or b.get("memo") or "").strip() return b