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:
+56
-2
@@ -17,8 +17,21 @@ from typing import Any, Iterable
|
||||
# 슈퍼 관리자 — 강등/삭제 불가
|
||||
SUPER_ADMIN_EMAIL = "king@dbxcorp.co.kr"
|
||||
|
||||
# 시스템에서 지원하는 모듈 키 — 신규 추가시 여기 + 메뉴/템플릿 동시 갱신
|
||||
MODULE_KEYS: tuple[str, ...] = ("corm", "order", "expense", "vacation")
|
||||
# 시스템에서 지원하는 권한 키.
|
||||
# - 접근권한: 모듈 페이지 진입 허용
|
||||
# - 승인자: 결재 워크플로에서 승인/반려 가능
|
||||
# 신규 추가 시 여기 + admin.html MODULE_LABELS + 라우터 검사 동시 갱신.
|
||||
MODULE_KEYS: tuple[str, ...] = (
|
||||
"corm",
|
||||
"order",
|
||||
"expense",
|
||||
"vacation",
|
||||
"expense_approver",
|
||||
"vacation_approver",
|
||||
)
|
||||
|
||||
# 승인자 권한 키 — 결재 워크플로에서 별도 검사할 때 사용
|
||||
APPROVER_KEYS: tuple[str, ...] = ("expense_approver", "vacation_approver")
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
@@ -88,6 +101,42 @@ class UserStore:
|
||||
data = self._read()
|
||||
return [self._enrich(email, rec) for email, rec in data["users"].items()]
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
name: str = "",
|
||||
role: str = "user",
|
||||
modules: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""관리자가 이메일만으로 신규 사용자 등록. 로그인 전이라도 권한 부여 가능."""
|
||||
email = email.lower().strip()
|
||||
if not email or "@" not in email:
|
||||
raise ValueError("올바른 이메일 형식이 아닙니다.")
|
||||
if role not in ("admin", "user"):
|
||||
raise ValueError(f"role 값이 잘못되었습니다: {role}")
|
||||
is_super = email == SUPER_ADMIN_EMAIL
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
if email in data["users"]:
|
||||
raise ValueError(f"이미 등록된 사용자입니다: {email}")
|
||||
now = _now_iso()
|
||||
final_role = "admin" if is_super else role
|
||||
rec = {
|
||||
"email": email,
|
||||
"name": name or email.split("@")[0],
|
||||
"picture": "",
|
||||
"role": final_role,
|
||||
"modules": self._normalize_modules(
|
||||
modules, is_admin=(final_role == "admin")
|
||||
),
|
||||
"created_at": now,
|
||||
"last_login": "",
|
||||
}
|
||||
data["users"][email] = rec
|
||||
self._write_atomic(data)
|
||||
return self._enrich(email, rec)
|
||||
|
||||
def upsert_login(self, *, email: str, name: str, picture: str) -> dict[str, Any]:
|
||||
"""로그인 시 호출. 신규면 생성, 기존이면 last_login/name/picture 갱신."""
|
||||
email = email.lower().strip()
|
||||
@@ -182,5 +231,10 @@ def has_module(user_rec: dict[str, Any] | None, module: str) -> bool:
|
||||
return bool(mods.get(module))
|
||||
|
||||
|
||||
def is_approver(user_rec: dict[str, Any] | None, kind: str) -> bool:
|
||||
"""결재 승인자 권한 검사. kind 예: 'expense', 'vacation'."""
|
||||
return has_module(user_rec, f"{kind}_approver")
|
||||
|
||||
|
||||
def allowed_modules(user_rec: dict[str, Any] | None) -> set[str]:
|
||||
return {m for m in MODULE_KEYS if has_module(user_rec, m)}
|
||||
|
||||
Reference in New Issue
Block a user