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
+38
View File
@@ -16,6 +16,7 @@ from starlette.middleware.sessions import SessionMiddleware
from .modules.expense import build_expense_store from .modules.expense import build_expense_store
from .modules.expense import router as expense_router from .modules.expense import router as expense_router
from .store import ( from .store import (
APPROVER_KEYS,
MODULE_KEYS, MODULE_KEYS,
SUPER_ADMIN_EMAIL, SUPER_ADMIN_EMAIL,
UserStore, UserStore,
@@ -24,6 +25,16 @@ from .store import (
is_admin, is_admin,
) )
# 권한 키 한글 라벨 (admin.html / 사이드바 공용)
MODULE_LABELS: dict[str, str] = {
"corm": "CORM",
"order": "Order",
"expense": "개인경비",
"vacation": "휴가",
"expense_approver": "개인경비",
"vacation_approver": "휴가",
}
# OMS(orderlist) 와 공유하는 세션 키. SessionMiddleware 의 session_cookie 도 동일 이름. # OMS(orderlist) 와 공유하는 세션 키. SessionMiddleware 의 session_cookie 도 동일 이름.
SESSION_COOKIE_DEFAULT = "session" SESSION_COOKIE_DEFAULT = "session"
@@ -104,6 +115,7 @@ user_store = UserStore(DATA_DIR / "users.json")
# 모듈별 데이터 저장소. # 모듈별 데이터 저장소.
# EXPENSE_DB_URL 가 있으면 expense_db(PostgreSQL), 없으면 JSON 파일. # EXPENSE_DB_URL 가 있으면 expense_db(PostgreSQL), 없으면 JSON 파일.
app.state.data_dir = DATA_DIR # 모듈에서 첨부 저장 경로 등으로 참조
app.state.expense_store = build_expense_store( app.state.expense_store = build_expense_store(
dsn=env("EXPENSE_DB_URL") or None, dsn=env("EXPENSE_DB_URL") or None,
json_path=DATA_DIR / "expense.json", json_path=DATA_DIR / "expense.json",
@@ -459,6 +471,8 @@ async def admin_page(request: Request) -> HTMLResponse:
"user": rec, "user": rec,
"users": users, "users": users,
"module_keys": list(MODULE_KEYS), "module_keys": list(MODULE_KEYS),
"module_labels": MODULE_LABELS,
"approver_keys": list(APPROVER_KEYS),
"super_admin_email": SUPER_ADMIN_EMAIL, "super_admin_email": SUPER_ADMIN_EMAIL,
"is_admin": True, "is_admin": True,
}, },
@@ -471,11 +485,35 @@ class UpdatePermissionsBody(BaseModel):
modules: dict[str, bool] | None = None modules: dict[str, bool] | None = None
class CreateUserBody(BaseModel):
email: str
name: str = ""
role: str = "user"
modules: dict[str, bool] | None = None
@app.get("/api/users") @app.get("/api/users")
async def api_list_users(_: dict[str, Any] = Depends(require_admin)) -> JSONResponse: async def api_list_users(_: dict[str, Any] = Depends(require_admin)) -> JSONResponse:
return JSONResponse({"users": user_store.list_all()}) return JSONResponse({"users": user_store.list_all()})
@app.post("/api/users")
async def api_create_user(
body: CreateUserBody,
_: dict[str, Any] = Depends(require_admin),
) -> JSONResponse:
try:
rec = user_store.create_user(
email=body.email,
name=body.name,
role=body.role,
modules=body.modules,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return JSONResponse({"user": rec}, status_code=201)
@app.put("/api/users/{email}") @app.put("/api/users/{email}")
async def api_update_user( async def api_update_user(
email: str, email: str,
+261
View File
@@ -178,6 +178,267 @@ class ExpenseDBStore:
if cur.rowcount == 0: if cur.rowcount == 0:
raise KeyError(item_id) 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]: def summary_for(self, email: str) -> dict[str, Any]:
email = email.lower().strip() email = email.lower().strip()
+504 -8
View File
@@ -2,31 +2,77 @@
- 경로: /expense - 경로: /expense
- 권한: 로그인 + `expense` 모듈 권한 필요 (관리자는 항상 통과). - 권한: 로그인 + `expense` 모듈 권한 필요 (관리자는 항상 통과).
- 데이터: ExpenseStore (JSON). app.state.expense_store 에 연결. - 데이터: ExpenseStore (JSON) 또는 ExpenseDBStore (PostgreSQL).
- 워크플로/첨부/집계/엑셀 내보내기는 DB 모드에서만 완전 지원.
""" """
from __future__ import annotations 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 typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import (
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse APIRouter,
Depends,
File,
Form,
HTTPException,
Request,
UploadFile,
)
from fastapi.responses import (
FileResponse,
HTMLResponse,
JSONResponse,
RedirectResponse,
StreamingResponse,
)
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore
router = APIRouter(prefix="/expense", tags=["expense"]) 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) store = getattr(request.app.state, "expense_store", None)
if store is None: if store is None:
raise RuntimeError("ExpenseStore 가 app.state 에 등록되지 않았습니다.") raise RuntimeError("ExpenseStore 가 app.state 에 등록되지 않았습니다.")
return store 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]: def _require_user(request: Request) -> dict[str, Any]:
# 순환 import 회피: 핸들러 호출 시점에 main 모듈에서 helper 를 가져온다.
from app.main import get_current_user_record # noqa: WPS433 from app.main import get_current_user_record # noqa: WPS433
from app.store import has_module # noqa: WPS433 from app.store import has_module # noqa: WPS433
@@ -38,6 +84,31 @@ def _require_user(request: Request) -> dict[str, Any]:
return user 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): class ExpensePayload(BaseModel):
spent_at: str = Field(..., description="YYYY-MM-DD") spent_at: str = Field(..., description="YYYY-MM-DD")
category: str category: str
@@ -48,6 +119,13 @@ class ExpensePayload(BaseModel):
status: str | None = None status: str | None = None
class RejectBody(BaseModel):
reason: str
# ────────────────────────────────────────────────────────────
# 페이지
# ────────────────────────────────────────────────────────────
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
async def expense_index(request: Request) -> HTMLResponse: async def expense_index(request: Request) -> HTMLResponse:
from app.main import ( # noqa: WPS433 from app.main import ( # noqa: WPS433
@@ -75,12 +153,19 @@ async def expense_index(request: Request) -> HTMLResponse:
reverse=True, reverse=True,
) )
summary = store.summary_for(user["email"]) 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( return render_template(
request, request,
"expense/index.html", "expense/index.html",
{ {
"user": user, "user": user,
"is_admin": is_admin(user), "is_admin": is_admin(user),
"is_approver": is_approver,
"pending_count": pending_count,
"items": items, "items": items,
"summary": summary, "summary": summary,
"categories": list(CATEGORIES), "categories": list(CATEGORIES),
@@ -89,19 +174,110 @@ async def expense_index(request: Request) -> HTMLResponse:
"nav_items": build_erp_nav(user, active="expense"), "nav_items": build_erp_nav(user, active="expense"),
"page_title": "개인경비", "page_title": "개인경비",
"page_subtitle": "법인카드 · 개인지출 · 정산 신청", "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") @router.get("/api/items")
async def list_items( async def list_items(
request: Request, request: Request,
user: dict[str, Any] = Depends(_require_user), user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse: ) -> JSONResponse:
store = _store(request)
return JSONResponse( return JSONResponse(
{ {
"items": _store(request).list_for(user["email"]), "items": store.list_for(user["email"]),
"summary": _store(request).summary_for(user["email"]), "summary": store.summary_for(user["email"]),
} }
) )
@@ -123,8 +299,17 @@ async def update_item(
body: ExpensePayload, body: ExpensePayload,
user: dict[str, Any] = Depends(_require_user), user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse: ) -> 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: try:
item = _store(request).update( item = store.update(
item_id=item_id, owner=user["email"], payload=body.model_dump() item_id=item_id, owner=user["email"], payload=body.model_dump()
) )
except KeyError: except KeyError:
@@ -138,6 +323,14 @@ async def delete_item(
item_id: str, item_id: str,
user: dict[str, Any] = Depends(_require_user), user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse: ) -> 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: try:
_store(request).delete(item_id=item_id, owner=user["email"]) _store(request).delete(item_id=item_id, owner=user["email"])
except KeyError: except KeyError:
@@ -145,6 +338,309 @@ async def delete_item(
return JSONResponse({"ok": True}) 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") @router.get("/health")
async def health() -> dict[str, str]: async def health() -> dict[str, str]:
return {"status": "ok", "module": "expense"} return {"status": "ok", "module": "expense"}
+213 -67
View File
@@ -3,6 +3,20 @@
{% block content %} {% block content %}
<section class="erp-expense"> <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-grid">
<div class="erp-summary-card"> <div class="erp-summary-card">
@@ -29,36 +43,28 @@
<div class="erp-card-block"> <div class="erp-card-block">
<div class="erp-card-block-head"> <div class="erp-card-block-head">
<h2>경비 등록</h2> <h2>경비 등록</h2>
<span class="erp-muted">필수 항목 입력하고 등록을 누르세요.</span> <span class="erp-muted">필수 항목 입력 등록. 등록 후 항목별로 첨부/제출.</span>
</div> </div>
<form id="ex-form" class="erp-form-grid"> <form id="ex-form" class="erp-form-grid">
<input type="hidden" name="id" /> <input type="hidden" name="id" />
<label class="erp-field"> <label class="erp-field"><span>사용일</span><input type="date" name="spent_at" required /></label>
<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> <select name="category" required>
{% for c in categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %} {% for c in categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
</select> </select>
</label> </label>
<label class="erp-field"> <label class="erp-field"><span>결제수단</span>
<span>결제수단</span>
<select name="method" required> <select name="method" required>
{% for m in methods %}<option value="{{ m }}">{{ m }}</option>{% endfor %} {% for m in methods %}<option value="{{ m }}">{{ m }}</option>{% endfor %}
</select> </select>
</label> </label>
<label class="erp-field"> <label class="erp-field"><span>금액</span>
<span>금액</span>
<input type="number" name="amount" min="0" step="1" required placeholder="원" /> <input type="number" name="amount" min="0" step="1" required placeholder="원" />
</label> </label>
<label class="erp-field erp-field-wide"> <label class="erp-field erp-field-wide"><span>가맹점/사용처</span>
<span>가맹점/사용처</span>
<input type="text" name="merchant" placeholder="예) 스타벅스 강남점" /> <input type="text" name="merchant" placeholder="예) 스타벅스 강남점" />
</label> </label>
<label class="erp-field erp-field-wide"> <label class="erp-field erp-field-wide"><span>메모</span>
<span>메모</span>
<input type="text" name="memo" placeholder="비고" /> <input type="text" name="memo" placeholder="비고" />
</label> </label>
<div class="erp-form-actions"> <div class="erp-form-actions">
@@ -78,36 +84,93 @@
<table class="erp-table" id="ex-table"> <table class="erp-table" id="ex-table">
<thead> <thead>
<tr> <tr>
<th style="width: 28px;"></th>
<th style="width: 110px;">사용일</th> <th style="width: 110px;">사용일</th>
<th style="width: 90px;">분류</th> <th style="width: 90px;">분류</th>
<th style="width: 100px;">수단</th> <th style="width: 100px;">수단</th>
<th>가맹점</th> <th>가맹점</th>
<th style="width: 120px; text-align: right;">금액</th> <th style="width: 120px; text-align: right;">금액</th>
<th style="width: 90px;">상태</th> <th style="width: 100px;">상태</th>
<th style="width: 130px; text-align: right;">동작</th> <th style="width: 220px; text-align: right;">동작</th>
</tr> </tr>
</thead> </thead>
<tbody id="ex-tbody"> <tbody id="ex-tbody">
{% for it in items %} {% 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.spent_at }}</td>
<td>{{ it.category }}</td> <td>{{ it.category }}</td>
<td>{{ it.method }}</td> <td>{{ it.method }}</td>
<td> <td>
<div>{{ it.merchant }}</div> <div>{{ it.merchant }}</div>
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %} {% 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>
<td style="text-align: right; font-variant-numeric: tabular-nums;"> <td style="text-align: right; font-variant-numeric: tabular-nums;">
{{ "{:,}".format(it.amount) }} 원 {{ "{:,}".format(it.amount) }} 원
</td> </td>
<td><span class="erp-badge erp-badge-neutral">{{ it.status }}</span></td> <td>
<td style="text-align: right;"> {% 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-edit">수정</button>
<button class="erp-btn erp-btn-ghost erp-btn-sm js-delete">삭제</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> </td>
</tr> </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 %} {% 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 %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -115,11 +178,40 @@
</div> </div>
</section> </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 %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
(function () { (function () {
const supportsWorkflow = {{ 'true' if supports_workflow else 'false' }};
const form = document.getElementById("ex-form"); const form = document.getElementById("ex-form");
const submitBtn = document.getElementById("ex-submit"); const submitBtn = document.getElementById("ex-submit");
const resetBtn = document.getElementById("ex-reset"); const resetBtn = document.getElementById("ex-reset");
@@ -128,6 +220,12 @@
const totalEl = document.getElementById("ex-total-amount"); const totalEl = document.getElementById("ex-total-amount");
const fmt = (n) => `${Number(n || 0).toLocaleString("ko-KR")}`; 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) { function setEditing(id, data) {
form.id.value = id || ""; form.id.value = id || "";
@@ -143,7 +241,6 @@
submitBtn.textContent = "등록"; submitBtn.textContent = "등록";
} }
} }
resetBtn.addEventListener("click", () => setEditing("", null)); resetBtn.addEventListener("click", () => setEditing("", null));
form.addEventListener("submit", async (e) => { form.addEventListener("submit", async (e) => {
@@ -165,70 +262,119 @@
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error((await res.json()).detail || res.status);
await reload(); location.reload();
form.reset();
setEditing("", null);
} catch (err) { } catch (err) {
alert(`저장 실패: ${err.message || 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) => { tbody.addEventListener("click", async (e) => {
const btn = e.target.closest("button"); const btn = e.target.closest("button");
if (!btn) return; if (!btn) return;
const tr = btn.closest("tr"); const tr = btn.closest("tr[data-id]");
const id = tr && tr.dataset.id; if (!tr) return;
if (!id) return; const id = tr.dataset.id;
if (btn.classList.contains("js-delete")) {
if (!confirm("이 항목을 삭제할까요?")) return; if (btn.classList.contains("js-toggle")) {
const res = await fetch(`/expense/api/items/${id}`, { method: "DELETE" }); const detail = tbody.querySelector(`tr.ex-detail[data-detail-for="${id}"]`);
if (res.ok) reload(); else alert("삭제 실패"); 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")) { } else if (btn.classList.contains("js-edit")) {
const res = await fetch(`/expense/api/items`); const res = await fetch(`/expense/api/items`);
if (!res.ok) return; if (!res.ok) return;
const j = await res.json(); const { items } = await res.json();
const found = (j.items || []).find((x) => x.id === id); const found = items.find((x) => x.id === id);
if (found) setEditing(id, found); if (found) setEditing(id, found);
window.scrollTo({ top: 0, behavior: "smooth" }); 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`); tbody.addEventListener("change", async (e) => {
if (!res.ok) return; const input = e.target.closest("input.js-upload");
const j = await res.json(); if (!input || !input.files.length) return;
const items = j.items || []; const tr = input.closest("tr.ex-detail");
const summary = j.summary || { count: 0, total: 0 }; if (!tr) return;
countEl.textContent = `${items.length}`; const itemId = tr.dataset.detailFor;
if (totalEl) totalEl.textContent = fmt(summary.total); const fd = new FormData();
fd.append("file", input.files[0]);
tbody.innerHTML = ""; fd.append("kind", input.dataset.kind || "other");
if (items.length === 0) { try {
tbody.innerHTML = `<tr><td colspan="7" class="erp-empty">등록된 경비가 없습니다.</td></tr>`; const res = await fetch(`/expense/api/items/${itemId}/attachments`, {
return; method: "POST", body: fd,
}
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);
}); });
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) { if (!form.spent_at.value) {
const d = new Date(); const d = new Date();
form.spent_at.value = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; 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 %}
+56 -2
View File
@@ -17,8 +17,21 @@ from typing import Any, Iterable
# 슈퍼 관리자 — 강등/삭제 불가 # 슈퍼 관리자 — 강등/삭제 불가
SUPER_ADMIN_EMAIL = "king@dbxcorp.co.kr" SUPER_ADMIN_EMAIL = "king@dbxcorp.co.kr"
# 시스템에서 지원하는 모듈 키 — 신규 추가시 여기 + 메뉴/템플릿 동시 갱신 # 시스템에서 지원하는 권한 키.
MODULE_KEYS: tuple[str, ...] = ("corm", "order", "expense", "vacation") # - 접근권한: 모듈 페이지 진입 허용
# - 승인자: 결재 워크플로에서 승인/반려 가능
# 신규 추가 시 여기 + admin.html MODULE_LABELS + 라우터 검사 동시 갱신.
MODULE_KEYS: tuple[str, ...] = (
"corm",
"order",
"expense",
"vacation",
"expense_approver",
"vacation_approver",
)
# 승인자 권한 키 — 결재 워크플로에서 별도 검사할 때 사용
APPROVER_KEYS: tuple[str, ...] = ("expense_approver", "vacation_approver")
def _now_iso() -> str: def _now_iso() -> str:
@@ -88,6 +101,42 @@ class UserStore:
data = self._read() data = self._read()
return [self._enrich(email, rec) for email, rec in data["users"].items()] return [self._enrich(email, rec) for email, rec in data["users"].items()]
def create_user(
self,
*,
email: str,
name: str = "",
role: str = "user",
modules: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""관리자가 이메일만으로 신규 사용자 등록. 로그인 전이라도 권한 부여 가능."""
email = email.lower().strip()
if not email or "@" not in email:
raise ValueError("올바른 이메일 형식이 아닙니다.")
if role not in ("admin", "user"):
raise ValueError(f"role 값이 잘못되었습니다: {role}")
is_super = email == SUPER_ADMIN_EMAIL
with self._lock:
data = self._read()
if email in data["users"]:
raise ValueError(f"이미 등록된 사용자입니다: {email}")
now = _now_iso()
final_role = "admin" if is_super else role
rec = {
"email": email,
"name": name or email.split("@")[0],
"picture": "",
"role": final_role,
"modules": self._normalize_modules(
modules, is_admin=(final_role == "admin")
),
"created_at": now,
"last_login": "",
}
data["users"][email] = rec
self._write_atomic(data)
return self._enrich(email, rec)
def upsert_login(self, *, email: str, name: str, picture: str) -> dict[str, Any]: def upsert_login(self, *, email: str, name: str, picture: str) -> dict[str, Any]:
"""로그인 시 호출. 신규면 생성, 기존이면 last_login/name/picture 갱신.""" """로그인 시 호출. 신규면 생성, 기존이면 last_login/name/picture 갱신."""
email = email.lower().strip() email = email.lower().strip()
@@ -182,5 +231,10 @@ def has_module(user_rec: dict[str, Any] | None, module: str) -> bool:
return bool(mods.get(module)) return bool(mods.get(module))
def is_approver(user_rec: dict[str, Any] | None, kind: str) -> bool:
"""결재 승인자 권한 검사. kind 예: 'expense', 'vacation'."""
return has_module(user_rec, f"{kind}_approver")
def allowed_modules(user_rec: dict[str, Any] | None) -> set[str]: def allowed_modules(user_rec: dict[str, Any] | None) -> set[str]:
return {m for m in MODULE_KEYS if has_module(user_rec, m)} return {m for m in MODULE_KEYS if has_module(user_rec, m)}
+109 -39
View File
@@ -5,12 +5,36 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>사용자/권한 관리 — DBX ERP</title> <title>사용자/권한 관리 — DBX ERP</title>
<link rel="stylesheet" href="/static/erp.css" /> <link rel="stylesheet" href="/static/erp.css" />
<style>
.erp-admin-toolbar {
display: flex; align-items: center; gap: var(--sp-12);
margin: var(--sp-16) 0;
flex-wrap: wrap;
}
.erp-admin-toolbar .erp-input { flex: 0 1 280px; }
.erp-add-form {
display: flex; gap: var(--sp-8); align-items: center;
margin-left: auto; flex-wrap: wrap;
}
.erp-add-form input, .erp-add-form select {
border: 1px solid var(--color-subtle-ash);
border-radius: var(--r-input);
padding: 6px 10px; font-size: 13px;
font-family: inherit;
}
.erp-mod-th { text-align: center; font-size: 11px; padding: 6px 4px; }
.erp-mod-cell { text-align: center; padding: 4px; }
.erp-mod-group { display: inline-block; padding: 2px 6px; border-radius: 6px;
background: var(--color-ghost-gray); color: var(--color-midtone-gray);
font-size: 10px; font-weight: 500; }
</style>
</head> </head>
<body class="erp-body"> <body class="erp-body">
<div class="erp-shell"> <div class="erp-shell">
<!-- ── 상단 네비게이션 ── -->
<nav class="erp-nav"> <nav class="erp-nav">
<div class="erp-nav-left"> <div class="erp-nav-left">
<a href="/" class="erp-brand"> <a href="/" class="erp-brand">
@@ -19,7 +43,6 @@
<span class="erp-brand-system">ERP 시스템</span> <span class="erp-brand-system">ERP 시스템</span>
</a> </a>
</div> </div>
<div class="erp-nav-right"> <div class="erp-nav-right">
<a class="erp-btn erp-btn-ghost" href="/">← 대시보드</a> <a class="erp-btn erp-btn-ghost" href="/">← 대시보드</a>
<div class="erp-user-chip"> <div class="erp-user-chip">
@@ -37,39 +60,45 @@
</div> </div>
</nav> </nav>
<main class="erp-main" style="max-width: 1100px;"> <main class="erp-main" style="max-width: 1280px;">
<h1 class="erp-greeting">사용자 / 권한 관리</h1> <h1 class="erp-greeting">사용자 / 권한 관리</h1>
<p class="erp-greeting-sub"> <p class="erp-greeting-sub">
회사 계정으로 로그인한 임직원의 모듈 접근 권한을 관리합니다. 이메일만으로 사용자 등록 가능 (회사 도메인 자동 검사).
토글을 변경한 뒤 <strong>저장</strong> 버튼을 누르세요. 토글을 변경한 뒤 <strong>저장</strong>을 누르세요.
</p> </p>
<!-- 이메일 등록 폼 + 검색 -->
<div class="erp-admin-toolbar"> <div class="erp-admin-toolbar">
<input type="search" <input type="search" id="user-search" class="erp-input"
id="user-search" placeholder="이메일/이름 검색…" style="padding: 6px 10px;" />
class="erp-input"
placeholder="이메일 또는 이름으로 검색…" />
<span class="erp-section-meta" id="user-count">총 {{ users | length }}명</span> <span class="erp-section-meta" id="user-count">총 {{ users | length }}명</span>
<form id="add-user-form" class="erp-add-form">
<input type="email" name="email" required placeholder="email@dbxcorp.co.kr" style="width: 220px;" />
<input type="text" name="name" placeholder="이름(선택)" style="width: 120px;" />
<select name="role">
<option value="user">user</option>
<option value="admin">admin</option>
</select>
<button type="submit" class="erp-btn erp-btn-primary">사용자 추가</button>
</form>
</div> </div>
<div class="erp-table-wrap"> <div class="erp-table-wrap">
<table class="erp-table" id="users-table"> <table class="erp-table" id="users-table">
<thead> <thead>
<tr> <tr>
<th style="width: 28%;">사용자</th> <th style="width: 22%;">사용자</th>
<th style="width: 11%;">역할</th> <th style="width: 80px;">역할</th>
{% for key in module_keys %} {% for key in module_keys %}
<th style="text-align:center; width: 9%;"> <th class="erp-mod-th">
{% if key == 'corm' %}CORM {{ module_labels.get(key, key) }}
{% elif key == 'order' %}Order {% if key in approver_keys %}<br><span class="erp-mod-group">승인</span>{% endif %}
{% elif key == 'expense' %}개인경비
{% elif key == 'vacation' %}휴가
{% else %}{{ key }}{% endif %}
</th> </th>
{% endfor %} {% endfor %}
<th style="width: 13%;">최근 로그인</th> <th style="width: 120px;">최근 로그인</th>
<th style="width: 12%; text-align: right;">동작</th> <th style="width: 130px; text-align: right;">동작</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -102,7 +131,7 @@
</select> </select>
</td> </td>
{% for key in module_keys %} {% for key in module_keys %}
<td style="text-align:center;"> <td class="erp-mod-cell">
<label class="erp-switch"> <label class="erp-switch">
<input type="checkbox" <input type="checkbox"
data-field="module" data-field="module"
@@ -114,11 +143,15 @@
</td> </td>
{% endfor %} {% endfor %}
<td style="font-size:12px; color: var(--color-midtone-gray);"> <td style="font-size:12px; color: var(--color-midtone-gray);">
{{ (u.last_login or '')[:16].replace('T', ' ') }} {{ (u.last_login or '미접속')[:16].replace('T', ' ') }}
</td> </td>
<td style="text-align: right;"> <td style="text-align: right;">
<button class="erp-btn erp-btn-primary" data-action="save" <button class="erp-btn erp-btn-primary" data-action="save"
{% if u.is_super_admin %}disabled{% endif %}>저장</button> {% if u.is_super_admin %}disabled{% endif %}>저장</button>
{% if not u.is_super_admin %}
<button class="erp-btn erp-btn-outline" data-action="delete"
style="padding: 4px 8px; font-size: 12px;">삭제</button>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -127,8 +160,9 @@
</div> </div>
<p class="erp-section-meta" style="margin-top: var(--sp-16);"> <p class="erp-section-meta" style="margin-top: var(--sp-16);">
<strong>{{ super_admin_email }}</strong> 은(는) 슈퍼 관리자이므로 권한 변경할 수 없습니다. <strong>{{ super_admin_email }}</strong> 은(는) 슈퍼 관리자, 권한 변경/삭제 불가.
관리자(admin) 역할로 지정된 사용자에게는 모든 모듈 권한 자동 부여됩니다. admin 역할 모든 모듈 권한 자동 부여.
<code>expense_approver</code> / <code>vacation_approver</code> 는 결재 승인 권한.
</p> </p>
</main> </main>
@@ -141,6 +175,7 @@
const searchInput = document.getElementById("user-search"); const searchInput = document.getElementById("user-search");
const userCountEl = document.getElementById("user-count"); const userCountEl = document.getElementById("user-count");
const toastEl = document.getElementById("toast"); const toastEl = document.getElementById("toast");
const addForm = document.getElementById("add-user-form");
function markDirty(tr) { tr.setAttribute("data-dirty", "true"); } function markDirty(tr) { tr.setAttribute("data-dirty", "true"); }
function clearDirty(tr) { tr.setAttribute("data-dirty", "false"); } function clearDirty(tr) { tr.setAttribute("data-dirty", "false"); }
@@ -150,20 +185,16 @@
toastEl.dataset.type = type || "info"; toastEl.dataset.type = type || "info";
toastEl.dataset.visible = "true"; toastEl.dataset.visible = "true";
clearTimeout(showToast._t); clearTimeout(showToast._t);
showToast._t = setTimeout(() => { showToast._t = setTimeout(() => { toastEl.dataset.visible = "false"; }, 2400);
toastEl.dataset.visible = "false";
}, 2400);
} }
// 역할 변경시 모듈 토글 활성/비활성 갱신
function syncModuleSwitches(tr) { function syncModuleSwitches(tr) {
const isSuper = tr.dataset.super === "true"; const isSuper = tr.dataset.super === "true";
const role = tr.querySelector("select[data-field='role']").value; const role = tr.querySelector("select[data-field='role']").value;
const adminRole = role === "admin"; const adminRole = role === "admin";
tr.querySelectorAll("input[data-field='module']").forEach((cb) => { tr.querySelectorAll("input[data-field='module']").forEach((cb) => {
if (isSuper || adminRole) { if (isSuper || adminRole) {
cb.checked = true; cb.checked = true; cb.disabled = true;
cb.disabled = true;
} else { } else {
cb.disabled = false; cb.disabled = false;
} }
@@ -173,25 +204,23 @@
tbody.addEventListener("change", (e) => { tbody.addEventListener("change", (e) => {
const tr = e.target.closest("tr"); const tr = e.target.closest("tr");
if (!tr) return; if (!tr) return;
if (e.target.dataset.field === "role") { if (e.target.dataset.field === "role") syncModuleSwitches(tr);
syncModuleSwitches(tr);
}
markDirty(tr); markDirty(tr);
}); });
tbody.addEventListener("click", async (e) => { tbody.addEventListener("click", async (e) => {
const btn = e.target.closest("button[data-action='save']"); const btn = e.target.closest("button");
if (!btn) return; if (!btn) return;
const tr = btn.closest("tr"); const tr = btn.closest("tr");
const email = tr.dataset.email; const email = tr.dataset.email;
if (btn.dataset.action === "save") {
const role = tr.querySelector("select[data-field='role']").value; const role = tr.querySelector("select[data-field='role']").value;
const modules = {}; const modules = {};
tr.querySelectorAll("input[data-field='module']").forEach((cb) => { tr.querySelectorAll("input[data-field='module']").forEach((cb) => {
modules[cb.dataset.module] = cb.checked; modules[cb.dataset.module] = cb.checked;
}); });
btn.disabled = true; btn.textContent = "저장 중…";
btn.disabled = true;
btn.textContent = "저장 중…";
try { try {
const res = await fetch(`/api/users/${encodeURIComponent(email)}`, { const res = await fetch(`/api/users/${encodeURIComponent(email)}`, {
method: "PUT", method: "PUT",
@@ -208,12 +237,53 @@
} catch (err) { } catch (err) {
showToast(`저장 실패: ${err.message}`, "error"); showToast(`저장 실패: ${err.message}`, "error");
} finally { } finally {
btn.disabled = false; btn.disabled = false; btn.textContent = "저장";
btn.textContent = "저장"; }
} else if (btn.dataset.action === "delete") {
if (!confirm(`${email} 사용자를 삭제할까요? 복구 불가.`)) return;
try {
const res = await fetch(`/api/users/${encodeURIComponent(email)}`, {
method: "DELETE", credentials: "same-origin",
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || `HTTP ${res.status}`);
}
tr.remove();
showToast(`${email} 삭제 완료`, "info");
} catch (err) {
showToast(`삭제 실패: ${err.message}`, "error");
}
}
});
// 사용자 추가
addForm.addEventListener("submit", async (e) => {
e.preventDefault();
const fd = new FormData(addForm);
const payload = {
email: fd.get("email").trim().toLowerCase(),
name: fd.get("name").trim(),
role: fd.get("role"),
};
try {
const res = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || `HTTP ${res.status}`);
}
showToast(`${payload.email} 등록 완료. 새로고침합니다.`, "info");
setTimeout(() => location.reload(), 800);
} catch (err) {
showToast(`등록 실패: ${err.message}`, "error");
} }
}); });
// 검색
searchInput.addEventListener("input", () => { searchInput.addEventListener("input", () => {
const q = searchInput.value.toLowerCase().trim(); const q = searchInput.value.toLowerCase().trim();
let visible = 0; let visible = 0;
+44
View File
@@ -105,10 +105,54 @@ DDL: `scripts/sql/expense_db_init.sql` (멱등). DB·역할·테이블·인덱
| `amount` | BIGINT | 원 단위, ≥ 0 | | `amount` | BIGINT | 원 단위, ≥ 0 |
| `memo` | TEXT | 비고 | | `memo` | TEXT | 비고 |
| `status` | TEXT | 작성중/제출/승인/반려/정산완료 | | `status` | TEXT | 작성중/제출/승인/반려/정산완료 |
| `approver_email` | TEXT | 결재자 email (승인/반려/정산 시 기록) |
| `decided_at` | TIMESTAMPTZ | 결재 시점 |
| `reject_reason` | TEXT | 반려 사유 |
| `created_at` / `updated_at` | TIMESTAMPTZ | 트리거로 자동 갱신 | | `created_at` / `updated_at` | TIMESTAMPTZ | 트리거로 자동 갱신 |
인덱스: `(owner, spent_at DESC)`, `(status)`, `(created_at DESC)`. 인덱스: `(owner, spent_at DESC)`, `(status)`, `(created_at DESC)`.
### 테이블 `expense_attachments`
영수증/기타파일 메타데이터. 실제 파일은 `DATA_DIR/uploads/expense/{item_id}/` 에 저장.
| 컬럼 | 타입 | 비고 |
| --- | --- | --- |
| `id` | TEXT PK | 12자 hex |
| `item_id` | TEXT FK | `expense_items(id)` ON DELETE CASCADE |
| `owner` | TEXT | 업로드한 사용자 email |
| `kind` | TEXT | `receipt` 또는 `other` (CHECK) |
| `filename` | TEXT | 원본 파일명 |
| `stored_path` | TEXT | 디스크 경로 (절대) |
| `content_type` | TEXT | MIME |
| `size_bytes` | BIGINT | 바이트 |
| `uploaded_at` | TIMESTAMPTZ | 업로드 시각 |
인덱스: `(item_id)`.
### 결재 워크플로
```
작성중 ─submit──▶ 제출 ─approve──▶ 승인 ─settle──▶ 정산완료
▲ │
└─revert/reject──┴─reject──▶ 반려 ─revert──▶ 작성중
```
- `submit`/`revert`: owner 본인
- `approve`/`reject`/`settle`: `expense_approver` 또는 `admin`
- 첨부 추가/항목 수정/삭제: `작성중` 또는 `반려` 상태에서만
### 마이그레이션
기존 운영 DB 에 신규 컬럼/테이블 적용:
```bash
docker exec -i postgres-db psql -U postgres -d expense_db \
< scripts/sql/expense_db_002_workflow_attachments.sql
```
신규 설치는 `expense_db_init.sql` 하나로 충분 (둘 다 멱등).
### 운영 서버 초기화 (1회) ### 운영 서버 초기화 (1회)
```bash ```bash
+1
View File
@@ -116,6 +116,7 @@ docker compose down # 컨테이너 제거 (볼륨 유지)
| `CUSTOMER_ORDER_LIST_URL` | 고객 주문리스트 프로그램 버튼 이동 주소 | | `CUSTOMER_ORDER_LIST_URL` | 고객 주문리스트 프로그램 버튼 이동 주소 |
| `*_DB_HOST`, `*_DB_USER`, `*_DB_PASSWORD`, `*_DB_NAME` | 각 PostgreSQL DB 접속 정보 | | `*_DB_HOST`, `*_DB_USER`, `*_DB_PASSWORD`, `*_DB_NAME` | 각 PostgreSQL DB 접속 정보 |
| `EXPENSE_DB_URL` | 개인경비 DB DSN (예: `postgresql://expense_app:<pwd>@postgres-db:5432/expense_db`). 미설정 시 JSON 폴백 | | `EXPENSE_DB_URL` | 개인경비 DB DSN (예: `postgresql://expense_app:<pwd>@postgres-db:5432/expense_db`). 미설정 시 JSON 폴백 |
| `DATA_DIR` | 영구 데이터 경로 (Docker 볼륨 마운트). 첨부파일은 `$DATA_DIR/uploads/expense/{item_id}/` 에 저장 |
--- ---
+12 -1
View File
@@ -17,9 +17,20 @@
| CS관리 | 문의/응대 이력, 발주 업무 트리거 | | CS관리 | 문의/응대 이력, 발주 업무 트리거 |
| 반품관리 | 반품/교환 접수, 처리, 환불 연계 | | 반품관리 | 반품/교환 접수, 처리, 환불 연계 |
| 외부 연동 | 쇼핑몰·통합관리·택배사·문자 API 연동 | | 외부 연동 | 쇼핑몰·통합관리·택배사·문자 API 연동 |
| 개인경비 | 법인카드/개인지출 등록·증빙·정산 신청 (`app/modules/expense/`) | | 개인경비 | 법인카드/개인지출 등록·증빙·정산 신청 (`app/modules/expense/`) — 결재 워크플로 / 첨부(영수증·기타) / 월별 집계 / 엑셀 내보내기 |
| 휴가 (준비중) | 연차/반차/특별휴가 신청·잔여일수 관리 | | 휴가 (준비중) | 연차/반차/특별휴가 신청·잔여일수 관리 |
### 권한 키 (`MODULE_KEYS`)
| 키 | 종류 | 설명 |
| --- | --- | --- |
| `corm` / `order` | 접근 | 외부 모듈 진입 |
| `expense` / `vacation` | 접근 | 내부 모듈 진입 |
| `expense_approver` | 결재 | 개인경비 승인/반려/정산 |
| `vacation_approver` | 결재 | 휴가 승인/반려 (모듈 미개발) |
`admin` 역할은 모든 권한 자동 부여. 신규 사용자는 관리자 페이지(`/admin`)에서 이메일만으로 등록 가능.
--- ---
## 모듈 디렉토리 규약 ## 모듈 디렉토리 규약
+2
View File
@@ -6,3 +6,5 @@ jinja2>=3.1
itsdangerous>=2.2 itsdangerous>=2.2
python-dotenv>=1.0 python-dotenv>=1.0
psycopg[binary,pool]>=3.2 psycopg[binary,pool]>=3.2
python-multipart>=0.0.20
openpyxl>=3.1
@@ -0,0 +1,36 @@
-- =====================================================================
-- expense_db 마이그레이션 #002 — 결재 워크플로 + 첨부파일
-- =====================================================================
-- 실행:
-- docker exec -i postgres-db psql -U postgres -d expense_db \
-- < scripts/sql/expense_db_002_workflow_attachments.sql
--
-- 멱등. init.sql 도 동일 변경을 포함하므로 신규 설치는 init 하나로 충분.
-- 이미 운영 중인 DB 에만 별도로 적용한다.
-- =====================================================================
\set ON_ERROR_STOP on
-- expense_items 컬럼 추가
ALTER TABLE expense_items
ADD COLUMN IF NOT EXISTS approver_email TEXT,
ADD COLUMN IF NOT EXISTS decided_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS reject_reason TEXT;
-- 첨부파일 테이블
CREATE TABLE IF NOT EXISTS expense_attachments (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL REFERENCES expense_items(id) ON DELETE CASCADE,
owner TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('receipt', 'other')),
filename TEXT NOT NULL,
stored_path TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size_bytes BIGINT NOT NULL DEFAULT 0 CHECK (size_bytes >= 0),
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_attach_item ON expense_attachments (item_id);
GRANT SELECT, INSERT, UPDATE, DELETE ON expense_attachments TO expense_app;
SELECT 'migration 002 applied' AS status;
+24
View File
@@ -50,14 +50,37 @@ CREATE TABLE IF NOT EXISTS expense_items (
amount BIGINT NOT NULL DEFAULT 0 CHECK (amount >= 0), amount BIGINT NOT NULL DEFAULT 0 CHECK (amount >= 0),
memo TEXT NOT NULL DEFAULT '', memo TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '작성중', status TEXT NOT NULL DEFAULT '작성중',
approver_email TEXT,
decided_at TIMESTAMPTZ,
reject_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
-- 기존 DB 호환: 컬럼이 없으면 추가
ALTER TABLE expense_items
ADD COLUMN IF NOT EXISTS approver_email TEXT,
ADD COLUMN IF NOT EXISTS decided_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS reject_reason TEXT;
CREATE INDEX IF NOT EXISTS idx_expense_owner_spent ON expense_items (owner, spent_at DESC); CREATE INDEX IF NOT EXISTS idx_expense_owner_spent ON expense_items (owner, spent_at DESC);
CREATE INDEX IF NOT EXISTS idx_expense_status ON expense_items (status); CREATE INDEX IF NOT EXISTS idx_expense_status ON expense_items (status);
CREATE INDEX IF NOT EXISTS idx_expense_created_at ON expense_items (created_at DESC); CREATE INDEX IF NOT EXISTS idx_expense_created_at ON expense_items (created_at DESC);
-- 첨부파일 (영수증/기타). 실제 파일은 파일시스템 저장, 메타만 DB.
CREATE TABLE IF NOT EXISTS expense_attachments (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL REFERENCES expense_items(id) ON DELETE CASCADE,
owner TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('receipt', 'other')),
filename TEXT NOT NULL,
stored_path TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size_bytes BIGINT NOT NULL DEFAULT 0 CHECK (size_bytes >= 0),
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_attach_item ON expense_attachments (item_id);
-- updated_at 자동 갱신 트리거 -- updated_at 자동 갱신 트리거
CREATE OR REPLACE FUNCTION expense_set_updated_at() RETURNS trigger AS $$ CREATE OR REPLACE FUNCTION expense_set_updated_at() RETURNS trigger AS $$
BEGIN BEGIN
@@ -74,6 +97,7 @@ CREATE TRIGGER trg_expense_set_updated_at
-- 권한 -- 권한
GRANT USAGE ON SCHEMA public TO expense_app; GRANT USAGE ON SCHEMA public TO expense_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON expense_items TO expense_app; GRANT SELECT, INSERT, UPDATE, DELETE ON expense_items TO expense_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON expense_attachments TO expense_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO expense_app; GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO expense_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public ALTER DEFAULT PRIVILEGES IN SCHEMA public