Files
dbx-main/app/modules/expense/router.py
T
king 5b0fe2f1a1 style(expense): 승인 목록 엑셀 서식 — 제목행 파랑/금액 쉼표/열너비 자동
- 제목행: 파랑(4472C4) 배경 + 흰 굵은 글씨 + 가운데 정렬
- 금액/합계금액: #,##0 쉼표 표시형식
- A~K 열너비 내용 기준 자동(한글/전각 2칸 계산)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:57:21 +09:00

1038 lines
36 KiB
Python

"""개인경비 모듈 라우터.
- 경로: /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)}",
},
)
def _xl_display_len(value: Any) -> int:
"""엑셀 열너비 산정용 표시 폭. 한글/전각은 2칸으로 계산."""
s = "" if value is None else str(value)
width = 0
for ch in s:
# CJK(한글/한자/가나) 및 전각 문자 → 2칸
width += 2 if ord(ch) > 0x2E7F else 1
return width
def _autofit_columns(ws: Any, ncols: int, *, min_w: int = 6, max_w: int = 60) -> None:
"""A~열 내용 기준 자동 너비. 헤더 포함 최댓값 + 여유."""
for col in range(1, ncols + 1):
longest = 0
for cell in ws[ws.cell(row=1, column=col).column_letter]:
longest = max(longest, _xl_display_len(cell.value))
ws.column_dimensions[ws.cell(row=1, column=col).column_letter].width = (
max(min_w, min(max_w, longest + 2))
)
def _style_header(ws: Any, ncols: int) -> None:
"""제목 행: 파랑 배경, 흰 굵은 글씨, 가운데 정렬."""
from openpyxl.styles import Alignment, Font, PatternFill
fill = PatternFill(start_color="FF4472C4", end_color="FF4472C4", fill_type="solid")
font = Font(color="FFFFFFFF", bold=True)
align = Alignment(horizontal="center", vertical="center")
for col in range(1, ncols + 1):
cell = ws.cell(row=1, column=col)
cell.fill = fill
cell.font = font
cell.alignment = align
def _build_approved_xlsx(
items: list[dict[str, Any]], names: dict[str, str]
) -> io.BytesIO:
"""승인완료 목록 + 직원별합계 시트 xlsx 바이트 생성.
서식: 제목행 파랑/흰굵은글씨/가운데, 금액 쉼표(#,##0), 열너비 내용 자동.
"""
from openpyxl import Workbook # 지연 import
money_fmt = "#,##0"
wb = Workbook()
ws = wb.active
ws.title = "승인완료"
header = [
"사용일", "이름", "이메일", "분류", "결제수단", "가맹점",
"금액", "메모", "상태", "승인자", "결재일시",
]
ws.append(header)
amount_col = header.index("금액") + 1 # 7 (G열)
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 "",
])
# 금액 열 쉼표 표시형식
for row in range(2, ws.max_row + 1):
ws.cell(row=row, column=amount_col).number_format = money_fmt
_style_header(ws, len(header))
_autofit_columns(ws, len(header))
# 직원별 합계 시트
ws2 = wb.create_sheet("직원별합계")
header2 = ["이름", "이메일", "건수", "합계금액"]
ws2.append(header2)
for row in _approved_aggregate(items, names):
ws2.append([row["name"], row["owner"], row["count"], row["total"]])
for row in range(2, ws2.max_row + 1):
ws2.cell(row=row, column=4).number_format = money_fmt # 합계금액
_style_header(ws2, len(header2))
_autofit_columns(ws2, len(header2))
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
return buf
@router.get("/api/approved/export.xlsx")
async def approved_export(
request: Request,
user: dict[str, Any] = Depends(_require_approver),
) -> StreamingResponse:
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)
buf = _build_approved_xlsx(items, names)
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)
items = store.list_approved(year=year, month=mon)
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:
# 1) 승인 목록 엑셀 동봉
xlsx_buf = _build_approved_xlsx(items, names)
zf.writestr(f"승인목록_{raw}.xlsx", xlsx_buf.getvalue())
# 2) 첨부 파일들
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"}