diff --git a/app/modules/expense/db.py b/app/modules/expense/db.py index 6fed8d8..39af638 100644 --- a/app/modules/expense/db.py +++ b/app/modules/expense/db.py @@ -20,7 +20,7 @@ from psycopg_pool import ConnectionPool from app.timezone import KST -from .store import CATEGORIES, METHODS, STATUSES +from .store import APPROVED_STATUSES, CATEGORIES, METHODS, STATUSES class ExpenseDBStore: @@ -370,6 +370,46 @@ class ExpenseDBStore: out["size_bytes"] = int(out.get("size_bytes", 0)) return out + # ── 승인완료 (월별, 전 직원) ── + def list_approved(self, *, year: int, month: int) -> list[dict[str, Any]]: + """해당 월(spent_at)의 승인완료 항목 — 전 직원.""" + with self._pool.connection() as conn: + rows = conn.execute( + """ + SELECT * FROM expense_items + WHERE status = ANY(%s) + AND EXTRACT(YEAR FROM spent_at) = %s + AND EXTRACT(MONTH FROM spent_at) = %s + ORDER BY owner ASC, spent_at ASC, created_at ASC + """, + (list(APPROVED_STATUSES), year, month), + ).fetchall() + return [self._serialize(r) for r in rows] + + def approved_attachments( + self, *, year: int, month: int + ) -> list[dict[str, Any]]: + """해당 월 승인완료 항목의 첨부 — zip 다운로드용. + + uploaded_at(datetime), spent_at(date) 를 가공 없이 반환(파일명 생성용). + """ + with self._pool.connection() as conn: + rows = conn.execute( + """ + SELECT a.id, a.item_id, a.owner, a.kind, a.filename, + a.stored_path, a.content_type, a.uploaded_at, + i.spent_at + FROM expense_attachments a + JOIN expense_items i ON i.id = a.item_id + WHERE i.status = ANY(%s) + AND EXTRACT(YEAR FROM i.spent_at) = %s + AND EXTRACT(MONTH FROM i.spent_at) = %s + ORDER BY a.owner ASC, a.uploaded_at ASC + """, + (list(APPROVED_STATUSES), year, month), + ).fetchall() + return [dict(r) for r in rows] + # ── 집계 (월별) ── def monthly_summary( self, *, email: str, year: int diff --git a/app/modules/expense/router.py b/app/modules/expense/router.py index 7a5c8e0..c17ea0c 100644 --- a/app/modules/expense/router.py +++ b/app/modules/expense/router.py @@ -13,10 +13,12 @@ import mimetypes import os import re import uuid +import zipfile +from datetime import datetime from pathlib import Path from typing import Any -from app.timezone import now_kst +from app.timezone import KST, now_kst from fastapi import ( APIRouter, @@ -69,6 +71,52 @@ def _categories(request: Request) -> list[str]: return _category_store(request).list() +_WEEKDAYS_KR = ("월", "화", "수", "목", "금", "토", "일") + + +def _parse_month(request: Request) -> tuple[int, int, str]: + """?month=YYYY-MM 파싱. 누락/오류 시 이번 달(KST).""" + raw = request.query_params.get("month") or "" + try: + y_str, m_str = raw.split("-") + year, mon = int(y_str), int(m_str) + if not (1 <= mon <= 12): + raise ValueError + except (ValueError, AttributeError): + n = now_kst() + year, mon = n.year, n.month + return year, mon, f"{year:04d}-{mon:02d}" + + +def _name_map(request: Request) -> dict[str, str]: + """email(소문자) → 표시 이름. 미등록 사용자는 이메일 로컬파트.""" + from app.main import user_store # noqa: WPS433 + + out: dict[str, str] = {} + for u in user_store.list_all(): + email = str(u.get("email", "")).lower().strip() + if email: + out[email] = u.get("name") or email.split("@")[0] + return out + + +def _display_name(email: str, names: dict[str, str]) -> str: + email = (email or "").lower().strip() + return names.get(email) or (email.split("@")[0] if email else "") + + +def _att_download_name(uploaded_at: Any, name: str, original: str) -> str: + """첨부 다운로드 파일명: `yy년 mm월 dd일(요일)_이름{ext}`. 날짜=업로드일(KST).""" + if isinstance(uploaded_at, datetime): + d = uploaded_at.astimezone(KST) + else: + d = now_kst() + wd = _WEEKDAYS_KR[d.weekday()] + base = f"{d.strftime('%y')}년 {d.strftime('%m')}월 {d.strftime('%d')}일({wd})_{name}" + ext = os.path.splitext(original or "")[1].lower() + return f"{base}{ext}" + + def _upload_dir(request: Request) -> Path: """첨부 저장 루트. DATA_DIR/uploads/expense — DATA_DIR 와 같은 볼륨에 보관.""" base = getattr(request.app.state, "data_dir", None) @@ -288,6 +336,170 @@ async def reports_page(request: Request) -> HTMLResponse: ) +# ──────────────────────────────────────────────────────────── +# 승인완료 — 전 직원 월별 (승인자/관리자) +# ──────────────────────────────────────────────────────────── +def _approved_aggregate( + items: list[dict[str, Any]], names: dict[str, str] +) -> list[dict[str, Any]]: + """직원별 승인 금액 합계.""" + agg: dict[str, dict[str, Any]] = {} + for it in items: + owner = it.get("owner", "") + row = agg.setdefault( + owner, + {"owner": owner, "name": _display_name(owner, names), "count": 0, "total": 0}, + ) + row["count"] += 1 + row["total"] += int(it.get("amount", 0)) + return sorted(agg.values(), key=lambda x: -x["total"]) + + +@router.get("/approved", response_class=HTMLResponse) +async def approved_page(request: Request) -> HTMLResponse: + """전 직원 승인완료 항목 — 월별 + 직원별 합계.""" + from app.main import ( # noqa: WPS433 + build_erp_nav, + get_current_user_record, + render_template, + ) + from app.store import has_module, is_admin # noqa: WPS433 + + user = get_current_user_record(request) + if user is None: + return RedirectResponse(url="/login", status_code=303) + if not (is_admin(user) or has_module(user, "expense_approver")): + return render_template( + request, + "denied.html", + {"reason": "개인경비 승인자만 접근할 수 있습니다."}, + status_code=403, + ) + store = _store(request) + _require_db_store(store) + year, mon, raw = _parse_month(request) + items = store.list_approved(year=year, month=mon) + names = _name_map(request) + for it in items: + it["owner_name"] = _display_name(it.get("owner", ""), names) + owner_summary = _approved_aggregate(items, names) + grand_total = sum(r["total"] for r in owner_summary) + return render_template( + request, + "expense/approved.html", + { + "user": user, + "is_admin": is_admin(user), + "items": items, + "owner_summary": owner_summary, + "grand_total": grand_total, + "month": raw, + "count": len(items), + "nav_items": build_erp_nav(user, active="expense"), + "page_title": "개인경비 — 승인완료", + "page_subtitle": f"{raw} · 전 직원 승인완료 {len(items)}건", + }, + ) + + +@router.get("/api/approved/export.xlsx") +async def approved_export( + request: Request, + user: dict[str, Any] = Depends(_require_approver), +) -> StreamingResponse: + from openpyxl import Workbook # 지연 import + + store = _store(request) + _require_db_store(store) + year, mon, raw = _parse_month(request) + items = store.list_approved(year=year, month=mon) + names = _name_map(request) + + wb = Workbook() + ws = wb.active + ws.title = "승인완료" + header = [ + "사용일", "이름", "이메일", "분류", "결제수단", "가맹점", + "금액", "메모", "상태", "승인자", "결재일시", + ] + ws.append(header) + for r in items: + owner = r.get("owner", "") + ws.append([ + r.get("spent_at", ""), + _display_name(owner, names), + owner, + r.get("category", ""), + r.get("method", ""), + r.get("merchant", ""), + int(r.get("amount", 0)), + r.get("memo", ""), + r.get("status", ""), + r.get("approver_email", "") or "", + r.get("decided_at", "") or "", + ]) + widths = [12, 14, 28, 10, 12, 24, 12, 30, 10, 24, 22] + for col, w in enumerate(widths, start=1): + ws.column_dimensions[ws.cell(row=1, column=col).column_letter].width = w + + # 직원별 합계 시트 + ws2 = wb.create_sheet("직원별합계") + ws2.append(["이름", "이메일", "건수", "합계금액"]) + for row in _approved_aggregate(items, names): + ws2.append([row["name"], row["owner"], row["count"], row["total"]]) + for col, w in enumerate([14, 28, 8, 16], start=1): + ws2.column_dimensions[ws2.cell(row=1, column=col).column_letter].width = w + + buf = io.BytesIO() + wb.save(buf) + buf.seek(0) + fname = f"expense_approved_{raw}.xlsx" + return StreamingResponse( + buf, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{fname}"'}, + ) + + +@router.get("/api/approved/attachments.zip") +async def approved_attachments_zip( + request: Request, + user: dict[str, Any] = Depends(_require_approver), +) -> StreamingResponse: + """해당 월 승인완료 항목의 첨부 전체를 zip 한 개로. 파일명 규칙 적용.""" + store = _store(request) + _require_db_store(store) + year, mon, raw = _parse_month(request) + atts = store.approved_attachments(year=year, month=mon) + names = _name_map(request) + + buf = io.BytesIO() + used: dict[str, int] = {} + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for a in atts: + path = Path(a.get("stored_path", "")) + if not path.exists(): + continue + nm = _display_name(a.get("owner", ""), names) + fname = _att_download_name(a.get("uploaded_at"), nm, a.get("filename", "")) + # 동일명 충돌 → 접미사 + if fname in used: + used[fname] += 1 + stem, ext = os.path.splitext(fname) + arcname = f"{stem}_{used[fname]}{ext}" + else: + used[fname] = 1 + arcname = fname + zf.write(str(path), arcname=arcname) + buf.seek(0) + zipname = f"expense_approved_{raw}.zip" + return StreamingResponse( + buf, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{zipname}"'}, + ) + + # ──────────────────────────────────────────────────────────── # 승인자 설정 — 분류 관리 # ──────────────────────────────────────────────────────────── diff --git a/app/modules/expense/store.py b/app/modules/expense/store.py index b2af956..3f19e84 100644 --- a/app/modules/expense/store.py +++ b/app/modules/expense/store.py @@ -21,6 +21,8 @@ from app.timezone import now_kst_iso CATEGORIES: tuple[str, ...] = ("식대", "교통", "숙박", "비품", "접대", "통신", "기타") METHODS: tuple[str, ...] = ("법인카드", "개인지출", "현금") STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "정산완료") +# 승인 완료(결재 승인 이후) 상태 — "승인완료" 집계/내보내기 대상. +APPROVED_STATUSES: tuple[str, ...] = ("승인", "정산완료") def _now_iso() -> str: diff --git a/app/modules/expense/templates/expense/approved.html b/app/modules/expense/templates/expense/approved.html new file mode 100644 index 0000000..97a7e0a --- /dev/null +++ b/app/modules/expense/templates/expense/approved.html @@ -0,0 +1,139 @@ +{% extends "erp_base.html" %} + +{% block content %} +
+ + +
+ ← 개인경비 +
+ + +
+ 엑셀 다운 + 첨부 일괄(zip) +
+ + +
+
+

