Files
dbx-main/app/modules/expense/router.py
T
king 9c58b022e2 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 삭제) 사용자 승인 정책
2026-05-29 02:21:03 +09:00

151 lines
4.6 KiB
Python

"""개인경비 모듈 라우터.
- 경로: /expense
- 권한: 로그인 + `expense` 모듈 권한 필요 (관리자는 항상 통과).
- 데이터: ExpenseStore (JSON). app.state.expense_store 에 연결.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from pydantic import BaseModel, Field
from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore
router = APIRouter(prefix="/expense", tags=["expense"])
def _store(request: Request) -> ExpenseStore:
store = getattr(request.app.state, "expense_store", None)
if store is None:
raise RuntimeError("ExpenseStore 가 app.state 에 등록되지 않았습니다.")
return store
def _require_user(request: Request) -> dict[str, Any]:
# 순환 import 회피: 핸들러 호출 시점에 main 모듈에서 helper 를 가져온다.
from app.main import get_current_user_record # noqa: WPS433
from app.store import has_module # noqa: WPS433
user = get_current_user_record(request)
if user is None:
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
if not has_module(user, "expense"):
raise HTTPException(status_code=403, detail="개인경비 모듈 권한이 없습니다.")
return user
class ExpensePayload(BaseModel):
spent_at: str = Field(..., description="YYYY-MM-DD")
category: str
method: str
merchant: str = ""
amount: int = Field(ge=0)
memo: str = ""
status: str | None = None
@router.get("/", response_class=HTMLResponse)
async def expense_index(request: Request) -> HTMLResponse:
from app.main import ( # noqa: WPS433
build_erp_nav,
get_current_user_record,
render_template,
)
from app.store import has_module, is_admin # noqa: WPS433
user = get_current_user_record(request)
if user is None:
return RedirectResponse(url="/login", status_code=303)
if not has_module(user, "expense"):
return render_template(
request,
"denied.html",
{"reason": "개인경비 모듈 접근 권한이 없습니다."},
status_code=403,
)
store = _store(request)
items = sorted(
store.list_for(user["email"]),
key=lambda i: (i.get("spent_at", ""), i.get("created_at", "")),
reverse=True,
)
summary = store.summary_for(user["email"])
return render_template(
request,
"expense/index.html",
{
"user": user,
"is_admin": is_admin(user),
"items": items,
"summary": summary,
"categories": list(CATEGORIES),
"methods": list(METHODS),
"statuses": list(STATUSES),
"nav_items": build_erp_nav(user, active="expense"),
"page_title": "개인경비",
"page_subtitle": "법인카드 · 개인지출 · 정산 신청",
},
)
@router.get("/api/items")
async def list_items(
request: Request,
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
return JSONResponse(
{
"items": _store(request).list_for(user["email"]),
"summary": _store(request).summary_for(user["email"]),
}
)
@router.post("/api/items")
async def create_item(
request: Request,
body: ExpensePayload,
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
item = _store(request).create(owner=user["email"], payload=body.model_dump())
return JSONResponse({"item": item}, status_code=201)
@router.put("/api/items/{item_id}")
async def update_item(
request: Request,
item_id: str,
body: ExpensePayload,
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
try:
item = _store(request).update(
item_id=item_id, owner=user["email"], payload=body.model_dump()
)
except KeyError:
raise HTTPException(status_code=404, detail="항목을 찾을 수 없습니다.")
return JSONResponse({"item": item})
@router.delete("/api/items/{item_id}")
async def delete_item(
request: Request,
item_id: str,
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
try:
_store(request).delete(item_id=item_id, owner=user["email"])
except KeyError:
raise HTTPException(status_code=404, detail="항목을 찾을 수 없습니다.")
return JSONResponse({"ok": True})
@router.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "module": "expense"}