diff --git a/app/main.py b/app/main.py index 50575f5..9239f29 100644 --- a/app/main.py +++ b/app/main.py @@ -16,6 +16,7 @@ from starlette.middleware.sessions import SessionMiddleware from .modules.expense import build_expense_store from .modules.expense import router as expense_router from .store import ( + APPROVER_KEYS, MODULE_KEYS, SUPER_ADMIN_EMAIL, UserStore, @@ -24,6 +25,16 @@ from .store import ( is_admin, ) +# 권한 키 한글 라벨 (admin.html / 사이드바 공용) +MODULE_LABELS: dict[str, str] = { + "corm": "CORM", + "order": "Order", + "expense": "개인경비", + "vacation": "휴가", + "expense_approver": "개인경비", + "vacation_approver": "휴가", +} + # OMS(orderlist) 와 공유하는 세션 키. SessionMiddleware 의 session_cookie 도 동일 이름. SESSION_COOKIE_DEFAULT = "session" @@ -104,6 +115,7 @@ user_store = UserStore(DATA_DIR / "users.json") # 모듈별 데이터 저장소. # EXPENSE_DB_URL 가 있으면 expense_db(PostgreSQL), 없으면 JSON 파일. +app.state.data_dir = DATA_DIR # 모듈에서 첨부 저장 경로 등으로 참조 app.state.expense_store = build_expense_store( dsn=env("EXPENSE_DB_URL") or None, json_path=DATA_DIR / "expense.json", @@ -459,6 +471,8 @@ async def admin_page(request: Request) -> HTMLResponse: "user": rec, "users": users, "module_keys": list(MODULE_KEYS), + "module_labels": MODULE_LABELS, + "approver_keys": list(APPROVER_KEYS), "super_admin_email": SUPER_ADMIN_EMAIL, "is_admin": True, }, @@ -471,11 +485,35 @@ class UpdatePermissionsBody(BaseModel): 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") async def api_list_users(_: dict[str, Any] = Depends(require_admin)) -> JSONResponse: 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}") async def api_update_user( email: str, diff --git a/app/modules/expense/db.py b/app/modules/expense/db.py index 5157c8e..d83433e 100644 --- a/app/modules/expense/db.py +++ b/app/modules/expense/db.py @@ -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() diff --git a/app/modules/expense/router.py b/app/modules/expense/router.py index 43057b4..70354ed 100644 --- a/app/modules/expense/router.py +++ b/app/modules/expense/router.py @@ -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"} diff --git a/app/modules/expense/templates/expense/index.html b/app/modules/expense/templates/expense/index.html index 7edc408..2c759b7 100644 --- a/app/modules/expense/templates/expense/index.html +++ b/app/modules/expense/templates/expense/index.html @@ -3,6 +3,20 @@ {% block content %}
+ +
+ 월별 집계 + {% if is_approver %} + + 승인 대기 {% if pending_count %}{{ pending_count }}{% endif %} + + {% endif %} + 엑셀(내 항목) + {% if is_approver %} + 엑셀(전체) + {% endif %} +
+
@@ -29,36 +43,28 @@

경비 등록

- 필수 항목을 입력하고 등록을 누르세요. + 필수 항목 입력 후 등록. 등록 후 항목별로 첨부/제출.
- -
+ + {% endblock %} {% block scripts %} +{% endblock %} diff --git a/app/modules/expense/templates/expense/reports.html b/app/modules/expense/templates/expense/reports.html new file mode 100644 index 0000000..46a7e4e --- /dev/null +++ b/app/modules/expense/templates/expense/reports.html @@ -0,0 +1,78 @@ +{% extends "erp_base.html" %} + +{% block content %} +
+ +
+ ← 내 개인경비 + + + + + + 엑셀 +
+ +
+
+

{{ year }}년 월별 / 카테고리별 합계

+ 총 {{ "{:,}".format(grand_total) }} 원 +
+ +
+ + + + + {% for c in categories %} + + {% endfor %} + + + + + {% for m in months %} + {% set row_total = pivot[m].values() | sum %} + + + {% for c in categories %} + + {% endfor %} + + + {% else %} + + {% endfor %} + + {% if months %} + + + + {% for c in categories %} + + {% endfor %} + + + + {% endif %} +
{{ c }}합계
{{ m }} + {% if pivot[m].get(c) %}{{ "{:,}".format(pivot[m][c]) }}{% else %}-{% endif %} + + {{ "{:,}".format(row_total) }} +
데이터 없음
합계 + {{ "{:,}".format(cat_totals[c]) }} + + {{ "{:,}".format(grand_total) }} +
+
+
+ +
+ + +{% endblock %} diff --git a/app/store.py b/app/store.py index bb9ceaf..4ccf352 100644 --- a/app/store.py +++ b/app/store.py @@ -17,8 +17,21 @@ from typing import Any, Iterable # 슈퍼 관리자 — 강등/삭제 불가 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: @@ -88,6 +101,42 @@ class UserStore: data = self._read() 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]: """로그인 시 호출. 신규면 생성, 기존이면 last_login/name/picture 갱신.""" 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)) +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]: return {m for m in MODULE_KEYS if has_module(user_rec, m)} diff --git a/app/templates/admin.html b/app/templates/admin.html index d98d68a..ad2b144 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -5,12 +5,36 @@ 사용자/권한 관리 — DBX ERP +
- -
+

사용자 / 권한 관리

- 회사 계정으로 로그인한 임직원의 모듈 접근 권한을 관리합니다. - 토글을 변경한 뒤 저장 버튼을 누르세요. + 이메일만으로 사용자 등록 가능 (회사 도메인 자동 검사). + 토글을 변경한 뒤 저장을 누르세요.

+
- + + +
+ + + + +
- - + + {% for key in module_keys %} - {% endfor %} - - + + @@ -102,7 +131,7 @@ {% for key in module_keys %} - {% endfor %} @@ -127,8 +160,9 @@ @@ -141,6 +175,7 @@ const searchInput = document.getElementById("user-search"); const userCountEl = document.getElementById("user-count"); const toastEl = document.getElementById("toast"); + const addForm = document.getElementById("add-user-form"); function markDirty(tr) { tr.setAttribute("data-dirty", "true"); } function clearDirty(tr) { tr.setAttribute("data-dirty", "false"); } @@ -150,20 +185,16 @@ toastEl.dataset.type = type || "info"; toastEl.dataset.visible = "true"; clearTimeout(showToast._t); - showToast._t = setTimeout(() => { - toastEl.dataset.visible = "false"; - }, 2400); + showToast._t = setTimeout(() => { toastEl.dataset.visible = "false"; }, 2400); } - // 역할 변경시 모듈 토글 활성/비활성 갱신 function syncModuleSwitches(tr) { const isSuper = tr.dataset.super === "true"; const role = tr.querySelector("select[data-field='role']").value; const adminRole = role === "admin"; tr.querySelectorAll("input[data-field='module']").forEach((cb) => { if (isSuper || adminRole) { - cb.checked = true; - cb.disabled = true; + cb.checked = true; cb.disabled = true; } else { cb.disabled = false; } @@ -173,47 +204,86 @@ tbody.addEventListener("change", (e) => { const tr = e.target.closest("tr"); if (!tr) return; - if (e.target.dataset.field === "role") { - syncModuleSwitches(tr); - } + if (e.target.dataset.field === "role") syncModuleSwitches(tr); markDirty(tr); }); tbody.addEventListener("click", async (e) => { - const btn = e.target.closest("button[data-action='save']"); + const btn = e.target.closest("button"); if (!btn) return; const tr = btn.closest("tr"); const email = tr.dataset.email; - const role = tr.querySelector("select[data-field='role']").value; - const modules = {}; - tr.querySelectorAll("input[data-field='module']").forEach((cb) => { - modules[cb.dataset.module] = cb.checked; - }); - btn.disabled = true; - btn.textContent = "저장 중…"; + if (btn.dataset.action === "save") { + const role = tr.querySelector("select[data-field='role']").value; + const modules = {}; + tr.querySelectorAll("input[data-field='module']").forEach((cb) => { + modules[cb.dataset.module] = cb.checked; + }); + btn.disabled = true; btn.textContent = "저장 중…"; + try { + const res = await fetch(`/api/users/${encodeURIComponent(email)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ role, modules }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || `HTTP ${res.status}`); + } + clearDirty(tr); + showToast(`${email} 저장 완료`, "info"); + } catch (err) { + showToast(`저장 실패: ${err.message}`, "error"); + } finally { + btn.disabled = false; 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/${encodeURIComponent(email)}`, { - method: "PUT", + const res = await fetch("/api/users", { + method: "POST", headers: { "Content-Type": "application/json" }, credentials: "same-origin", - body: JSON.stringify({ role, modules }), + body: JSON.stringify(payload), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.detail || `HTTP ${res.status}`); } - clearDirty(tr); - showToast(`${email} 저장 완료`, "info"); + showToast(`${payload.email} 등록 완료. 새로고침합니다.`, "info"); + setTimeout(() => location.reload(), 800); } catch (err) { - showToast(`저장 실패: ${err.message}`, "error"); - } finally { - btn.disabled = false; - btn.textContent = "저장"; + showToast(`등록 실패: ${err.message}`, "error"); } }); - // 검색 searchInput.addEventListener("input", () => { const q = searchInput.value.toLowerCase().trim(); let visible = 0; diff --git a/docs/DATABASES.md b/docs/DATABASES.md index f6a1105..ee7c312 100644 --- a/docs/DATABASES.md +++ b/docs/DATABASES.md @@ -105,10 +105,54 @@ DDL: `scripts/sql/expense_db_init.sql` (멱등). DB·역할·테이블·인덱 | `amount` | BIGINT | 원 단위, ≥ 0 | | `memo` | TEXT | 비고 | | `status` | TEXT | 작성중/제출/승인/반려/정산완료 | +| `approver_email` | TEXT | 결재자 email (승인/반려/정산 시 기록) | +| `decided_at` | TIMESTAMPTZ | 결재 시점 | +| `reject_reason` | TEXT | 반려 사유 | | `created_at` / `updated_at` | TIMESTAMPTZ | 트리거로 자동 갱신 | 인덱스: `(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회) ```bash diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 0cd91de..4536bad 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -116,6 +116,7 @@ docker compose down # 컨테이너 제거 (볼륨 유지) | `CUSTOMER_ORDER_LIST_URL` | 고객 주문리스트 프로그램 버튼 이동 주소 | | `*_DB_HOST`, `*_DB_USER`, `*_DB_PASSWORD`, `*_DB_NAME` | 각 PostgreSQL DB 접속 정보 | | `EXPENSE_DB_URL` | 개인경비 DB DSN (예: `postgresql://expense_app:@postgres-db:5432/expense_db`). 미설정 시 JSON 폴백 | +| `DATA_DIR` | 영구 데이터 경로 (Docker 볼륨 마운트). 첨부파일은 `$DATA_DIR/uploads/expense/{item_id}/` 에 저장 | --- diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md index 0006c63..2c72ab5 100644 --- a/docs/PROJECT_OVERVIEW.md +++ b/docs/PROJECT_OVERVIEW.md @@ -17,9 +17,20 @@ | CS관리 | 문의/응대 이력, 발주 업무 트리거 | | 반품관리 | 반품/교환 접수, 처리, 환불 연계 | | 외부 연동 | 쇼핑몰·통합관리·택배사·문자 API 연동 | -| 개인경비 | 법인카드/개인지출 등록·증빙·정산 신청 (`app/modules/expense/`) | +| 개인경비 | 법인카드/개인지출 등록·증빙·정산 신청 (`app/modules/expense/`) — 결재 워크플로 / 첨부(영수증·기타) / 월별 집계 / 엑셀 내보내기 | | 휴가 (준비중) | 연차/반차/특별휴가 신청·잔여일수 관리 | +### 권한 키 (`MODULE_KEYS`) + +| 키 | 종류 | 설명 | +| --- | --- | --- | +| `corm` / `order` | 접근 | 외부 모듈 진입 | +| `expense` / `vacation` | 접근 | 내부 모듈 진입 | +| `expense_approver` | 결재 | 개인경비 승인/반려/정산 | +| `vacation_approver` | 결재 | 휴가 승인/반려 (모듈 미개발) | + +`admin` 역할은 모든 권한 자동 부여. 신규 사용자는 관리자 페이지(`/admin`)에서 이메일만으로 등록 가능. + --- ## 모듈 디렉토리 규약 diff --git a/requirements.txt b/requirements.txt index 4412fcb..d569596 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,5 @@ jinja2>=3.1 itsdangerous>=2.2 python-dotenv>=1.0 psycopg[binary,pool]>=3.2 +python-multipart>=0.0.20 +openpyxl>=3.1 diff --git a/scripts/sql/expense_db_002_workflow_attachments.sql b/scripts/sql/expense_db_002_workflow_attachments.sql new file mode 100644 index 0000000..998e82b --- /dev/null +++ b/scripts/sql/expense_db_002_workflow_attachments.sql @@ -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; diff --git a/scripts/sql/expense_db_init.sql b/scripts/sql/expense_db_init.sql index 6ddfe7f..bab1a35 100644 --- a/scripts/sql/expense_db_init.sql +++ b/scripts/sql/expense_db_init.sql @@ -41,23 +41,46 @@ GRANT CONNECT ON DATABASE expense_db TO expense_app; \connect expense_db CREATE TABLE IF NOT EXISTS expense_items ( - id TEXT PRIMARY KEY, - owner TEXT NOT NULL, - spent_at DATE NOT NULL, - category TEXT NOT NULL, - method TEXT NOT NULL, - merchant TEXT NOT NULL DEFAULT '', - amount BIGINT NOT NULL DEFAULT 0 CHECK (amount >= 0), - memo TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT '작성중', - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + spent_at DATE NOT NULL, + category TEXT NOT NULL, + method TEXT NOT NULL, + merchant TEXT NOT NULL DEFAULT '', + amount BIGINT NOT NULL DEFAULT 0 CHECK (amount >= 0), + memo 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(), + 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_status ON expense_items (status); 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 자동 갱신 트리거 CREATE OR REPLACE FUNCTION expense_set_updated_at() RETURNS trigger AS $$ BEGIN @@ -74,6 +97,7 @@ CREATE TRIGGER trg_expense_set_updated_at -- 권한 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_attachments TO expense_app; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO expense_app; ALTER DEFAULT PRIVILEGES IN SCHEMA public
사용자역할사용자역할 - {% if key == 'corm' %}CORM - {% elif key == 'order' %}Order - {% elif key == 'expense' %}개인경비 - {% elif key == 'vacation' %}휴가 - {% else %}{{ key }}{% endif %} + + {{ module_labels.get(key, key) }} + {% if key in approver_keys %}
승인{% endif %}
최근 로그인동작최근 로그인동작
+ - {{ (u.last_login or '')[:16].replace('T', ' ') }} + {{ (u.last_login or '미접속')[:16].replace('T', ' ') }} + {% if not u.is_super_admin %} + + {% endif %}