직원별 승인 금액 합계

+ {{ month }} · 총 {{ "{:,}".format(grand_total) }} 원 +
+
+ + + + + + + + + + + {% for r in owner_summary %} + + + + + + + {% else %} + + {% endfor %} + + {% if owner_summary %} + + + + + + + + {% endif %} +
이름이메일건수합계금액
{{ r.name }}{{ r.owner }}{{ r.count }}{{ "{:,}".format(r.total) }} 원
해당 월 승인완료 항목이 없습니다.
합계{{ count }}{{ "{:,}".format(grand_total) }} 원
+
+
+ + +
+
+

승인완료 항목 ({{ count }}건)

+ 전 직원 · 사용일 기준 {{ month }} +
+
+ + + + + + + + + + + + + + + {% for it in items %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
사용일이름분류수단가맹점/메모금액상태첨부
{{ it.spent_at }}{{ it.owner_name }}
{{ it.owner }}
{{ it.category }}{{ it.method }} +
{{ it.merchant }}
+ {% if it.memo %}
{{ it.memo }}
{% endif %} +
{{ "{:,}".format(it.amount) }} 원 + {% if it.status == '정산완료' %} + {{ it.status }} + {% else %} + {{ it.status }} + {% endif %} + + +
해당 월 승인완료 항목이 없습니다.
+
+
+ +
+ + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/modules/expense/templates/expense/index.html b/app/modules/expense/templates/expense/index.html index 62cc4dd..64309bd 100644 --- a/app/modules/expense/templates/expense/index.html +++ b/app/modules/expense/templates/expense/index.html @@ -10,6 +10,7 @@ 승인 대기 {% if pending_count %}{{ pending_count }}{% endif %} + 승인완료 {% endif %} 엑셀(내 항목) {% if is_approver %}