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:
+119
-5
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user