feat(expense): 개인경비 모듈 + ERP 좌측메뉴 셸 + expense_db 연결
- ERP 셸: 좌측 사이드바 + 상단 헤더 + 콘텐츠 영역(erp_base.html)
- 분기: 슈퍼관리자 → 업무모듈 카드, 일반사용자 → ERP 메인(erp_home.html)
- 신규 라우트: /modules (슈퍼관리자 모듈 카드 재진입)
- 개인경비 모듈: app/modules/expense/ (라우터/저장소/템플릿)
- JSON 저장소(ExpenseStore) + PostgreSQL 저장소(ExpenseDBStore)
- 팩토리: EXPENSE_DB_URL 있으면 DB, 없으면 JSON 폴백
- DB: scripts/sql/expense_db_init.sql (멱등 DDL, postgres-db 컨테이너)
- expense_db, 역할 expense_app, 테이블 expense_items, 인덱스/트리거
- 마이그레이션: scripts/migrate_expense_json_to_db.py (멱등, --dry-run)
- 의존성: psycopg[binary,pool]>=3.2
- 환경변수: EXPENSE_DB_URL 추가
- 문서: CLAUDE.md 작업 기준 + docs/{PROJECT_OVERVIEW,SERVER_ARCHITECTURE,DATABASES,DEPLOYMENT}.md
- main-app 배포 경로 /opt/www/main 고정 명시
- 위험 명령(DROP/TRUNCATE/rm -rf/volume 삭제) 사용자 승인 정책
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"""개인경비 항목 JSON 저장소.
|
||||
|
||||
- 저장 위치: DATA_DIR/expense.json
|
||||
- 사용자(email) 단위 소유. 본인 항목만 조회/수정/삭제.
|
||||
- 향후 expense_db(PostgreSQL)로 마이그레이션 예정. 현재는 DB 생성 승인 전이라 JSON 사용.
|
||||
- 동시성: 프로세스 내 threading.Lock + 원자적 쓰기(temp → rename). UserStore 와 동일 패턴.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
CATEGORIES: tuple[str, ...] = ("식대", "교통", "숙박", "비품", "접대", "통신", "기타")
|
||||
METHODS: tuple[str, ...] = ("법인카드", "개인지출", "현금")
|
||||
STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "정산완료")
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
class ExpenseStore:
|
||||
def __init__(self, path: Path):
|
||||
self._path = path
|
||||
self._lock = threading.Lock()
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not self._path.exists():
|
||||
self._write_atomic({"items": []})
|
||||
|
||||
def _read(self) -> dict[str, Any]:
|
||||
try:
|
||||
with self._path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
data = {"items": []}
|
||||
if not isinstance(data.get("items"), list):
|
||||
data["items"] = []
|
||||
return data
|
||||
|
||||
def _write_atomic(self, data: dict[str, Any]) -> None:
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
prefix=".expense.", suffix=".json.tmp", dir=str(self._path.parent)
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, self._path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def list_for(self, email: str) -> list[dict[str, Any]]:
|
||||
email = email.lower().strip()
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
return [it for it in data["items"] if it.get("owner") == email]
|
||||
|
||||
def list_all(self) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
return list(self._read()["items"])
|
||||
|
||||
def get(self, *, item_id: str, owner: str) -> dict[str, Any] | None:
|
||||
owner = owner.lower().strip()
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
for it in data["items"]:
|
||||
if it["id"] == item_id and it.get("owner") == owner:
|
||||
return dict(it)
|
||||
return None
|
||||
|
||||
def create(self, *, owner: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
owner = owner.lower().strip()
|
||||
item = self._normalize(payload)
|
||||
item["id"] = uuid.uuid4().hex[:12]
|
||||
item["owner"] = owner
|
||||
item["status"] = payload.get("status") or "작성중"
|
||||
item["created_at"] = _now_iso()
|
||||
item["updated_at"] = item["created_at"]
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
data["items"].append(item)
|
||||
self._write_atomic(data)
|
||||
return item
|
||||
|
||||
def update(
|
||||
self, *, item_id: str, owner: str, payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
owner = owner.lower().strip()
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
for idx, it in enumerate(data["items"]):
|
||||
if it["id"] == item_id and it.get("owner") == owner:
|
||||
new = self._normalize(payload, base=it)
|
||||
new["id"] = it["id"]
|
||||
new["owner"] = it["owner"]
|
||||
new["created_at"] = it.get("created_at", _now_iso())
|
||||
new["status"] = payload.get("status") or it.get("status", "작성중")
|
||||
new["updated_at"] = _now_iso()
|
||||
data["items"][idx] = new
|
||||
self._write_atomic(data)
|
||||
return new
|
||||
raise KeyError(item_id)
|
||||
|
||||
def delete(self, *, item_id: str, owner: str) -> None:
|
||||
owner = owner.lower().strip()
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
before = len(data["items"])
|
||||
data["items"] = [
|
||||
it
|
||||
for it in data["items"]
|
||||
if not (it["id"] == item_id and it.get("owner") == owner)
|
||||
]
|
||||
if len(data["items"]) == before:
|
||||
raise KeyError(item_id)
|
||||
self._write_atomic(data)
|
||||
|
||||
def summary_for(self, email: str) -> dict[str, Any]:
|
||||
items = self.list_for(email)
|
||||
total = sum(int(i.get("amount", 0)) for i in items)
|
||||
by_status = {s: sum(1 for i in items if i.get("status") == s) for s in STATUSES}
|
||||
by_category = {
|
||||
c: sum(int(i.get("amount", 0)) for i in items if i.get("category") == c)
|
||||
for c in CATEGORIES
|
||||
}
|
||||
return {
|
||||
"count": len(items),
|
||||
"total": total,
|
||||
"by_status": by_status,
|
||||
"by_category": by_category,
|
||||
}
|
||||
|
||||
@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
|
||||
Reference in New Issue
Block a user