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:
@@ -178,6 +178,267 @@ class ExpenseDBStore:
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(item_id)
|
||||
|
||||
# ── 워크플로 ──
|
||||
def submit(self, *, item_id: str, owner: str) -> dict[str, Any]:
|
||||
return self._transition_owner(
|
||||
item_id=item_id, owner=owner, from_status="작성중", to_status="제출"
|
||||
)
|
||||
|
||||
def revert_to_draft(self, *, item_id: str, owner: str) -> dict[str, Any]:
|
||||
"""반려 또는 제출 상태에서 본인이 작성중으로 되돌림."""
|
||||
owner = owner.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE expense_items
|
||||
SET status = '작성중',
|
||||
reject_reason = NULL,
|
||||
decided_at = NULL,
|
||||
approver_email = NULL
|
||||
WHERE id = %s AND owner = %s
|
||||
AND status IN ('제출', '반려')
|
||||
RETURNING *
|
||||
""",
|
||||
(item_id, owner),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError("작성중 으로 되돌릴 수 없는 상태입니다.")
|
||||
return self._serialize(row)
|
||||
|
||||
def approve(self, *, item_id: str, approver_email: str) -> dict[str, Any]:
|
||||
return self._transition_approver(
|
||||
item_id=item_id,
|
||||
approver_email=approver_email,
|
||||
from_statuses=("제출",),
|
||||
to_status="승인",
|
||||
)
|
||||
|
||||
def reject(
|
||||
self, *, item_id: str, approver_email: str, reason: str
|
||||
) -> dict[str, Any]:
|
||||
if not reason.strip():
|
||||
raise ValueError("반려 사유 필수")
|
||||
approver_email = approver_email.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE expense_items
|
||||
SET status = '반려',
|
||||
approver_email = %s,
|
||||
decided_at = now(),
|
||||
reject_reason = %s
|
||||
WHERE id = %s AND status = '제출'
|
||||
RETURNING *
|
||||
""",
|
||||
(approver_email, reason.strip(), item_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError("제출 상태가 아니거나 항목 없음")
|
||||
return self._serialize(row)
|
||||
|
||||
def settle(self, *, item_id: str, approver_email: str) -> dict[str, Any]:
|
||||
return self._transition_approver(
|
||||
item_id=item_id,
|
||||
approver_email=approver_email,
|
||||
from_statuses=("승인",),
|
||||
to_status="정산완료",
|
||||
)
|
||||
|
||||
def _transition_owner(
|
||||
self, *, item_id: str, owner: str, from_status: str, to_status: str
|
||||
) -> dict[str, Any]:
|
||||
owner = owner.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE expense_items
|
||||
SET status = %s
|
||||
WHERE id = %s AND owner = %s AND status = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(to_status, item_id, owner, from_status),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"전이 불가: {from_status} → {to_status}")
|
||||
return self._serialize(row)
|
||||
|
||||
def _transition_approver(
|
||||
self,
|
||||
*,
|
||||
item_id: str,
|
||||
approver_email: str,
|
||||
from_statuses: tuple[str, ...],
|
||||
to_status: str,
|
||||
) -> dict[str, Any]:
|
||||
approver_email = approver_email.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE expense_items
|
||||
SET status = %s,
|
||||
approver_email = %s,
|
||||
decided_at = now()
|
||||
WHERE id = %s AND status = ANY(%s)
|
||||
RETURNING *
|
||||
""",
|
||||
(to_status, approver_email, item_id, list(from_statuses)),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"전이 불가 → {to_status}")
|
||||
return self._serialize(row)
|
||||
|
||||
# ── 승인자 대기열 ──
|
||||
def list_pending_approval(self) -> list[dict[str, Any]]:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM expense_items WHERE status = '제출' "
|
||||
"ORDER BY created_at ASC"
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
def get_any(self, *, item_id: str) -> dict[str, Any] | None:
|
||||
"""승인자/관리자용 — owner 무시하고 단일 조회."""
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM expense_items WHERE id = %s", (item_id,)
|
||||
).fetchone()
|
||||
return self._serialize(row) if row else None
|
||||
|
||||
# ── 첨부 ──
|
||||
def add_attachment(
|
||||
self,
|
||||
*,
|
||||
item_id: str,
|
||||
owner: str,
|
||||
kind: str,
|
||||
filename: str,
|
||||
stored_path: str,
|
||||
content_type: str,
|
||||
size_bytes: int,
|
||||
) -> dict[str, Any]:
|
||||
if kind not in ("receipt", "other"):
|
||||
raise ValueError("kind 는 receipt|other")
|
||||
owner = owner.lower().strip()
|
||||
att_id = uuid.uuid4().hex[:12]
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO expense_attachments
|
||||
(id, item_id, owner, kind, filename, stored_path,
|
||||
content_type, size_bytes)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
att_id,
|
||||
item_id,
|
||||
owner,
|
||||
kind,
|
||||
filename,
|
||||
stored_path,
|
||||
content_type,
|
||||
size_bytes,
|
||||
),
|
||||
).fetchone()
|
||||
return self._att_serialize(row)
|
||||
|
||||
def list_attachments(self, *, item_id: str) -> list[dict[str, Any]]:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM expense_attachments WHERE item_id = %s "
|
||||
"ORDER BY uploaded_at ASC",
|
||||
(item_id,),
|
||||
).fetchall()
|
||||
return [self._att_serialize(r) for r in rows]
|
||||
|
||||
def get_attachment(self, *, att_id: str) -> dict[str, Any] | None:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM expense_attachments WHERE id = %s", (att_id,)
|
||||
).fetchone()
|
||||
return self._att_serialize(row) if row else None
|
||||
|
||||
def delete_attachment(self, *, att_id: str, owner: str) -> dict[str, Any]:
|
||||
"""삭제된 행 반환 (파일 정리용 stored_path 포함)."""
|
||||
owner = owner.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"DELETE FROM expense_attachments "
|
||||
"WHERE id = %s AND owner = %s RETURNING *",
|
||||
(att_id, owner),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise KeyError(att_id)
|
||||
return self._att_serialize(row)
|
||||
|
||||
@staticmethod
|
||||
def _att_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
v = out.get("uploaded_at")
|
||||
if isinstance(v, datetime):
|
||||
out["uploaded_at"] = v.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
out["size_bytes"] = int(out.get("size_bytes", 0))
|
||||
return out
|
||||
|
||||
# ── 집계 (월별) ──
|
||||
def monthly_summary(
|
||||
self, *, email: str, year: int
|
||||
) -> list[dict[str, Any]]:
|
||||
email = email.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT to_char(spent_at, 'YYYY-MM') AS month,
|
||||
category,
|
||||
COUNT(*) AS cnt,
|
||||
COALESCE(SUM(amount), 0) AS total
|
||||
FROM expense_items
|
||||
WHERE owner = %s AND EXTRACT(YEAR FROM spent_at) = %s
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2
|
||||
""",
|
||||
(email, year),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"month": r["month"],
|
||||
"category": r["category"],
|
||||
"count": int(r["cnt"]),
|
||||
"total": int(r["total"]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def list_for_export(
|
||||
self,
|
||||
*,
|
||||
email: str | None,
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""엑셀 내보내기용. email=None 이면 전체 (승인자/관리자용)."""
|
||||
clauses = []
|
||||
params: list[Any] = []
|
||||
if email:
|
||||
clauses.append("owner = %s")
|
||||
params.append(email.lower().strip())
|
||||
if date_from:
|
||||
clauses.append("spent_at >= %s")
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
clauses.append("spent_at <= %s")
|
||||
params.append(date_to)
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM expense_items {where} "
|
||||
f"ORDER BY spent_at ASC, created_at ASC",
|
||||
params,
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
# ── 요약 ──
|
||||
def summary_for(self, email: str) -> dict[str, Any]:
|
||||
email = email.lower().strip()
|
||||
|
||||
Reference in New Issue
Block a user