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 .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
+213 -1
View File
@@ -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}"'},
)
# ────────────────────────────────────────────────────────────
# 승인자 설정 — 분류 관리
# ────────────────────────────────────────────────────────────
+2
View File
@@ -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:
@@ -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">
승인 대기 {% if pending_count %}<strong style="margin-left:6px;">{{ pending_count }}</strong>{% endif %}
</a>
<a class="erp-btn erp-btn-outline" href="/expense/approved">승인완료</a>
{% endif %}
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=mine">엑셀(내 항목)</a>
{% if is_approver %}