feat(expense): 개인경비 모듈 + ERP 좌측메뉴 셸 + expense_db 연결
- ERP 셸: 좌측 사이드바 + 상단 헤더 + 콘텐츠 영역(erp_base.html)
- 분기: 슈퍼관리자 → 업무모듈 카드, 일반사용자 → ERP 메인(erp_home.html)
- 신규 라우트: /modules (슈퍼관리자 모듈 카드 재진입)
- 개인경비 모듈: app/modules/expense/ (라우터/저장소/템플릿)
- JSON 저장소(ExpenseStore) + PostgreSQL 저장소(ExpenseDBStore)
- 팩토리: EXPENSE_DB_URL 있으면 DB, 없으면 JSON 폴백
- DB: scripts/sql/expense_db_init.sql (멱등 DDL, postgres-db 컨테이너)
- expense_db, 역할 expense_app, 테이블 expense_items, 인덱스/트리거
- 마이그레이션: scripts/migrate_expense_json_to_db.py (멱등, --dry-run)
- 의존성: psycopg[binary,pool]>=3.2
- 환경변수: EXPENSE_DB_URL 추가
- 문서: CLAUDE.md 작업 기준 + docs/{PROJECT_OVERVIEW,SERVER_ARCHITECTURE,DATABASES,DEPLOYMENT}.md
- main-app 배포 경로 /opt/www/main 고정 명시
- 위험 명령(DROP/TRUNCATE/rm -rf/volume 삭제) 사용자 승인 정책
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
"""개인경비(expense) 모듈.
|
||||
|
||||
라우터/저장소/템플릿을 한 디렉토리에서 관리한다.
|
||||
- 라우터: `router.py` (FastAPI APIRouter, prefix=/expense)
|
||||
- 저장소: `store.py` (DATA_DIR/expense.json, 향후 expense_db 후보)
|
||||
- 템플릿: `templates/expense/index.html`
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .router import router
|
||||
from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"ExpenseStore",
|
||||
"CATEGORIES",
|
||||
"METHODS",
|
||||
"STATUSES",
|
||||
"build_expense_store",
|
||||
]
|
||||
|
||||
|
||||
def build_expense_store(*, dsn: str | None, json_path: Path) -> Any:
|
||||
"""env 의 EXPENSE_DB_URL 이 있으면 DB 저장소, 없으면 JSON 저장소.
|
||||
|
||||
DB 저장소 실패(드라이버 미설치/접속 실패) 시 예외를 그대로 전파한다 —
|
||||
의도치 않게 JSON 으로 폴백해 운영 데이터가 갈라지는 것을 막기 위함.
|
||||
"""
|
||||
if dsn:
|
||||
from .db import ExpenseDBStore # 지연 import (개발 환경 deps 없을 수 있음)
|
||||
|
||||
return ExpenseDBStore(dsn)
|
||||
return ExpenseStore(json_path)
|
||||
@@ -0,0 +1,244 @@
|
||||
"""expense_db PostgreSQL 저장소.
|
||||
|
||||
JSON 저장소(`ExpenseStore`)와 같은 인터페이스를 제공하여, 라우터 코드를
|
||||
바꾸지 않고도 교체할 수 있다.
|
||||
|
||||
- 드라이버: psycopg 3 (`psycopg[binary,pool]`)
|
||||
- 연결 정보: 환경변수 `EXPENSE_DB_URL` (예: postgresql://user:pwd@host:5432/expense_db)
|
||||
- 스키마: `scripts/sql/expense_db_init.sql` 로 사전 초기화한다. 본 클래스는
|
||||
앱 부팅 시 `CREATE TABLE IF NOT EXISTS`로 보강만 한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
from .store import CATEGORIES, METHODS, STATUSES
|
||||
|
||||
_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS expense_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
spent_at DATE NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
merchant TEXT NOT NULL DEFAULT '',
|
||||
amount BIGINT NOT NULL DEFAULT 0 CHECK (amount >= 0),
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT '작성중',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_expense_owner_spent ON expense_items (owner, spent_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_expense_status ON expense_items (status);
|
||||
"""
|
||||
|
||||
|
||||
class ExpenseDBStore:
|
||||
"""`ExpenseStore` 와 동일한 메서드 시그니처."""
|
||||
|
||||
def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5):
|
||||
self._pool = ConnectionPool(
|
||||
conninfo=dsn,
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
kwargs={"row_factory": dict_row, "autocommit": True},
|
||||
open=True,
|
||||
)
|
||||
self._ensure_schema()
|
||||
|
||||
def close(self) -> None:
|
||||
self._pool.close()
|
||||
|
||||
def _ensure_schema(self) -> None:
|
||||
with self._pool.connection() as conn:
|
||||
conn.execute(_DDL)
|
||||
|
||||
# ── 조회 ──
|
||||
def list_for(self, email: str) -> list[dict[str, Any]]:
|
||||
email = email.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM expense_items WHERE owner = %s "
|
||||
"ORDER BY spent_at DESC, created_at DESC",
|
||||
(email,),
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
def list_all(self) -> list[dict[str, Any]]:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM expense_items ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
def get(self, *, item_id: str, owner: str) -> dict[str, Any] | None:
|
||||
owner = owner.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM expense_items WHERE id = %s AND owner = %s",
|
||||
(item_id, owner),
|
||||
).fetchone()
|
||||
return self._serialize(row) if row else None
|
||||
|
||||
# ── 변경 ──
|
||||
def create(self, *, owner: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
owner = owner.lower().strip()
|
||||
norm = self._normalize(payload)
|
||||
if not norm["spent_at"]:
|
||||
raise ValueError("spent_at 필수")
|
||||
status = (payload.get("status") or "작성중").strip()
|
||||
item_id = uuid.uuid4().hex[:12]
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO expense_items
|
||||
(id, owner, spent_at, category, method, merchant, amount, memo, status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
item_id,
|
||||
owner,
|
||||
norm["spent_at"],
|
||||
norm["category"],
|
||||
norm["method"],
|
||||
norm["merchant"],
|
||||
norm["amount"],
|
||||
norm["memo"],
|
||||
status,
|
||||
),
|
||||
).fetchone()
|
||||
return self._serialize(row)
|
||||
|
||||
def update(
|
||||
self, *, item_id: str, owner: str, payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
owner = owner.lower().strip()
|
||||
norm = self._normalize(payload)
|
||||
status = payload.get("status")
|
||||
with self._pool.connection() as conn:
|
||||
if status:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE expense_items
|
||||
SET spent_at = %s, category = %s, method = %s,
|
||||
merchant = %s, amount = %s, memo = %s, status = %s
|
||||
WHERE id = %s AND owner = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
norm["spent_at"] or None,
|
||||
norm["category"],
|
||||
norm["method"],
|
||||
norm["merchant"],
|
||||
norm["amount"],
|
||||
norm["memo"],
|
||||
status,
|
||||
item_id,
|
||||
owner,
|
||||
),
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE expense_items
|
||||
SET spent_at = %s, category = %s, method = %s,
|
||||
merchant = %s, amount = %s, memo = %s
|
||||
WHERE id = %s AND owner = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
norm["spent_at"] or None,
|
||||
norm["category"],
|
||||
norm["method"],
|
||||
norm["merchant"],
|
||||
norm["amount"],
|
||||
norm["memo"],
|
||||
item_id,
|
||||
owner,
|
||||
),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise KeyError(item_id)
|
||||
return self._serialize(row)
|
||||
|
||||
def delete(self, *, item_id: str, owner: str) -> None:
|
||||
owner = owner.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM expense_items WHERE id = %s AND owner = %s",
|
||||
(item_id, owner),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(item_id)
|
||||
|
||||
# ── 요약 ──
|
||||
def summary_for(self, email: str) -> dict[str, Any]:
|
||||
email = email.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
head = conn.execute(
|
||||
"SELECT COUNT(*) AS count, COALESCE(SUM(amount), 0) AS total "
|
||||
"FROM expense_items WHERE owner = %s",
|
||||
(email,),
|
||||
).fetchone()
|
||||
status_rows = conn.execute(
|
||||
"SELECT status, COUNT(*) AS c FROM expense_items "
|
||||
"WHERE owner = %s GROUP BY status",
|
||||
(email,),
|
||||
).fetchall()
|
||||
cat_rows = conn.execute(
|
||||
"SELECT category, COALESCE(SUM(amount), 0) AS s "
|
||||
"FROM expense_items WHERE owner = %s GROUP BY category",
|
||||
(email,),
|
||||
).fetchall()
|
||||
by_status = {s: 0 for s in STATUSES}
|
||||
for r in status_rows:
|
||||
by_status[r["status"]] = int(r["c"])
|
||||
by_category = {c: 0 for c in CATEGORIES}
|
||||
for r in cat_rows:
|
||||
by_category[r["category"]] = int(r["s"])
|
||||
return {
|
||||
"count": int(head["count"]) if head else 0,
|
||||
"total": int(head["total"]) if head else 0,
|
||||
"by_status": by_status,
|
||||
"by_category": by_category,
|
||||
}
|
||||
|
||||
# ── helpers ──
|
||||
@staticmethod
|
||||
def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
if isinstance(out.get("spent_at"), date):
|
||||
out["spent_at"] = out["spent_at"].isoformat()
|
||||
for k in ("created_at", "updated_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
out["amount"] = int(out.get("amount", 0))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _normalize(
|
||||
payload: dict[str, Any], base: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
b = dict(base or {})
|
||||
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["method"] = method if method in METHODS else "법인카드"
|
||||
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
|
||||
try:
|
||||
b["amount"] = max(0, int(payload.get("amount") or b.get("amount") or 0))
|
||||
except (TypeError, ValueError):
|
||||
b["amount"] = 0
|
||||
b["memo"] = str(payload.get("memo") or b.get("memo") or "").strip()
|
||||
return b
|
||||
@@ -0,0 +1,150 @@
|
||||
"""개인경비 모듈 라우터.
|
||||
|
||||
- 경로: /expense
|
||||
- 권한: 로그인 + `expense` 모듈 권한 필요 (관리자는 항상 통과).
|
||||
- 데이터: ExpenseStore (JSON). app.state.expense_store 에 연결.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore
|
||||
|
||||
router = APIRouter(prefix="/expense", tags=["expense"])
|
||||
|
||||
|
||||
def _store(request: Request) -> ExpenseStore:
|
||||
store = getattr(request.app.state, "expense_store", None)
|
||||
if store is None:
|
||||
raise RuntimeError("ExpenseStore 가 app.state 에 등록되지 않았습니다.")
|
||||
return store
|
||||
|
||||
|
||||
def _require_user(request: Request) -> dict[str, Any]:
|
||||
# 순환 import 회피: 핸들러 호출 시점에 main 모듈에서 helper 를 가져온다.
|
||||
from app.main import get_current_user_record # noqa: WPS433
|
||||
from app.store import has_module # noqa: WPS433
|
||||
|
||||
user = get_current_user_record(request)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
|
||||
if not has_module(user, "expense"):
|
||||
raise HTTPException(status_code=403, detail="개인경비 모듈 권한이 없습니다.")
|
||||
return user
|
||||
|
||||
|
||||
class ExpensePayload(BaseModel):
|
||||
spent_at: str = Field(..., description="YYYY-MM-DD")
|
||||
category: str
|
||||
method: str
|
||||
merchant: str = ""
|
||||
amount: int = Field(ge=0)
|
||||
memo: str = ""
|
||||
status: str | None = None
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def expense_index(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 has_module(user, "expense"):
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{"reason": "개인경비 모듈 접근 권한이 없습니다."},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
store = _store(request)
|
||||
items = sorted(
|
||||
store.list_for(user["email"]),
|
||||
key=lambda i: (i.get("spent_at", ""), i.get("created_at", "")),
|
||||
reverse=True,
|
||||
)
|
||||
summary = store.summary_for(user["email"])
|
||||
return render_template(
|
||||
request,
|
||||
"expense/index.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"items": items,
|
||||
"summary": summary,
|
||||
"categories": list(CATEGORIES),
|
||||
"methods": list(METHODS),
|
||||
"statuses": list(STATUSES),
|
||||
"nav_items": build_erp_nav(user, active="expense"),
|
||||
"page_title": "개인경비",
|
||||
"page_subtitle": "법인카드 · 개인지출 · 정산 신청",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/items")
|
||||
async def list_items(
|
||||
request: Request,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
"items": _store(request).list_for(user["email"]),
|
||||
"summary": _store(request).summary_for(user["email"]),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/items")
|
||||
async def create_item(
|
||||
request: Request,
|
||||
body: ExpensePayload,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> JSONResponse:
|
||||
item = _store(request).create(owner=user["email"], payload=body.model_dump())
|
||||
return JSONResponse({"item": item}, status_code=201)
|
||||
|
||||
|
||||
@router.put("/api/items/{item_id}")
|
||||
async def update_item(
|
||||
request: Request,
|
||||
item_id: str,
|
||||
body: ExpensePayload,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
item = _store(request).update(
|
||||
item_id=item_id, owner=user["email"], payload=body.model_dump()
|
||||
)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="항목을 찾을 수 없습니다.")
|
||||
return JSONResponse({"item": item})
|
||||
|
||||
|
||||
@router.delete("/api/items/{item_id}")
|
||||
async def delete_item(
|
||||
request: Request,
|
||||
item_id: str,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
_store(request).delete(item_id=item_id, owner=user["email"])
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="항목을 찾을 수 없습니다.")
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok", "module": "expense"}
|
||||
@@ -0,0 +1,159 @@
|
||||
"""개인경비 항목 JSON 저장소.
|
||||
|
||||
- 저장 위치: DATA_DIR/expense.json
|
||||
- 사용자(email) 단위 소유. 본인 항목만 조회/수정/삭제.
|
||||
- 향후 expense_db(PostgreSQL)로 마이그레이션 예정. 현재는 DB 생성 승인 전이라 JSON 사용.
|
||||
- 동시성: 프로세스 내 threading.Lock + 원자적 쓰기(temp → rename). UserStore 와 동일 패턴.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
CATEGORIES: tuple[str, ...] = ("식대", "교통", "숙박", "비품", "접대", "통신", "기타")
|
||||
METHODS: tuple[str, ...] = ("법인카드", "개인지출", "현금")
|
||||
STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "정산완료")
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
class ExpenseStore:
|
||||
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({"items": []})
|
||||
|
||||
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 = {"items": []}
|
||||
if not isinstance(data.get("items"), list):
|
||||
data["items"] = []
|
||||
return data
|
||||
|
||||
def _write_atomic(self, data: dict[str, Any]) -> None:
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
prefix=".expense.", 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_for(self, email: str) -> list[dict[str, Any]]:
|
||||
email = email.lower().strip()
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
return [it for it in data["items"] if it.get("owner") == email]
|
||||
|
||||
def list_all(self) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
return list(self._read()["items"])
|
||||
|
||||
def get(self, *, item_id: str, owner: str) -> dict[str, Any] | None:
|
||||
owner = owner.lower().strip()
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
for it in data["items"]:
|
||||
if it["id"] == item_id and it.get("owner") == owner:
|
||||
return dict(it)
|
||||
return None
|
||||
|
||||
def create(self, *, owner: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
owner = owner.lower().strip()
|
||||
item = self._normalize(payload)
|
||||
item["id"] = uuid.uuid4().hex[:12]
|
||||
item["owner"] = owner
|
||||
item["status"] = payload.get("status") or "작성중"
|
||||
item["created_at"] = _now_iso()
|
||||
item["updated_at"] = item["created_at"]
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
data["items"].append(item)
|
||||
self._write_atomic(data)
|
||||
return item
|
||||
|
||||
def update(
|
||||
self, *, item_id: str, owner: str, payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
owner = owner.lower().strip()
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
for idx, it in enumerate(data["items"]):
|
||||
if it["id"] == item_id and it.get("owner") == owner:
|
||||
new = self._normalize(payload, base=it)
|
||||
new["id"] = it["id"]
|
||||
new["owner"] = it["owner"]
|
||||
new["created_at"] = it.get("created_at", _now_iso())
|
||||
new["status"] = payload.get("status") or it.get("status", "작성중")
|
||||
new["updated_at"] = _now_iso()
|
||||
data["items"][idx] = new
|
||||
self._write_atomic(data)
|
||||
return new
|
||||
raise KeyError(item_id)
|
||||
|
||||
def delete(self, *, item_id: str, owner: str) -> None:
|
||||
owner = owner.lower().strip()
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
before = len(data["items"])
|
||||
data["items"] = [
|
||||
it
|
||||
for it in data["items"]
|
||||
if not (it["id"] == item_id and it.get("owner") == owner)
|
||||
]
|
||||
if len(data["items"]) == before:
|
||||
raise KeyError(item_id)
|
||||
self._write_atomic(data)
|
||||
|
||||
def summary_for(self, email: str) -> dict[str, Any]:
|
||||
items = self.list_for(email)
|
||||
total = sum(int(i.get("amount", 0)) for i in items)
|
||||
by_status = {s: sum(1 for i in items if i.get("status") == s) for s in STATUSES}
|
||||
by_category = {
|
||||
c: sum(int(i.get("amount", 0)) for i in items if i.get("category") == c)
|
||||
for c in CATEGORIES
|
||||
}
|
||||
return {
|
||||
"count": len(items),
|
||||
"total": total,
|
||||
"by_status": by_status,
|
||||
"by_category": by_category,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize(
|
||||
payload: dict[str, Any], base: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
b = dict(base or {})
|
||||
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["method"] = method if method in METHODS else "법인카드"
|
||||
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
|
||||
try:
|
||||
b["amount"] = max(0, int(payload.get("amount") or b.get("amount") or 0))
|
||||
except (TypeError, ValueError):
|
||||
b["amount"] = 0
|
||||
b["memo"] = str(payload.get("memo") or b.get("memo") or "").strip()
|
||||
return b
|
||||
@@ -0,0 +1,238 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="erp-expense">
|
||||
|
||||
<!-- ── 요약 카드 ── -->
|
||||
<div class="erp-summary-grid">
|
||||
<div class="erp-summary-card">
|
||||
<span class="erp-summary-label">총 건수</span>
|
||||
<strong class="erp-summary-value">{{ summary.count }} 건</strong>
|
||||
</div>
|
||||
<div class="erp-summary-card">
|
||||
<span class="erp-summary-label">총 금액</span>
|
||||
<strong class="erp-summary-value" id="ex-total-amount">
|
||||
{{ "{:,}".format(summary.total) }} 원
|
||||
</strong>
|
||||
</div>
|
||||
{% for status in statuses %}
|
||||
<div class="erp-summary-card erp-summary-card--mini">
|
||||
<span class="erp-summary-label">{{ status }}</span>
|
||||
<strong class="erp-summary-value erp-summary-value--sm">
|
||||
{{ summary.by_status.get(status, 0) }}
|
||||
</strong>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- ── 입력 폼 ── -->
|
||||
<div class="erp-card-block">
|
||||
<div class="erp-card-block-head">
|
||||
<h2>경비 등록</h2>
|
||||
<span class="erp-muted">필수 항목을 입력하고 등록을 누르세요.</span>
|
||||
</div>
|
||||
<form id="ex-form" class="erp-form-grid">
|
||||
<input type="hidden" name="id" />
|
||||
<label class="erp-field">
|
||||
<span>사용일</span>
|
||||
<input type="date" name="spent_at" required />
|
||||
</label>
|
||||
<label class="erp-field">
|
||||
<span>분류</span>
|
||||
<select name="category" required>
|
||||
{% for c in categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="erp-field">
|
||||
<span>결제수단</span>
|
||||
<select name="method" required>
|
||||
{% for m in methods %}<option value="{{ m }}">{{ m }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="erp-field">
|
||||
<span>금액</span>
|
||||
<input type="number" name="amount" min="0" step="1" required placeholder="원" />
|
||||
</label>
|
||||
<label class="erp-field erp-field-wide">
|
||||
<span>가맹점/사용처</span>
|
||||
<input type="text" name="merchant" placeholder="예) 스타벅스 강남점" />
|
||||
</label>
|
||||
<label class="erp-field erp-field-wide">
|
||||
<span>메모</span>
|
||||
<input type="text" name="memo" placeholder="비고" />
|
||||
</label>
|
||||
<div class="erp-form-actions">
|
||||
<button type="reset" class="erp-btn erp-btn-outline" id="ex-reset">초기화</button>
|
||||
<button type="submit" class="erp-btn erp-btn-primary" id="ex-submit">등록</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ── 항목 목록 ── -->
|
||||
<div class="erp-card-block">
|
||||
<div class="erp-card-block-head">
|
||||
<h2>경비 내역</h2>
|
||||
<span class="erp-muted" id="ex-count">{{ items | length }}건</span>
|
||||
</div>
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table" id="ex-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 110px;">사용일</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: 130px; text-align: right;">동작</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ex-tbody">
|
||||
{% for it in items %}
|
||||
<tr data-id="{{ it.id }}">
|
||||
<td>{{ it.spent_at }}</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><span class="erp-badge erp-badge-neutral">{{ it.status }}</span></td>
|
||||
<td style="text-align: right;">
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-edit">수정</button>
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-delete">삭제</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr id="ex-empty"><td colspan="7" class="erp-empty">등록된 경비가 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
const form = document.getElementById("ex-form");
|
||||
const submitBtn = document.getElementById("ex-submit");
|
||||
const resetBtn = document.getElementById("ex-reset");
|
||||
const tbody = document.getElementById("ex-tbody");
|
||||
const countEl = document.getElementById("ex-count");
|
||||
const totalEl = document.getElementById("ex-total-amount");
|
||||
|
||||
const fmt = (n) => `${Number(n || 0).toLocaleString("ko-KR")} 원`;
|
||||
|
||||
function setEditing(id, data) {
|
||||
form.id.value = id || "";
|
||||
if (data) {
|
||||
form.spent_at.value = data.spent_at || "";
|
||||
form.category.value = data.category || "";
|
||||
form.method.value = data.method || "";
|
||||
form.amount.value = data.amount || 0;
|
||||
form.merchant.value = data.merchant || "";
|
||||
form.memo.value = data.memo || "";
|
||||
submitBtn.textContent = "수정 저장";
|
||||
} else {
|
||||
submitBtn.textContent = "등록";
|
||||
}
|
||||
}
|
||||
|
||||
resetBtn.addEventListener("click", () => setEditing("", null));
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const id = form.id.value.trim();
|
||||
const payload = {
|
||||
spent_at: form.spent_at.value,
|
||||
category: form.category.value,
|
||||
method: form.method.value,
|
||||
amount: parseInt(form.amount.value || "0", 10),
|
||||
merchant: form.merchant.value,
|
||||
memo: form.memo.value,
|
||||
};
|
||||
const url = id ? `/expense/api/items/${id}` : "/expense/api/items";
|
||||
const method = id ? "PUT" : "POST";
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await reload();
|
||||
form.reset();
|
||||
setEditing("", null);
|
||||
} catch (err) {
|
||||
alert(`저장 실패: ${err.message || err}`);
|
||||
}
|
||||
});
|
||||
|
||||
tbody.addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn) return;
|
||||
const tr = btn.closest("tr");
|
||||
const id = tr && tr.dataset.id;
|
||||
if (!id) return;
|
||||
if (btn.classList.contains("js-delete")) {
|
||||
if (!confirm("이 항목을 삭제할까요?")) return;
|
||||
const res = await fetch(`/expense/api/items/${id}`, { method: "DELETE" });
|
||||
if (res.ok) reload(); else alert("삭제 실패");
|
||||
} else if (btn.classList.contains("js-edit")) {
|
||||
const res = await fetch(`/expense/api/items`);
|
||||
if (!res.ok) return;
|
||||
const j = await res.json();
|
||||
const found = (j.items || []).find((x) => x.id === id);
|
||||
if (found) setEditing(id, found);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
});
|
||||
|
||||
async function reload() {
|
||||
const res = await fetch(`/expense/api/items`);
|
||||
if (!res.ok) return;
|
||||
const j = await res.json();
|
||||
const items = j.items || [];
|
||||
const summary = j.summary || { count: 0, total: 0 };
|
||||
countEl.textContent = `${items.length}건`;
|
||||
if (totalEl) totalEl.textContent = fmt(summary.total);
|
||||
|
||||
tbody.innerHTML = "";
|
||||
if (items.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="7" class="erp-empty">등록된 경비가 없습니다.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
items
|
||||
.sort((a, b) => (b.spent_at || "").localeCompare(a.spent_at || ""))
|
||||
.forEach((it) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.id = it.id;
|
||||
tr.innerHTML = `
|
||||
<td>${it.spent_at || ""}</td>
|
||||
<td>${it.category || ""}</td>
|
||||
<td>${it.method || ""}</td>
|
||||
<td><div>${it.merchant || ""}</div>${it.memo ? `<div class="erp-row-sub">${it.memo}</div>` : ""}</td>
|
||||
<td style="text-align:right; font-variant-numeric: tabular-nums;">${fmt(it.amount)}</td>
|
||||
<td><span class="erp-badge erp-badge-neutral">${it.status || "작성중"}</span></td>
|
||||
<td style="text-align:right;">
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-edit">수정</button>
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-delete">삭제</button>
|
||||
</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
// 사용일 기본값 = 오늘
|
||||
if (!form.spent_at.value) {
|
||||
const d = new Date();
|
||||
form.spent_at.value = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user