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:
@@ -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
|
||||
|
||||
@@ -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}"'},
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# 승인자 설정 — 분류 관리
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user