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:
+38
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user