feat(expense+admin): 결재 워크플로 + 첨부 + 집계 + 엑셀 + 사용자 직접 등록

[admin]
- POST /api/users 사용자 직접 등록 (이메일만으로). UserStore.create_user 추가
- admin.html: 등록 폼 + 삭제 버튼 + 한글 라벨 + 신규 권한 표시
- 신규 권한 키: expense_approver, vacation_approver (APPROVER_KEYS)
- is_approver(user_rec, kind) 헬퍼

[expense 워크플로]
- 상태 전이: 작성중 → 제출 → 승인/반려 → 정산완료
- API: /api/items/{id}/{submit,revert,approve,reject,settle}
- 권한: submit/revert=owner, approve/reject/settle=expense_approver 또는 admin
- expense_items 컬럼: approver_email, decided_at, reject_reason
- 작성중/반려 상태에서만 수정/삭제/첨부 가능

[expense 첨부]
- 영수증/기타 (kind: receipt|other). 최대 20MB, 확장자 화이트리스트
- 저장: DATA_DIR/uploads/expense/{item_id}/{att_id}_{filename}
- expense_attachments 테이블 (CASCADE on item delete)
- API: 업로드/목록/다운로드/삭제

[expense 집계]
- /expense/reports: 연도별 월×카테고리 피벗 표

[expense 엑셀]
- GET /expense/api/export.xlsx?from&to&scope=mine|all
- scope=all 은 승인자/관리자만

[DDL]
- expense_db_init.sql: 신규 컬럼/테이블 포함 (멱등)
- expense_db_002_workflow_attachments.sql: 운영 DB 마이그레이션용

[deps]
- openpyxl>=3.1, python-multipart>=0.0.20

