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:
2026-05-29 02:21:03 +09:00
parent d7b6c7d029
commit 9c58b022e2
19 changed files with 2337 additions and 5 deletions
+119 -5
View File
@@ -9,9 +9,12 @@ from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from jinja2 import ChoiceLoader, FileSystemLoader
from pydantic import BaseModel
from starlette.middleware.sessions import SessionMiddleware
from .modules.expense import build_expense_store
from .modules.expense import router as expense_router
from .store import (
MODULE_KEYS,
SUPER_ADMIN_EMAIL,
@@ -83,10 +86,32 @@ app.add_middleware(
)
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
# 모듈별 templates 디렉토리를 추가로 검색하도록 ChoiceLoader 설정.
# 신규 모듈 추가 시 아래 리스트에 `BASE_DIR / "modules" / "<name>" / "templates"` 만 추가.
_MODULE_TEMPLATE_DIRS = [
BASE_DIR / "modules" / "expense" / "templates",
]
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
templates.env.loader = ChoiceLoader(
[
FileSystemLoader(str(BASE_DIR / "templates")),
*[FileSystemLoader(str(p)) for p in _MODULE_TEMPLATE_DIRS if p.exists()],
]
)
oauth = build_google_oauth()
user_store = UserStore(DATA_DIR / "users.json")
# 모듈별 데이터 저장소.
# EXPENSE_DB_URL 가 있으면 expense_db(PostgreSQL), 없으면 JSON 파일.
app.state.expense_store = build_expense_store(
dsn=env("EXPENSE_DB_URL") or None,
json_path=DATA_DIR / "expense.json",
)
# 모듈 라우터 등록 — 신규 모듈 추가 시 여기 한 줄.
app.include_router(expense_router)
def public_url_for(request: Request, route_name: str) -> str:
public_base_url = env("PUBLIC_BASE_URL").rstrip("/")
@@ -203,9 +228,9 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
"title": "개인경비",
"subtitle": "Personal Expense",
"description": "법인카드/개인경비 사용 내역을 등록·증빙하고 정산을 신청합니다.",
"url": "#",
"health_url": None,
"status": "preparing",
"url": "/expense/",
"health_url": "/expense/health",
"status": "ready",
"category": "관리",
},
{
@@ -225,6 +250,62 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
return items
def _icon_svg(name: str) -> str:
"""좌측 사이드바용 인라인 아이콘. 외부 의존 없는 작은 SVG."""
paths = {
"home": '<path d="M3 11 12 3l9 8"/><path d="M5 10v10h14V10"/>',
"expense": '<rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18"/><path d="M7 15h4"/>',
"vacation": '<path d="M8 2v4"/><path d="M16 2v4"/><rect x="3" y="6" width="18" height="15" rx="2"/><path d="M3 11h18"/>',
"corm": '<path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5z"/>',
"order": '<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>',
"modules": '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
}
body = paths.get(name, paths["modules"])
return (
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
'stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'
f"{body}</svg>"
)
def build_erp_nav(
user_rec: dict[str, Any], active: str | None = None
) -> list[dict[str, Any]]:
"""좌측 사이드바 메뉴. 모듈 정의(_menu_items_for)와 동기화한다.
각 항목: key, label, group, url, target, icon, active, disabled, disabled_reason.
"""
items: list[dict[str, Any]] = [
{
"key": "home",
"label": "",
"group": "ERP",
"url": "/",
"target": "_self",
"icon": _icon_svg("home"),
},
]
for m in _menu_items_for(user_rec):
if not m["allowed"]:
continue
target = "_blank" if m["url"].startswith("http") else "_self"
items.append(
{
"key": m["key"],
"label": m["title"],
"group": m["category"],
"url": m["url"] if m["status"] == "ready" else "#",
"target": target,
"icon": _icon_svg(m["key"]),
"disabled": m["status"] != "ready",
"disabled_reason": "준비중" if m["status"] != "ready" else None,
}
)
for it in items:
it["active"] = it["key"] == active
return items
@app.get("/", response_class=HTMLResponse)
async def home(request: Request) -> HTMLResponse:
sess = get_session_user(request)
@@ -235,13 +316,46 @@ async def home(request: Request) -> HTMLResponse:
# 도메인은 통과했으나 저장소에 없음 — 세션 정리 후 재로그인
request.session.clear()
return render_template(request, "login.html")
menu_items = _menu_items_for(user_rec)
# 슈퍼 관리자 → 업무 모듈 선택 화면(main.html).
# 일반 사용자 → ERP 메인(좌측 메뉴 + 우측 콘텐츠).
if user_rec.get("is_super_admin"):
menu_items = _menu_items_for(user_rec)
return render_template(
request,
"main.html",
{
"user": user_rec,
"menu_items": menu_items,
"is_admin": is_admin(user_rec),
},
)
return render_template(
request,
"erp_home.html",
{
"user": user_rec,
"is_admin": is_admin(user_rec),
"nav_items": build_erp_nav(user_rec, active="home"),
"page_title": "ERP 홈",
"page_subtitle": "오늘의 업무를 시작하세요.",
},
)
@app.get("/modules", response_class=HTMLResponse)
async def modules_page(request: Request) -> HTMLResponse:
"""업무 모듈 선택 화면. 슈퍼 관리자가 사이드바에서 다시 진입할 때 사용."""
user_rec = get_current_user_record(request)
if user_rec is None:
return RedirectResponse(url="/login", status_code=303)
return render_template(
request,
"main.html",
{
"user": user_rec,
"menu_items": menu_items,
"menu_items": _menu_items_for(user_rec),
"is_admin": is_admin(user_rec),
},
)