"""개인경비 모듈 라우터. - 경로: /expense - 권한: 로그인 + `expense` 모듈 권한 필요 (관리자는 항상 통과). - 데이터: ExpenseStore (JSON) 또는 ExpenseDBStore (PostgreSQL). - 워크플로/첨부/집계/엑셀 내보내기는 DB 모드에서만 완전 지원. """ from __future__ import annotations import io import mimetypes import os import re import uuid from pathlib import Path from typing import Any from app.timezone import now_kst from fastapi import ( APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, ) from fastapi.responses import ( FileResponse, HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse, ) from pydantic import BaseModel, Field from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore router = APIRouter(prefix="/expense", tags=["expense"]) # 첨부 파일 정책 ALLOWED_EXTS = { ".pdf", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".heic", ".tif", ".tiff", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".hwp", ".hwpx", ".txt", ".csv", ".zip", } MAX_UPLOAD_BYTES = 20 * 1024 * 1024 # 20MB def _store(request: Request) -> Any: store = getattr(request.app.state, "expense_store", None) if store is None: raise RuntimeError("ExpenseStore 가 app.state 에 등록되지 않았습니다.") return store def _upload_dir(request: Request) -> Path: """첨부 저장 루트. DATA_DIR/uploads/expense — DATA_DIR 와 같은 볼륨에 보관.""" base = getattr(request.app.state, "data_dir", None) if base is None: base = Path(os.getenv("DATA_DIR", "/data")) root = Path(base) / "uploads" / "expense" root.mkdir(parents=True, exist_ok=True) return root def _safe_filename(name: str) -> str: name = os.path.basename(name or "").strip() or "file" name = re.sub(r"[\\/:*?\"<>|\r\n\t]", "_", name) return name[:200] def _require_user(request: Request) -> dict[str, Any]: 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 def _require_approver(request: Request) -> dict[str, Any]: from app.main import get_current_user_record # noqa: WPS433 from app.store import has_module, is_admin # noqa: WPS433 user = get_current_user_record(request) if user is None: raise HTTPException(status_code=401, detail="로그인이 필요합니다.") if not (is_admin(user) or has_module(user, "expense_approver")): raise HTTPException(status_code=403, detail="개인경비 승인자 권한이 필요합니다.") return user def _require_db_store(store: Any) -> None: """워크플로/첨부/집계는 DB 모드 전용. JSON 폴백에서는 501.""" if not hasattr(store, "submit"): raise HTTPException( status_code=501, detail="이 기능은 expense_db (PostgreSQL) 모드에서만 지원됩니다. " "EXPENSE_DB_URL 환경변수를 설정하세요.", ) # ──────────────────────────────────────────────────────────── # Pydantic 모델 # ──────────────────────────────────────────────────────────── 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 class RejectBody(BaseModel): reason: str # ──────────────────────────────────────────────────────────── # 페이지 # ──────────────────────────────────────────────────────────── @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"]) pending_count = 0 is_approver = is_admin(user) or has_module(user, "expense_approver") if is_approver and hasattr(store, "list_pending_approval"): pending_count = len(store.list_pending_approval()) return render_template( request, "expense/index.html", { "user": user, "is_admin": is_admin(user), "is_approver": is_approver, "pending_count": pending_count, "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": "법인카드 · 개인지출 · 정산 신청", "supports_workflow": hasattr(store, "submit"), }, ) @router.get("/pending", response_class=HTMLResponse) async def pending_page(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 (is_admin(user) or has_module(user, "expense_approver")): return render_template( request, "denied.html", {"reason": "개인경비 승인자 권한이 없습니다."}, status_code=403, ) store = _store(request) _require_db_store(store) pending = store.list_pending_approval() return render_template( request, "expense/pending.html", { "user": user, "is_admin": is_admin(user), "items": pending, "nav_items": build_erp_nav(user, active="expense"), "page_title": "개인경비 — 승인 대기", "page_subtitle": f"제출 상태 {len(pending)}건", }, ) @router.get("/reports", response_class=HTMLResponse) async def reports_page(request: Request) -> HTMLResponse: """월별/카테고리별 집계.""" from app.main import ( # noqa: WPS433 build_erp_nav, get_current_user_record, render_template, ) from app.store import is_admin # noqa: WPS433 user = get_current_user_record(request) if user is None: return RedirectResponse(url="/login", status_code=303) store = _store(request) _require_db_store(store) year_str = request.query_params.get("year") or str(now_kst().year) try: year = int(year_str) except ValueError: year = now_kst().year rows = store.monthly_summary(email=user["email"], year=year) # 피벗: month → {category → total} months: dict[str, dict[str, int]] = {} for r in rows: months.setdefault(r["month"], {})[r["category"]] = r["total"] month_keys = sorted(months.keys()) cat_totals = {c: sum(months[m].get(c, 0) for m in month_keys) for c in CATEGORIES} grand_total = sum(cat_totals.values()) return render_template( request, "expense/reports.html", { "user": user, "is_admin": is_admin(user), "year": year, "months": month_keys, "pivot": months, "categories": list(CATEGORIES), "cat_totals": cat_totals, "grand_total": grand_total, "nav_items": build_erp_nav(user, active="expense"), "page_title": "개인경비 — 집계", "page_subtitle": f"{year}년 월별/카테고리별", }, ) # ──────────────────────────────────────────────────────────── # 항목 CRUD API # ──────────────────────────────────────────────────────────── @router.get("/api/items") async def list_items( request: Request, user: dict[str, Any] = Depends(_require_user), ) -> JSONResponse: store = _store(request) return JSONResponse( { "items": store.list_for(user["email"]), "summary": store.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: store = _store(request) # 워크플로 모드: 작성중/반려 상태에서만 수정 가능 if hasattr(store, "get"): current = store.get(item_id=item_id, owner=user["email"]) if current and current.get("status") not in ("작성중", "반려"): raise HTTPException( status_code=409, detail=f"{current['status']} 상태에서는 수정할 수 없습니다.", ) try: item = store.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: store = _store(request) if hasattr(store, "get"): current = store.get(item_id=item_id, owner=user["email"]) if current and current.get("status") not in ("작성중", "반려"): raise HTTPException( status_code=409, detail=f"{current['status']} 상태에서는 삭제할 수 없습니다.", ) try: _store(request).delete(item_id=item_id, owner=user["email"]) except KeyError: raise HTTPException(status_code=404, detail="항목을 찾을 수 없습니다.") return JSONResponse({"ok": True}) # ──────────────────────────────────────────────────────────── # 워크플로 API # ──────────────────────────────────────────────────────────── @router.post("/api/items/{item_id}/submit") async def submit_item( request: Request, item_id: str, user: dict[str, Any] = Depends(_require_user), ) -> JSONResponse: store = _store(request) _require_db_store(store) try: item = store.submit(item_id=item_id, owner=user["email"]) except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) return JSONResponse({"item": item}) @router.post("/api/items/{item_id}/revert") async def revert_item( request: Request, item_id: str, user: dict[str, Any] = Depends(_require_user), ) -> JSONResponse: store = _store(request) _require_db_store(store) try: item = store.revert_to_draft(item_id=item_id, owner=user["email"]) except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) return JSONResponse({"item": item}) @router.post("/api/items/{item_id}/approve") async def approve_item( request: Request, item_id: str, user: dict[str, Any] = Depends(_require_approver), ) -> JSONResponse: store = _store(request) _require_db_store(store) try: item = store.approve(item_id=item_id, approver_email=user["email"]) except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) return JSONResponse({"item": item}) @router.post("/api/items/{item_id}/reject") async def reject_item( request: Request, item_id: str, body: RejectBody, user: dict[str, Any] = Depends(_require_approver), ) -> JSONResponse: store = _store(request) _require_db_store(store) try: item = store.reject( item_id=item_id, approver_email=user["email"], reason=body.reason ) except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) return JSONResponse({"item": item}) @router.post("/api/items/{item_id}/settle") async def settle_item( request: Request, item_id: str, user: dict[str, Any] = Depends(_require_approver), ) -> JSONResponse: store = _store(request) _require_db_store(store) try: item = store.settle(item_id=item_id, approver_email=user["email"]) except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) return JSONResponse({"item": item}) # ──────────────────────────────────────────────────────────── # 첨부 API # ──────────────────────────────────────────────────────────── @router.get("/api/items/{item_id}/attachments") async def list_attachments( request: Request, item_id: str, user: dict[str, Any] = Depends(_require_user), ) -> JSONResponse: from app.store import has_module, is_admin # noqa: WPS433 store = _store(request) _require_db_store(store) item = store.get_any(item_id=item_id) if not item: raise HTTPException(status_code=404, detail="항목 없음") # 본인 또는 승인자/관리자만 조회 if item["owner"] != user["email"] and not ( is_admin(user) or has_module(user, "expense_approver") ): raise HTTPException(status_code=403, detail="조회 권한 없음") return JSONResponse({"attachments": store.list_attachments(item_id=item_id)}) @router.post("/api/items/{item_id}/attachments") async def upload_attachment( request: Request, item_id: str, kind: str = Form("receipt"), file: UploadFile = File(...), user: dict[str, Any] = Depends(_require_user), ) -> JSONResponse: store = _store(request) _require_db_store(store) if kind not in ("receipt", "other"): raise HTTPException(status_code=400, detail="kind 는 receipt|other") item = store.get(item_id=item_id, owner=user["email"]) if not item: raise HTTPException(status_code=404, detail="항목 없음") if item["status"] not in ("작성중", "반려"): raise HTTPException( status_code=409, detail=f"{item['status']} 상태에서는 첨부할 수 없습니다.", ) raw_name = _safe_filename(file.filename or "") ext = os.path.splitext(raw_name)[1].lower() if ext and ext not in ALLOWED_EXTS: raise HTTPException( status_code=415, detail=f"허용되지 않는 확장자: {ext}", ) # 디스크에 저장 (스트리밍, 사이즈 제한) att_id = uuid.uuid4().hex[:12] sub_dir = _upload_dir(request) / item_id sub_dir.mkdir(parents=True, exist_ok=True) stored_name = f"{att_id}_{raw_name}" stored_path = sub_dir / stored_name size_total = 0 chunk_size = 1024 * 256 try: with stored_path.open("wb") as out: while True: chunk = await file.read(chunk_size) if not chunk: break size_total += len(chunk) if size_total > MAX_UPLOAD_BYTES: out.close() stored_path.unlink(missing_ok=True) raise HTTPException( status_code=413, detail=f"파일 크기 초과 (최대 {MAX_UPLOAD_BYTES // (1024*1024)}MB)", ) out.write(chunk) finally: await file.close() content_type = ( file.content_type or mimetypes.guess_type(raw_name)[0] or "application/octet-stream" ) rec = store.add_attachment( item_id=item_id, owner=user["email"], kind=kind, filename=raw_name, stored_path=str(stored_path), content_type=content_type, size_bytes=size_total, ) return JSONResponse({"attachment": rec}, status_code=201) @router.get("/api/attachments/{att_id}") async def download_attachment( request: Request, att_id: str, user: dict[str, Any] = Depends(_require_user), ) -> FileResponse: from app.store import has_module, is_admin # noqa: WPS433 store = _store(request) _require_db_store(store) rec = store.get_attachment(att_id=att_id) if not rec: raise HTTPException(status_code=404, detail="첨부 없음") item = store.get_any(item_id=rec["item_id"]) if not item: raise HTTPException(status_code=404, detail="항목 없음") if item["owner"] != user["email"] and not ( is_admin(user) or has_module(user, "expense_approver") ): raise HTTPException(status_code=403, detail="조회 권한 없음") path = Path(rec["stored_path"]) if not path.exists(): raise HTTPException(status_code=410, detail="파일이 사라졌습니다.") return FileResponse( path=str(path), filename=rec["filename"], media_type=rec["content_type"], ) @router.delete("/api/attachments/{att_id}") async def delete_attachment( request: Request, att_id: str, user: dict[str, Any] = Depends(_require_user), ) -> JSONResponse: store = _store(request) _require_db_store(store) try: rec = store.delete_attachment(att_id=att_id, owner=user["email"]) except KeyError: raise HTTPException(status_code=404, detail="첨부 없음") # 파일 정리 (실패해도 DB 삭제는 유효 — 고아 파일 남을 수 있으니 로그만) try: Path(rec["stored_path"]).unlink(missing_ok=True) except OSError: pass return JSONResponse({"ok": True}) # ──────────────────────────────────────────────────────────── # 엑셀 내보내기 # ──────────────────────────────────────────────────────────── @router.get("/api/export.xlsx") async def export_xlsx( request: Request, user: dict[str, Any] = Depends(_require_user), ) -> StreamingResponse: from app.store import has_module, is_admin # noqa: WPS433 from openpyxl import Workbook # 지연 import store = _store(request) _require_db_store(store) date_from = request.query_params.get("from") date_to = request.query_params.get("to") scope = request.query_params.get("scope", "mine") target_email: str | None = user["email"] if scope == "all": if not (is_admin(user) or has_module(user, "expense_approver")): raise HTTPException(status_code=403, detail="전체 내보내기 권한 없음") target_email = None rows = store.list_for_export( email=target_email, date_from=date_from, date_to=date_to ) wb = Workbook() ws = wb.active ws.title = "expense" header = [ "사용일", "소유자", "분류", "결제수단", "가맹점", "금액", "메모", "상태", "승인자", "결재일시", "반려사유", "생성일시", "수정일시", ] ws.append(header) for r in rows: ws.append([ r.get("spent_at", ""), r.get("owner", ""), r.get("category", ""), r.get("method", ""), r.get("merchant", ""), int(r.get("amount", 0)), r.get("memo", ""), r.get("status", ""), r.get("approver_email", "") or "", r.get("decided_at", "") or "", r.get("reject_reason", "") or "", r.get("created_at", ""), r.get("updated_at", ""), ]) # 컬럼 너비 대충 widths = [12, 28, 10, 12, 24, 12, 30, 10, 24, 22, 24, 22, 22] for col, w in enumerate(widths, start=1): ws.column_dimensions[ws.cell(row=1, column=col).column_letter].width = w buf = io.BytesIO() wb.save(buf) buf.seek(0) fname_scope = "all" if target_email is None else "mine" fname = f"expense_{fname_scope}_{now_kst().strftime('%Y%m%d_%H%M%S')}.xlsx" return StreamingResponse( buf, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f'attachment; filename="{fname}"'}, ) @router.get("/health") async def health() -> dict[str, str]: return {"status": "ok", "module": "expense"}