[docs]
- DATABASES.md: 워크플로 다이어그램, attachments 테이블, 마이그레이션
- PROJECT_OVERVIEW.md: 권한 키 표
- DEPLOYMENT.md: DATA_DIR/uploads 안내
This commit is contained in:
2026-05-29 02:52:44 +09:00
parent afacc9a7db
commit 550867ba0c
14 changed files with 1492 additions and 142 deletions
+261
View File
@@ -178,6 +178,267 @@ class ExpenseDBStore:
if cur.rowcount == 0:
raise KeyError(item_id)
# ── 워크플로 ──
def submit(self, *, item_id: str, owner: str) -> dict[str, Any]:
return self._transition_owner(
item_id=item_id, owner=owner, from_status="작성중", to_status="제출"
)
def revert_to_draft(self, *, item_id: str, owner: str) -> dict[str, Any]:
"""반려 또는 제출 상태에서 본인이 작성중으로 되돌림."""
owner = owner.lower().strip()
with self._pool.connection() as conn:
row = conn.execute(
"""
UPDATE expense_items
SET status = '작성중',
reject_reason = NULL,
decided_at = NULL,
approver_email = NULL
WHERE id = %s AND owner = %s
AND status IN ('제출', '반려')
RETURNING *
""",
(item_id, owner),
).fetchone()
if not row:
raise ValueError("작성중 으로 되돌릴 수 없는 상태입니다.")
return self._serialize(row)
def approve(self, *, item_id: str, approver_email: str) -> dict[str, Any]:
return self._transition_approver(
item_id=item_id,
approver_email=approver_email,
from_statuses=("제출",),
to_status="승인",
)
def reject(
self, *, item_id: str, approver_email: str, reason: str
) -> dict[str, Any]:
if not reason.strip():
raise ValueError("반려 사유 필수")
approver_email = approver_email.lower().strip()
with self._pool.connection() as conn:
row = conn.execute(
"""
UPDATE expense_items
SET status = '반려',
approver_email = %s,
decided_at = now(),
reject_reason = %s
WHERE id = %s AND status = '제출'
RETURNING *
""",
(approver_email, reason.strip(), item_id),
).fetchone()
if not row:
raise ValueError("제출 상태가 아니거나 항목 없음")
return self._serialize(row)
def settle(self, *, item_id: str, approver_email: str) -> dict[str, Any]:
return self._transition_approver(
item_id=item_id,
approver_email=approver_email,
from_statuses=("승인",),
to_status="정산완료",
)
def _transition_owner(
self, *, item_id: str, owner: str, from_status: str, to_status: str
) -> dict[str, Any]:
owner = owner.lower().strip()
with self._pool.connection() as conn:
row = conn.execute(
"""
UPDATE expense_items
SET status = %s
WHERE id = %s AND owner = %s AND status = %s
RETURNING *
""",
(to_status, item_id, owner, from_status),
).fetchone()
if not row:
raise ValueError(f"전이 불가: {from_status}{to_status}")
return self._serialize(row)
def _transition_approver(
self,
*,
item_id: str,
approver_email: str,
from_statuses: tuple[str, ...],
to_status: str,
) -> dict[str, Any]:
approver_email = approver_email.lower().strip()
with self._pool.connection() as conn:
row = conn.execute(
"""
UPDATE expense_items
SET status = %s,
approver_email = %s,
decided_at = now()
WHERE id = %s AND status = ANY(%s)
RETURNING *
""",
(to_status, approver_email, item_id, list(from_statuses)),
).fetchone()
if not row:
raise ValueError(f"전이 불가 → {to_status}")
return self._serialize(row)
# ── 승인자 대기열 ──
def list_pending_approval(self) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM expense_items WHERE status = '제출' "
"ORDER BY created_at ASC"
).fetchall()
return [self._serialize(r) for r in rows]
def get_any(self, *, item_id: str) -> dict[str, Any] | None:
"""승인자/관리자용 — owner 무시하고 단일 조회."""
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM expense_items WHERE id = %s", (item_id,)
).fetchone()
return self._serialize(row) if row else None
# ── 첨부 ──
def add_attachment(
self,
*,
item_id: str,
owner: str,
kind: str,
filename: str,
stored_path: str,
content_type: str,
size_bytes: int,
) -> dict[str, Any]:
if kind not in ("receipt", "other"):
raise ValueError("kind 는 receipt|other")
owner = owner.lower().strip()
att_id = uuid.uuid4().hex[:12]
with self._pool.connection() as conn:
row = conn.execute(
"""
INSERT INTO expense_attachments
(id, item_id, owner, kind, filename, stored_path,
content_type, size_bytes)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""",
(
att_id,
item_id,
owner,
kind,
filename,
stored_path,
content_type,
size_bytes,
),
).fetchone()
return self._att_serialize(row)
def list_attachments(self, *, item_id: str) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM expense_attachments WHERE item_id = %s "
"ORDER BY uploaded_at ASC",
(item_id,),
).fetchall()
return [self._att_serialize(r) for r in rows]
def get_attachment(self, *, att_id: str) -> dict[str, Any] | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM expense_attachments WHERE id = %s", (att_id,)
).fetchone()
return self._att_serialize(row) if row else None
def delete_attachment(self, *, att_id: str, owner: str) -> dict[str, Any]:
"""삭제된 행 반환 (파일 정리용 stored_path 포함)."""
owner = owner.lower().strip()
with self._pool.connection() as conn:
row = conn.execute(
"DELETE FROM expense_attachments "
"WHERE id = %s AND owner = %s RETURNING *",
(att_id, owner),
).fetchone()
if not row:
raise KeyError(att_id)
return self._att_serialize(row)
@staticmethod
def _att_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
v = out.get("uploaded_at")
if isinstance(v, datetime):
out["uploaded_at"] = v.astimezone(timezone.utc).isoformat(timespec="seconds")
out["size_bytes"] = int(out.get("size_bytes", 0))
return out
# ── 집계 (월별) ──
def monthly_summary(
self, *, email: str, year: int
) -> list[dict[str, Any]]:
email = email.lower().strip()
with self._pool.connection() as conn:
rows = conn.execute(
"""
SELECT to_char(spent_at, 'YYYY-MM') AS month,
category,
COUNT(*) AS cnt,
COALESCE(SUM(amount), 0) AS total
FROM expense_items
WHERE owner = %s AND EXTRACT(YEAR FROM spent_at) = %s
GROUP BY 1, 2
ORDER BY 1, 2
""",
(email, year),
).fetchall()
return [
{
"month": r["month"],
"category": r["category"],
"count": int(r["cnt"]),
"total": int(r["total"]),
}
for r in rows
]
def list_for_export(
self,
*,
email: str | None,
date_from: str | None = None,
date_to: str | None = None,
) -> list[dict[str, Any]]:
"""엑셀 내보내기용. email=None 이면 전체 (승인자/관리자용)."""
clauses = []
params: list[Any] = []
if email:
clauses.append("owner = %s")
params.append(email.lower().strip())
if date_from:
clauses.append("spent_at >= %s")
params.append(date_from)
if date_to:
clauses.append("spent_at <= %s")
params.append(date_to)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT * FROM expense_items {where} "
f"ORDER BY spent_at ASC, created_at ASC",
params,
).fetchall()
return [self._serialize(r) for r in rows]
# ── 요약 ──
def summary_for(self, email: str) -> dict[str, Any]:
email = email.lower().strip()
+504 -8
View File
@@ -2,31 +2,77 @@
- 경로: /expense
- 권한: 로그인 + `expense` 모듈 권한 필요 (관리자는 항상 통과).
- 데이터: ExpenseStore (JSON). app.state.expense_store 에 연결.
- 데이터: ExpenseStore (JSON) 또는 ExpenseDBStore (PostgreSQL).
- 워크플로/첨부/집계/엑셀 내보내기는 DB 모드에서만 완전 지원.
"""
from __future__ import annotations
import io
import mimetypes
import os
import re
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
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) -> ExpenseStore:
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]:
# 순환 import 회피: 핸들러 호출 시점에 main 모듈에서 helper 를 가져온다.
from app.main import get_current_user_record # noqa: WPS433
from app.store import has_module # noqa: WPS433
@@ -38,6 +84,31 @@ def _require_user(request: Request) -> dict[str, Any]:
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
@@ -48,6 +119,13 @@ class ExpensePayload(BaseModel):
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
@@ -75,12 +153,19 @@ async def expense_index(request: Request) -> HTMLResponse:
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),
@@ -89,19 +174,110 @@ async def expense_index(request: Request) -> HTMLResponse:
"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(datetime.now().year)
try:
year = int(year_str)
except ValueError:
year = datetime.now().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(request).list_for(user["email"]),
"summary": _store(request).summary_for(user["email"]),
"items": store.list_for(user["email"]),
"summary": store.summary_for(user["email"]),
}
)
@@ -123,8 +299,17 @@ async def update_item(
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(request).update(
item = store.update(
item_id=item_id, owner=user["email"], payload=body.model_dump()
)
except KeyError:
@@ -138,6 +323,14 @@ async def delete_item(
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:
@@ -145,6 +338,309 @@ async def delete_item(
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}_{datetime.now().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"}
+216 -70
View File
@@ -3,6 +3,20 @@
{% block content %}
<section class="erp-expense">
<!-- ── 페이지 액션 ── -->
<div class="erp-page-actions">
<a class="erp-btn erp-btn-outline" href="/expense/reports">월별 집계</a>
{% if is_approver %}
<a class="erp-btn erp-btn-outline" href="/expense/pending">
승인 대기 {% if pending_count %}<strong style="margin-left:6px;">{{ pending_count }}</strong>{% endif %}
</a>
{% endif %}
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=mine">엑셀(내 항목)</a>
{% if is_approver %}
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=all">엑셀(전체)</a>
{% endif %}
</div>
<!-- ── 요약 카드 ── -->
<div class="erp-summary-grid">
<div class="erp-summary-card">
@@ -29,36 +43,28 @@
<div class="erp-card-block">
<div class="erp-card-block-head">
<h2>경비 등록</h2>
<span class="erp-muted">필수 항목 입력하고 등록을 누르세요.</span>
<span class="erp-muted">필수 항목 입력 등록. 등록 후 항목별로 첨부/제출.</span>
</div>
<form id="ex-form" class="erp-form-grid">
<input type="hidden" name="id" />
<label class="erp-field">
<span>사용일</span>
<input type="date" name="spent_at" required />
</label>
<label class="erp-field">
<span>분류</span>
<label class="erp-field"><span>사용일</span><input type="date" name="spent_at" required /></label>
<label class="erp-field"><span>분류</span>
<select name="category" required>
{% for c in categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
</select>
</label>
<label class="erp-field">
<span>결제수단</span>
<label class="erp-field"><span>결제수단</span>
<select name="method" required>
{% for m in methods %}<option value="{{ m }}">{{ m }}</option>{% endfor %}
</select>
</label>
<label class="erp-field">
<span>금액</span>
<label class="erp-field"><span>금액</span>
<input type="number" name="amount" min="0" step="1" required placeholder="원" />
</label>
<label class="erp-field erp-field-wide">
<span>가맹점/사용처</span>
<label class="erp-field erp-field-wide"><span>가맹점/사용처</span>
<input type="text" name="merchant" placeholder="예) 스타벅스 강남점" />
</label>
<label class="erp-field erp-field-wide">
<span>메모</span>
<label class="erp-field erp-field-wide"><span>메모</span>
<input type="text" name="memo" placeholder="비고" />
</label>
<div class="erp-form-actions">
@@ -78,36 +84,93 @@
<table class="erp-table" id="ex-table">
<thead>
<tr>
<th style="width: 28px;"></th>
<th style="width: 110px;">사용일</th>
<th style="width: 90px;">분류</th>
<th style="width: 100px;">수단</th>
<th>가맹점</th>
<th style="width: 120px; text-align: right;">금액</th>
<th style="width: 90px;">상태</th>
<th style="width: 130px; text-align: right;">동작</th>
<th style="width: 100px;">상태</th>
<th style="width: 220px; text-align: right;">동작</th>
</tr>
</thead>
<tbody id="ex-tbody">
{% for it in items %}
<tr data-id="{{ it.id }}">
<tr data-id="{{ it.id }}" data-status="{{ it.status }}">
<td>
{% if supports_workflow %}
<button class="erp-btn erp-btn-ghost erp-btn-sm js-toggle" title="첨부/상세"></button>
{% endif %}
</td>
<td>{{ it.spent_at }}</td>
<td>{{ it.category }}</td>
<td>{{ it.method }}</td>
<td>
<div>{{ it.merchant }}</div>
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
{% if it.reject_reason %}
<div class="erp-row-sub" style="color: var(--color-callout-red);">반려: {{ it.reject_reason }}</div>
{% endif %}
</td>
<td style="text-align: right; font-variant-numeric: tabular-nums;">
{{ "{:,}".format(it.amount) }} 원
</td>
<td><span class="erp-badge erp-badge-neutral">{{ it.status }}</span></td>
<td style="text-align: right;">
<button class="erp-btn erp-btn-ghost erp-btn-sm js-edit">수정</button>
<button class="erp-btn erp-btn-ghost erp-btn-sm js-delete">삭제</button>
<td>
{% if it.status == '작성중' or it.status == '반려' %}
<span class="erp-badge erp-badge-outline">{{ it.status }}</span>
{% elif it.status == '제출' %}
<span class="erp-badge erp-badge-neutral">{{ it.status }}</span>
{% elif it.status == '승인' %}
<span class="erp-badge erp-badge-inverse">{{ it.status }}</span>
{% else %}
<span class="erp-badge erp-badge-neutral">{{ it.status }}</span>
{% endif %}
</td>
<td style="text-align: right;" class="js-actions">
{% if it.status in ('작성중', '반려') %}
<button class="erp-btn erp-btn-ghost erp-btn-sm js-edit">수정</button>
<button class="erp-btn erp-btn-ghost erp-btn-sm js-delete">삭제</button>
{% if supports_workflow %}
<button class="erp-btn erp-btn-primary erp-btn-sm js-submit">제출</button>
{% endif %}
{% elif it.status == '제출' and supports_workflow %}
<button class="erp-btn erp-btn-ghost erp-btn-sm js-revert">취소(작성중)</button>
{% endif %}
</td>
</tr>
{% if supports_workflow %}
<tr class="ex-detail" data-detail-for="{{ it.id }}" hidden>
<td colspan="8">
<div class="ex-detail-box">
<div class="ex-attach-head">
<strong>첨부파일</strong>
{% if it.status in ('작성중', '반려') %}
<div class="ex-upload">
<label class="erp-btn erp-btn-outline erp-btn-sm">
영수증 추가
<input type="file" class="js-upload" data-kind="receipt" hidden />
</label>
<label class="erp-btn erp-btn-outline erp-btn-sm">
기타파일 추가
<input type="file" class="js-upload" data-kind="other" hidden />
</label>
</div>
{% endif %}
</div>
<ul class="ex-attach-list" data-list="{{ it.id }}">
<li class="erp-muted">불러오는 중…</li>
</ul>
{% if it.approver_email %}
<div class="erp-muted" style="margin-top: 6px;">
결재: {{ it.approver_email }} · {{ (it.decided_at or '')[:16].replace('T',' ') }}
</div>
{% endif %}
</div>
</td>
</tr>
{% endif %}
{% else %}
<tr id="ex-empty"><td colspan="7" class="erp-empty">등록된 경비가 없습니다.</td></tr>
<tr id="ex-empty"><td colspan="8" class="erp-empty">등록된 경비가 없습니다.</td></tr>
{% endfor %}
</tbody>
</table>
@@ -115,11 +178,40 @@
</div>
</section>
<style>
.erp-page-actions {
display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); flex-wrap: wrap;
}
.ex-detail-box {
background: var(--color-ghost-gray);
padding: var(--sp-12) var(--sp-16);
border-radius: 8px;
}
.ex-attach-head {
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-8);
margin-bottom: 8px;
}
.ex-upload { display: flex; gap: 6px; }
.ex-attach-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.ex-attach-list li {
display: flex; align-items: center; gap: 8px; padding: 6px 8px;
background: var(--color-canvas-white); border: 1px solid var(--color-subtle-ash); border-radius: 6px;
font-size: 13px;
}
.ex-attach-list li .ex-kind {
padding: 1px 6px; border-radius: 8px; font-size: 11px;
background: var(--color-ghost-gray); color: var(--color-midtone-gray);
}
.ex-attach-list li .ex-name { flex: 1; }
.ex-attach-list li .ex-size { color: var(--color-midtone-gray); font-size: 12px; }
</style>
{% endblock %}
{% block scripts %}
<script>
(function () {
const supportsWorkflow = {{ 'true' if supports_workflow else 'false' }};
const form = document.getElementById("ex-form");
const submitBtn = document.getElementById("ex-submit");
const resetBtn = document.getElementById("ex-reset");
@@ -128,6 +220,12 @@
const totalEl = document.getElementById("ex-total-amount");
const fmt = (n) => `${Number(n || 0).toLocaleString("ko-KR")}`;
const fmtSize = (b) => {
b = Number(b || 0);
if (b < 1024) return `${b} B`;
if (b < 1024*1024) return `${(b/1024).toFixed(1)} KB`;
return `${(b/1024/1024).toFixed(2)} MB`;
};
function setEditing(id, data) {
form.id.value = id || "";
@@ -143,7 +241,6 @@
submitBtn.textContent = "등록";
}
}
resetBtn.addEventListener("click", () => setEditing("", null));
form.addEventListener("submit", async (e) => {
@@ -165,70 +262,119 @@
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await res.text());
await reload();
form.reset();
setEditing("", null);
if (!res.ok) throw new Error((await res.json()).detail || res.status);
location.reload();
} catch (err) {
alert(`저장 실패: ${err.message || err}`);
}
});
async function postAction(id, action, body) {
const res = await fetch(`/expense/api/items/${id}/${action}`, {
method: "POST",
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error((await res.json()).detail || res.status);
return res.json();
}
async function loadAttachments(itemId) {
const ul = tbody.querySelector(`.ex-attach-list[data-list="${itemId}"]`);
if (!ul) return;
ul.innerHTML = `<li class="erp-muted">불러오는 중…</li>`;
try {
const res = await fetch(`/expense/api/items/${itemId}/attachments`);
if (!res.ok) throw new Error((await res.json()).detail || res.status);
const { attachments } = await res.json();
if (!attachments.length) {
ul.innerHTML = `<li class="erp-muted">첨부 없음</li>`;
return;
}
ul.innerHTML = "";
attachments.forEach((a) => {
const li = document.createElement("li");
li.innerHTML = `
<span class="ex-kind">${a.kind === 'receipt' ? '영수증' : '기타'}</span>
<a class="ex-name" href="/expense/api/attachments/${a.id}" target="_blank">${a.filename}</a>
<span class="ex-size">${fmtSize(a.size_bytes)}</span>
<button class="erp-btn erp-btn-ghost erp-btn-sm js-del-att" data-att="${a.id}">삭제</button>
`;
ul.appendChild(li);
});
} catch (err) {
ul.innerHTML = `<li style="color: var(--color-callout-red);">로드 실패: ${err.message || err}</li>`;
}
}
tbody.addEventListener("click", async (e) => {
const btn = e.target.closest("button");
if (!btn) return;
const tr = btn.closest("tr");
const id = tr && tr.dataset.id;
if (!id) return;
if (btn.classList.contains("js-delete")) {
if (!confirm("이 항목을 삭제할까요?")) return;
const res = await fetch(`/expense/api/items/${id}`, { method: "DELETE" });
if (res.ok) reload(); else alert("삭제 실패");
const tr = btn.closest("tr[data-id]");
if (!tr) return;
const id = tr.dataset.id;
if (btn.classList.contains("js-toggle")) {
const detail = tbody.querySelector(`tr.ex-detail[data-detail-for="${id}"]`);
if (!detail) return;
const opening = detail.hasAttribute("hidden");
if (opening) {
detail.removeAttribute("hidden");
btn.textContent = "▾";
loadAttachments(id);
} else {
detail.setAttribute("hidden", "");
btn.textContent = "▸";
}
} else if (btn.classList.contains("js-edit")) {
const res = await fetch(`/expense/api/items`);
if (!res.ok) return;
const j = await res.json();
const found = (j.items || []).find((x) => x.id === id);
const { items } = await res.json();
const found = items.find((x) => x.id === id);
if (found) setEditing(id, found);
window.scrollTo({ top: 0, behavior: "smooth" });
} else if (btn.classList.contains("js-delete")) {
if (!confirm("이 항목을 삭제할까요? 첨부도 함께 삭제됩니다.")) return;
const res = await fetch(`/expense/api/items/${id}`, { method: "DELETE" });
if (res.ok) location.reload(); else alert((await res.json()).detail || "삭제 실패");
} else if (btn.classList.contains("js-submit")) {
if (!confirm("결재 제출할까요? 제출 후에는 수정할 수 없습니다.")) return;
try { await postAction(id, "submit"); location.reload(); }
catch (err) { alert(`제출 실패: ${err.message || err}`); }
} else if (btn.classList.contains("js-revert")) {
if (!confirm("작성중 상태로 되돌릴까요?")) return;
try { await postAction(id, "revert"); location.reload(); }
catch (err) { alert(`취소 실패: ${err.message || err}`); }
} else if (btn.classList.contains("js-del-att")) {
if (!confirm("첨부를 삭제할까요?")) return;
const attId = btn.dataset.att;
const res = await fetch(`/expense/api/attachments/${attId}`, { method: "DELETE" });
if (res.ok) loadAttachments(id); else alert((await res.json()).detail || "삭제 실패");
}
});
async function reload() {
const res = await fetch(`/expense/api/items`);
if (!res.ok) return;
const j = await res.json();
const items = j.items || [];
const summary = j.summary || { count: 0, total: 0 };
countEl.textContent = `${items.length}`;
if (totalEl) totalEl.textContent = fmt(summary.total);
tbody.innerHTML = "";
if (items.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" class="erp-empty">등록된 경비가 없습니다.</td></tr>`;
return;
}
items
.sort((a, b) => (b.spent_at || "").localeCompare(a.spent_at || ""))
.forEach((it) => {
const tr = document.createElement("tr");
tr.dataset.id = it.id;
tr.innerHTML = `
<td>${it.spent_at || ""}</td>
<td>${it.category || ""}</td>
<td>${it.method || ""}</td>
<td><div>${it.merchant || ""}</div>${it.memo ? `<div class="erp-row-sub">${it.memo}</div>` : ""}</td>
<td style="text-align:right; font-variant-numeric: tabular-nums;">${fmt(it.amount)}</td>
<td><span class="erp-badge erp-badge-neutral">${it.status || "작성중"}</span></td>
<td style="text-align:right;">
<button class="erp-btn erp-btn-ghost erp-btn-sm js-edit">수정</button>
<button class="erp-btn erp-btn-ghost erp-btn-sm js-delete">삭제</button>
</td>`;
tbody.appendChild(tr);
// 첨부 업로드
tbody.addEventListener("change", async (e) => {
const input = e.target.closest("input.js-upload");
if (!input || !input.files.length) return;
const tr = input.closest("tr.ex-detail");
if (!tr) return;
const itemId = tr.dataset.detailFor;
const fd = new FormData();
fd.append("file", input.files[0]);
fd.append("kind", input.dataset.kind || "other");
try {
const res = await fetch(`/expense/api/items/${itemId}/attachments`, {
method: "POST", body: fd,
});
}
if (!res.ok) throw new Error((await res.json()).detail || res.status);
input.value = "";
loadAttachments(itemId);
} catch (err) {
alert(`업로드 실패: ${err.message || err}`);
}
});
// 사용일 기본값 = 오늘
if (!form.spent_at.value) {
const d = new Date();
form.spent_at.value = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
@@ -0,0 +1,89 @@
{% extends "erp_base.html" %}
{% block content %}
<section class="erp-pending">
<div class="erp-page-actions">
<a class="erp-btn erp-btn-ghost" href="/expense/">← 내 개인경비</a>
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=all">엑셀(전체)</a>
</div>
<div class="erp-card-block">
<div class="erp-card-block-head">
<h2>승인 대기 ({{ items | length }}건)</h2>
<span class="erp-muted">제출 상태 항목 — 승인/반려 처리</span>
</div>
<div class="erp-table-wrap">
<table class="erp-table">
<thead>
<tr>
<th style="width: 110px;">사용일</th>
<th style="width: 200px;">소유자</th>
<th style="width: 90px;">분류</th>
<th>가맹점/메모</th>
<th style="width: 120px; text-align: right;">금액</th>
<th style="width: 260px; text-align: right;">결재</th>
</tr>
</thead>
<tbody id="pend-tbody">
{% for it in items %}
<tr data-id="{{ it.id }}">
<td>{{ it.spent_at }}</td>
<td>{{ it.owner }}</td>
<td>{{ it.category }}</td>
<td>
<div>{{ it.merchant }}</div>
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
</td>
<td style="text-align: right; font-variant-numeric: tabular-nums;">
{{ "{:,}".format(it.amount) }} 원
</td>
<td style="text-align: right;">
<a class="erp-btn erp-btn-ghost erp-btn-sm" href="/expense/api/items/{{ it.id }}/attachments" target="_blank">첨부</a>
<button class="erp-btn erp-btn-outline erp-btn-sm js-reject">반려</button>
<button class="erp-btn erp-btn-primary erp-btn-sm js-approve">승인</button>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="erp-empty">대기 항목이 없습니다.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
<style>
.erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); }
</style>
{% endblock %}
{% block scripts %}
<script>
document.getElementById("pend-tbody").addEventListener("click", async (e) => {
const btn = e.target.closest("button");
if (!btn) return;
const tr = btn.closest("tr[data-id]");
const id = tr.dataset.id;
if (btn.classList.contains("js-approve")) {
if (!confirm("승인할까요?")) return;
const res = await fetch(`/expense/api/items/${id}/approve`, { method: "POST" });
if (res.ok) { tr.remove(); }
else { alert((await res.json()).detail || "승인 실패"); }
} else if (btn.classList.contains("js-reject")) {
const reason = prompt("반려 사유를 입력하세요:");
if (!reason || !reason.trim()) return;
const res = await fetch(`/expense/api/items/${id}/reject`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: reason.trim() }),
});
if (res.ok) { tr.remove(); }
else { alert((await res.json()).detail || "반려 실패"); }
}
});
</script>
{% endblock %}
@@ -0,0 +1,78 @@
{% extends "erp_base.html" %}
{% block content %}
<section class="erp-reports">
<div class="erp-page-actions">
<a class="erp-btn erp-btn-ghost" href="/expense/">← 내 개인경비</a>
<form method="get" style="display: inline-flex; gap: 6px; align-items: center;">
<label class="erp-muted">연도</label>
<input type="number" name="year" value="{{ year }}" min="2000" max="2100"
style="width: 100px; padding: 6px 10px; border: 1px solid var(--color-subtle-ash);
border-radius: var(--r-input); font-family: inherit;" />
<button type="submit" class="erp-btn erp-btn-outline">조회</button>
</form>
<a class="erp-btn erp-btn-outline"
href="/expense/api/export.xlsx?from={{ year }}-01-01&to={{ year }}-12-31">엑셀</a>
</div>
<div class="erp-card-block">
<div class="erp-card-block-head">
<h2>{{ year }}년 월별 / 카테고리별 합계</h2>
<span class="erp-muted">총 {{ "{:,}".format(grand_total) }} 원</span>
</div>
<div class="erp-table-wrap">
<table class="erp-table">
<thead>
<tr>
<th style="width: 110px;"></th>
{% for c in categories %}
<th style="text-align: right;">{{ c }}</th>
{% endfor %}
<th style="text-align: right; background: var(--color-ghost-gray);">합계</th>
</tr>
</thead>
<tbody>
{% for m in months %}
{% set row_total = pivot[m].values() | sum %}
<tr>
<td><strong>{{ m }}</strong></td>
{% for c in categories %}
<td style="text-align: right; font-variant-numeric: tabular-nums;">
{% if pivot[m].get(c) %}{{ "{:,}".format(pivot[m][c]) }}{% else %}-{% endif %}
</td>
{% endfor %}
<td style="text-align: right; font-variant-numeric: tabular-nums; background: var(--color-ghost-gray);">
<strong>{{ "{:,}".format(row_total) }}</strong>
</td>
</tr>
{% else %}
<tr><td colspan="{{ categories | length + 2 }}" class="erp-empty">데이터 없음</td></tr>
{% endfor %}
</tbody>
{% if months %}
<tfoot>
<tr style="background: var(--color-ghost-gray);">
<th>합계</th>
{% for c in categories %}
<th style="text-align: right; font-variant-numeric: tabular-nums;">
{{ "{:,}".format(cat_totals[c]) }}
</th>
{% endfor %}
<th style="text-align: right; font-variant-numeric: tabular-nums;">
{{ "{:,}".format(grand_total) }}
</th>
</tr>
</tfoot>
{% endif %}
</table>
</div>
</div>
</section>
<style>
.erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); align-items: center; flex-wrap: wrap; }
</style>
{% endblock %}