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,150 @@
|
||||
"""개인경비 모듈 라우터.
|
||||
|
||||
- 경로: /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"}
|
||||
Reference in New Issue
Block a user