feat(expense): 관리자 분류 설정 메뉴 추가 (동적 분류)

- CategoryStore: 분류 항목 JSON 저장소(DATA_DIR/expense_categories.json)
- 관리자 설정 페이지(/expense/settings) + 분류 CRUD API
- _normalize 분류 고정목록 검증 제거 — 추가 분류 즉시 등록 폼/집계 반영
- index/reports 분류를 동적 목록으로 교체

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 17:22:01 +09:00
parent ec8c9edcc9
commit 0b04a05b26
8 changed files with 341 additions and 7 deletions
+3
View File
@@ -9,12 +9,15 @@
from pathlib import Path
from typing import Any
from .categories import DEFAULT_CATEGORIES, CategoryStore
from .router import router
from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore
__all__ = [
"router",
"ExpenseStore",
"CategoryStore",
"DEFAULT_CATEGORIES",
"CATEGORIES",
"METHODS",
"STATUSES",
+98
View File
@@ -0,0 +1,98 @@
"""개인경비 분류(category) 설정 저장소.
- 저장 위치: DATA_DIR/expense_categories.json
- 관리자만 추가/삭제. 저장 즉시 사용자 등록 폼/집계에 반영.
- JSON 파일 기반 — expense_db(PostgreSQL) 모드와 무관하게 동작(설정값이라
트랜잭션 데이터와 분리). DB 스키마 변경(superuser SQL) 불필요.
- 동시성: ExpenseStore 와 동일 패턴(threading.Lock + temp→rename 원자적 쓰기).
"""
from __future__ import annotations
import json
import os
import tempfile
import threading
from pathlib import Path
from typing import Any
# 최초 1회 시드. 기존 하드코딩 CATEGORIES 와 동일.
DEFAULT_CATEGORIES: tuple[str, ...] = (
"식대", "교통", "숙박", "비품", "접대", "통신", "기타",
)
class CategoryStore:
def __init__(self, path: Path):
self._path = path
self._lock = threading.Lock()
self._path.parent.mkdir(parents=True, exist_ok=True)
if not self._path.exists():
self._write_atomic({"categories": list(DEFAULT_CATEGORIES)})
def _read(self) -> dict[str, Any]:
try:
with self._path.open("r", encoding="utf-8") as f:
data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
data = {}
cats = data.get("categories")
if not isinstance(cats, list) or not cats:
data["categories"] = list(DEFAULT_CATEGORIES)
else:
# 문자열만, 공백 제거, 중복 제거(순서 유지)
seen: set[str] = set()
clean: list[str] = []
for c in cats:
name = str(c).strip()
if name and name not in seen:
seen.add(name)
clean.append(name)
data["categories"] = clean or list(DEFAULT_CATEGORIES)
return data
def _write_atomic(self, data: dict[str, Any]) -> None:
fd, tmp = tempfile.mkstemp(
prefix=".expense_categories.", suffix=".json.tmp",
dir=str(self._path.parent),
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp, self._path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def list(self) -> list[str]:
with self._lock:
return list(self._read()["categories"])
def add(self, name: str) -> list[str]:
name = str(name or "").strip()
if not name:
raise ValueError("분류명을 입력하세요.")
if len(name) > 30:
raise ValueError("분류명은 30자 이하로 입력하세요.")
with self._lock:
data = self._read()
if name in data["categories"]:
raise ValueError(f"이미 존재하는 분류입니다: {name}")
data["categories"].append(name)
self._write_atomic(data)
return list(data["categories"])
def delete(self, name: str) -> list[str]:
name = str(name or "").strip()
with self._lock:
data = self._read()
if name not in data["categories"]:
raise KeyError(name)
if len(data["categories"]) <= 1:
raise ValueError("분류는 최소 1개 이상이어야 합니다.")
data["categories"] = [c for c in data["categories"] if c != name]
self._write_atomic(data)
return list(data["categories"])
+2 -1
View File
@@ -482,7 +482,8 @@ class ExpenseDBStore:
b["spent_at"] = str(payload.get("spent_at") or b.get("spent_at") or "").strip()
category = str(payload.get("category") or b.get("category") or "기타").strip()
method = str(payload.get("method") or b.get("method") or "법인카드").strip()
b["category"] = category if category in CATEGORIES else "기타"
# 분류는 관리자 설정으로 동적 추가되므로 고정 목록 검증 없이 그대로 저장.
b["category"] = category or "기타"
b["method"] = method if method in METHODS else "법인카드"
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
try:
+111 -4
View File
@@ -36,7 +36,7 @@ from fastapi.responses import (
)
from pydantic import BaseModel, Field
from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore
from .store import METHODS, STATUSES, ExpenseStore
router = APIRouter(prefix="/expense", tags=["expense"])
@@ -57,6 +57,18 @@ def _store(request: Request) -> Any:
return store
def _category_store(request: Request) -> Any:
store = getattr(request.app.state, "expense_category_store", None)
if store is None:
raise RuntimeError("CategoryStore 가 app.state 에 등록되지 않았습니다.")
return store
def _categories(request: Request) -> list[str]:
"""현재 활성 분류 목록 — 등록 폼/집계에서 사용."""
return _category_store(request).list()
def _upload_dir(request: Request) -> Path:
"""첨부 저장 루트. DATA_DIR/uploads/expense — DATA_DIR 와 같은 볼륨에 보관."""
base = getattr(request.app.state, "data_dir", None)
@@ -97,6 +109,18 @@ def _require_approver(request: Request) -> dict[str, Any]:
return user
def _require_admin(request: Request) -> dict[str, Any]:
from app.main import get_current_user_record # noqa: WPS433
from app.store import 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):
raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.")
return user
def _require_db_store(store: Any) -> None:
"""워크플로/첨부/집계는 DB 모드 전용. JSON 폴백에서는 501."""
if not hasattr(store, "submit"):
@@ -124,6 +148,10 @@ class RejectBody(BaseModel):
reason: str
class CategoryBody(BaseModel):
name: str
# ────────────────────────────────────────────────────────────
# 페이지
# ────────────────────────────────────────────────────────────
@@ -169,7 +197,7 @@ async def expense_index(request: Request) -> HTMLResponse:
"pending_count": pending_count,
"items": items,
"summary": summary,
"categories": list(CATEGORIES),
"categories": _categories(request),
"methods": list(METHODS),
"statuses": list(STATUSES),
"nav_items": build_erp_nav(user, active="expense"),
@@ -244,7 +272,13 @@ async def reports_page(request: Request) -> HTMLResponse:
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}
# 활성 분류 + 데이터에만 있는(삭제된) 분류 모두 포함
categories = list(_categories(request))
for m in month_keys:
for c in months[m]:
if c not in categories:
categories.append(c)
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(
@@ -256,7 +290,7 @@ async def reports_page(request: Request) -> HTMLResponse:
"year": year,
"months": month_keys,
"pivot": months,
"categories": list(CATEGORIES),
"categories": categories,
"cat_totals": cat_totals,
"grand_total": grand_total,
"nav_items": build_erp_nav(user, active="expense"),
@@ -266,6 +300,79 @@ async def reports_page(request: Request) -> HTMLResponse:
)
# ────────────────────────────────────────────────────────────
# 관리자 설정 — 분류 관리
# ────────────────────────────────────────────────────────────
@router.get("/settings", response_class=HTMLResponse)
async def settings_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)
if not is_admin(user):
return render_template(
request,
"denied.html",
{"reason": "개인경비 설정은 관리자만 접근할 수 있습니다."},
status_code=403,
)
return render_template(
request,
"expense/settings.html",
{
"user": user,
"is_admin": True,
"categories": _categories(request),
"nav_items": build_erp_nav(user, active="expense"),
"page_title": "개인경비 — 설정",
"page_subtitle": "분류 항목 관리",
},
)
@router.get("/api/categories")
async def list_categories(
request: Request,
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
return JSONResponse({"categories": _categories(request)})
@router.post("/api/categories")
async def add_category(
request: Request,
body: CategoryBody,
user: dict[str, Any] = Depends(_require_admin),
) -> JSONResponse:
try:
categories = _category_store(request).add(body.name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return JSONResponse({"categories": categories}, status_code=201)
@router.delete("/api/categories/{name}")
async def delete_category(
request: Request,
name: str,
user: dict[str, Any] = Depends(_require_admin),
) -> JSONResponse:
try:
categories = _category_store(request).delete(name)
except KeyError:
raise HTTPException(status_code=404, detail="분류를 찾을 수 없습니다.")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return JSONResponse({"categories": categories})
# ────────────────────────────────────────────────────────────
# 항목 CRUD API
# ────────────────────────────────────────────────────────────
+2 -1
View File
@@ -149,7 +149,8 @@ class ExpenseStore:
b["spent_at"] = str(payload.get("spent_at") or b.get("spent_at") or "").strip()
category = str(payload.get("category") or b.get("category") or "기타").strip()
method = str(payload.get("method") or b.get("method") or "법인카드").strip()
b["category"] = category if category in CATEGORIES else "기타"
# 분류는 관리자 설정으로 동적 추가되므로 고정 목록 검증 없이 그대로 저장.
b["category"] = category or "기타"
b["method"] = method if method in METHODS else "법인카드"
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
try:
@@ -15,6 +15,9 @@
{% if is_approver %}
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=all">엑셀(전체)</a>
{% endif %}
{% if is_admin %}
<a class="erp-btn erp-btn-outline" href="/expense/settings">⚙ 설정</a>
{% endif %}
</div>
<!-- ── 요약 카드 ── -->
@@ -0,0 +1,119 @@
{% extends "erp_base.html" %}
{% block content %}
<section class="erp-ex-settings">
<div class="erp-page-actions">
<a class="erp-btn erp-btn-ghost" href="/expense/">← 개인경비</a>
</div>
<div class="erp-card-block" style="max-width: 640px;">
<div class="erp-card-block-head">
<h2>분류 항목 관리</h2>
<span class="erp-muted">추가/삭제 즉시 사용자 등록 폼에 반영됩니다.</span>
</div>
<form id="cat-form" class="erp-form-grid" style="grid-template-columns: 1fr auto; align-items: end; gap: var(--sp-12);">
<label class="erp-field"><span>새 분류명</span>
<input type="text" name="name" maxlength="30" placeholder="예) 마케팅" required />
</label>
<div class="erp-form-actions" style="margin: 0;">
<button type="submit" class="erp-btn erp-btn-primary">추가</button>
</div>
</form>
<div class="erp-table-wrap" style="margin-top: var(--sp-16);">
<table class="erp-table" id="cat-table">
<thead>
<tr>
<th>분류명</th>
<th style="width: 100px; text-align: right;">동작</th>
</tr>
</thead>
<tbody id="cat-tbody">
{% for c in categories %}
<tr data-name="{{ c }}">
<td>{{ c }}</td>
<td style="text-align: right;">
<button class="erp-btn erp-btn-outline erp-btn-sm js-del">삭제</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="erp-muted" style="margin-top: var(--sp-12);">
삭제해도 이미 등록된 경비 항목의 분류는 그대로 유지됩니다. 분류는 최소 1개 이상이어야 합니다.
</p>
</div>
</section>
<style>
.erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); }
#cat-tbody td { vertical-align: middle; }
</style>
{% endblock %}
{% block scripts %}
<script>
(function () {
const form = document.getElementById("cat-form");
const tbody = document.getElementById("cat-tbody");
function rowFor(name) {
const tr = document.createElement("tr");
tr.dataset.name = name;
tr.innerHTML =
`<td></td>` +
`<td style="text-align: right;">` +
`<button class="erp-btn erp-btn-outline erp-btn-sm js-del">삭제</button></td>`;
tr.children[0].textContent = name;
return tr;
}
function render(categories) {
tbody.innerHTML = "";
categories.forEach((c) => tbody.appendChild(rowFor(c)));
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
const name = form.name.value.trim();
if (!name) return;
try {
const res = await fetch("/expense/api/categories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || res.status);
render(data.categories);
form.reset();
form.name.focus();
} catch (err) {
alert(`추가 실패: ${err.message || err}`);
}
});
tbody.addEventListener("click", async (e) => {
const btn = e.target.closest("button.js-del");
if (!btn) return;
const tr = btn.closest("tr[data-name]");
const name = tr.dataset.name;
if (!confirm(`분류 "${name}" 을(를) 삭제할까요?`)) return;
try {
const res = await fetch(`/expense/api/categories/${encodeURIComponent(name)}`, {
method: "DELETE",
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || res.status);
render(data.categories);
} catch (err) {
alert(`삭제 실패: ${err.message || err}`);
}
});
})();
</script>
{% endblock %}