"""개인경비 모듈 라우터. - 경로: /expense - 권한: 로그인 + `expense` 모듈 권한 필요 (관리자는 항상 통과). - 데이터: ExpenseStore (JSON) 또는 ExpenseDBStore (PostgreSQL). - 워크플로/첨부/집계/엑셀 내보내기는 DB 모드에서만 완전 지원. """ from __future__ import annotations import io import mimetypes import os import re import uuid import zipfile from datetime import datetime from pathlib import Path from typing import Any from app.timezone import KST, 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 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 _category_store(request: Request) -> Any: store = getattr(request.app.state, "expense_category_store", None) if store is None: raise RuntimeError("CategoryStore 가 app.state 에 등록되지 않았습니다.") return store def _categories(request: Request) -> list[str]: """현재 활성 분류 목록 — 등록 폼/집계에서 사용.""" return _category_store(request).list() _WEEKDAYS_KR = ("월", "화", "수", "목", "금", "토", "일") def _parse_month(request: Request) -> tuple[int, int, str]: """?month=YYYY-MM 파싱. 누락/오류 시 이번 달(KST).""" raw = request.query_params.get("month") or "" try: y_str, m_str = raw.split("-") year, mon = int(y_str), int(m_str) if not (1 <= mon <= 12): raise ValueError except (ValueError, AttributeError): n = now_kst() year, mon = n.year, n.month return year, mon, f"{year:04d}-{mon:02d}" def _name_map(request: Request) -> dict[str, str]: """email(소문자) → 표시 이름. 미등록 사용자는 이메일 로컬파트.""" from app.main import user_store # noqa: WPS433 out: dict[str, str] = {} for u in user_store.list_all(): email = str(u.get("email", "")).lower().strip() if email: out[email] = u.get("name") or email.split("@")[0] return out def _display_name(email: str, names: dict[str, str]) -> str: email = (email or "").lower().strip() return names.get(email) or (email.split("@")[0] if email else "") def _att_download_name(uploaded_at: Any, name: str, original: str) -> str: """첨부 다운로드 파일명: `yy년 mm월 dd일(요일)_이름{ext}`. 날짜=업로드일(KST).""" if isinstance(uploaded_at, datetime): d = uploaded_at.astimezone(KST) else: d = now_kst() wd = _WEEKDAYS_KR[d.weekday()] base = f"{d.strftime('%y')}년 {d.strftime('%m')}월 {d.strftime('%d')}일({wd})_{name}" ext = os.path.splitext(original or "")[1].lower() return f"{base}{ext}" 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 class CategoryBody(BaseModel): name: 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": _categories(request), "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()) # 활성 분류 + 데이터에만 있는(삭제된) 분류 모두 포함 categories = list(_categories(request)) for m in month_keys: for c in months[m]: if c not in categories: categories.append(c) 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": categories, "cat_totals": cat_totals, "grand_total": grand_total, "nav_items": build_erp_nav(user, active="expense"), "page_title": "개인경비 — 집계", "page_subtitle": f"{year}년 월별/카테고리별", }, ) # ──────────────────────────────────────────────────────────── # 승인완료 — 전 직원 월별 (승인자/관리자) # ──────────────────────────────────────────────────────────── def _approved_aggregate( items: list[dict[str, Any]], names: dict[str, str] ) -> list[dict[str, Any]]: """직원별 승인 금액 합계.""" agg: dict[str, dict[str, Any]] = {} for it in items: owner = it.get("owner", "") row = agg.setdefault( owner, {"owner": owner, "name": _display_name(owner, names), "count": 0, "total": 0}, ) row["count"] += 1 row["total"] += int(it.get("amount", 0)) return sorted(agg.values(), key=lambda x: -x["total"]) @router.get("/approved", response_class=HTMLResponse) async def approved_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) year, mon, raw = _parse_month(request) items = store.list_approved(year=year, month=mon) names = _name_map(request) for it in items: it["owner_name"] = _display_name(it.get("owner", ""), names) owner_summary = _approved_aggregate(items, names) grand_total = sum(r["total"] for r in owner_summary) return render_template( request, "expense/approved.html", { "user": user, "is_admin": is_admin(user), "items": items, "owner_summary": owner_summary, "grand_total": grand_total, "month": raw, "count": len(items), "nav_items": build_erp_nav(user, active="expense"), "page_title": "개인경비 — 승인완료", "page_subtitle": f"{raw} · 전 직원 승인완료 {len(items)}건", }, ) @router.get("/api/approved/export.xlsx") async def approved_export( request: Request, user: dict[str, Any] = Depends(_require_approver), ) -> StreamingResponse: from openpyxl import Workbook # 지연 import store = _store(request) _require_db_store(store) year, mon, raw = _parse_month(request) items = store.list_approved(year=year, month=mon) names = _name_map(request) wb = Workbook() ws = wb.active ws.title = "승인완료" header = [ "사용일", "이름", "이메일", "분류", "결제수단", "가맹점", "금액", "메모", "상태", "승인자", "결재일시", ] ws.append(header) for r in items: owner = r.get("owner", "") ws.append([ r.get("spent_at", ""), _display_name(owner, names), 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 "", ]) widths = [12, 14, 28, 10, 12, 24, 12, 30, 10, 24, 22] for col, w in enumerate(widths, start=1): ws.column_dimensions[ws.cell(row=1, column=col).column_letter].width = w # 직원별 합계 시트 ws2 = wb.create_sheet("직원별합계") ws2.append(["이름", "이메일", "건수", "합계금액"]) for row in _approved_aggregate(items, names): ws2.append([row["name"], row["owner"], row["count"], row["total"]]) for col, w in enumerate([14, 28, 8, 16], start=1): ws2.column_dimensions[ws2.cell(row=1, column=col).column_letter].width = w buf = io.BytesIO() wb.save(buf) buf.seek(0) fname = f"expense_approved_{raw}.xlsx" return StreamingResponse( buf, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f'attachment; filename="{fname}"'}, ) @router.get("/api/approved/attachments.zip") async def approved_attachments_zip( request: Request, user: dict[str, Any] = Depends(_require_approver), ) -> StreamingResponse: """해당 월 승인완료 항목의 첨부 전체를 zip 한 개로. 파일명 규칙 적용.""" store = _store(request) _require_db_store(store) year, mon, raw = _parse_month(request) atts = store.approved_attachments(year=year, month=mon) names = _name_map(request) upload_root = _upload_dir(request) def _resolve(a: dict[str, Any]) -> Path | None: """stored_path 우선, 없으면 현재 업로드 루트/item_id/파일명 으로 폴백.""" raw_path = a.get("stored_path") or "" p = Path(raw_path) if p.exists(): return p # DATA_DIR 변경 등으로 절대경로가 깨진 경우 — basename 으로 재구성 item_id = a.get("item_id", "") base = os.path.basename(raw_path) or a.get("filename", "") if item_id and base: alt = upload_root / item_id / base if alt.exists(): return alt return None total = len(atts) added = 0 missing = 0 buf = io.BytesIO() used: dict[str, int] = {} with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: for a in atts: path = _resolve(a) if path is None: missing += 1 continue nm = _display_name(a.get("owner", ""), names) fname = _att_download_name(a.get("uploaded_at"), nm, a.get("filename", "")) # 동일명 충돌 → 접미사 if fname in used: used[fname] += 1 stem, ext = os.path.splitext(fname) arcname = f"{stem}_{used[fname]}{ext}" else: used[fname] = 1 arcname = fname zf.write(str(path), arcname=arcname) added += 1 buf.seek(0) zipname = f"expense_approved_{raw}.zip" return StreamingResponse( buf, media_type="application/zip", headers={ "Content-Disposition": f'attachment; filename="{zipname}"', # 진단용: 첨부 총개수 / 담긴 개수 / 디스크에서 못 찾은 개수 "X-Attachment-Stats": f"total={total};added={added};missing={missing}", }, ) # ──────────────────────────────────────────────────────────── # 승인자 설정 — 분류 관리 # ──────────────────────────────────────────────────────────── @router.get("/settings", response_class=HTMLResponse) async def settings_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, ) return render_template( request, "expense/settings.html", { "user": user, "is_admin": is_admin(user), "categories": _categories(request), "nav_items": build_erp_nav(user, active="expense"), "page_title": "개인경비 — 설정", "page_subtitle": "분류 항목 관리", }, ) @router.get("/api/categories") async def list_categories( request: Request, user: dict[str, Any] = Depends(_require_user), ) -> JSONResponse: return JSONResponse({"categories": _categories(request)}) @router.post("/api/categories") async def add_category( request: Request, body: CategoryBody, user: dict[str, Any] = Depends(_require_approver), ) -> JSONResponse: try: categories = _category_store(request).add(body.name) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) return JSONResponse({"categories": categories}, status_code=201) @router.delete("/api/categories/{name}") async def delete_category( request: Request, name: str, user: dict[str, Any] = Depends(_require_approver), ) -> JSONResponse: try: categories = _category_store(request).delete(name) except KeyError: raise HTTPException(status_code=404, detail="분류를 찾을 수 없습니다.") except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) return JSONResponse({"categories": categories}) # ──────────────────────────────────────────────────────────── # 항목 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"}