feat(expense+admin): 결재 워크플로 + 첨부 + 집계 + 엑셀 + 사용자 직접 등록

[admin]
- POST /api/users 사용자 직접 등록 (이메일만으로). UserStore.create_user 추가
- admin.html: 등록 폼 + 삭제 버튼 + 한글 라벨 + 신규 권한 표시
- 신규 권한 키: expense_approver, vacation_approver (APPROVER_KEYS)
- is_approver(user_rec, kind) 헬퍼

[expense 워크플로]
- 상태 전이: 작성중 → 제출 → 승인/반려 → 정산완료
- API: /api/items/{id}/{submit,revert,approve,reject,settle}
- 권한: submit/revert=owner, approve/reject/settle=expense_approver 또는 admin
- expense_items 컬럼: approver_email, decided_at, reject_reason
- 작성중/반려 상태에서만 수정/삭제/첨부 가능

[expense 첨부]
- 영수증/기타 (kind: receipt|other). 최대 20MB, 확장자 화이트리스트
- 저장: DATA_DIR/uploads/expense/{item_id}/{att_id}_{filename}
- expense_attachments 테이블 (CASCADE on item delete)
- API: 업로드/목록/다운로드/삭제

[expense 집계]
- /expense/reports: 연도별 월×카테고리 피벗 표

[expense 엑셀]
- GET /expense/api/export.xlsx?from&to&scope=mine|all
- scope=all 은 승인자/관리자만

[DDL]
- expense_db_init.sql: 신규 컬럼/테이블 포함 (멱등)
- expense_db_002_workflow_attachments.sql: 운영 DB 마이그레이션용

[deps]
- openpyxl>=3.1, python-multipart>=0.0.20

[docs]
- DATABASES.md: 워크플로 다이어그램, attachments 테이블, 마이그레이션
- PROJECT_OVERVIEW.md: 권한 키 표
- DEPLOYMENT.md: DATA_DIR/uploads 안내
This commit is contained in:
2026-05-29 02:52:44 +09:00
parent afacc9a7db
commit 550867ba0c
14 changed files with 1492 additions and 142 deletions
+38
View File
@@ -16,6 +16,7 @@ from starlette.middleware.sessions import SessionMiddleware
from .modules.expense import build_expense_store
from .modules.expense import router as expense_router
from .store import (
APPROVER_KEYS,
MODULE_KEYS,
SUPER_ADMIN_EMAIL,
UserStore,
@@ -24,6 +25,16 @@ from .store import (
is_admin,
)
# 권한 키 한글 라벨 (admin.html / 사이드바 공용)
MODULE_LABELS: dict[str, str] = {
"corm": "CORM",
"order": "Order",
"expense": "개인경비",
"vacation": "휴가",
"expense_approver": "개인경비",
"vacation_approver": "휴가",
}
# OMS(orderlist) 와 공유하는 세션 키. SessionMiddleware 의 session_cookie 도 동일 이름.
SESSION_COOKIE_DEFAULT = "session"
@@ -104,6 +115,7 @@ user_store = UserStore(DATA_DIR / "users.json")
# 모듈별 데이터 저장소.
# EXPENSE_DB_URL 가 있으면 expense_db(PostgreSQL), 없으면 JSON 파일.
app.state.data_dir = DATA_DIR # 모듈에서 첨부 저장 경로 등으로 참조
app.state.expense_store = build_expense_store(
dsn=env("EXPENSE_DB_URL") or None,
json_path=DATA_DIR / "expense.json",
@@ -459,6 +471,8 @@ async def admin_page(request: Request) -> HTMLResponse:
"user": rec,
"users": users,
"module_keys": list(MODULE_KEYS),
"module_labels": MODULE_LABELS,
"approver_keys": list(APPROVER_KEYS),
"super_admin_email": SUPER_ADMIN_EMAIL,
"is_admin": True,
},
@@ -471,11 +485,35 @@ class UpdatePermissionsBody(BaseModel):
modules: dict[str, bool] | None = None
class CreateUserBody(BaseModel):
email: str
name: str = ""
role: str = "user"
modules: dict[str, bool] | None = None
@app.get("/api/users")
async def api_list_users(_: dict[str, Any] = Depends(require_admin)) -> JSONResponse:
return JSONResponse({"users": user_store.list_all()})
@app.post("/api/users")
async def api_create_user(
body: CreateUserBody,
_: dict[str, Any] = Depends(require_admin),
) -> JSONResponse:
try:
rec = user_store.create_user(
email=body.email,
name=body.name,
role=body.role,
modules=body.modules,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return JSONResponse({"user": rec}, status_code=201)
@app.put("/api/users/{email}")
async def api_update_user(
email: str,