feat(expense): 승인완료 메뉴 — 전 직원 월별 + 엑셀/첨부 zip

- /expense/approved: 승인자/관리자 전용, 월 선택 + 전 직원 승인완료 항목
- 직원별 승인 금액 합계표 + 총합
- 엑셀 다운(리스트 + 직원별합계 시트)
- 첨부 일괄 zip: 파일명 yy년 mm월 dd일(요일)_이름.확장자 (업로드일 기준)
- store: APPROVED_STATUSES(승인,정산완료) / db: list_approved, approved_attachments

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 17:46:41 +09:00
parent 506db5ff07
commit a107b06826
5 changed files with 396 additions and 2 deletions
+41 -1
View File
@@ -20,7 +20,7 @@ from psycopg_pool import ConnectionPool
from app.timezone import KST from app.timezone import KST
from .store import CATEGORIES, METHODS, STATUSES from .store import APPROVED_STATUSES, CATEGORIES, METHODS, STATUSES
class ExpenseDBStore: class ExpenseDBStore:
@@ -370,6 +370,46 @@ class ExpenseDBStore:
out["size_bytes"] = int(out.get("size_bytes", 0)) out["size_bytes"] = int(out.get("size_bytes", 0))
return out 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( def monthly_summary(
self, *, email: str, year: int self, *, email: str, year: int
+213 -1
View File
@@ -13,10 +13,12 @@ import mimetypes
import os import os
import re import re
import uuid import uuid
import zipfile
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from app.timezone import now_kst from app.timezone import KST, now_kst
from fastapi import ( from fastapi import (
APIRouter, APIRouter,
@@ -69,6 +71,52 @@ def _categories(request: Request) -> list[str]:
return _category_store(request).list() 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: def _upload_dir(request: Request) -> Path:
"""첨부 저장 루트. DATA_DIR/uploads/expense — DATA_DIR 와 같은 볼륨에 보관.""" """첨부 저장 루트. DATA_DIR/uploads/expense — DATA_DIR 와 같은 볼륨에 보관."""
base = getattr(request.app.state, "data_dir", None) 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}"'},
)
# ──────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────
# 승인자 설정 — 분류 관리 # 승인자 설정 — 분류 관리
# ──────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────
+2
View File
@@ -21,6 +21,8 @@ from app.timezone import now_kst_iso
CATEGORIES: tuple[str, ...] = ("식대", "교통", "숙박", "비품", "접대", "통신", "기타") CATEGORIES: tuple[str, ...] = ("식대", "교통", "숙박", "비품", "접대", "통신", "기타")
METHODS: tuple[str, ...] = ("법인카드", "개인지출", "현금") METHODS: tuple[str, ...] = ("법인카드", "개인지출", "현금")
STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "정산완료") STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "정산완료")
# 승인 완료(결재 승인 이후) 상태 — "승인완료" 집계/내보내기 대상.
APPROVED_STATUSES: tuple[str, ...] = ("승인", "정산완료")
def _now_iso() -> str: def _now_iso() -> str:
@@ -0,0 +1,139 @@
{% extends "erp_base.html" %}
{% block content %}
<section class="erp-ex-approved">
<!-- ── 상단 액션 + 월 선택 ── -->
<div class="erp-page-actions">
<a class="erp-btn erp-btn-ghost" href="/expense/">← 개인경비</a>
<form id="mon-form" method="get" action="/expense/approved" style="display:flex; gap:var(--sp-8); align-items:center; margin:0;">
<input type="month" name="month" value="{{ month }}" />
<button type="submit" class="erp-btn erp-btn-outline">조회</button>
</form>
<a class="erp-btn erp-btn-primary" href="/expense/api/approved/export.xlsx?month={{ month }}">엑셀 다운</a>
<a class="erp-btn erp-btn-outline" href="/expense/api/approved/attachments.zip?month={{ month }}">첨부 일괄(zip)</a>
</div>
<!-- ── 직원별 합계 ── -->
<div class="erp-card-block">
<div class="erp-card-block-head">
<h2>직원별 승인 금액 합계</h2>
<span class="erp-muted">{{ month }} · 총 {{ "{:,}".format(grand_total) }} 원</span>
</div>
<div class="erp-table-wrap">
<table class="erp-table">
<thead>
<tr>
<th>이름</th>
<th>이메일</th>
<th style="width:100px; text-align:right;">건수</th>
<th style="width:160px; text-align:right;">합계금액</th>
</tr>
</thead>
<tbody>
{% for r in owner_summary %}
<tr>
<td>{{ r.name }}</td>
<td class="erp-muted">{{ r.owner }}</td>
<td style="text-align:right;">{{ r.count }}</td>
<td style="text-align:right; font-variant-numeric: tabular-nums;">{{ "{:,}".format(r.total) }} 원</td>
</tr>
{% else %}
<tr><td colspan="4" class="erp-empty">해당 월 승인완료 항목이 없습니다.</td></tr>
{% endfor %}
</tbody>
{% if owner_summary %}
<tfoot>
<tr>
<th colspan="2" style="text-align:right;">합계</th>
<th style="text-align:right;">{{ count }}</th>
<th style="text-align:right;">{{ "{:,}".format(grand_total) }} 원</th>
</tr>
</tfoot>
{% endif %}
</table>
</div>
</div>
<!-- ── 승인완료 항목 목록 ── -->
<div class="erp-card-block">
<div class="erp-card-block-head">
<h2>승인완료 항목 ({{ count }}건)</h2>
<span class="erp-muted">전 직원 · 사용일 기준 {{ month }}</span>
</div>
<div class="erp-table-wrap">
<table class="erp-table" id="appr-table">
<thead>
<tr>
<th style="width:110px;">사용일</th>
<th style="width:120px;">이름</th>
<th style="width:90px;">분류</th>
<th style="width:100px;">수단</th>
<th>가맹점/메모</th>
<th style="width:120px; text-align:right;">금액</th>
<th style="width:90px;">상태</th>
<th style="width:60px; text-align:center;">첨부</th>
</tr>
</thead>
<tbody>
{% for it in items %}
<tr data-id="{{ it.id }}">
<td>{{ it.spent_at }}</td>
<td>{{ it.owner_name }}<div class="erp-row-sub">{{ it.owner }}</div></td>
<td>{{ it.category }}</td>
<td>{{ it.method }}</td>
<td>
<div>{{ it.merchant }}</div>
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
</td>
<td style="text-align:right; font-variant-numeric: tabular-nums;">{{ "{:,}".format(it.amount) }} 원</td>
<td>
{% if it.status == '정산완료' %}
<span class="erp-badge erp-badge-neutral">{{ it.status }}</span>
{% else %}
<span class="erp-badge erp-badge-inverse">{{ it.status }}</span>
{% endif %}
</td>
<td style="text-align:center;">
<button class="erp-btn erp-btn-ghost erp-btn-sm js-view-att" title="첨부 보기">📎</button>
</td>
</tr>
{% else %}
<tr><td colspan="8" 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); flex-wrap:wrap; align-items:center; }
.erp-ex-approved .erp-row-sub { font-size:12px; color:var(--color-text-muted, #888); }
#appr-table td { vertical-align: middle; }
</style>
{% endblock %}
{% block scripts %}
<script>
(function () {
// 월 선택 변경 시 자동 조회
const monInput = document.querySelector('#mon-form input[name="month"]');
if (monInput) monInput.addEventListener("change", () => monInput.form.submit());
// 첨부 보기 (공용 뷰어)
const tbody = document.querySelector("#appr-table tbody");
if (tbody) {
tbody.addEventListener("click", (e) => {
const btn = e.target.closest("button.js-view-att");
if (!btn) return;
const tr = btn.closest("tr[data-id]");
const id = tr.dataset.id;
const who = tr.children[1].textContent.trim();
window.ErpAttachViewer.openFor(id, { title: `첨부 — ${who}` });
});
}
})();
</script>
{% endblock %}
@@ -10,6 +10,7 @@
<a class="erp-btn erp-btn-outline" href="/expense/pending"> <a class="erp-btn erp-btn-outline" href="/expense/pending">
승인 대기 {% if pending_count %}<strong style="margin-left:6px;">{{ pending_count }}</strong>{% endif %} 승인 대기 {% if pending_count %}<strong style="margin-left:6px;">{{ pending_count }}</strong>{% endif %}
</a> </a>
<a class="erp-btn erp-btn-outline" href="/expense/approved">승인완료</a>
{% endif %} {% endif %}
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=mine">엑셀(내 항목)</a> <a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=mine">엑셀(내 항목)</a>
{% if is_approver %} {% if is_approver %}