e782cb4ea7
- 가로 > 1000px 이미지: 비율 유지 1000px 축소 후 JPEG 품질 80 재인코딩 - EXIF 회전 보정, 알파/팔레트 → RGB 변환, 확장자 .jpg 로 통일 - 1000px 이하/비이미지/처리 실패 시 원본 그대로 저장 - 업로드를 메모리 읽기 후 처리 방식으로 변경(20MB 제한 유지) - requirements: pillow>=10.0 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1109 lines
39 KiB
Python
1109 lines
39 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
|
|
|
|
# 이미지 자동 리사이즈 정책: 가로 > RESIZE_MAX_W 면 비율 유지 축소 후 JPEG q80 재인코딩.
|
|
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tif", ".tiff"}
|
|
RESIZE_MAX_W = 1000
|
|
JPEG_QUALITY = 80
|
|
|
|
|
|
def _maybe_downscale_image(
|
|
data: bytes, raw_name: str, ext: str
|
|
) -> tuple[bytes, str, str, str | None]:
|
|
"""가로 1000px 초과 이미지를 1000px(비율 유지)로 축소 + JPEG q80 재인코딩.
|
|
|
|
반환: (바이트, 파일명, 확장자, content_type|None).
|
|
이미지 아님/축소 불필요/처리 실패 시 원본 그대로(content_type=None).
|
|
"""
|
|
if ext not in IMAGE_EXTS:
|
|
return data, raw_name, ext, None
|
|
try:
|
|
from PIL import Image, ImageOps # 지연 import
|
|
|
|
im = Image.open(io.BytesIO(data))
|
|
im = ImageOps.exif_transpose(im) # EXIF 회전 보정
|
|
w, h = im.size
|
|
if w <= RESIZE_MAX_W:
|
|
return data, raw_name, ext, None # 작은 이미지는 원본 유지
|
|
new_h = max(1, round(h * RESIZE_MAX_W / w))
|
|
im = im.resize((RESIZE_MAX_W, new_h), Image.LANCZOS)
|
|
if im.mode not in ("RGB", "L"):
|
|
im = im.convert("RGB")
|
|
out = io.BytesIO()
|
|
im.save(out, format="JPEG", quality=JPEG_QUALITY, optimize=True)
|
|
stem = os.path.splitext(raw_name)[0] or "image"
|
|
return out.getvalue(), f"{stem}.jpg", ".jpg", "image/jpeg"
|
|
except Exception:
|
|
return data, raw_name, ext, None # 처리 실패 시 원본 저장
|
|
|
|
|
|
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 _safe_part(value: Any) -> str:
|
|
"""파일명 구성요소 정리 — 경로/금지문자 제거, 공백 압축."""
|
|
s = str(value or "").strip()
|
|
s = re.sub(r"[\\/:*?\"<>|\r\n\t]", " ", s)
|
|
s = re.sub(r"\s+", " ", s).strip()
|
|
return s
|
|
|
|
|
|
def _fmt_date_kr(value: Any) -> str:
|
|
"""날짜 → `yy년 mm월 dd일(요일)`. date/datetime/문자열(YYYY-MM-DD) 허용."""
|
|
d: Any = value
|
|
if isinstance(value, str):
|
|
try:
|
|
d = datetime.strptime(value[:10], "%Y-%m-%d").date()
|
|
except ValueError:
|
|
return _safe_part(value)
|
|
if isinstance(d, datetime):
|
|
d = d.astimezone(KST)
|
|
if hasattr(d, "weekday"):
|
|
wd = _WEEKDAYS_KR[d.weekday()]
|
|
return f"{d.strftime('%y')}년 {d.strftime('%m')}월 {d.strftime('%d')}일({wd})"
|
|
return _safe_part(value)
|
|
|
|
|
|
def _att_download_name(att: dict[str, Any], name: str) -> str:
|
|
"""첨부 다운로드 파일명: `날짜_이름_분류_결제수단_금액_가맹점{ext}`.
|
|
|
|
날짜=사용일(spent_at). 금액=쉼표 표기. 빈 가맹점은 생략.
|
|
"""
|
|
parts = [
|
|
_fmt_date_kr(att.get("spent_at")),
|
|
_safe_part(name),
|
|
_safe_part(att.get("category")),
|
|
_safe_part(att.get("method")),
|
|
f"{int(att.get('amount', 0) or 0):,}원",
|
|
]
|
|
merchant = _safe_part(att.get("merchant"))
|
|
if merchant:
|
|
parts.append(merchant)
|
|
base = "_".join(p for p in parts if p)
|
|
ext = os.path.splitext(att.get("filename") 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, nm)
|
|
# 동일명 충돌 → 접미사
|
|
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}",
|
|
)
|
|
|
|
# 메모리로 읽기 (사이즈 제한)
|
|
data = bytearray()
|
|
chunk_size = 1024 * 256
|
|
try:
|
|
while True:
|
|
chunk = await file.read(chunk_size)
|
|
if not chunk:
|
|
break
|
|
data.extend(chunk)
|
|
if len(data) > MAX_UPLOAD_BYTES:
|
|
raise HTTPException(
|
|
status_code=413,
|
|
detail=f"파일 크기 초과 (최대 {MAX_UPLOAD_BYTES // (1024*1024)}MB)",
|
|
)
|
|
finally:
|
|
await file.close()
|
|
|
|
# 이미지면 가로 1000px 초과 시 비율 유지 축소 + JPEG q80
|
|
upload_ct = file.content_type
|
|
data_bytes, raw_name, ext, forced_ct = _maybe_downscale_image(
|
|
bytes(data), raw_name, ext
|
|
)
|
|
content_type = (
|
|
forced_ct
|
|
or upload_ct
|
|
or mimetypes.guess_type(raw_name)[0]
|
|
or "application/octet-stream"
|
|
)
|
|
|
|
# 디스크에 저장
|
|
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
|
|
stored_path.write_bytes(data_bytes)
|
|
|
|
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=len(data_bytes),
|
|
)
|
|
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"}
|